From acff0135a546100c467297c9f053ffbc6fdb12f3 Mon Sep 17 00:00:00 2001 From: Mingholy Date: Sun, 7 Jun 2026 19:12:44 +0800 Subject: [PATCH 1/2] fix(server): resolve vault SSH key paths for host-side git operations sshCommandForUser used -F to load the vault's .ssh/config on the host. The config's IdentityFile directives use ~ paths designed for sandbox use (where vault .ssh/ is bind-mounted to $HOME/.ssh/). On the host, ~ resolves to /home/admin/, causing "no such identity" warnings and authentication failures. Replace -F with config parsing: extract IdentityFile entries, rewrite ~ to the vault home absolute path, and pass via -i. Glob id_* as fallback for vaults without a config. Use -F /dev/null to suppress host config pollution, -o IdentitiesOnly=yes to exclude ssh-agent noise. Co-Authored-By: Claude Opus 4.6 --- server/src/loops.ts | 76 +++++++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/server/src/loops.ts b/server/src/loops.ts index fed67e0c..e5f0bf65 100644 --- a/server/src/loops.ts +++ b/server/src/loops.ts @@ -3,7 +3,7 @@ import { chmod, copyFile, mkdir, mkdtemp, readdir, readFile, rename, writeFile, import { randomUUID } from "node:crypto" import { execFile } from "node:child_process" import { promisify } from "node:util" -import { existsSync, chmodSync } from "node:fs" +import { existsSync, chmodSync, readFileSync, readdirSync } from "node:fs" import { tmpdir } from "node:os" import { loopsDir, @@ -1012,28 +1012,64 @@ export async function importPersonalFromRepo( /** * TEAM key: the ssh command a host-side git op uses to reach SHARED context — - * knowledge / notes / repos — as the user, with their OWN vault. We just point - * ssh at the vault's `.ssh` and let it follow the standard: the standard-named - * key (`id_ed25519`, via `-i` because on the host `~` isn't the vault) and the - * vault's own `config` (via `-F`, so the user's Host / known-hosts / strict- - * checking choices apply). No `IdentitiesOnly` / `UserKnownHostsFile` overrides - * — loopat doesn't special-case the key, it follows ssh's standard resolution. - * If the key isn't there the op simply fails: we deliberately do NOT fall back - * to the host deploy-key, so a loop never borrows access it wasn't granted + * knowledge / notes / repos — as the user, with their OWN vault key(s). + * + * The vault's `.ssh/config` is designed for sandbox use (IdentityFile paths + * use `~` which resolves to the sandbox home, not the host home). We can't + * load it via `-F` on the host — that would resolve `~/.ssh/foo` to + * `/home/admin/.ssh/foo` (doesn't exist) instead of the vault path. Instead + * we parse the config to extract IdentityFile entries, rewrite `~` to the + * vault home absolute path, and pass them via `-i`. As a fallback for vaults + * without a config, we also glob `id_*` (the SSH standard default names). + * + * If no key is found the op fails: we deliberately do NOT fall back to the + * host deploy-key, so a loop never borrows access it wasn't granted * (see behavior/02-personal-permissions.md). */ function sshCommandForUser(userId: string, vault: string = "default"): string { - const sshDir = join(personalVaultDir(userId, vault), "mounts", "home", ".ssh") - const vaultKey = join(sshDir, "id_ed25519") - const vaultConfig = join(sshDir, "config") - // git can't persist 0600 — it only tracks the exec bit — so a fresh checkout - // of the git-crypt vault lands the key at the umask default (0664 under a 002 - // umask), and ssh refuses it ("permissions too open"). Force 0600 at point of - // use: this fixes every host-side git op AND the file the sandbox bind-mounts - // into $HOME, regardless of how/when it was checked out. Cheap + idempotent. - try { chmodSync(vaultKey, 0o600) } catch {} - const f = existsSyncBase(vaultConfig) ? `-F ${vaultConfig} ` : "" - return `ssh ${f}-i ${vaultKey}` + const vaultHome = join(personalVaultDir(userId, vault), "mounts", "home") + const sshDir = join(vaultHome, ".ssh") + + const candidates = new Set() + + // Source 1: parse IdentityFile from vault config, rewrite ~ → vault home + const configPath = join(sshDir, "config") + if (existsSyncBase(configPath)) { + try { + const lines = readFileSync(configPath, "utf8").split("\n") + for (const line of lines) { + const m = line.match(/^\s*IdentityFile\s+(.+)/) + if (!m) continue + const raw = m[1].trim() + if (raw.startsWith("~/")) candidates.add(join(vaultHome, raw.slice(2))) + else if (raw.startsWith("/")) candidates.add(raw) + else candidates.add(join(sshDir, raw)) + } + } catch {} + } + + // Source 2: glob id_* in vault .ssh/ (SSH standard defaults, covers vaults + // without a config or with a config that doesn't list every key) + try { + for (const f of readdirSync(sshDir)) { + if (f.startsWith("id_") && !f.endsWith(".pub")) candidates.add(join(sshDir, f)) + } + } catch {} + + // Filter to files that actually exist, chmod 0600 (git can't persist it) + const available: string[] = [] + for (const k of candidates) { + if (!existsSyncBase(k)) continue + try { chmodSync(k, 0o600) } catch {} + available.push(k) + } + + if (available.length === 0) { + return `ssh -F /dev/null -o IdentitiesOnly=yes` + } + + const identity = available.map(k => `-i ${k}`).join(" ") + return `ssh -F /dev/null ${identity} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null` } /** From 32a51e693552613ffc52ac32e8a45e6f3716ddb8 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Sun, 7 Jun 2026 22:49:27 +0800 Subject: [PATCH 2/2] fix(server): rewrite vault SSH config instead of discarding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach used -F /dev/null to avoid broken IdentityFile ~ paths, but discarded all SSH config (ProxyCommand, Host/Match routing, known_hosts, StrictHostKeyChecking). Instead, generate a temp config with only IdentityFile paths rewritten to absolute vault paths — all other directives are preserved. Also fixes: - Case-insensitive IdentityFile matching (OpenSSH keywords are insensitive) - Shell-quoting for paths with spaces/metacharacters - Log warning on config parse failure (was silent catch) - Log warning on zero-key fallback for easier debugging - Drop StrictHostKeyChecking=accept-new and UserKnownHostsFile=/dev/null overrides that downgraded host key verification security Co-Authored-By: Claude Opus 4.6 --- server/src/loops.ts | 70 ++++++++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/server/src/loops.ts b/server/src/loops.ts index e5f0bf65..c373cae7 100644 --- a/server/src/loops.ts +++ b/server/src/loops.ts @@ -3,7 +3,7 @@ import { chmod, copyFile, mkdir, mkdtemp, readdir, readFile, rename, writeFile, import { randomUUID } from "node:crypto" import { execFile } from "node:child_process" import { promisify } from "node:util" -import { existsSync, chmodSync, readFileSync, readdirSync } from "node:fs" +import { existsSync, chmodSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { loopsDir, @@ -1030,48 +1030,58 @@ function sshCommandForUser(userId: string, vault: string = "default"): string { const vaultHome = join(personalVaultDir(userId, vault), "mounts", "home") const sshDir = join(vaultHome, ".ssh") - const candidates = new Set() - - // Source 1: parse IdentityFile from vault config, rewrite ~ → vault home const configPath = join(sshDir, "config") if (existsSyncBase(configPath)) { + // Rewrite vault SSH config: resolve IdentityFile ~ and relative paths to + // absolute host-side paths. Everything else (Host/Match blocks, ProxyCommand, + // known_hosts, StrictHostKeyChecking) is preserved intact. try { - const lines = readFileSync(configPath, "utf8").split("\n") - for (const line of lines) { - const m = line.match(/^\s*IdentityFile\s+(.+)/) - if (!m) continue - const raw = m[1].trim() - if (raw.startsWith("~/")) candidates.add(join(vaultHome, raw.slice(2))) - else if (raw.startsWith("/")) candidates.add(raw) - else candidates.add(join(sshDir, raw)) - } - } catch {} + const raw = readFileSync(configPath, "utf8") + const rewritten = raw.split("\n").map(line => { + const m = line.match(/^(\s*identityfile\s+)(.+)/i) + if (!m) return line + const p = m[2].trim() + let abs: string + if (p.startsWith("~/")) abs = join(vaultHome, p.slice(2)) + else if (p.startsWith("/")) abs = p + else abs = join(sshDir, p) + if (existsSyncBase(abs)) try { chmodSync(abs, 0o600) } catch {} + return `${m[1]}${abs}` + }).join("\n") + + const resolvedDir = join(tmpdir(), "loopat-ssh", userId, vault) + mkdirSync(resolvedDir, { recursive: true }) + const resolvedConfig = join(resolvedDir, "config") + writeFileSync(resolvedConfig, rewritten, { mode: 0o600 }) + return `ssh -F ${sq(resolvedConfig)}` + } catch (e) { + console.warn(`[loopat] failed to rewrite vault SSH config ${configPath}: ${e}`) + } } - // Source 2: glob id_* in vault .ssh/ (SSH standard defaults, covers vaults - // without a config or with a config that doesn't list every key) + // No config (or rewrite failed): discover id_* keys in vault .ssh/ directly + const keys: string[] = [] try { for (const f of readdirSync(sshDir)) { - if (f.startsWith("id_") && !f.endsWith(".pub")) candidates.add(join(sshDir, f)) + if (f.startsWith("id_") && !f.endsWith(".pub")) { + const p = join(sshDir, f) + if (existsSyncBase(p)) { + try { chmodSync(p, 0o600) } catch {} + keys.push(p) + } + } } } catch {} - // Filter to files that actually exist, chmod 0600 (git can't persist it) - const available: string[] = [] - for (const k of candidates) { - if (!existsSyncBase(k)) continue - try { chmodSync(k, 0o600) } catch {} - available.push(k) - } - - if (available.length === 0) { - return `ssh -F /dev/null -o IdentitiesOnly=yes` + if (keys.length === 0) { + console.warn(`[loopat] no SSH keys found for vault ${vault} user ${userId}`) + return "ssh -o IdentitiesOnly=yes" } - - const identity = available.map(k => `-i ${k}`).join(" ") - return `ssh -F /dev/null ${identity} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null` + return `ssh ${keys.map(k => `-i ${sq(k)}`).join(" ")} -o IdentitiesOnly=yes` } +function sq(s: string): string { return `'${s.replace(/'/g, "'\\''")}'` } + /** * PERSONAL key: reaching the user's OWN personal repo (clone / pull / push) uses * the host-managed per-user deploy-key — permanently, not just at bootstrap.