loopat 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  > **Self-hosted AI workspace built around context management — works solo, scales to teams**
4
4
 
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/loopat"><img src="https://img.shields.io/npm/v/loopat?logo=npm&color=cb3837" alt="npm version"></a>
7
+ <a href="https://github.com/simpx/loopat/pkgs/container/loopat"><img src="https://img.shields.io/badge/ghcr.io-simpx%2Floopat-2496ED?logo=docker&logoColor=white" alt="GHCR image"></a>
8
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-green" alt="License"></a>
9
+ </p>
10
+
5
11
  <p align="center">
6
12
  <img src="docs/screenshot.png" alt="loopat — Loop view with chat, workdir, terminal, and team DM" width="100%">
7
13
  </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopat",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -210,6 +210,11 @@ export type PersonalConfigDisk = {
210
210
  shell?: string
211
211
  /** Optional. Missing = "fresh" (user hasn't started or dismissed yet). */
212
212
  onboarding?: OnboardingState
213
+ /** Authoritative kn/notes remotes — the personal repo is self-describing.
214
+ * host config.json's knowledge/notes are a display mirror; these are what a
215
+ * loop actually connects to (with the user's vault key). */
216
+ knowledge?: RemoteSpec
217
+ notes?: RemoteSpec
213
218
  }
214
219
 
215
220
  export type PersonalConfig = {
@@ -224,6 +229,9 @@ export type PersonalConfig = {
224
229
  vaultEnvs: Record<string, string>
225
230
  shell?: string
226
231
  onboarding?: OnboardingState
232
+ /** Authoritative kn/notes remotes (self-describing personal repo). */
233
+ knowledge?: RemoteSpec
234
+ notes?: RemoteSpec
227
235
  }
228
236
 
229
237
  /**
@@ -443,6 +451,8 @@ export async function loadPersonalConfig(
443
451
  vaultEnvs,
444
452
  ...(disk.shell ? { shell: disk.shell } : {}),
445
453
  ...(disk.onboarding ? { onboarding: disk.onboarding } : {}),
454
+ ...(disk.knowledge ? { knowledge: disk.knowledge } : {}),
455
+ ...(disk.notes ? { notes: disk.notes } : {}),
446
456
  }
447
457
  personalCache.set(cacheKey, { cfg, configMtimeMs, envsDirMtimeMs })
448
458
  return cfg
@@ -1416,23 +1416,23 @@ function syncDirFor(resource: string, name?: string): string | null {
1416
1416
  return null
1417
1417
  }
1418
1418
 
1419
- app.get("/api/sync/knowledge/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceKnowledgeDir())))
1419
+ app.get("/api/sync/knowledge/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceKnowledgeDir(), c.get("userId") as string)))
1420
1420
  app.post("/api/sync/knowledge/pull", requireAuth, async (c) => {
1421
- const r = await pullRepoFromRemote(workspaceKnowledgeDir())
1421
+ const r = await pullRepoFromRemote(workspaceKnowledgeDir(), c.get("userId") as string)
1422
1422
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1423
1423
  })
1424
1424
  app.post("/api/sync/knowledge/push", requireAuth, async (c) => {
1425
- const r = await pushRepoToRemote(workspaceKnowledgeDir())
1425
+ const r = await pushRepoToRemote(workspaceKnowledgeDir(), c.get("userId") as string)
1426
1426
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1427
1427
  })
1428
1428
 
1429
- app.get("/api/sync/notes/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceNotesDir())))
1429
+ app.get("/api/sync/notes/status", requireAuth, async (c) => c.json(await inspectRepoSync(workspaceNotesDir(), c.get("userId") as string)))
1430
1430
  app.post("/api/sync/notes/pull", requireAuth, async (c) => {
1431
- const r = await pullRepoFromRemote(workspaceNotesDir())
1431
+ const r = await pullRepoFromRemote(workspaceNotesDir(), c.get("userId") as string)
1432
1432
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1433
1433
  })
1434
1434
  app.post("/api/sync/notes/push", requireAuth, async (c) => {
1435
- const r = await pushRepoToRemote(workspaceNotesDir())
1435
+ const r = await pushRepoToRemote(workspaceNotesDir(), c.get("userId") as string)
1436
1436
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1437
1437
  })
1438
1438
 
@@ -1454,12 +1454,12 @@ app.get("/api/sync/repos", requireAuth, async (c) => {
1454
1454
  app.get("/api/sync/repos/:name/status", requireAuth, async (c) => {
1455
1455
  const dir = syncDirFor("repos", c.req.param("name"))
1456
1456
  if (!dir) return c.json({ error: "repo not found" }, 404)
1457
- return c.json(await inspectRepoSync(dir))
1457
+ return c.json(await inspectRepoSync(dir, c.get("userId") as string))
1458
1458
  })
1459
1459
  app.post("/api/sync/repos/:name/pull", requireAuth, async (c) => {
1460
1460
  const dir = syncDirFor("repos", c.req.param("name"))
1461
1461
  if (!dir) return c.json({ error: "repo not found" }, 404)
1462
- const r = await pullRepoFromRemote(dir)
1462
+ const r = await pullRepoFromRemote(dir, c.get("userId") as string)
1463
1463
  return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
1464
1464
  })
1465
1465
 
@@ -24,6 +24,7 @@ import {
24
24
  workspaceOriginsDir,
25
25
  workspaceOriginPath,
26
26
  personalDir,
27
+ personalVaultDir,
27
28
  uiNotesDir,
28
29
  personalMemoryDir,
29
30
  workspaceMemoryDir,
@@ -35,7 +36,7 @@ import {
35
36
  } from "./paths"
36
37
  import type { RepoSpec } from "./config"
37
38
  import { existsSync as existsSyncBase } from "node:fs"
38
- import { loadConfig } from "./config"
39
+ import { loadConfig, loadPersonalConfig } from "./config"
39
40
  import { ensurePersonalKeypair } from "./personal-keys"
40
41
  import { composeLoopClaudeConfig, writeLoopSettings } from "./compose"
41
42
  import { getProvider } from "./git-host"
@@ -316,6 +317,39 @@ async function ensureContextRepo(dir: string, name: string, url?: string): Promi
316
317
  }
317
318
  }
318
319
 
320
+ /**
321
+ * The personal repo is self-describing: its `.loopat/config.json` declares the
322
+ * authoritative kn/notes remotes, and a loop connects to them with the user's
323
+ * OWN key from the selected vault (`vaults/<vault>/mounts/home/.ssh/id`), not
324
+ * the host's ssh. Called at loop creation, which has the user + vault in hand.
325
+ *
326
+ * The startup clone (driven by host config.json) stays as a display mirror;
327
+ * here the personal-declared url wins and becomes the context repo's origin.
328
+ *
329
+ * We only set the origin + fetch with the vault key — we deliberately do NOT
330
+ * persist a `core.sshCommand`: the same `.git` is mounted into the sandbox,
331
+ * where that host path is invalid. The sandbox's own git already authenticates
332
+ * with the vault key via `$HOME/.ssh` (the vault home-mount), so its promote
333
+ * pushes as the user without any per-repo config.
334
+ */
335
+ export async function ensureUserContext(user: string, vault: string = "default"): Promise<void> {
336
+ const cfg = await loadPersonalConfig(user, vault)
337
+ const sshEnv = { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user, vault) }
338
+ const layers: Array<[string, string | undefined]> = [
339
+ [workspaceKnowledgeDir(), cfg.knowledge?.git],
340
+ [workspaceNotesDir(), cfg.notes?.git],
341
+ ]
342
+ for (const [dir, url] of layers) {
343
+ if (!url) continue // personal didn't declare one → keep the host/local origin
344
+ if (!existsSyncBase(join(dir, ".git"))) continue // not initialized yet (startup hasn't run)
345
+ const has = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false)
346
+ await execFileP("git", ["-C", dir, "remote", has ? "set-url" : "add", "origin", url]).catch(() => {})
347
+ // validate connectivity + populate origin/* with the user's key (transient,
348
+ // server-side only).
349
+ await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 20_000 }).catch(() => {})
350
+ }
351
+ }
352
+
319
353
  /**
320
354
  * Repos are clone-on-demand — they can be large, so we don't pre-clone the
321
355
  * whole set. Instead write a manifest (REPOS.md) listing the full roster, and
@@ -340,7 +374,7 @@ async function writeReposManifest(specs: RepoSpec[]) {
340
374
  * Clone a single registered repo if it isn't present yet. Returns whether the
341
375
  * repo dir exists afterwards. Used by loop creation and any on-demand path.
342
376
  */
343
- async function ensureRepoCloned(name: string): Promise<boolean> {
377
+ async function ensureRepoCloned(name: string, sshCommand?: string): Promise<boolean> {
344
378
  const dir = workspaceRepoDir(name)
345
379
  if (existsSyncBase(dir)) return true
346
380
  const cfg = await loadConfig()
@@ -348,7 +382,8 @@ async function ensureRepoCloned(name: string): Promise<boolean> {
348
382
  if (!spec?.git) return false
349
383
  try {
350
384
  await mkdir(workspaceReposDir(), { recursive: true })
351
- await execFileP("git", ["clone", "--", spec.git, dir])
385
+ const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env
386
+ await execFileP("git", ["clone", "--", spec.git, dir], { env })
352
387
  console.log(`[loopat] cloned on demand ${spec.git} → ${dir}`)
353
388
  return true
354
389
  } catch (e: any) {
@@ -639,7 +674,9 @@ export async function importPersonalFromRepo(
639
674
  // Clone into a tmp dir. ssh uses the deploy key (StrictHostKeyChecking=
640
675
  // accept-new, no pre-populated known_hosts on first run); https auths via url.
641
676
  const tmp = await mkdtemp(join(tmpdir(), `loopat-import-${userId}-`))
642
- const cloneEnv = isHttps ? { ...process.env } : { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(userId) }
677
+ // Bootstrap: first clone of the personal repo uses the host deploy-key (no
678
+ // vault key exists yet). Every later op uses the user's vault key.
679
+ const cloneEnv = isHttps ? { ...process.env } : { ...process.env, GIT_SSH_COMMAND: bootstrapSshCommand(userId) }
643
680
  try {
644
681
  await execFileP("git", ["clone", "--", repoUrl, tmp], { env: cloneEnv })
645
682
  } catch (e: any) {
@@ -714,9 +751,25 @@ export async function importPersonalFromRepo(
714
751
  return { ok: true, autoInitialized: true, cryptKey: init.cryptKey }
715
752
  }
716
753
 
717
- function sshCommandForUser(userId: string): string {
718
- const priv = hostDeployKeyPath(userId)
719
- return `ssh -i ${priv} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`
754
+ /**
755
+ * The ssh command a server-side git op uses to act AS the user — its OWN key
756
+ * from the selected vault (`vaults/<vault>/mounts/home/.ssh/id`). If the key
757
+ * isn't there the op simply fails: we deliberately do NOT fall back to the host
758
+ * deploy-key, so a loop never borrows the host's access. Authorization tracks
759
+ * the personal repo, not the host (see behavior/02-personal-permissions.md).
760
+ */
761
+ function sshCommandForUser(userId: string, vault: string = "default"): string {
762
+ const vaultKey = join(personalVaultDir(userId, vault), "mounts", "home", ".ssh", "id")
763
+ return `ssh -i ${vaultKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`
764
+ }
765
+
766
+ /**
767
+ * Bootstrap ONLY: the first clone/decrypt of the personal repo itself, before
768
+ * any vault key exists. Uses the host-managed deploy-key — the one credential
769
+ * the host legitimately holds to unlock a personal repo.
770
+ */
771
+ function bootstrapSshCommand(userId: string): string {
772
+ return `ssh -i ${hostDeployKeyPath(userId)} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`
720
773
  }
721
774
 
722
775
  async function swapPersonalDir(
@@ -1273,13 +1326,16 @@ export async function syncUiNotes(user: string): Promise<PersonalPushResult> {
1273
1326
  const branch = await remoteDefaultBranch(dir)
1274
1327
  const c = await commitLocalChanges(dir, "loopat: edit notes")
1275
1328
  if (!c.ok) return { ok: false, error: c.error }
1276
- const reb = await rebaseOntoOrigin(dir, branch)
1329
+ const userSsh = sshCommandForUser(user)
1330
+ const reb = await rebaseOntoOrigin(dir, branch, userSsh)
1277
1331
  if (!reb.ok) {
1278
1332
  if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files }
1279
1333
  return { ok: false, error: reb.error }
1280
1334
  }
1281
1335
  try {
1282
- await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`])
1336
+ await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], {
1337
+ env: { ...process.env, GIT_SSH_COMMAND: userSsh },
1338
+ })
1283
1339
  } catch (e: any) {
1284
1340
  const stderr = (e?.stderr ?? "").toString().trim()
1285
1341
  return { ok: false, error: `push failed: ${stderr || e?.message || e}`, needsPull: true }
@@ -1300,7 +1356,7 @@ export async function ffUpdateUiNotes(
1300
1356
  const branch = await remoteDefaultBranch(dir)
1301
1357
  try {
1302
1358
  await execFileP("git", ["-C", dir, "fetch", "origin"], {
1303
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, timeout: 30_000,
1359
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: sshCommandForUser(user) }, timeout: 30_000,
1304
1360
  })
1305
1361
  } catch (e: any) {
1306
1362
  return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` }
@@ -1332,7 +1388,7 @@ export async function notesBehind(user: string): Promise<number> {
1332
1388
  const branch = await remoteDefaultBranch(dir)
1333
1389
  try {
1334
1390
  await execFileP("git", ["-C", dir, "fetch", "origin"], {
1335
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, timeout: 30_000,
1391
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: sshCommandForUser(user) }, timeout: 30_000,
1336
1392
  })
1337
1393
  } catch {
1338
1394
  return 0
@@ -1371,7 +1427,7 @@ export type RepoSyncResult =
1371
1427
  * failures are tolerated (offline / auth glitch) — status still reflects
1372
1428
  * last-known remote state.
1373
1429
  */
1374
- export async function inspectRepoSync(dir: string): Promise<RepoSyncStatus> {
1430
+ export async function inspectRepoSync(dir: string, user?: string): Promise<RepoSyncStatus> {
1375
1431
  if (!existsSyncBase(dir) || !existsSyncBase(join(dir, ".git"))) {
1376
1432
  return { isGitRepo: false, hasRemote: false, branch: "", ahead: 0, behind: 0, uncommitted: 0 }
1377
1433
  }
@@ -1390,7 +1446,8 @@ export async function inspectRepoSync(dir: string): Promise<RepoSyncStatus> {
1390
1446
 
1391
1447
  if (hasRemote) {
1392
1448
  try {
1393
- await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { timeout: 15_000 })
1449
+ const env = user ? { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user) } : process.env
1450
+ await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env, timeout: 15_000 })
1394
1451
  } catch {}
1395
1452
  }
1396
1453
 
@@ -1420,7 +1477,7 @@ export async function inspectRepoSync(dir: string): Promise<RepoSyncStatus> {
1420
1477
  * changes (we don't auto-stash workspace repos — caller decides) and on
1421
1478
  * any non-ff condition.
1422
1479
  */
1423
- export async function pullRepoFromRemote(dir: string): Promise<RepoSyncResult> {
1480
+ export async function pullRepoFromRemote(dir: string, user?: string): Promise<RepoSyncResult> {
1424
1481
  if (!existsSyncBase(join(dir, ".git"))) {
1425
1482
  return { ok: false, error: "not a git repo" }
1426
1483
  }
@@ -1449,7 +1506,8 @@ export async function pullRepoFromRemote(dir: string): Promise<RepoSyncResult> {
1449
1506
  }
1450
1507
 
1451
1508
  try {
1452
- await execFileP("git", ["-C", dir, "fetch", "origin"], { timeout: 30_000 })
1509
+ const env = user ? { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user) } : process.env
1510
+ await execFileP("git", ["-C", dir, "fetch", "origin"], { env, timeout: 30_000 })
1453
1511
  } catch (e: any) {
1454
1512
  return { ok: false, error: `fetch failed: ${e?.stderr ?? e?.message ?? e}` }
1455
1513
  }
@@ -1469,7 +1527,7 @@ export async function pullRepoFromRemote(dir: string): Promise<RepoSyncResult> {
1469
1527
  * non-ff by default, which is exactly the abort-on-conflict behavior we
1470
1528
  * want. Caller pulls first if rejected.
1471
1529
  */
1472
- export async function pushRepoToRemote(dir: string): Promise<RepoSyncResult> {
1530
+ export async function pushRepoToRemote(dir: string, user?: string): Promise<RepoSyncResult> {
1473
1531
  if (!existsSyncBase(join(dir, ".git"))) {
1474
1532
  return { ok: false, error: "not a git repo" }
1475
1533
  }
@@ -1489,7 +1547,8 @@ export async function pushRepoToRemote(dir: string): Promise<RepoSyncResult> {
1489
1547
  if (!branch) return { ok: false, error: "HEAD is detached" }
1490
1548
 
1491
1549
  try {
1492
- await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`])
1550
+ const env = user ? { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user) } : process.env
1551
+ await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`], { env })
1493
1552
  } catch (e: any) {
1494
1553
  const stderr = (e?.stderr ?? "").toString().trim()
1495
1554
  return { ok: false, error: `push failed: ${stderr || e?.message || e}` }
@@ -1691,14 +1750,15 @@ async function remoteDefaultBranch(dir: string): Promise<string> {
1691
1750
  return "main"
1692
1751
  }
1693
1752
 
1694
- async function remoteStartPoint(repo: string): Promise<string | null> {
1753
+ async function remoteStartPoint(repo: string, sshCommand?: string): Promise<string | null> {
1695
1754
  try {
1696
1755
  await execFileP("git", ["-C", repo, "remote", "get-url", "origin"])
1697
1756
  } catch {
1698
1757
  return null
1699
1758
  }
1700
1759
  try {
1701
- await execFileP("git", ["-C", repo, "fetch", "--quiet", "origin"], { timeout: 15_000 })
1760
+ const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env
1761
+ await execFileP("git", ["-C", repo, "fetch", "--quiet", "origin"], { env, timeout: 15_000 })
1702
1762
  } catch {}
1703
1763
  const branch = await remoteDefaultBranch(repo)
1704
1764
  try {
@@ -1794,8 +1854,10 @@ export async function createLoop(opts: {
1794
1854
 
1795
1855
  // workdir = git worktree add (if repo selected) OR plain mkdir
1796
1856
  if (opts.repo) {
1857
+ // clone + fetch as the user (their vault key), not the host's ssh.
1858
+ const userSsh = sshCommandForUser(opts.createdBy, opts.vault ?? "default")
1797
1859
  // clone-on-demand: pull the repo down only now that a loop actually needs it
1798
- if (!(await ensureRepoCloned(opts.repo))) {
1860
+ if (!(await ensureRepoCloned(opts.repo, userSsh))) {
1799
1861
  throw new Error(`repo "${opts.repo}" not found / clone failed`)
1800
1862
  }
1801
1863
  const repoPath = workspaceRepoDir(opts.repo)
@@ -1804,7 +1866,7 @@ export async function createLoop(opts: {
1804
1866
  // ① pull (docs/context-flow.md): base the workdir branch on origin/main
1805
1867
  // (best-effort fetch) so it starts from latest consensus; fall back to
1806
1868
  // local HEAD when there's no remote / no origin/main.
1807
- const start = await remoteStartPoint(repoPath)
1869
+ const start = await remoteStartPoint(repoPath, userSsh)
1808
1870
  const wtArgs = ["-C", repoPath, "worktree", "add", "-b", branch, loopWorkdir(id)]
1809
1871
  if (start) wtArgs.push(start)
1810
1872
  await execFileP("git", wtArgs)
@@ -1819,6 +1881,11 @@ export async function createLoop(opts: {
1819
1881
  await mkdir(loopWorkdir(id), { recursive: true })
1820
1882
  }
1821
1883
 
1884
+ // Point the context repos at the personal-declared kn/notes remotes and
1885
+ // connect with the user's vault key (personal repo is self-describing).
1886
+ await ensureUserContext(opts.createdBy, opts.vault ?? "default").catch(
1887
+ (e: any) => console.warn(`[loopat] ensureUserContext(${opts.createdBy}): ${e?.message ?? e}`),
1888
+ )
1822
1889
  await ensureContextMounts(id, effectiveDriver(meta))
1823
1890
  await writeFile(loopMetaPath(id), JSON.stringify(meta, null, 2))
1824
1891
  return meta
@@ -100,7 +100,12 @@ const LABEL_CONFIG_HASH = "loopat.config-hash"
100
100
 
101
101
  // Image used as the base for every loop container. Built locally from
102
102
  // server/templates/sandbox/Containerfile via ensureSandboxImage().
103
- export const SANDBOX_IMAGE = process.env.LOOPAT_SANDBOX_IMAGE || "loopat-sandbox:latest"
103
+ // Per-workspace image name so multiple LOOPAT_HOMEs on one host don't share
104
+ // (and can't accidentally delete) each other's images. `uninstall` finds them
105
+ // by the loopat.workspace label, not this name — the name only prevents tag
106
+ // collisions. Same-Containerfile builds still share overlay layers, so the
107
+ // per-workspace tags don't multiply disk usage.
108
+ export const SANDBOX_IMAGE = process.env.LOOPAT_SANDBOX_IMAGE || `loopat-sandbox-${WORKSPACE}:latest`
104
109
 
105
110
  // Container name: prefix with workspace to avoid collisions between loopat
106
111
  // instances running on the same host with different LOOPAT_HOME. Loop UUIDs
@@ -369,7 +374,7 @@ export async function buildPodmanCreateArgs(opts: ContainerOptions): Promise<str
369
374
  "--device", "/dev/fuse",
370
375
  // Shared bridge network so the serve container can reach loop
371
376
  // containers by name (aardvark-dns). Outbound API calls via NAT.
372
- "--network", "loopat",
377
+ "--network", LOOPAT_NETWORK,
373
378
  "--hostname", `loop-${opts.loopId.slice(0, 8)}`,
374
379
  // Container cwd at creation; per-exec we override with -w.
375
380
  "--workdir", V_LOOP_WORKDIR(opts.loopId),
@@ -554,7 +559,7 @@ export async function ensureSandboxImage(opts?: { onProgress?: (msg: string) =>
554
559
 
555
560
  // Hash the Containerfile so the base image auto-rebuilds when it changes.
556
561
  const hash = await baseContainerfileHash()
557
- const hashTag = `loopat-sandbox-${hash}:latest`
562
+ const hashTag = `loopat-sandbox-${WORKSPACE}-${hash}:latest`
558
563
 
559
564
  const present = await runPodman(["image", "exists", hashTag], { allowFail: true })
560
565
  if (present.code === 0) {
@@ -571,7 +576,7 @@ export async function ensureSandboxImage(opts?: { onProgress?: (msg: string) =>
571
576
  const buildDir = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox")
572
577
  let lastStep = ""
573
578
  const r = await runPodman(
574
- ["build", "-t", SANDBOX_IMAGE, "-t", hashTag, "-f", containerfile, buildDir],
579
+ ["build", "-t", SANDBOX_IMAGE, "-t", hashTag, "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, "-f", containerfile, buildDir],
575
580
  {
576
581
  onLine: (line) => {
577
582
  const m = line.match(/^STEP\s+(\d+)\/(\d+):\s+(.+)/)
@@ -660,7 +665,7 @@ export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg
660
665
  // after the nested-podman base change shipped).
661
666
  const baseHash = await baseContainerfileHash()
662
667
  const hash = createHash("sha256").update(`base:${baseHash}\n`).update(content).digest("hex").slice(0, 16)
663
- const tag = `loopat-sandbox-${hash}:latest`
668
+ const tag = `loopat-sandbox-${WORKSPACE}-${hash}:latest`
664
669
 
665
670
  const existing = _loopImageInFlight.get(tag)
666
671
  if (existing) return existing
@@ -717,7 +722,7 @@ export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg
717
722
  await writeFile(join(buildDir, "Containerfile"), childContainerfile)
718
723
 
719
724
  const r = await runPodman(
720
- ["build", "-t", tag, "-f", join(buildDir, "Containerfile"), buildDir],
725
+ ["build", "-t", tag, "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, "-f", join(buildDir, "Containerfile"), buildDir],
721
726
  {
722
727
  allowFail: true,
723
728
  onLine: (line) => {
@@ -823,7 +828,9 @@ export async function getEphemeralHostPort(
823
828
  return Number.isFinite(port) && port > 0 ? port : null
824
829
  }
825
830
 
826
- const LOOPAT_NETWORK = "loopat"
831
+ // Per-workspace network (+ loopat.workspace label) so parallel LOOPAT_HOMEs
832
+ // stay isolated and `uninstall` removes only its own.
833
+ const LOOPAT_NETWORK = `loopat-${WORKSPACE}`
827
834
  const SERVE_CONTAINER = `loopat-${WORKSPACE}-serve`
828
835
 
829
836
  let _networkReady = false
@@ -835,7 +842,7 @@ export async function ensureLoopatNetwork(): Promise<void> {
835
842
  const r = await runPodman(["network", "exists", LOOPAT_NETWORK], { allowFail: true })
836
843
  if (r.code !== 0) {
837
844
  console.log(`[podman] creating network ${LOOPAT_NETWORK}`)
838
- const create = await runPodman(["network", "create", LOOPAT_NETWORK])
845
+ const create = await runPodman(["network", "create", "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, LOOPAT_NETWORK])
839
846
  if (create.code !== 0) {
840
847
  throw new Error(`Failed to create podman network ${LOOPAT_NETWORK}: ${create.stderr}`)
841
848
  }
@@ -1,17 +1,15 @@
1
1
  /**
2
- * `loopat uninstall` — clean removal of everything loopat itself created.
2
+ * `loopat uninstall` — clean removal of everything THIS workspace created.
3
3
  *
4
- * Boundary (deliberate): we remove ONLY loopat's own resources the per-loop
5
- * sandbox containers, the sandbox images, the `loopat` podman network, and the
6
- * workspace data dir (LOOPAT_HOME). We do NOT touch shared infrastructure the
7
- * host may use for other things — the podman machine (a Linux VM on macOS) and
8
- * the npx/bun cache are only PRINTED as hints. Deleting a shared VM out from
9
- * under the user would be the opposite of a clean uninstall.
4
+ * Every loopat resource is workspace-scoped: containers, images, and the
5
+ * network all carry a `loopat.workspace=<ws>` label, and the data dir IS this
6
+ * workspace's LOOPAT_HOME. So uninstall removes only its own — even when other
7
+ * LOOPAT_HOMEs exist on the same host, there's no cross-workspace collateral.
10
8
  *
11
- * Containers are found by the `loopat.workspace` label (set at create time in
12
- * podman.ts), not by guessing name prefixes. Shared images/network are removed
13
- * only once no loopat container remains anywhere, so a second workspace on the
14
- * same host isn't collateral.
9
+ * Shared host infrastructure loopat merely uses the podman machine (a Linux
10
+ * VM on macOS) and the npx/bun cache is only PRINTED as a hint, never
11
+ * touched. Deleting a shared VM out from under the user would be the opposite
12
+ * of a clean uninstall.
15
13
  *
16
14
  * Run via the launcher: `npx loopat uninstall [--yes]`.
17
15
  */
@@ -25,12 +23,11 @@ import { LOOPAT_HOME, WORKSPACE } from "./paths"
25
23
 
26
24
  const execFileP = promisify(execFile)
27
25
 
28
- // Stable external contract values kept in sync with podman.ts. (These are the
29
- // network name, container label key, and image repo prefix; they essentially
30
- // never change, so a local copy avoids pulling the whole podman module in.)
26
+ // The label every loopat container/image/network carries (set at create/build
27
+ // time in podman.ts). Deleting by label is exact no name-prefix ambiguity
28
+ // (e.g. workspace "foo" vs "foobar").
31
29
  const LABEL_WORKSPACE = "loopat.workspace"
32
- const LOOPAT_NETWORK = "loopat"
33
- const IMAGE_REF = "loopat-sandbox" // base `loopat-sandbox:latest` + child `loopat-sandbox-<hash>:latest`
30
+ const labelFilter = `label=${LABEL_WORKSPACE}=${WORKSPACE}`
34
31
 
35
32
  type Run = { code: number; out: string; err: string }
36
33
  async function podman(args: string[]): Promise<Run> {
@@ -48,22 +45,18 @@ async function podmanAvailable(): Promise<boolean> {
48
45
  return (await podman(["--version"])).code === 0
49
46
  }
50
47
 
51
- /** Containers belonging to THIS workspace. */
52
48
  async function workspaceContainers(): Promise<string[]> {
53
- const r = await podman(["ps", "-aq", "--filter", `label=${LABEL_WORKSPACE}=${WORKSPACE}`])
49
+ const r = await podman(["ps", "-aq", "--filter", labelFilter])
54
50
  return r.code === 0 ? lines(r.out) : []
55
51
  }
56
-
57
- /** Any loopat container (any workspace) used to decide if shared resources are still in use. */
58
- async function anyLoopatContainers(): Promise<string[]> {
59
- const r = await podman(["ps", "-aq", "--filter", `label=${LABEL_WORKSPACE}`])
60
- return r.code === 0 ? lines(r.out) : []
61
- }
62
-
63
- async function loopatImageIds(): Promise<string[]> {
64
- const r = await podman(["images", "--filter", `reference=${IMAGE_REF}*`, "--format", "{{.ID}}"])
52
+ async function workspaceImageIds(): Promise<string[]> {
53
+ const r = await podman(["images", "--filter", labelFilter, "--format", "{{.ID}}"])
65
54
  return r.code === 0 ? [...new Set(lines(r.out))] : []
66
55
  }
56
+ async function workspaceNetworks(): Promise<string[]> {
57
+ const r = await podman(["network", "ls", "--filter", labelFilter, "--format", "{{.Name}}"])
58
+ return r.code === 0 ? lines(r.out) : []
59
+ }
67
60
 
68
61
  /** TTY confirm. Non-interactive (piped) without --yes → treated as "no". */
69
62
  function confirm(question: string): boolean {
@@ -75,62 +68,56 @@ export async function runUninstall(argv: string[]): Promise<void> {
75
68
  const yes = argv.includes("--yes") || argv.includes("-y")
76
69
  const hasPodman = await podmanAvailable()
77
70
  const containers = hasPodman ? await workspaceContainers() : []
71
+ const images = hasPodman ? await workspaceImageIds() : []
72
+ const networks = hasPodman ? await workspaceNetworks() : []
78
73
  const dataExists = existsSync(LOOPAT_HOME)
79
74
 
80
- // ── Plan (so the user sees the exact boundary before anything happens) ──
75
+ // ── Plan (the user sees the exact boundary before anything happens) ──
81
76
  console.log(`loopat uninstall — workspace "${WORKSPACE}"`)
82
77
  console.log("")
83
- console.log("Will remove:")
78
+ console.log("Will remove (this workspace only):")
84
79
  console.log(` • ${containers.length} sandbox container(s)`)
85
- console.log(` • sandbox images + the "${LOOPAT_NETWORK}" network (if no loopat container remains)`)
80
+ console.log(` • ${images.length} sandbox image(s)`)
81
+ console.log(` • ${networks.length} network(s)${networks.length ? ` (${networks.join(", ")})` : ""}`)
86
82
  console.log(` • data dir: ${LOOPAT_HOME}${dataExists ? "" : " (absent)"}`)
87
83
  if (!hasPodman) console.log(" • note: podman not found — skipping container/image/network cleanup")
88
84
  console.log("")
89
85
 
90
- if (!yes && !confirm("Proceed? This permanently deletes your workspace data. [y/N] ")) {
86
+ if (!yes && !confirm("Proceed? This permanently deletes this workspace's data. [y/N] ")) {
91
87
  console.log("Aborted — nothing removed.")
92
88
  return
93
89
  }
94
90
 
95
- // 1. Our containers (label-scoped).
91
+ // Every resource below is label-scoped to THIS workspace — no shared-resource
92
+ // guessing, so other workspaces on the host are never touched.
96
93
  if (hasPodman && containers.length) {
97
94
  process.stdout.write(`Removing ${containers.length} container(s)… `)
98
95
  await podman(["rm", "-f", ...containers])
99
96
  console.log("done")
100
97
  }
101
-
102
- // 2. Shared images + network only when no loopat container is left anywhere.
103
- if (hasPodman) {
104
- const remaining = await anyLoopatContainers()
105
- if (remaining.length === 0) {
106
- const imgs = await loopatImageIds()
107
- if (imgs.length) {
108
- process.stdout.write(`Removing ${imgs.length} image(s) `)
109
- await podman(["rmi", "-f", ...imgs])
110
- console.log("done")
111
- }
112
- if ((await podman(["network", "exists", LOOPAT_NETWORK])).code === 0) {
113
- process.stdout.write(`Removing network "${LOOPAT_NETWORK}"… `)
114
- await podman(["network", "rm", LOOPAT_NETWORK])
115
- console.log("done")
116
- }
117
- } else {
118
- console.log(`Keeping shared images/network — ${remaining.length} loopat container(s) from other workspaces still present.`)
119
- }
98
+ if (hasPodman && images.length) {
99
+ // rmi by image ID removes this workspace's tags; shared overlay layers
100
+ // stay alive (refcounted) for any other workspace still using them.
101
+ process.stdout.write(`Removing ${images.length} image(s)… `)
102
+ await podman(["rmi", "-f", ...images])
103
+ console.log("done")
104
+ }
105
+ for (const net of networks) {
106
+ process.stdout.write(`Removing network "${net}" `)
107
+ await podman(["network", "rm", net])
108
+ console.log("done")
120
109
  }
121
-
122
- // 3. Workspace data.
123
110
  if (dataExists) {
124
111
  process.stdout.write(`Removing data dir ${LOOPAT_HOME}… `)
125
112
  await rm(LOOPAT_HOME, { recursive: true, force: true })
126
113
  console.log("done")
127
114
  }
128
115
 
129
- // 4. Second-layer hints — shared infra we deliberately do NOT touch.
116
+ // Second-layer hints — shared infra we deliberately do NOT touch.
130
117
  console.log("")
131
- console.log("Done. loopat's own resources are gone.")
118
+ console.log("Done. This workspace's resources are gone.")
132
119
  console.log("")
133
- console.log("Left untouched (remove yourself only if loopat was their only user):")
120
+ console.log("Left untouched (shared host infra — remove yourself only if loopat was their only user):")
134
121
  if (process.platform === "darwin") {
135
122
  console.log(" • podman machine (Linux VM): podman machine stop && podman machine rm")
136
123
  }