loopat 0.1.1 → 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.1",
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.
@@ -1270,15 +1270,16 @@ export async function ensureUiNotesWorktree(user: string): Promise<void> {
1270
1270
  export async function syncUiNotes(user: string): Promise<PersonalPushResult> {
1271
1271
  const dir = uiNotesDir(user)
1272
1272
  await ensureUiNotesWorktree(user)
1273
+ const branch = await remoteDefaultBranch(dir)
1273
1274
  const c = await commitLocalChanges(dir, "loopat: edit notes")
1274
1275
  if (!c.ok) return { ok: false, error: c.error }
1275
- const reb = await rebaseOntoOrigin(dir, "main")
1276
+ const reb = await rebaseOntoOrigin(dir, branch)
1276
1277
  if (!reb.ok) {
1277
1278
  if ("conflict" in reb) return { ok: false, error: "conflict with remote", conflict: true, files: reb.files }
1278
1279
  return { ok: false, error: reb.error }
1279
1280
  }
1280
1281
  try {
1281
- await execFileP("git", ["-C", dir, "push", "origin", "HEAD:main"])
1282
+ await execFileP("git", ["-C", dir, "push", "origin", `HEAD:${branch}`])
1282
1283
  } catch (e: any) {
1283
1284
  const stderr = (e?.stderr ?? "").toString().trim()
1284
1285
  return { ok: false, error: `push failed: ${stderr || e?.message || e}`, needsPull: true }
@@ -1296,6 +1297,7 @@ export async function ffUpdateUiNotes(
1296
1297
  ): Promise<{ ok: true } | { ok: false; diverged?: boolean; error: string }> {
1297
1298
  const dir = uiNotesDir(user)
1298
1299
  await ensureUiNotesWorktree(user)
1300
+ const branch = await remoteDefaultBranch(dir)
1299
1301
  try {
1300
1302
  await execFileP("git", ["-C", dir, "fetch", "origin"], {
1301
1303
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, timeout: 30_000,
@@ -1305,12 +1307,12 @@ export async function ffUpdateUiNotes(
1305
1307
  }
1306
1308
  // No upstream yet → nothing to pull.
1307
1309
  try {
1308
- await execFileP("git", ["-C", dir, "rev-parse", "--verify", "--quiet", "origin/main"])
1310
+ await execFileP("git", ["-C", dir, "rev-parse", "--verify", "--quiet", `origin/${branch}`])
1309
1311
  } catch {
1310
1312
  return { ok: true }
1311
1313
  }
1312
1314
  try {
1313
- await execFileP("git", ["-C", dir, "merge", "--ff-only", "origin/main"], {
1315
+ await execFileP("git", ["-C", dir, "merge", "--ff-only", `origin/${branch}`], {
1314
1316
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
1315
1317
  })
1316
1318
  return { ok: true }
@@ -1327,6 +1329,7 @@ export async function ffUpdateUiNotes(
1327
1329
  export async function notesBehind(user: string): Promise<number> {
1328
1330
  const dir = uiNotesDir(user)
1329
1331
  await ensureUiNotesWorktree(user)
1332
+ const branch = await remoteDefaultBranch(dir)
1330
1333
  try {
1331
1334
  await execFileP("git", ["-C", dir, "fetch", "origin"], {
1332
1335
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, timeout: 30_000,
@@ -1335,7 +1338,7 @@ export async function notesBehind(user: string): Promise<number> {
1335
1338
  return 0
1336
1339
  }
1337
1340
  try {
1338
- const { stdout } = await execFileP("git", ["-C", dir, "rev-list", "--count", "HEAD..origin/main"])
1341
+ const { stdout } = await execFileP("git", ["-C", dir, "rev-list", "--count", `HEAD..origin/${branch}`])
1339
1342
  return parseInt(stdout.trim(), 10) || 0
1340
1343
  } catch {
1341
1344
  return 0
@@ -1655,10 +1658,13 @@ async function ensureContextWorktree(repo: string, path: string, branchName: str
1655
1658
 
1656
1659
  // Stale state (old symlink, empty dir, leftover from manual cleanup) → wipe + create.
1657
1660
  try { await rm(path, { recursive: true, force: true }) } catch {}
1658
- // pull (docs/context-flow.md): open the worktree from origin/main so the
1659
- // loop starts from latest consensus, not a possibly-stale local HEAD.
1661
+ try { await execFileP("git", ["-C", repo, "worktree", "prune"]) } catch {}
1662
+ // pull (docs/context-flow.md): open the worktree from origin's default
1663
+ // branch so the loop starts from latest consensus, not a stale local HEAD.
1660
1664
  const start = await remoteStartPoint(repo)
1661
- const args = ["-C", repo, "worktree", "add", "-b", branchName, path]
1665
+ // -B (not -b): reset the branch if it lingers from a removed worktree, so a
1666
+ // rebuild always re-opens cleanly from the start point.
1667
+ const args = ["-C", repo, "worktree", "add", "-B", branchName, path]
1662
1668
  if (start) args.push(start)
1663
1669
  await execFileP("git", args)
1664
1670
  }
@@ -1669,6 +1675,22 @@ async function ensureContextWorktree(repo: string, path: string, branchName: str
1669
1675
  * loop opens from the latest shared state. Returns null to fall back to local
1670
1676
  * HEAD (solo / offline / no remote / no origin/main yet).
1671
1677
  */
1678
+ /** The remote's default branch (origin/HEAD) — e.g. main or master. Falls back
1679
+ * to "main". loopat must NOT assume "main": team repos are often on "master". */
1680
+ async function remoteDefaultBranch(dir: string): Promise<string> {
1681
+ try {
1682
+ const { stdout } = await execFileP("git", ["-C", dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"])
1683
+ const b = stdout.trim().replace(/^origin\//, "")
1684
+ if (b) return b
1685
+ } catch {}
1686
+ try {
1687
+ const { stdout } = await execFileP("git", ["-C", dir, "ls-remote", "--symref", "origin", "HEAD"])
1688
+ const m = stdout.match(/ref:\s+refs\/heads\/(\S+)\s+HEAD/)
1689
+ if (m?.[1]) return m[1]
1690
+ } catch {}
1691
+ return "main"
1692
+ }
1693
+
1672
1694
  async function remoteStartPoint(repo: string): Promise<string | null> {
1673
1695
  try {
1674
1696
  await execFileP("git", ["-C", repo, "remote", "get-url", "origin"])
@@ -1678,9 +1700,10 @@ async function remoteStartPoint(repo: string): Promise<string | null> {
1678
1700
  try {
1679
1701
  await execFileP("git", ["-C", repo, "fetch", "--quiet", "origin"], { timeout: 15_000 })
1680
1702
  } catch {}
1703
+ const branch = await remoteDefaultBranch(repo)
1681
1704
  try {
1682
- await execFileP("git", ["-C", repo, "rev-parse", "--verify", "--quiet", "origin/main^{commit}"])
1683
- return "origin/main"
1705
+ await execFileP("git", ["-C", repo, "rev-parse", "--verify", "--quiet", `origin/${branch}^{commit}`])
1706
+ return `origin/${branch}`
1684
1707
  } catch {
1685
1708
  return null
1686
1709
  }
@@ -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