loopat 0.1.45 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopat",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "Self-hosted AI workspace built around context management — works solo, scales to teams",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/simpx/loopat",
@@ -7,14 +7,13 @@ import { existsSync } from "node:fs"
7
7
  import { execFileSync } from "node:child_process"
8
8
  import { join } from "node:path"
9
9
  import { resolveSandboxClaudeBinary } from "./claude-binary"
10
- import { configPath, loadKnowledgeConfig, type WorkspaceConfig, type KnowledgeConfig } from "./config"
10
+ import { configPath, loadKnowledgeConfig, type WorkspaceConfig } from "./config"
11
11
  import {
12
12
  WORKSPACE,
13
13
  usersPath,
14
14
  workspaceDir,
15
15
  workspaceKnowledgeDir,
16
16
  workspaceNotesDir,
17
- workspaceRepoDir,
18
17
  workspaceTeamClaudeMdPath,
19
18
  } from "./paths"
20
19
  import { listUsers } from "./auth"
@@ -109,19 +108,6 @@ function describeRemote(dir: string, url: string | undefined): string {
109
108
  return "local-only (no remote)"
110
109
  }
111
110
 
112
- function describeRepos(kcfg: KnowledgeConfig): Check {
113
- const specs = kcfg.repos ?? []
114
- if (specs.length === 0) return { ok: true, label: `repos: (none configured)` }
115
- // Repos are clone-on-demand (cloned only when a loop selects one), so a repo
116
- // that isn't cloned yet is NORMAL, not a failure. Report cloned vs on-demand;
117
- // never a blocker.
118
- const parts = specs.map((r) => {
119
- const present = existsSync(workspaceRepoDir(r.name))
120
- return present ? r.name : `${r.name} (on demand)`
121
- })
122
- return { ok: true, label: `repos: ${parts.join(", ")}` }
123
- }
124
-
125
111
  async function checkUsers(): Promise<Check> {
126
112
  const path = usersPath()
127
113
  if (!existsSync(path)) {
@@ -137,14 +123,15 @@ async function checkUsers(): Promise<Check> {
137
123
  }
138
124
 
139
125
  export async function printBootstrapBanner(cfg: WorkspaceConfig) {
140
- // notes + repos are declared inside the knowledge repo's .loopat/config.json.
126
+ // notes is declared inside the knowledge repo's .loopat/config.json. The repo
127
+ // roster is per-user (personal config), so the workspace banner can't list it.
141
128
  const kcfg = await loadKnowledgeConfig()
142
129
  const checks: Check[] = [
143
130
  { ok: true, label: `workspace: ${workspaceDir()}` },
144
131
  { ok: true, label: `team .claude/CLAUDE.md (${existsSync(workspaceTeamClaudeMdPath()) ? "present" : "absent"})` },
145
132
  { ok: existsSync(workspaceKnowledgeDir()), label: `knowledge: ${describeRemote(workspaceKnowledgeDir(), cfg.knowledge?.git || undefined)}` },
146
133
  { ok: existsSync(workspaceNotesDir()), label: `notes: ${describeRemote(workspaceNotesDir(), kcfg.notes?.git || undefined)}` },
147
- describeRepos(kcfg),
134
+ { ok: true, label: `repos: (per-user, in personal config)` },
148
135
  await checkUsers(),
149
136
  { ok: existsSync(configPath()), label: `config: ${configPath()}` },
150
137
  checkPodman(),
@@ -113,17 +113,17 @@ export type RepoSpec = {
113
113
 
114
114
  /**
115
115
  * Config living INSIDE the knowledge repo at <knowledge>/.loopat/config.json.
116
- * The knowledge repo is the SoT for the team's notes remote + the repo roster;
117
- * workspace/personal config only hold the `knowledge` entry pointer. To read
118
- * it the knowledge repo must already be cloned (its url comes from
119
- * personal/workspace config), so this is loaded AFTER the knowledge clone.
116
+ * The knowledge repo is the SoT for the team's notes remote; workspace/personal
117
+ * config only hold the `knowledge` entry pointer. To read it the knowledge repo
118
+ * must already be cloned (its url comes from personal/workspace config), so this
119
+ * is loaded AFTER the knowledge clone. (The repo roster is NO LONGER here — it
120
+ * moved to personal config; see PersonalConfig.repos.)
120
121
  */
121
122
  export type KnowledgeConfig = {
122
123
  notes?: RemoteSpec
123
- repos?: RepoSpec[]
124
124
  }
125
125
 
126
- const KNOWLEDGE_CONFIG_TEMPLATE: KnowledgeConfig = { notes: { git: "" }, repos: [] }
126
+ const KNOWLEDGE_CONFIG_TEMPLATE: KnowledgeConfig = { notes: { git: "" } }
127
127
 
128
128
  /** The .loopat root holding the knowledge config. With a user → that user's
129
129
  * per-user knowledge repo; without → the workspace-default knowledge repo
@@ -140,7 +140,7 @@ export async function loadKnowledgeConfig(user?: string): Promise<KnowledgeConfi
140
140
  if (!existsSync(path)) return JSON.parse(JSON.stringify(KNOWLEDGE_CONFIG_TEMPLATE))
141
141
  try {
142
142
  const disk = JSON.parse(await readFile(path, "utf8")) as KnowledgeConfig
143
- return { notes: disk.notes, repos: Array.isArray(disk.repos) ? disk.repos : [] }
143
+ return { notes: disk.notes }
144
144
  } catch (e: any) {
145
145
  console.warn(`[loopat] knowledge config: ${path} malformed (${e?.message ?? e}), treating as empty`)
146
146
  return JSON.parse(JSON.stringify(KNOWLEDGE_CONFIG_TEMPLATE))
@@ -153,7 +153,6 @@ export async function saveKnowledgeConfig(user: string | undefined, patch: Knowl
153
153
  const cur = await loadKnowledgeConfig(user)
154
154
  const next: KnowledgeConfig = {
155
155
  notes: patch.notes !== undefined ? patch.notes : cur.notes,
156
- repos: patch.repos !== undefined ? patch.repos : cur.repos,
157
156
  }
158
157
  const dir = knowledgeLoopatRoot(user)
159
158
  await mkdir(dir, { recursive: true })
@@ -180,9 +179,9 @@ export type OperatorMount = {
180
179
  */
181
180
  export type WorkspaceConfig = {
182
181
  /** Workspace-default entry pointer to the knowledge repo. The notes remote
183
- * and the repo roster now live INSIDE the knowledge repo's
184
- * .loopat/config.json (KnowledgeConfig) see loadKnowledgeConfig. A
185
- * per-user override lives in personal config. */
182
+ * lives INSIDE the knowledge repo's .loopat/config.json (KnowledgeConfig) —
183
+ * see loadKnowledgeConfig. The repo roster is per-user (PersonalConfig.repos).
184
+ * A per-user knowledge override lives in personal config. */
186
185
  knowledge?: RemoteSpec
187
186
  providers?: Record<string, ProviderConfig>
188
187
  default?: string
@@ -246,9 +245,12 @@ export type PersonalConfigDisk = {
246
245
  /** Mixed: "default" key is a string, all other keys are providers. */
247
246
  providers: Record<string, ProviderConfigDisk | string>
248
247
  /** Per-user entry pointer to the knowledge repo (authoritative over the
249
- * workspace default). The notes remote + repo roster live inside the
250
- * knowledge repo's own .loopat/config.json, not here. */
248
+ * workspace default). The notes remote lives inside the knowledge repo's
249
+ * own .loopat/config.json, not here. */
251
250
  knowledge?: RemoteSpec
251
+ /** Per-user repo roster — clone-on-demand at loop creation. Personal, not
252
+ * team-shared (moved here from the knowledge repo). Edited via /context/repos. */
253
+ repos?: RepoSpec[]
252
254
  }
253
255
 
254
256
  export type PersonalConfig = {
@@ -261,9 +263,11 @@ export type PersonalConfig = {
261
263
  * in mcpServers works, and (b) substitute `${VAR}` in provider.apiKey.
262
264
  */
263
265
  vaultEnvs: Record<string, string>
264
- /** Per-user entry pointer to the knowledge repo (notes/repos live in the
266
+ /** Per-user entry pointer to the knowledge repo (notes lives in the
265
267
  * knowledge repo's own .loopat/config.json). */
266
268
  knowledge?: RemoteSpec
269
+ /** Per-user repo roster — clone-on-demand at loop creation. */
270
+ repos: RepoSpec[]
267
271
  }
268
272
 
269
273
  /**
@@ -311,6 +315,7 @@ const PERSONAL_TEMPLATE: PersonalConfig = {
311
315
  default: PROVIDER_PRESETS[0] ? `${PROVIDER_PRESETS[0].name}/${PROVIDER_PRESETS[0].models[0]}` : "",
312
316
  providers: buildPresetProviders(),
313
317
  vaultEnvs: {},
318
+ repos: [],
314
319
  }
315
320
 
316
321
  /** On-disk shape used when a config.json is missing or malformed. Seeded
@@ -477,6 +482,7 @@ export async function loadPersonalConfig(
477
482
  default: defaultProviderName && providers[defaultProviderName] ? rawDefault : "",
478
483
  providers,
479
484
  vaultEnvs,
485
+ repos: Array.isArray(disk.repos) ? disk.repos : [],
480
486
  ...(disk.knowledge ? { knowledge: disk.knowledge } : {}),
481
487
  }
482
488
  personalCache.set(cacheKey, { cfg, configMtimeMs, envsDirMtimeMs })
@@ -667,6 +673,13 @@ export async function savePersonalDisk(
667
673
  disk.providers = patch.providers
668
674
  }
669
675
 
676
+ if (patch.repos !== undefined) {
677
+ disk.repos = patch.repos
678
+ .filter((r) => r && typeof r.name === "string" && typeof r.git === "string")
679
+ .map((r) => ({ name: r.name.trim(), git: r.git.trim() }))
680
+ .filter((r) => r.name && r.git)
681
+ }
682
+
670
683
  await mkdir(personalLoopatDir(user), { recursive: true })
671
684
  await writeFile(personalLoopatConfigPath(user), JSON.stringify(disk, null, 2) + "\n")
672
685
  clearPersonalCache(user)
@@ -4,7 +4,7 @@ import { createBunWebSocket } from "hono/bun"
4
4
  import { existsSync } from "node:fs"
5
5
  import { execFile, execFileSync } from "node:child_process"
6
6
  import { promisify } from "node:util"
7
- import { listLoops, createLoop, getLoop, loopExists, patchLoopMeta, backfillAllMounts, ensureWorkspaceDirs, provisionUserPersonal, importPersonalFromRepo, setupPersonalViaProvider, listPersonalReposViaProvider, authenticateViaProvider, isPersonalFresh, ensureUiNotesWorktree, syncUiNotes, ffUpdateUiNotes, notesBehind, inspectPersonalDirty, syncPersonalToRemote, deletePersonalVault, pullPersonalFromRemote, pushPersonalToRemote, ensureContextMounts, effectiveDriver, isDriver, distillLoop, inspectRepoSync, pullRepoFromRemote, pushRepoToRemote, ensureUserContext, promoteKnowledgeConfig, listVaultPublicKeys, userOnboarding, submitOnboarding } from "./loops"
7
+ import { listLoops, createLoop, getLoop, loopExists, patchLoopMeta, backfillAllMounts, ensureWorkspaceDirs, provisionUserPersonal, importPersonalFromRepo, setupPersonalViaProvider, listPersonalReposViaProvider, authenticateViaProvider, isPersonalFresh, ensureUiNotesWorktree, syncUiNotes, ffUpdateUiNotes, notesBehind, inspectPersonalDirty, syncPersonalToRemote, deletePersonalVault, pullPersonalFromRemote, pushPersonalToRemote, ensureContextMounts, effectiveDriver, isDriver, distillLoop, inspectRepoSync, pullRepoFromRemote, pushRepoToRemote, listVaultPublicKeys, userOnboarding, submitOnboarding } from "./loops"
8
8
  import { getEphemeralHostPort, probePodman, stopAllWorkspaceContainers, ensureServeContainer, ensurePortProxyContainer, ensureSandboxImage } from "./podman"
9
9
  import { startMcpAuth, completeMcpAuth, probeOAuthSupport, evictOAuthProbe, parseBearerEnvName, mcpRequiredEnvs, parseTemplateVars, type OAuthSupport } from "./mcp-oauth"
10
10
  import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults"
@@ -55,7 +55,7 @@ import {
55
55
  personalReposDir,
56
56
  loopsDir,
57
57
  } from "./paths"
58
- import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadKnowledgeConfig, saveKnowledgeConfig, loadA2AConfig, saveA2AConfig, type ProviderConfig, type ModelEntry } from "./config"
58
+ import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadA2AConfig, saveA2AConfig, type ProviderConfig, type ModelEntry } from "./config"
59
59
  import { createApiToken, listApiTokens, revokeApiToken } from "./api-tokens"
60
60
  import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban"
61
61
  import { printBootstrapBanner, printReadyLine } from "./bootstrap"
@@ -2423,14 +2423,14 @@ app.get("/api/workspace/backlinks", requireAuth, async (c) => {
2423
2423
  return c.json({ backlinks: await vaultBacklinks(vault as VaultId, path, userId) })
2424
2424
  })
2425
2425
 
2426
- // Context repos roster — DECLARATIVE, lives in the per-user knowledge repo's
2427
- // .loopat/config.json (notes remote + repos[]). Physical clones are still
2428
- // on-demand at loop creation (ensureRepoCloned). GET returns the roster; PUT
2429
- // rewrites it and promotes (commit + push) back to the knowledge repo.
2426
+ // Context repos roster — PERSONAL: lives in the user's own
2427
+ // personal/<user>/.loopat/config.json. Physical clones stay on-demand at loop
2428
+ // creation. GET returns the roster; PUT writes it straight to personal config
2429
+ // (no git promote personal config syncs through the personal-repo path).
2430
2430
  app.get("/api/context/repos", requireAuth, async (c) => {
2431
2431
  const u = c.get("userId") as string
2432
- const kcfg = await loadKnowledgeConfig(u)
2433
- return c.json({ notes: kcfg.notes ?? null, repos: kcfg.repos ?? [] })
2432
+ const pcfg = await loadPersonalConfig(u)
2433
+ return c.json({ repos: pcfg.repos ?? [] })
2434
2434
  })
2435
2435
 
2436
2436
  app.put("/api/context/repos", requireAuth, async (c) => {
@@ -2439,17 +2439,9 @@ app.put("/api/context/repos", requireAuth, async (c) => {
2439
2439
  const repos = Array.isArray(body.repos)
2440
2440
  ? body.repos.filter((r: any) => r?.name && r?.git).map((r: any) => ({ name: String(r.name).trim(), git: String(r.git).trim() }))
2441
2441
  : []
2442
- const notes = body?.notes?.git ? { git: String(body.notes.git).trim() } : undefined
2443
- // Need the per-user knowledge repo present to write + promote. Best-effort
2444
- // clone it first (from personal.knowledge).
2445
- await ensureUserContext(u).catch(() => {})
2446
- if (!existsSync(join(personalKnowledgeDir(u), ".git"))) {
2447
- return c.json({ error: "knowledge repo not available — set personal.knowledge and make sure your key can clone it" }, 400)
2448
- }
2449
- await saveKnowledgeConfig(u, { notes, repos })
2450
- const r = await promoteKnowledgeConfig(u)
2451
- if (!r.ok) return c.json({ error: r.error, savedLocally: true }, 400)
2452
- return c.json({ ok: true, notes: notes ?? null, repos })
2442
+ const sp = await savePersonalDisk(u, { repos })
2443
+ if (!sp.ok) return c.json({ error: sp.error }, 400)
2444
+ return c.json({ ok: true, repos })
2453
2445
  })
2454
2446
 
2455
2447
  // ── topics ──
@@ -349,7 +349,7 @@ async function ensureContextRepo(dir: string, name: string, url?: string): Promi
349
349
  /**
350
350
  * The personal repo is self-describing: its `.loopat/config.json` declares the
351
351
  * authoritative kn/notes remotes, and a loop connects to them with the user's
352
- * OWN key from the selected vault (`vaults/<vault>/mounts/home/.ssh/id`), not
352
+ * OWN key from the selected vault (`vaults/<vault>/mounts/home/.ssh/id_ed25519`), not
353
353
  * the host's ssh. Called at loop creation, which has the user + vault in hand.
354
354
  *
355
355
  * The startup clone (driven by host config.json) stays as a display mirror;
@@ -370,13 +370,6 @@ async function _ensureUserContext(user: string, vault: string): Promise<string[]
370
370
  const errors: string[] = []
371
371
  const cfg = await loadPersonalConfig(user, vault)
372
372
  const sshEnv = { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user, vault) }
373
- // Sandbox-side promote: core.sshCommand points at the vault key's SANDBOX path
374
- // (/loopat/home/<user>/.ssh/id, where the vault home-mount lands) with
375
- // accept-new, so the AI's `git push` inside the sandbox authenticates as the
376
- // user without an interactive host-key prompt. Host-side ops override this
377
- // with GIT_SSH_COMMAND (env beats config), so the sandbox path is never used
378
- // server-side.
379
- const sandboxKey = `/loopat/home/${user}/.ssh/id`
380
373
  // Clone-or-sync a PER-USER context main repo from `url` with the vault key.
381
374
  // STRICT, per the context model: personal wins even when empty — an empty url
382
375
  // means the dir is REMOVED so the loop sees nothing (no fallback to any
@@ -406,18 +399,20 @@ async function _ensureUserContext(user: string, vault: string): Promise<string[]
406
399
  return false
407
400
  }
408
401
  }
409
- await execFileP("git", ["-C", dir, "config", "core.sshCommand",
410
- `ssh -i ${sandboxKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`]).catch(() => {})
402
+ // No core.sshCommand override: inside the sandbox the vault's .ssh is mounted
403
+ // at $HOME/.ssh, so the AI's `git push` resolves the standard-named key +
404
+ // config on its own (follow ssh standard, don't special-case). Host-side ops
405
+ // here use GIT_SSH_COMMAND (sshEnv).
411
406
  await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 30_000 }).catch(() => {})
412
407
  return true
413
408
  }
414
409
  // knowledge is the entry pointer (personal-declared url); clone it first, then
415
- // read ITS .loopat/config.json for the notes remote + repo roster — notes and
416
- // repos now live inside the per-user knowledge repo, not in personal config.
410
+ // read ITS .loopat/config.json for the notes remote. The repo roster is
411
+ // personal (cfg.repos), no longer inside the knowledge repo.
417
412
  const hasKnowledge = await ensurePerUserRepo(personalKnowledgeDir(user), cfg.knowledge?.git, "knowledge")
418
- const kcfg = hasKnowledge ? await loadKnowledgeConfig(user) : { notes: undefined, repos: [] as RepoSpec[] }
413
+ const kcfg = hasKnowledge ? await loadKnowledgeConfig(user) : { notes: undefined }
419
414
  await ensurePerUserRepo(personalNotesDir(user), kcfg.notes?.git, "notes")
420
- await writeReposManifest(personalReposDir(user), kcfg.repos ?? [])
415
+ await writeReposManifest(personalReposDir(user), cfg.repos ?? [])
421
416
  return errors
422
417
  }
423
418
 
@@ -446,9 +441,9 @@ async function writeReposManifest(reposDir: string, specs: RepoSpec[]) {
446
441
  * repo dir exists afterwards. Used by loop creation and any on-demand path.
447
442
  */
448
443
  async function ensureRepoMirror(user: string, name: string, sshCommand?: string): Promise<string | null> {
449
- // The roster lives in the user's OWN knowledge repo (per-user, no fallback).
450
- const kcfg = await loadKnowledgeConfig(user)
451
- const spec = kcfg.repos?.find((r) => r.name === name)
444
+ // The roster lives in the user's OWN personal config (per-user, no fallback).
445
+ const pcfg = await loadPersonalConfig(user)
446
+ const spec = pcfg.repos?.find((r) => r.name === name)
452
447
  if (!spec?.git) return null
453
448
  const dir = personalRepoCacheDir(user, name)
454
449
  const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env
@@ -500,7 +495,9 @@ export async function ensureWorkspaceDirs() {
500
495
  await ensureContextRepo(workspaceKnowledgeDir(), "knowledge", cfg.knowledge?.git || undefined)
501
496
  const kcfg = await loadKnowledgeConfig()
502
497
  await ensureContextRepo(workspaceNotesDir(), "notes", kcfg.notes?.git || undefined)
503
- await writeReposManifest(workspaceReposDir(), kcfg.repos ?? [])
498
+ // The repo roster is per-user (PersonalConfig.repos); there is no
499
+ // workspace-default roster, so the workspace repos manifest is empty.
500
+ await writeReposManifest(workspaceReposDir(), [])
504
501
 
505
502
  // workspace memory dir + stub
506
503
  const tm = workspaceMemoryDir()
@@ -938,23 +935,29 @@ export async function importPersonalFromRepo(
938
935
  }
939
936
 
940
937
  /**
941
- * TEAM key: the ssh command a git op uses to reach SHARED context — knowledge /
942
- * notes / repos — as the user, with their OWN key from the selected vault
943
- * (`vaults/<vault>/mounts/home/.ssh/id`). If the key isn't there the op simply
944
- * fails: we deliberately do NOT fall back to the host deploy-key, so a loop
945
- * never borrows access it wasn't granted. Authorization tracks the personal
946
- * repo (which declares the team it connects to), not the host
938
+ * TEAM key: the ssh command a host-side git op uses to reach SHARED context —
939
+ * knowledge / notes / repos — as the user, with their OWN vault. We just point
940
+ * ssh at the vault's `.ssh` and let it follow the standard: the standard-named
941
+ * key (`id_ed25519`, via `-i` because on the host `~` isn't the vault) and the
942
+ * vault's own `config` (via `-F`, so the user's Host / known-hosts / strict-
943
+ * checking choices apply). No `IdentitiesOnly` / `UserKnownHostsFile` overrides
944
+ * — loopat doesn't special-case the key, it follows ssh's standard resolution.
945
+ * If the key isn't there the op simply fails: we deliberately do NOT fall back
946
+ * to the host deploy-key, so a loop never borrows access it wasn't granted
947
947
  * (see behavior/02-personal-permissions.md).
948
948
  */
949
949
  function sshCommandForUser(userId: string, vault: string = "default"): string {
950
- const vaultKey = join(personalVaultDir(userId, vault), "mounts", "home", ".ssh", "id")
950
+ const sshDir = join(personalVaultDir(userId, vault), "mounts", "home", ".ssh")
951
+ const vaultKey = join(sshDir, "id_ed25519")
952
+ const vaultConfig = join(sshDir, "config")
951
953
  // git can't persist 0600 — it only tracks the exec bit — so a fresh checkout
952
954
  // of the git-crypt vault lands the key at the umask default (0664 under a 002
953
955
  // umask), and ssh refuses it ("permissions too open"). Force 0600 at point of
954
956
  // use: this fixes every host-side git op AND the file the sandbox bind-mounts
955
957
  // into $HOME, regardless of how/when it was checked out. Cheap + idempotent.
956
958
  try { chmodSync(vaultKey, 0o600) } catch {}
957
- return `ssh -i ${vaultKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`
959
+ const f = existsSyncBase(vaultConfig) ? `-F ${vaultConfig} ` : ""
960
+ return `ssh ${f}-i ${vaultKey}`
958
961
  }
959
962
 
960
963
  /**
@@ -1795,7 +1798,7 @@ export async function promoteKnowledgeConfig(user: string): Promise<RepoSyncResu
1795
1798
  * The user's per-vault SSH public keys — the keys a loop authenticates to TEAM
1796
1799
  * repos with (knowledge / notes / repos), one per vault. This is what the user
1797
1800
  * must register on the team git host. Distinct from the deploy key (host-
1798
- * secrets, personal-repo only). Reads vaults/<v>/mounts/home/.ssh/id.pub, or
1801
+ * secrets, personal-repo only). Reads vaults/<v>/mounts/home/.ssh/id_ed25519.pub, or
1799
1802
  * derives it from the private key.
1800
1803
  */
1801
1804
  export async function listVaultPublicKeys(user: string): Promise<{ vault: string; publicKey: string }[]> {
@@ -1803,8 +1806,8 @@ export async function listVaultPublicKeys(user: string): Promise<{ vault: string
1803
1806
  const out: { vault: string; publicKey: string }[] = []
1804
1807
  for (const vault of listVaults(user)) {
1805
1808
  const sshDir = join(personalVaultDir(user, vault), "mounts", "home", ".ssh")
1806
- const pubPath = join(sshDir, "id.pub")
1807
- const keyPath = join(sshDir, "id")
1809
+ const pubPath = join(sshDir, "id_ed25519.pub")
1810
+ const keyPath = join(sshDir, "id_ed25519")
1808
1811
  let pub = ""
1809
1812
  if (existsSyncBase(pubPath)) {
1810
1813
  pub = (await readFile(pubPath, "utf8")).trim()
@@ -1932,7 +1935,7 @@ async function unlockWithCryptKey(
1932
1935
  // The just-decrypted vault ssh key lands at the umask default (often 0664);
1933
1936
  // git can't carry 0600, so lock it down now — before the sandbox bind-mounts
1934
1937
  // it into $HOME — so both host git ops and in-container ssh accept it.
1935
- await chmod(join(repoDir, ".loopat", "vaults", "default", "mounts", "home", ".ssh", "id"), 0o600).catch(() => {})
1938
+ await chmod(join(repoDir, ".loopat", "vaults", "default", "mounts", "home", ".ssh", "id_ed25519"), 0o600).catch(() => {})
1936
1939
  return { ok: true }
1937
1940
  } catch (e: any) {
1938
1941
  if (await gitCryptKeyExists(userId)) {
@@ -52,6 +52,7 @@ import {
52
52
  personalKnowledgeDir,
53
53
  personalNotesDir,
54
54
  personalReposDir,
55
+ personalRepoCacheDir,
55
56
  LOOPAT_INSTALL_DIR,
56
57
  loopHomeUpper,
57
58
  workspaceHomeSkelDir,
@@ -128,6 +129,11 @@ export type ContainerOptions = {
128
129
  vaultName?: string
129
130
  knowledgeRw?: boolean
130
131
  mountAllLoops?: boolean
132
+ /** Source roster repo for this loop's workdir (meta.repo). The workdir is a
133
+ * `git worktree add` off this repo's bare mirror (repo-cache/<repo>), so its
134
+ * .git gitdir points into that mirror. When set, the mirror is bind-mounted
135
+ * at its host path (src=dst, rw) so the worktree resolves inside the sandbox. */
136
+ repo?: string
131
137
  /** Extra env vars to pre-bake into the container at create time. */
132
138
  extraEnv?: Record<string, string>
133
139
  /** Image to create the container from. Defaults to SANDBOX_IMAGE.
@@ -190,7 +196,7 @@ export type VolumeMount = {
190
196
  */
191
197
  export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeMount[]> {
192
198
  const hostHome = homedir()
193
- const { loopId, createdBy, vaultName, knowledgeRw, mountAllLoops } = opts
199
+ const { loopId, createdBy, vaultName, knowledgeRw, mountAllLoops, repo } = opts
194
200
  const virtualHome = V_HOME(createdBy)
195
201
  const mounts: VolumeMount[] = []
196
202
 
@@ -204,6 +210,17 @@ export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeM
204
210
 
205
211
  // Virtual mount points for AI / user:
206
212
  mounts.push({ src: loopWorkdir(loopId), dst: V_LOOP_WORKDIR(loopId) })
213
+
214
+ // Workdir built from a roster repo is a `git worktree add` off the repo's
215
+ // bare mirror (repo-cache/<repo>); the workdir's .git is a gitdir pointer INTO
216
+ // that mirror. Bind the mirror at its host path (src=dst) so the worktree's
217
+ // objects/refs/worktree-metadata resolve inside the sandbox — otherwise
218
+ // `git status` in the workdir is "fatal: not a git repository". rw because git
219
+ // writes the worktree's index/HEAD/logs under <mirror>/worktrees/<wt>/.
220
+ if (repo) {
221
+ const cache = personalRepoCacheDir(createdBy, repo)
222
+ mounts.push({ src: cache, dst: cache })
223
+ }
207
224
  mounts.push({ src: loopClaudeDir(loopId), dst: V_LOOP_CLAUDE(loopId) })
208
225
  mounts.push({
209
226
  src: loopContextKnowledge(loopId),
@@ -499,6 +499,7 @@ class LoopSession {
499
499
  vaultName: meta.config?.vault,
500
500
  knowledgeRw: meta.config?.knowledge_rw,
501
501
  mountAllLoops: meta.config?.mount_all_loops,
502
+ repo: meta.repo,
502
503
  extraEnv,
503
504
  ephemeralPorts: loopEphemeralPorts(meta),
504
505
  }, {
@@ -67,6 +67,7 @@ async function getOrSpawn(loopId: string, initCols = 80, initRows = 24): Promise
67
67
  vaultName: meta.config?.vault,
68
68
  knowledgeRw: meta.config?.knowledge_rw,
69
69
  mountAllLoops: meta.config?.mount_all_loops,
70
+ repo: meta.repo,
70
71
  extraEnv: personalCfg.vaultEnvs,
71
72
  ephemeralPorts: loopEphemeralPorts(meta),
72
73
  }, {