loopat 0.1.2 → 0.1.3

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
@@ -131,6 +131,14 @@ Loopat splits configuration along role lines — read whichever applies:
131
131
 
132
132
  ### Docker (recommended)
133
133
 
134
+ Pull the prebuilt image from GHCR:
135
+
136
+ ```sh
137
+ docker run -d --privileged -p 20001:10001 ghcr.io/simpx/loopat:latest
138
+ ```
139
+
140
+ Or, to build from source and persist the workspace in a named volume:
141
+
134
142
  ```sh
135
143
  docker compose up -d
136
144
  ```
package/bin/loopat.mjs CHANGED
@@ -19,6 +19,17 @@ const here = dirname(fileURLToPath(import.meta.url))
19
19
  const pkgRoot = join(here, "..")
20
20
  const serverEntry = join(pkgRoot, "server", "src", "index.ts")
21
21
 
22
+ // Subcommand routing. `loopat uninstall` runs a standalone cleanup entry (never
23
+ // boots the server); everything else starts the server. Kept here in the Node
24
+ // shim so the uninstall path doesn't pull in any server-side init.
25
+ const SUBCOMMANDS = {
26
+ uninstall: join(pkgRoot, "server", "src", "uninstall.ts"),
27
+ }
28
+ const sub = process.argv[2]
29
+ const subEntry = Object.prototype.hasOwnProperty.call(SUBCOMMANDS, sub) ? SUBCOMMANDS[sub] : null
30
+ const entry = subEntry ?? serverEntry
31
+ const forwardArgs = subEntry ? process.argv.slice(3) : process.argv.slice(2)
32
+
22
33
  function resolveBun() {
23
34
  // 1. Explicit override.
24
35
  if (process.env.LOOPAT_BUN && existsSync(process.env.LOOPAT_BUN)) {
@@ -49,7 +60,7 @@ function resolveBun() {
49
60
  }
50
61
 
51
62
  const bun = resolveBun()
52
- const child = spawn(bun, [serverEntry, ...process.argv.slice(2)], {
63
+ const child = spawn(bun, [entry, ...forwardArgs], {
53
64
  stdio: "inherit",
54
65
  env: process.env,
55
66
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopat",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
@@ -22,16 +22,33 @@ import { listUsers } from "./auth"
22
22
  type Check = { ok: boolean; label: string; hint?: string }
23
23
 
24
24
  function checkPodman(): Check {
25
+ const isMac = process.platform === "darwin"
26
+ let version: string
25
27
  try {
26
- const out = execFileSync("podman", ["--version"], { stdio: "pipe" }).toString().trim()
27
- return { ok: true, label: `podman (sandbox): ${out}` }
28
+ version = execFileSync("podman", ["--version"], { stdio: "pipe" }).toString().trim()
28
29
  } catch {
29
30
  return {
30
31
  ok: false,
31
32
  label: "podman (sandbox)",
32
- hint: "install with: sudo apt install podman uidmap fuse-overlayfs (Linux only)",
33
+ hint: isMac
34
+ ? "brew install podman, then: podman machine init && podman machine start"
35
+ : "sudo apt install podman uidmap fuse-overlayfs (Linux)",
33
36
  }
34
37
  }
38
+ // On macOS podman runs inside a Linux VM ("machine"). `--version` succeeds even
39
+ // when the machine is stopped — `podman info` is what actually needs the VM up.
40
+ if (isMac) {
41
+ try {
42
+ execFileSync("podman", ["info"], { stdio: "pipe", timeout: 8000 })
43
+ } catch {
44
+ return {
45
+ ok: false,
46
+ label: `podman (sandbox): ${version}`,
47
+ hint: "podman machine isn't running — start it: podman machine start (run `podman machine init` first if you never have)",
48
+ }
49
+ }
50
+ }
51
+ return { ok: true, label: `podman (sandbox): ${version}` }
35
52
  }
36
53
 
37
54
  function checkClaudeBinary(): Check {
@@ -47,6 +64,21 @@ function checkClaudeBinary(): Check {
47
64
  }
48
65
  }
49
66
 
67
+ function checkGitCrypt(): Check {
68
+ try {
69
+ const out = execFileSync("git-crypt", ["--version"], { stdio: "pipe" }).toString().trim()
70
+ return { ok: true, label: `git-crypt (personal vault): ${out}` }
71
+ } catch {
72
+ return {
73
+ ok: false,
74
+ label: "git-crypt (personal vault)",
75
+ hint: process.platform === "darwin"
76
+ ? "brew install git-crypt (encrypts your personal vault)"
77
+ : "sudo apt install git-crypt (encrypts your personal vault)",
78
+ }
79
+ }
80
+ }
81
+
50
82
 
51
83
  function describeRemote(dir: string, url: string | undefined): string {
52
84
  if (!existsSync(dir)) return "missing"
@@ -92,6 +124,7 @@ export async function printBootstrapBanner(cfg: WorkspaceConfig) {
92
124
  { ok: existsSync(configPath()), label: `config: ${configPath()}` },
93
125
  checkPodman(),
94
126
  checkClaudeBinary(),
127
+ checkGitCrypt(),
95
128
  ]
96
129
 
97
130
  const bar = "─".repeat(60)
@@ -0,0 +1,129 @@
1
+ /**
2
+ * host-cli proxy (POC). Some CLIs can only run on the host — macOS-only tools,
3
+ * or company tools bound to a specific machine. The sandbox can't run them, so
4
+ * we run them on the host *on behalf of* a loop:
5
+ *
6
+ * sandbox: `aone foo` → shim → loopat-host (forwarder) →
7
+ * server : POST /api/host-exec → execFile("aone", ["foo"]) on the host
8
+ *
9
+ * Trust model (deliberately simple): mounting the socket into a sandbox IS the
10
+ * trust decision — a host that turns on host-cli for a loop already trusts that
11
+ * loop, so there is NO whitelist. The loop may run any host cli (it can call the
12
+ * forwarder directly with a hand-built command).
13
+ *
14
+ * The mise-generated shims aren't a whitelist either — they're the declarative
15
+ * ENTRY POINT. "Which clis did the loop declare in `[host].clis`" shows up as
16
+ * "which shim binaries exist on PATH"; that's a UX convention (what the AI can
17
+ * conveniently reach), not a security boundary. The only boundary is whether
18
+ * the socket is mounted at all.
19
+ *
20
+ * - runs with HOST user permissions
21
+ * - cwd is a per-loop host workdir (mirrors the loop's workdir); the cli
22
+ * cannot see inside the sandbox
23
+ * - execFile, never a shell — argv is an array
24
+ */
25
+ import { execFile } from "node:child_process"
26
+ import { mkdir, writeFile, chmod } from "node:fs/promises"
27
+ import { existsSync, rmSync, mkdirSync } from "node:fs"
28
+ import { join } from "node:path"
29
+ import { loopDir, LOOPAT_HOME } from "./paths"
30
+
31
+ /** A per-loop host workdir — the host-side mirror of the loop's own workdir. */
32
+ export function hostWorkdir(loopId: string): string {
33
+ return join(loopDir(loopId), "host-workdir")
34
+ }
35
+
36
+ /** The dir that holds the host-exec unix socket. We mount the DIR (not the
37
+ * socket file) into sandboxes so a server restart — which recreates the
38
+ * socket inode — stays visible to already-running containers. */
39
+ export function hostExecDir(): string {
40
+ return join(LOOPAT_HOME, "host-exec")
41
+ }
42
+ export function hostExecSocketPath(): string {
43
+ return join(hostExecDir(), "host-exec.sock")
44
+ }
45
+
46
+ export type HostExecResult =
47
+ | { ok: true; exitCode: number; stdout: string; stderr: string }
48
+ | { ok: false; error: string }
49
+
50
+ /** Run a host-cli in the loop's host workdir, with host permissions. No
51
+ * whitelist — mounting the socket into the sandbox is the trust decision. */
52
+ export async function runHostCli(opts: {
53
+ cli: string
54
+ args: string[]
55
+ cwd: string
56
+ stdin?: string
57
+ timeoutMs?: number
58
+ }): Promise<HostExecResult> {
59
+ await mkdir(opts.cwd, { recursive: true })
60
+ return new Promise((resolve) => {
61
+ const child = execFile(
62
+ opts.cli,
63
+ opts.args,
64
+ { cwd: opts.cwd, timeout: opts.timeoutMs ?? 120_000, maxBuffer: 16 * 1024 * 1024 },
65
+ (err: any, stdout, stderr) => {
66
+ if (err && err.code === "ENOENT") {
67
+ resolve({ ok: false, error: `host has no '${opts.cli}'` })
68
+ return
69
+ }
70
+ const exitCode = typeof err?.code === "number" ? err.code : err ? 1 : 0
71
+ resolve({ ok: true, exitCode, stdout: String(stdout), stderr: String(stderr) })
72
+ },
73
+ )
74
+ if (opts.stdin !== undefined && child.stdin) {
75
+ child.stdin.write(opts.stdin)
76
+ child.stdin.end()
77
+ }
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Write a shim per declared host-cli into `binDir` (which the sandbox puts on
83
+ * PATH ahead of everything). Each shim just hands off to the forwarder.
84
+ */
85
+ export async function writeHostShims(binDir: string, clis: string[]): Promise<void> {
86
+ await mkdir(binDir, { recursive: true })
87
+ // The `loopat-host` forwarder is baked into the sandbox image; here we only
88
+ // emit the per-cli shims — each just hands off to it.
89
+ for (const cli of clis) {
90
+ const p = join(binDir, cli)
91
+ await writeFile(p, `#!/bin/sh\n# loopat host-cli shim — forwards "${cli}" to the host\nexec loopat-host "${cli}" "$@"\n`)
92
+ await chmod(p, 0o755)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Unix-socket server that runs host-clis for loops. The sandbox's
98
+ * forwarder connects over the *mounted* socket — no TCP, no exposed port, and
99
+ * only a container that has the socket mounted can reach it (the mount itself
100
+ * is a layer of isolation). Reuses runHostCli. stdout/stderr come back base64
101
+ * so the sh forwarder can pull them out of the JSON without escaping pain.
102
+ */
103
+ export function serveHostExec(socketPath: string, deps: { loopExists: (id: string) => Promise<boolean> }) {
104
+ try { mkdirSync(hostExecDir(), { recursive: true }) } catch {}
105
+ try { if (existsSync(socketPath)) rmSync(socketPath) } catch {}
106
+ return Bun.serve({
107
+ unix: socketPath,
108
+ async fetch(req) {
109
+ const url = new URL(req.url)
110
+ if (url.pathname !== "/host-exec" || req.method !== "POST") return new Response("not found", { status: 404 })
111
+ const b: any = await req.json().catch(() => ({}))
112
+ const loopId = typeof b.loopId === "string" ? b.loopId : ""
113
+ const cli = typeof b.cli === "string" ? b.cli : ""
114
+ const args = Array.isArray(b.args) ? b.args.map(String) : []
115
+ if (!loopId || !cli) return Response.json({ error: "loopId + cli required" })
116
+ if (!(await deps.loopExists(loopId))) return Response.json({ error: "unknown loop" })
117
+ const r = await runHostCli({
118
+ cli, args, cwd: hostWorkdir(loopId),
119
+ stdin: typeof b.stdin === "string" ? b.stdin : undefined,
120
+ })
121
+ if (!r.ok) return Response.json({ error: r.error })
122
+ return Response.json({
123
+ exitCode: r.exitCode,
124
+ stdout_b64: Buffer.from(r.stdout).toString("base64"),
125
+ stderr_b64: Buffer.from(r.stderr).toString("base64"),
126
+ })
127
+ },
128
+ })
129
+ }
@@ -55,6 +55,7 @@ import {
55
55
  import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, type ProviderConfig, type ModelEntry } from "./config"
56
56
  import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban"
57
57
  import { printBootstrapBanner } from "./bootstrap"
58
+ import { serveHostExec, hostExecSocketPath } from "./host-exec"
58
59
  import {
59
60
  createUser,
60
61
  findUser,
@@ -3177,6 +3178,16 @@ await printBootstrapBanner(cfg)
3177
3178
  const backfilled = await backfillAllMounts()
3178
3179
  if (backfilled > 0) console.log(`[loopat] backfilled context mounts on ${backfilled} loop(s)`)
3179
3180
 
3181
+ // host-cli proxy: a unix socket the loop sandboxes mount + forward to, so
3182
+ // host-only clis (macOS / machine-bound) run on the host on behalf of a loop.
3183
+ try {
3184
+ const sock = hostExecSocketPath()
3185
+ serveHostExec(sock, { loopExists })
3186
+ console.log(`[loopat] host-exec socket: ${sock}`)
3187
+ } catch (e: any) {
3188
+ console.warn(`[loopat] host-exec socket failed: ${e?.message ?? e}`)
3189
+ }
3190
+
3180
3191
  // Pull every imported personal repo from its remote on boot (best-effort).
3181
3192
  // personal is a per-user repo synced directly (not via loops), so a host that
3182
3193
  // was offline catches up here; settings edits then write-through on save.
@@ -36,7 +36,7 @@
36
36
  import { execFile, spawn } from "node:child_process"
37
37
  import { createHash } from "node:crypto"
38
38
  import { existsSync } from "node:fs"
39
- import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
39
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
40
40
  import { homedir, tmpdir } from "node:os"
41
41
  import { join } from "node:path"
42
42
  import { promisify } from "node:util"
@@ -59,6 +59,8 @@ import {
59
59
  } from "./paths"
60
60
  import { loadConfig } from "./config"
61
61
  import { DEFAULT_VAULT, listVaultHomeMounts } from "./vaults"
62
+ import { hostExecDir, writeHostShims } from "./host-exec"
63
+ import { parse as tomlParse, stringify as tomlStringify } from "smol-toml"
62
64
 
63
65
  const execFileP = promisify(execFile)
64
66
 
@@ -75,6 +77,13 @@ export const V_CONTEXT_PERSONAL_MEMORY = "/loopat/context/personal/memory"
75
77
  export const V_CONTEXT_REPOS = "/loopat/context/repos"
76
78
  export const V_CONTEXT_CHAT = "/loopat/context/chat"
77
79
 
80
+ // host-cli proxy: the dir holding the host-exec unix socket, mounted in so the
81
+ // loop's `loopat-host` forwarder can reach the host. Mount the DIR (not the
82
+ // socket file) so a server restart that recreates the socket inode stays
83
+ // visible inside running containers.
84
+ export const V_HOST_EXEC_DIR = "/loopat/host-exec"
85
+ export const V_HOST_EXEC_SOCK = "/loopat/host-exec/host-exec.sock"
86
+
78
87
  // $HOME inside the container. Deliberately NOT host's homedir — if we bound
79
88
  // host's $HOME at its real path, podman would auto-create parent dirs for
80
89
  // every nested bind (LOOPAT_HOME, LOOPAT_INSTALL_DIR, etc. all live under
@@ -247,6 +256,15 @@ export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeM
247
256
  mounts.push({ src: chatDir, dst: V_CONTEXT_CHAT, ro: true })
248
257
  }
249
258
 
259
+ // host-cli proxy: mount the host-exec socket dir so the loop's `loopat-host`
260
+ // forwarder can reach the host. Mounting the socket IS the trust decision —
261
+ // a loop with this mount may run any host cli (see host-exec.ts). The dir is
262
+ // created by serveHostExec at boot; mount if present (bind-try semantics).
263
+ const hostExec = hostExecDir()
264
+ if (existsSync(hostExec)) {
265
+ mounts.push({ src: hostExec, dst: V_HOST_EXEC_DIR })
266
+ }
267
+
250
268
  // All-loops ro view (admin-gated): expose LOOPAT_HOME/loops/ at /loopat/loops.
251
269
  if (mountAllLoops) {
252
270
  mounts.push({ src: loopsDir(), dst: V_ALL_LOOPS, ro: true })
@@ -292,6 +310,10 @@ export async function buildContainerEnv(opts: ContainerOptions): Promise<Record<
292
310
  const out: Record<string, string> = {}
293
311
  // Sandbox $HOME is /loopat/home/<user> (see V_HOME comment).
294
312
  out.HOME = V_HOME(opts.createdBy)
313
+ // host-cli proxy: tell the loop's `loopat-host` forwarder where the mounted
314
+ // socket is and which loop it speaks for (server uses it for the workdir).
315
+ out.LOOPAT_HOST_SOCK = V_HOST_EXEC_SOCK
316
+ out.LOOPAT_LOOP_ID = opts.loopId
295
317
  for (const [k, v] of Object.entries(opts.extraEnv ?? {})) {
296
318
  out[k] = v
297
319
  }
@@ -508,12 +530,17 @@ export async function probePodman(): Promise<PodmanProbeResult> {
508
530
  * through and invalidate stale child images.
509
531
  */
510
532
  export async function baseContainerfileHash(): Promise<string> {
511
- const containerfile = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox", "Containerfile")
533
+ const dir = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox")
534
+ const containerfile = join(dir, "Containerfile")
512
535
  if (!existsSync(containerfile)) {
513
536
  throw new Error(`Containerfile not found at ${containerfile}`)
514
537
  }
515
- const content = await readFile(containerfile, "utf8")
516
- return createHash("sha256").update(content).digest("hex").slice(0, 16)
538
+ const h = createHash("sha256").update(await readFile(containerfile, "utf8"))
539
+ // Files COPY'd by the Containerfile also affect the image — hash them too so
540
+ // editing the forwarder rebuilds the base image.
541
+ const forwarder = join(dir, "loopat-host")
542
+ if (existsSync(forwarder)) h.update(await readFile(forwarder, "utf8"))
543
+ return h.digest("hex").slice(0, 16)
517
544
  }
518
545
 
519
546
  let _imageBuildInFlight: Promise<void> | null = null
@@ -649,19 +676,44 @@ export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg
649
676
  opts?.onProgress?.("Installing tools from mise.toml…")
650
677
  const buildDir = await mkdtemp(join(tmpdir(), "loopat-img-"))
651
678
  try {
652
- await copyFile(miseTomlPath, join(buildDir, "mise.toml"))
679
+ // A loopat-native `[host]` table declares host-only clis (macOS /
680
+ // machine-bound) the sandbox can't run natively. We bake a forwarding
681
+ // shim per cli into the image's mise shims dir (already first on PATH),
682
+ // and strip the table before mise sees it — mise would reject the
683
+ // unknown table. The shim just hands off to `loopat-host` (in the base
684
+ // image) → mounted socket → host execFile. See host-exec.ts.
685
+ let hostClis: string[] = []
686
+ let miseConfig = content
687
+ try {
688
+ const parsed: any = tomlParse(content)
689
+ if (parsed && Array.isArray(parsed.host?.clis)) {
690
+ hostClis = parsed.host.clis.filter((x: unknown): x is string => typeof x === "string" && !!x)
691
+ }
692
+ if (parsed && "host" in parsed) {
693
+ const { host: _host, ...rest } = parsed
694
+ miseConfig = tomlStringify(rest)
695
+ }
696
+ } catch {}
697
+ await writeFile(join(buildDir, "mise.toml"), miseConfig)
653
698
  // Override `mise trust` interactively by marking the config path
654
699
  // trusted via env. `mise install -y` installs everything in
655
700
  // mise.toml; `mise reshim` ensures /opt/loopat-mise/shims/ has a
656
701
  // shim for every tool.
657
- const childContainerfile = [
702
+ const lines = [
658
703
  `FROM ${SANDBOX_IMAGE}`,
659
704
  `COPY mise.toml /opt/loopat-mise/config/config.toml`,
660
705
  `RUN MISE_TRUSTED_CONFIG_PATHS=/opt/loopat-mise/config/config.toml \\`,
661
706
  ` mise install -y \\`,
662
707
  ` && MISE_TRUSTED_CONFIG_PATHS=/opt/loopat-mise/config/config.toml \\`,
663
708
  ` mise reshim`,
664
- ].join("\n") + "\n"
709
+ ]
710
+ if (hostClis.length) {
711
+ // Generate the shims into the build context, then COPY them in AFTER
712
+ // reshim so mise's own reshim can't clobber them.
713
+ await writeHostShims(join(buildDir, "host-bin"), hostClis)
714
+ lines.push(`COPY host-bin/ /opt/loopat-mise/shims/`)
715
+ }
716
+ const childContainerfile = lines.join("\n") + "\n"
665
717
  await writeFile(join(buildDir, "Containerfile"), childContainerfile)
666
718
 
667
719
  const r = await runPodman(
@@ -883,17 +935,26 @@ let _portProxyReady: Promise<void> | null = null
883
935
  */
884
936
  function findOccupiedPorts(lo: number, hi: number): Set<number> {
885
937
  const ports = new Set<number>()
938
+ const { execFileSync } = require("node:child_process")
939
+ const add = (port: number) => { if (port >= lo && port <= hi) ports.add(port) }
940
+ // Linux: ss reads /proc/net/tcp (every listening socket).
886
941
  try {
887
- const { execFileSync } = require("node:child_process")
888
942
  const out = execFileSync("ss", ["-tlnH"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }) as string
889
943
  for (const line of out.split("\n")) {
890
944
  const parts = line.trim().split(/\s+/)
891
945
  if (parts.length < 4) continue
892
- const addr = parts[3] // e.g. "0.0.0.0:8080" or "[::]:8080" or "127.0.0.1:8080"
946
+ const addr = parts[3] // "0.0.0.0:8080" | "[::]:8080" | "127.0.0.1:8080"
893
947
  const colonIdx = addr.lastIndexOf(":")
894
- if (colonIdx === -1) continue
895
- const port = Number(addr.slice(colonIdx + 1))
896
- if (port >= lo && port <= hi) ports.add(port)
948
+ if (colonIdx !== -1) add(Number(addr.slice(colonIdx + 1)))
949
+ }
950
+ return ports
951
+ } catch {}
952
+ // macOS (no ss): lsof. NAME column looks like "*:8080" or "127.0.0.1:8080 (LISTEN)".
953
+ try {
954
+ const out = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }) as string
955
+ for (const line of out.split("\n")) {
956
+ const m = line.match(/:(\d+)\s*\(LISTEN\)/)
957
+ if (m) add(Number(m[1]))
897
958
  }
898
959
  } catch {}
899
960
  return ports
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `loopat uninstall` — clean removal of everything loopat itself created.
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.
10
+ *
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.
15
+ *
16
+ * Run via the launcher: `npx loopat uninstall [--yes]`.
17
+ */
18
+ import { existsSync } from "node:fs"
19
+ import { rm } from "node:fs/promises"
20
+ import { execFile } from "node:child_process"
21
+ import { promisify } from "node:util"
22
+ import { homedir } from "node:os"
23
+ import { join } from "node:path"
24
+ import { LOOPAT_HOME, WORKSPACE } from "./paths"
25
+
26
+ const execFileP = promisify(execFile)
27
+
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.)
31
+ 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`
34
+
35
+ type Run = { code: number; out: string; err: string }
36
+ async function podman(args: string[]): Promise<Run> {
37
+ try {
38
+ const { stdout, stderr } = await execFileP("podman", args, { maxBuffer: 16 * 1024 * 1024 })
39
+ return { code: 0, out: stdout, err: stderr }
40
+ } catch (e: any) {
41
+ return { code: typeof e?.code === "number" ? e.code : 1, out: e?.stdout ?? "", err: e?.stderr ?? String(e?.message ?? e) }
42
+ }
43
+ }
44
+
45
+ const lines = (s: string) => s.split("\n").map((x) => x.trim()).filter(Boolean)
46
+
47
+ async function podmanAvailable(): Promise<boolean> {
48
+ return (await podman(["--version"])).code === 0
49
+ }
50
+
51
+ /** Containers belonging to THIS workspace. */
52
+ async function workspaceContainers(): Promise<string[]> {
53
+ const r = await podman(["ps", "-aq", "--filter", `label=${LABEL_WORKSPACE}=${WORKSPACE}`])
54
+ return r.code === 0 ? lines(r.out) : []
55
+ }
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}}"])
65
+ return r.code === 0 ? [...new Set(lines(r.out))] : []
66
+ }
67
+
68
+ /** TTY confirm. Non-interactive (piped) without --yes → treated as "no". */
69
+ function confirm(question: string): boolean {
70
+ const ans = prompt(question)
71
+ return ans !== null && /^y(es)?$/i.test(ans.trim())
72
+ }
73
+
74
+ export async function runUninstall(argv: string[]): Promise<void> {
75
+ const yes = argv.includes("--yes") || argv.includes("-y")
76
+ const hasPodman = await podmanAvailable()
77
+ const containers = hasPodman ? await workspaceContainers() : []
78
+ const dataExists = existsSync(LOOPAT_HOME)
79
+
80
+ // ── Plan (so the user sees the exact boundary before anything happens) ──
81
+ console.log(`loopat uninstall — workspace "${WORKSPACE}"`)
82
+ console.log("")
83
+ console.log("Will remove:")
84
+ console.log(` • ${containers.length} sandbox container(s)`)
85
+ console.log(` • sandbox images + the "${LOOPAT_NETWORK}" network (if no loopat container remains)`)
86
+ console.log(` • data dir: ${LOOPAT_HOME}${dataExists ? "" : " (absent)"}`)
87
+ if (!hasPodman) console.log(" • note: podman not found — skipping container/image/network cleanup")
88
+ console.log("")
89
+
90
+ if (!yes && !confirm("Proceed? This permanently deletes your workspace data. [y/N] ")) {
91
+ console.log("Aborted — nothing removed.")
92
+ return
93
+ }
94
+
95
+ // 1. Our containers (label-scoped).
96
+ if (hasPodman && containers.length) {
97
+ process.stdout.write(`Removing ${containers.length} container(s)… `)
98
+ await podman(["rm", "-f", ...containers])
99
+ console.log("done")
100
+ }
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
+ }
120
+ }
121
+
122
+ // 3. Workspace data.
123
+ if (dataExists) {
124
+ process.stdout.write(`Removing data dir ${LOOPAT_HOME}… `)
125
+ await rm(LOOPAT_HOME, { recursive: true, force: true })
126
+ console.log("done")
127
+ }
128
+
129
+ // 4. Second-layer hints — shared infra we deliberately do NOT touch.
130
+ console.log("")
131
+ console.log("Done. loopat's own resources are gone.")
132
+ console.log("")
133
+ console.log("Left untouched (remove yourself only if loopat was their only user):")
134
+ if (process.platform === "darwin") {
135
+ console.log(" • podman machine (Linux VM): podman machine stop && podman machine rm")
136
+ }
137
+ console.log(` • npx/bun cache: rm -rf ${join(homedir(), ".npm", "_npx")}`)
138
+ console.log("")
139
+ }
140
+
141
+ if (import.meta.main) {
142
+ runUninstall(process.argv.slice(2)).catch((e) => {
143
+ console.error(`[loopat] uninstall failed: ${e?.message ?? e}`)
144
+ process.exit(1)
145
+ })
146
+ }
@@ -104,6 +104,17 @@ RUN mkdir -p /opt/loopat-mise/shims /opt/loopat-mise/config /opt/loopat-mise/cac
104
104
  && chown -R loopat:loopat /opt/loopat-mise \
105
105
  && chmod -R 755 /opt/loopat-mise
106
106
 
107
+ # Native host-cli forwarder. The sandbox's built-in way to run a host-only cli
108
+ # (macOS-only, or a machine-bound company cli) on behalf of the loop:
109
+ # shim(<cli>) → loopat-host → mounted unix socket → host execFile
110
+ # This forwarder is generic (same for every loop), so it's baked into the image.
111
+ # Per-cli shims are generated per-loop from the loop's mise.toml `[host].clis`
112
+ # and COPY'd into the mise shims dir (already first on PATH) by the per-loop
113
+ # child build (ensureLoopImage). The socket dir is mounted in and the
114
+ # LOOPAT_HOST_SOCK / LOOPAT_LOOP_ID env vars are injected at container create.
115
+ COPY loopat-host /usr/local/bin/loopat-host
116
+ RUN chmod 0755 /usr/local/bin/loopat-host
117
+
107
118
  # Switch to loopat. mise install (per-loop child build) and runtime
108
119
  # exec'd processes all run as this user.
109
120
  USER loopat
@@ -0,0 +1,28 @@
1
+ #!/bin/sh
2
+ # loopat-host <cli> [args...] — run <cli> on the HOST, on behalf of this loop,
3
+ # via the mounted unix socket. The sandbox can't run host-only clis (macOS
4
+ # tools, machine-bound company clis), so a shim hands off to this forwarder.
5
+ #
6
+ # Injected into the sandbox:
7
+ # LOOPAT_HOST_SOCK — path to the mounted host-exec socket
8
+ # LOOPAT_LOOP_ID — this loop's id (server uses it to pick the host workdir)
9
+ #
10
+ # POC notes: args are JSON-encoded naively (assumes no embedded quotes /
11
+ # backslashes); stdout/stderr come back base64 so they're safe to pull out of
12
+ # the JSON with grep. A hardened version would stream and length-prefix.
13
+ cli="$1"; shift
14
+ args=""
15
+ for a in "$@"; do args="$args\"$a\","; done
16
+ body="{\"loopId\":\"$LOOPAT_LOOP_ID\",\"cli\":\"$cli\",\"args\":[${args%,}]}"
17
+
18
+ resp=$(curl -fsS --unix-socket "$LOOPAT_HOST_SOCK" -X POST http://localhost/host-exec \
19
+ -H "content-type: application/json" -d "$body") \
20
+ || { echo "loopat-host: cannot reach host socket" >&2; exit 127; }
21
+
22
+ printf '%s' "$resp" | grep -o '"stdout_b64":"[^"]*"' | sed 's/.*"stdout_b64":"//; s/"$//' | base64 -d 2>/dev/null
23
+ printf '%s' "$resp" | grep -o '"stderr_b64":"[^"]*"' | sed 's/.*"stderr_b64":"//; s/"$//' | base64 -d 2>/dev/null >&2
24
+
25
+ err=$(printf '%s' "$resp" | grep -o '"error":"[^"]*"' | sed 's/.*"error":"//; s/"$//')
26
+ if [ -n "$err" ]; then echo "loopat-host: $err" >&2; exit 126; fi
27
+
28
+ exit "$(printf '%s' "$resp" | grep -o '"exitCode":[0-9]*' | grep -o '[0-9]*' || echo 0)"