loopat 0.1.2 → 0.1.4
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 +14 -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/podman.ts +88 -20
- package/server/src/uninstall.ts +133 -0
- package/server/templates/sandbox/Containerfile +11 -0
- package/server/templates/sandbox/loopat-host +28 -0
- package/web/dist/assets/{CodeEditor-CamHv98h.js → CodeEditor-DfIQi9Ks.js} +1 -1
- package/web/dist/assets/{Editor-CTMAVfli.js → Editor-CSZrkDg9.js} +1 -1
- package/web/dist/assets/{Markdown-DnH11crU.js → Markdown-Ci1M6VXe.js} +1 -1
- package/web/dist/assets/{MilkdownEditor-lhueImS3.js → MilkdownEditor-CkH6ILre.js} +1 -1
- package/web/dist/assets/{Terminal-CT8pf5uA.js → Terminal-oo3S6Viy.js} +1 -1
- package/web/dist/assets/{index-jjZzoDb1.js → index-Nx0RBMP4.js} +3 -3
- package/web/dist/index.html +1 -1
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>
|
|
@@ -131,6 +137,14 @@ Loopat splits configuration along role lines — read whichever applies:
|
|
|
131
137
|
|
|
132
138
|
### Docker (recommended)
|
|
133
139
|
|
|
140
|
+
Pull the prebuilt image from GHCR:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
docker run -d --privileged -p 20001:10001 ghcr.io/simpx/loopat:latest
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Or, to build from source and persist the workspace in a named volume:
|
|
147
|
+
|
|
134
148
|
```sh
|
|
135
149
|
docker compose up -d
|
|
136
150
|
```
|
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/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
|
|
@@ -91,7 +100,12 @@ const LABEL_CONFIG_HASH = "loopat.config-hash"
|
|
|
91
100
|
|
|
92
101
|
// Image used as the base for every loop container. Built locally from
|
|
93
102
|
// server/templates/sandbox/Containerfile via ensureSandboxImage().
|
|
94
|
-
|
|
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`
|
|
95
109
|
|
|
96
110
|
// Container name: prefix with workspace to avoid collisions between loopat
|
|
97
111
|
// instances running on the same host with different LOOPAT_HOME. Loop UUIDs
|
|
@@ -247,6 +261,15 @@ export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeM
|
|
|
247
261
|
mounts.push({ src: chatDir, dst: V_CONTEXT_CHAT, ro: true })
|
|
248
262
|
}
|
|
249
263
|
|
|
264
|
+
// host-cli proxy: mount the host-exec socket dir so the loop's `loopat-host`
|
|
265
|
+
// forwarder can reach the host. Mounting the socket IS the trust decision —
|
|
266
|
+
// a loop with this mount may run any host cli (see host-exec.ts). The dir is
|
|
267
|
+
// created by serveHostExec at boot; mount if present (bind-try semantics).
|
|
268
|
+
const hostExec = hostExecDir()
|
|
269
|
+
if (existsSync(hostExec)) {
|
|
270
|
+
mounts.push({ src: hostExec, dst: V_HOST_EXEC_DIR })
|
|
271
|
+
}
|
|
272
|
+
|
|
250
273
|
// All-loops ro view (admin-gated): expose LOOPAT_HOME/loops/ at /loopat/loops.
|
|
251
274
|
if (mountAllLoops) {
|
|
252
275
|
mounts.push({ src: loopsDir(), dst: V_ALL_LOOPS, ro: true })
|
|
@@ -292,6 +315,10 @@ export async function buildContainerEnv(opts: ContainerOptions): Promise<Record<
|
|
|
292
315
|
const out: Record<string, string> = {}
|
|
293
316
|
// Sandbox $HOME is /loopat/home/<user> (see V_HOME comment).
|
|
294
317
|
out.HOME = V_HOME(opts.createdBy)
|
|
318
|
+
// host-cli proxy: tell the loop's `loopat-host` forwarder where the mounted
|
|
319
|
+
// socket is and which loop it speaks for (server uses it for the workdir).
|
|
320
|
+
out.LOOPAT_HOST_SOCK = V_HOST_EXEC_SOCK
|
|
321
|
+
out.LOOPAT_LOOP_ID = opts.loopId
|
|
295
322
|
for (const [k, v] of Object.entries(opts.extraEnv ?? {})) {
|
|
296
323
|
out[k] = v
|
|
297
324
|
}
|
|
@@ -347,7 +374,7 @@ export async function buildPodmanCreateArgs(opts: ContainerOptions): Promise<str
|
|
|
347
374
|
"--device", "/dev/fuse",
|
|
348
375
|
// Shared bridge network so the serve container can reach loop
|
|
349
376
|
// containers by name (aardvark-dns). Outbound API calls via NAT.
|
|
350
|
-
"--network",
|
|
377
|
+
"--network", LOOPAT_NETWORK,
|
|
351
378
|
"--hostname", `loop-${opts.loopId.slice(0, 8)}`,
|
|
352
379
|
// Container cwd at creation; per-exec we override with -w.
|
|
353
380
|
"--workdir", V_LOOP_WORKDIR(opts.loopId),
|
|
@@ -508,12 +535,17 @@ export async function probePodman(): Promise<PodmanProbeResult> {
|
|
|
508
535
|
* through and invalidate stale child images.
|
|
509
536
|
*/
|
|
510
537
|
export async function baseContainerfileHash(): Promise<string> {
|
|
511
|
-
const
|
|
538
|
+
const dir = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox")
|
|
539
|
+
const containerfile = join(dir, "Containerfile")
|
|
512
540
|
if (!existsSync(containerfile)) {
|
|
513
541
|
throw new Error(`Containerfile not found at ${containerfile}`)
|
|
514
542
|
}
|
|
515
|
-
const
|
|
516
|
-
|
|
543
|
+
const h = createHash("sha256").update(await readFile(containerfile, "utf8"))
|
|
544
|
+
// Files COPY'd by the Containerfile also affect the image — hash them too so
|
|
545
|
+
// editing the forwarder rebuilds the base image.
|
|
546
|
+
const forwarder = join(dir, "loopat-host")
|
|
547
|
+
if (existsSync(forwarder)) h.update(await readFile(forwarder, "utf8"))
|
|
548
|
+
return h.digest("hex").slice(0, 16)
|
|
517
549
|
}
|
|
518
550
|
|
|
519
551
|
let _imageBuildInFlight: Promise<void> | null = null
|
|
@@ -527,7 +559,7 @@ export async function ensureSandboxImage(opts?: { onProgress?: (msg: string) =>
|
|
|
527
559
|
|
|
528
560
|
// Hash the Containerfile so the base image auto-rebuilds when it changes.
|
|
529
561
|
const hash = await baseContainerfileHash()
|
|
530
|
-
const hashTag = `loopat-sandbox-${hash}:latest`
|
|
562
|
+
const hashTag = `loopat-sandbox-${WORKSPACE}-${hash}:latest`
|
|
531
563
|
|
|
532
564
|
const present = await runPodman(["image", "exists", hashTag], { allowFail: true })
|
|
533
565
|
if (present.code === 0) {
|
|
@@ -544,7 +576,7 @@ export async function ensureSandboxImage(opts?: { onProgress?: (msg: string) =>
|
|
|
544
576
|
const buildDir = join(LOOPAT_INSTALL_DIR, "server", "templates", "sandbox")
|
|
545
577
|
let lastStep = ""
|
|
546
578
|
const r = await runPodman(
|
|
547
|
-
["build", "-t", SANDBOX_IMAGE, "-t", hashTag, "-f", containerfile, buildDir],
|
|
579
|
+
["build", "-t", SANDBOX_IMAGE, "-t", hashTag, "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, "-f", containerfile, buildDir],
|
|
548
580
|
{
|
|
549
581
|
onLine: (line) => {
|
|
550
582
|
const m = line.match(/^STEP\s+(\d+)\/(\d+):\s+(.+)/)
|
|
@@ -633,7 +665,7 @@ export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg
|
|
|
633
665
|
// after the nested-podman base change shipped).
|
|
634
666
|
const baseHash = await baseContainerfileHash()
|
|
635
667
|
const hash = createHash("sha256").update(`base:${baseHash}\n`).update(content).digest("hex").slice(0, 16)
|
|
636
|
-
const tag = `loopat-sandbox-${hash}:latest`
|
|
668
|
+
const tag = `loopat-sandbox-${WORKSPACE}-${hash}:latest`
|
|
637
669
|
|
|
638
670
|
const existing = _loopImageInFlight.get(tag)
|
|
639
671
|
if (existing) return existing
|
|
@@ -649,23 +681,48 @@ export async function ensureLoopImage(loopId: string, opts?: { onProgress?: (msg
|
|
|
649
681
|
opts?.onProgress?.("Installing tools from mise.toml…")
|
|
650
682
|
const buildDir = await mkdtemp(join(tmpdir(), "loopat-img-"))
|
|
651
683
|
try {
|
|
652
|
-
|
|
684
|
+
// A loopat-native `[host]` table declares host-only clis (macOS /
|
|
685
|
+
// machine-bound) the sandbox can't run natively. We bake a forwarding
|
|
686
|
+
// shim per cli into the image's mise shims dir (already first on PATH),
|
|
687
|
+
// and strip the table before mise sees it — mise would reject the
|
|
688
|
+
// unknown table. The shim just hands off to `loopat-host` (in the base
|
|
689
|
+
// image) → mounted socket → host execFile. See host-exec.ts.
|
|
690
|
+
let hostClis: string[] = []
|
|
691
|
+
let miseConfig = content
|
|
692
|
+
try {
|
|
693
|
+
const parsed: any = tomlParse(content)
|
|
694
|
+
if (parsed && Array.isArray(parsed.host?.clis)) {
|
|
695
|
+
hostClis = parsed.host.clis.filter((x: unknown): x is string => typeof x === "string" && !!x)
|
|
696
|
+
}
|
|
697
|
+
if (parsed && "host" in parsed) {
|
|
698
|
+
const { host: _host, ...rest } = parsed
|
|
699
|
+
miseConfig = tomlStringify(rest)
|
|
700
|
+
}
|
|
701
|
+
} catch {}
|
|
702
|
+
await writeFile(join(buildDir, "mise.toml"), miseConfig)
|
|
653
703
|
// Override `mise trust` interactively by marking the config path
|
|
654
704
|
// trusted via env. `mise install -y` installs everything in
|
|
655
705
|
// mise.toml; `mise reshim` ensures /opt/loopat-mise/shims/ has a
|
|
656
706
|
// shim for every tool.
|
|
657
|
-
const
|
|
707
|
+
const lines = [
|
|
658
708
|
`FROM ${SANDBOX_IMAGE}`,
|
|
659
709
|
`COPY mise.toml /opt/loopat-mise/config/config.toml`,
|
|
660
710
|
`RUN MISE_TRUSTED_CONFIG_PATHS=/opt/loopat-mise/config/config.toml \\`,
|
|
661
711
|
` mise install -y \\`,
|
|
662
712
|
` && MISE_TRUSTED_CONFIG_PATHS=/opt/loopat-mise/config/config.toml \\`,
|
|
663
713
|
` mise reshim`,
|
|
664
|
-
]
|
|
714
|
+
]
|
|
715
|
+
if (hostClis.length) {
|
|
716
|
+
// Generate the shims into the build context, then COPY them in AFTER
|
|
717
|
+
// reshim so mise's own reshim can't clobber them.
|
|
718
|
+
await writeHostShims(join(buildDir, "host-bin"), hostClis)
|
|
719
|
+
lines.push(`COPY host-bin/ /opt/loopat-mise/shims/`)
|
|
720
|
+
}
|
|
721
|
+
const childContainerfile = lines.join("\n") + "\n"
|
|
665
722
|
await writeFile(join(buildDir, "Containerfile"), childContainerfile)
|
|
666
723
|
|
|
667
724
|
const r = await runPodman(
|
|
668
|
-
["build", "-t", tag, "-f", join(buildDir, "Containerfile"), buildDir],
|
|
725
|
+
["build", "-t", tag, "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, "-f", join(buildDir, "Containerfile"), buildDir],
|
|
669
726
|
{
|
|
670
727
|
allowFail: true,
|
|
671
728
|
onLine: (line) => {
|
|
@@ -771,7 +828,9 @@ export async function getEphemeralHostPort(
|
|
|
771
828
|
return Number.isFinite(port) && port > 0 ? port : null
|
|
772
829
|
}
|
|
773
830
|
|
|
774
|
-
|
|
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}`
|
|
775
834
|
const SERVE_CONTAINER = `loopat-${WORKSPACE}-serve`
|
|
776
835
|
|
|
777
836
|
let _networkReady = false
|
|
@@ -783,7 +842,7 @@ export async function ensureLoopatNetwork(): Promise<void> {
|
|
|
783
842
|
const r = await runPodman(["network", "exists", LOOPAT_NETWORK], { allowFail: true })
|
|
784
843
|
if (r.code !== 0) {
|
|
785
844
|
console.log(`[podman] creating network ${LOOPAT_NETWORK}`)
|
|
786
|
-
const create = await runPodman(["network", "create", LOOPAT_NETWORK])
|
|
845
|
+
const create = await runPodman(["network", "create", "--label", `${LABEL_WORKSPACE}=${WORKSPACE}`, LOOPAT_NETWORK])
|
|
787
846
|
if (create.code !== 0) {
|
|
788
847
|
throw new Error(`Failed to create podman network ${LOOPAT_NETWORK}: ${create.stderr}`)
|
|
789
848
|
}
|
|
@@ -883,17 +942,26 @@ let _portProxyReady: Promise<void> | null = null
|
|
|
883
942
|
*/
|
|
884
943
|
function findOccupiedPorts(lo: number, hi: number): Set<number> {
|
|
885
944
|
const ports = new Set<number>()
|
|
945
|
+
const { execFileSync } = require("node:child_process")
|
|
946
|
+
const add = (port: number) => { if (port >= lo && port <= hi) ports.add(port) }
|
|
947
|
+
// Linux: ss reads /proc/net/tcp (every listening socket).
|
|
886
948
|
try {
|
|
887
|
-
const { execFileSync } = require("node:child_process")
|
|
888
949
|
const out = execFileSync("ss", ["-tlnH"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }) as string
|
|
889
950
|
for (const line of out.split("\n")) {
|
|
890
951
|
const parts = line.trim().split(/\s+/)
|
|
891
952
|
if (parts.length < 4) continue
|
|
892
|
-
const addr = parts[3] //
|
|
953
|
+
const addr = parts[3] // "0.0.0.0:8080" | "[::]:8080" | "127.0.0.1:8080"
|
|
893
954
|
const colonIdx = addr.lastIndexOf(":")
|
|
894
|
-
if (colonIdx
|
|
895
|
-
|
|
896
|
-
|
|
955
|
+
if (colonIdx !== -1) add(Number(addr.slice(colonIdx + 1)))
|
|
956
|
+
}
|
|
957
|
+
return ports
|
|
958
|
+
} catch {}
|
|
959
|
+
// macOS (no ss): lsof. NAME column looks like "*:8080" or "127.0.0.1:8080 (LISTEN)".
|
|
960
|
+
try {
|
|
961
|
+
const out = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }) as string
|
|
962
|
+
for (const line of out.split("\n")) {
|
|
963
|
+
const m = line.match(/:(\d+)\s*\(LISTEN\)/)
|
|
964
|
+
if (m) add(Number(m[1]))
|
|
897
965
|
}
|
|
898
966
|
} catch {}
|
|
899
967
|
return ports
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loopat uninstall` — clean removal of everything THIS workspace created.
|
|
3
|
+
*
|
|
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.
|
|
8
|
+
*
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* Run via the launcher: `npx loopat uninstall [--yes]`.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from "node:fs"
|
|
17
|
+
import { rm } from "node:fs/promises"
|
|
18
|
+
import { execFile } from "node:child_process"
|
|
19
|
+
import { promisify } from "node:util"
|
|
20
|
+
import { homedir } from "node:os"
|
|
21
|
+
import { join } from "node:path"
|
|
22
|
+
import { LOOPAT_HOME, WORKSPACE } from "./paths"
|
|
23
|
+
|
|
24
|
+
const execFileP = promisify(execFile)
|
|
25
|
+
|
|
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").
|
|
29
|
+
const LABEL_WORKSPACE = "loopat.workspace"
|
|
30
|
+
const labelFilter = `label=${LABEL_WORKSPACE}=${WORKSPACE}`
|
|
31
|
+
|
|
32
|
+
type Run = { code: number; out: string; err: string }
|
|
33
|
+
async function podman(args: string[]): Promise<Run> {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout, stderr } = await execFileP("podman", args, { maxBuffer: 16 * 1024 * 1024 })
|
|
36
|
+
return { code: 0, out: stdout, err: stderr }
|
|
37
|
+
} catch (e: any) {
|
|
38
|
+
return { code: typeof e?.code === "number" ? e.code : 1, out: e?.stdout ?? "", err: e?.stderr ?? String(e?.message ?? e) }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const lines = (s: string) => s.split("\n").map((x) => x.trim()).filter(Boolean)
|
|
43
|
+
|
|
44
|
+
async function podmanAvailable(): Promise<boolean> {
|
|
45
|
+
return (await podman(["--version"])).code === 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function workspaceContainers(): Promise<string[]> {
|
|
49
|
+
const r = await podman(["ps", "-aq", "--filter", labelFilter])
|
|
50
|
+
return r.code === 0 ? lines(r.out) : []
|
|
51
|
+
}
|
|
52
|
+
async function workspaceImageIds(): Promise<string[]> {
|
|
53
|
+
const r = await podman(["images", "--filter", labelFilter, "--format", "{{.ID}}"])
|
|
54
|
+
return r.code === 0 ? [...new Set(lines(r.out))] : []
|
|
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
|
+
}
|
|
60
|
+
|
|
61
|
+
/** TTY confirm. Non-interactive (piped) without --yes → treated as "no". */
|
|
62
|
+
function confirm(question: string): boolean {
|
|
63
|
+
const ans = prompt(question)
|
|
64
|
+
return ans !== null && /^y(es)?$/i.test(ans.trim())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function runUninstall(argv: string[]): Promise<void> {
|
|
68
|
+
const yes = argv.includes("--yes") || argv.includes("-y")
|
|
69
|
+
const hasPodman = await podmanAvailable()
|
|
70
|
+
const containers = hasPodman ? await workspaceContainers() : []
|
|
71
|
+
const images = hasPodman ? await workspaceImageIds() : []
|
|
72
|
+
const networks = hasPodman ? await workspaceNetworks() : []
|
|
73
|
+
const dataExists = existsSync(LOOPAT_HOME)
|
|
74
|
+
|
|
75
|
+
// ── Plan (the user sees the exact boundary before anything happens) ──
|
|
76
|
+
console.log(`loopat uninstall — workspace "${WORKSPACE}"`)
|
|
77
|
+
console.log("")
|
|
78
|
+
console.log("Will remove (this workspace only):")
|
|
79
|
+
console.log(` • ${containers.length} sandbox container(s)`)
|
|
80
|
+
console.log(` • ${images.length} sandbox image(s)`)
|
|
81
|
+
console.log(` • ${networks.length} network(s)${networks.length ? ` (${networks.join(", ")})` : ""}`)
|
|
82
|
+
console.log(` • data dir: ${LOOPAT_HOME}${dataExists ? "" : " (absent)"}`)
|
|
83
|
+
if (!hasPodman) console.log(" • note: podman not found — skipping container/image/network cleanup")
|
|
84
|
+
console.log("")
|
|
85
|
+
|
|
86
|
+
if (!yes && !confirm("Proceed? This permanently deletes this workspace's data. [y/N] ")) {
|
|
87
|
+
console.log("Aborted — nothing removed.")
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Every resource below is label-scoped to THIS workspace — no shared-resource
|
|
92
|
+
// guessing, so other workspaces on the host are never touched.
|
|
93
|
+
if (hasPodman && containers.length) {
|
|
94
|
+
process.stdout.write(`Removing ${containers.length} container(s)… `)
|
|
95
|
+
await podman(["rm", "-f", ...containers])
|
|
96
|
+
console.log("done")
|
|
97
|
+
}
|
|
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")
|
|
109
|
+
}
|
|
110
|
+
if (dataExists) {
|
|
111
|
+
process.stdout.write(`Removing data dir ${LOOPAT_HOME}… `)
|
|
112
|
+
await rm(LOOPAT_HOME, { recursive: true, force: true })
|
|
113
|
+
console.log("done")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Second-layer hints — shared infra we deliberately do NOT touch.
|
|
117
|
+
console.log("")
|
|
118
|
+
console.log("Done. This workspace's resources are gone.")
|
|
119
|
+
console.log("")
|
|
120
|
+
console.log("Left untouched (shared host infra — remove yourself only if loopat was their only user):")
|
|
121
|
+
if (process.platform === "darwin") {
|
|
122
|
+
console.log(" • podman machine (Linux VM): podman machine stop && podman machine rm")
|
|
123
|
+
}
|
|
124
|
+
console.log(` • npx/bun cache: rm -rf ${join(homedir(), ".npm", "_npx")}`)
|
|
125
|
+
console.log("")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (import.meta.main) {
|
|
129
|
+
runUninstall(process.argv.slice(2)).catch((e) => {
|
|
130
|
+
console.error(`[loopat] uninstall failed: ${e?.message ?? e}`)
|
|
131
|
+
process.exit(1)
|
|
132
|
+
})
|
|
133
|
+
}
|