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 +8 -0
- package/bin/loopat.mjs +12 -1
- package/package.json +1 -1
- package/server/src/bootstrap.ts +36 -3
- package/server/src/host-exec.ts +129 -0
- package/server/src/index.ts +11 -0
- package/server/src/loops.ts +33 -10
- package/server/src/podman.ts +73 -12
- package/server/src/uninstall.ts +146 -0
- package/server/templates/sandbox/Containerfile +11 -0
- package/server/templates/sandbox/loopat-host +28 -0
- package/web/dist/assets/{CodeEditor-DtAB2rxk.js → CodeEditor-CJIDxH-3.js} +1 -1
- package/web/dist/assets/{Editor-CPvLoTyV.js → Editor-DsE3Bcm0.js} +1 -1
- package/web/dist/assets/{Markdown-CMg20D2_.js → Markdown-sWG7Jmg1.js} +1 -1
- package/web/dist/assets/{MilkdownEditor-CODBxSpm.js → MilkdownEditor-B9ECu-iU.js} +1 -1
- package/web/dist/assets/{Terminal-DbnbQ8a-.js → Terminal-CcsuXlvr.js} +1 -1
- package/web/dist/assets/{index-C4XQ0pTs.js → index-gkdQO9_w.js} +3 -3
- package/web/dist/index.html +1 -1
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, [
|
|
63
|
+
const child = spawn(bun, [entry, ...forwardArgs], {
|
|
53
64
|
stdio: "inherit",
|
|
54
65
|
env: process.env,
|
|
55
66
|
})
|
package/package.json
CHANGED
package/server/src/bootstrap.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
+
}
|
package/server/src/index.ts
CHANGED
|
@@ -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.
|
package/server/src/loops.ts
CHANGED
|
@@ -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,
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
-
|
|
1659
|
-
//
|
|
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
|
-
|
|
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",
|
|
1683
|
-
return
|
|
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
|
}
|
package/server/src/podman.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
516
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
]
|
|
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] //
|
|
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
|
|
895
|
-
|
|
896
|
-
|
|
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
|