@spunto/build 0.16.0 → 0.17.0

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.
@@ -0,0 +1,1174 @@
1
+ // Spunto sandbox agent (RFC 0035) — runs *inside* a sandbox, whatever runs the sandbox.
2
+ //
3
+ // One file, no dependencies, Node >= 22 (global WebSocket). It dials the platform, says hello with
4
+ // the sandbox's own token, and then serves what the platform asks: run a command, open a terminal,
5
+ // report its health. The provider that booted the box never sees any of it — which is the point:
6
+ // a Docker host, a Kubernetes pod and a boat.dev VM all get the same data plane for free.
7
+ //
8
+ // Env: SPUNTO_API_URL, SPUNTO_SANDBOX_TOKEN (required), SPUNTO_WORKSPACE (default /workspace),
9
+ // SPUNTO_AGENT_CONNECT_URL (default $SPUNTO_API_URL/api/sandbox-agent/connect, ws: or wss:).
10
+ //
11
+ // Shipped in @spunto/build (`./sandbox-agent`), served as is by whichever control plane runs the
12
+ // box (RFC 0047); the wire is written down in `./agent-protocol`.
13
+
14
+ import { spawn, execFileSync } from "node:child_process"
15
+ import { mkdirSync, existsSync, readdirSync, readFileSync, readlinkSync, writeFileSync, openSync, statSync, statfsSync } from "node:fs"
16
+ import { writeFile } from "node:fs/promises"
17
+ import { dirname, join } from "node:path"
18
+ import { fileURLToPath } from "node:url"
19
+ import os from "node:os"
20
+ import net from "node:net"
21
+
22
+ const VERSION = "0.10.0"
23
+ const API = (process.env.SPUNTO_API_URL ?? "").replace(/\/$/, "")
24
+ // `let`: a fork (RFC 0039 § 4.10) hands this process the identity of the copy it now runs in.
25
+ let TOKEN = process.env.SPUNTO_SANDBOX_TOKEN ?? ""
26
+ const CONNECT_URL = process.env.SPUNTO_AGENT_CONNECT_URL || `${API.replace(/^http/, "ws")}/api/sandbox-agent/connect`
27
+ const WORKSPACE = process.env.SPUNTO_WORKSPACE || "/workspace"
28
+ const MAX_OUTPUT = 1024 * 1024 // per stream, per exec
29
+
30
+ if (!API || !TOKEN) {
31
+ console.error("[sandbox-agent] SPUNTO_API_URL and SPUNTO_SANDBOX_TOKEN are required")
32
+ process.exit(2)
33
+ }
34
+ if (typeof WebSocket === "undefined") {
35
+ console.error(`[sandbox-agent] Node ${process.versions.node} has no global WebSocket — need Node >= 22`)
36
+ process.exit(2)
37
+ }
38
+
39
+ try {
40
+ mkdirSync(WORKSPACE, { recursive: true })
41
+ } catch {}
42
+ const CWD = existsSync(WORKSPACE) ? WORKSPACE : os.homedir()
43
+
44
+ // Child processes must not inherit the agent's credential: a command run in the sandbox is the
45
+ // user's code, and the token would let it impersonate the agent.
46
+ const baseEnv = { ...process.env }
47
+ delete baseEnv.SPUNTO_SANDBOX_TOKEN
48
+ delete baseEnv.SPUNTO_AGENT_PIDFILE
49
+ delete baseEnv.SPUNTO_AGENT_CONNECT_URL
50
+ // Plus the project's environment (secrets…) once a project plan arrived — see `applyProject`.
51
+ let childEnv = { ...baseEnv }
52
+
53
+ const has = (bin) => {
54
+ try {
55
+ execFileSync("sh", ["-c", `command -v ${bin}`], { stdio: "ignore" })
56
+ return true
57
+ } catch {
58
+ return false
59
+ }
60
+ }
61
+ const SHELL = has("bash") ? "bash" : "sh"
62
+ const HAS_SCRIPT = has("script")
63
+ // python3 alone is not enough: a "minimal" python (pulled in by some feature, seen for real on
64
+ // base:ubuntu + node) has no `pty` module. Ask for the modules the PTY helper actually imports.
65
+ // SPUNTO_SANDBOX_PTY=script forces the fallback (the agent's own tests, which run both).
66
+ const HAS_PYTHON = (() => {
67
+ if (process.env.SPUNTO_SANDBOX_PTY === "script") return false
68
+ try {
69
+ execFileSync("python3", ["-c", "import pty, termios, fcntl, select, struct"], { stdio: "ignore" })
70
+ return true
71
+ } catch {
72
+ return false
73
+ }
74
+ })()
75
+ const HAS_RUNUSER = has("runuser")
76
+
77
+ // ── project (RFC 0035: a sandbox of a project gets what a worker of it gets) ──────
78
+ const DIR = join(dirname(fileURLToPath(import.meta.url)), "project")
79
+ // The worker script's home (overridable for the agent's own tests only).
80
+ const HOME_DIR = process.env.SPUNTO_SANDBOX_HOME || "/home/vscode"
81
+ const MP_STATUS = join(HOME_DIR, ".mp-status.json")
82
+ const SETUP_DONE = join(HOME_DIR, ".mp-setup-done")
83
+ let project = null // { plan, runner, exitCode, startedHere }
84
+ let runAs = null // user terminals and commands run as, once the project recipe created it
85
+
86
+ function userExists(name) {
87
+ try {
88
+ execFileSync("id", ["-u", name], { stdio: "ignore" })
89
+ return true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
95
+ /** [cmd, args] running `argv` as `runAs` when there is one (root otherwise). */
96
+ function asUser(argv) {
97
+ if (!runAs || process.getuid?.() !== 0 || !userExists(runAs)) return argv
98
+ if (HAS_RUNUSER) return ["runuser", ["-u", runAs, "--", argv[0], ...argv[1]]]
99
+ return ["su", [runAs, "-s", "/bin/sh", "-c", [argv[0], ...argv[1]].map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ")]]
100
+ }
101
+
102
+ /** What the feature recipe exported (see runner.sh), NUL-separated `K=V` — re-read on every spawn. */
103
+ function featureEnv() {
104
+ try {
105
+ const out = {}
106
+ for (const kv of readFileSync(join(DIR, "env.list"), "utf8").split("\0")) {
107
+ const i = kv.indexOf("=")
108
+ if (i > 0) out[kv.slice(0, i)] = kv.slice(i + 1)
109
+ }
110
+ return out
111
+ } catch {
112
+ return {}
113
+ }
114
+ }
115
+
116
+ function userEnv(extra = {}) {
117
+ const env = { ...childEnv, ...featureEnv(), ...extra }
118
+ if (runAs && userExists(runAs)) {
119
+ env.HOME = `/home/${runAs}`
120
+ env.USER = runAs
121
+ env.LOGNAME = runAs
122
+ }
123
+ return env
124
+ }
125
+
126
+ // The runner (agent/runner.sh) comes with the plan: the same script a provider's build runs.
127
+
128
+ function runnerPid() {
129
+ try {
130
+ const pid = Number(readFileSync(join(DIR, "runner.pid"), "utf8"))
131
+ // runner.sh execs worker.sh: either way the command line names a script of this directory.
132
+ if (pid && readFileSync(`/proc/${pid}/cmdline`, "utf8").includes(DIR)) return pid
133
+ } catch {}
134
+ return null
135
+ }
136
+
137
+ /** This boot of the box: the same after a resume (its RAM loaded), a new one after any boot. */
138
+ function bootId() {
139
+ try {
140
+ return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()
141
+ } catch {
142
+ return null
143
+ }
144
+ }
145
+
146
+ /** The runner's last start: under which boot, and how it ended (`exitCode`, null while it runs or unknown). */
147
+ const STARTED = () => join(DIR, "started.json")
148
+ function lastStart() {
149
+ try {
150
+ return JSON.parse(readFileSync(STARTED(), "utf8"))
151
+ } catch {
152
+ return null
153
+ }
154
+ }
155
+
156
+ function tail(file, bytes = 4000) {
157
+ try {
158
+ const size = statSync(file).size
159
+ const buf = readFileSync(file)
160
+ return buf.subarray(Math.max(0, size - bytes)).toString("utf8")
161
+ } catch {
162
+ return ""
163
+ }
164
+ }
165
+
166
+ /**
167
+ * The worker script said `ready` for *this* start of the runner — postStart included. The marker of
168
+ * the one-time setup is on the box's disk since its first boot (or its build): on its own, it
169
+ * called the box running at every start while postStart was still going. `.mp-status.json` is on
170
+ * that disk too, so a `ready` older than the runner's start is the previous boot's.
171
+ */
172
+ function startDone(worker) {
173
+ if (worker?.phase !== "ready") return false
174
+ try {
175
+ return statSync(MP_STATUS).mtimeMs >= statSync(STARTED()).mtimeMs
176
+ } catch {
177
+ return true // a runner started by an agent that kept no started.json: its word is all there is
178
+ }
179
+ }
180
+
181
+ function projectState() {
182
+ if (!project) return null
183
+ const { plan } = project
184
+ const baked = existsSync(join(DIR, `.baked-${plan.bakeKey}`))
185
+ const alive = runnerPid() != null
186
+ let worker = null
187
+ try {
188
+ worker = JSON.parse(readFileSync(MP_STATUS, "utf8"))
189
+ } catch {}
190
+ const phase = !alive && project.exitCode != null && project.exitCode !== 0
191
+ ? "failed"
192
+ : plan.bakeOnly
193
+ ? baked && !alive ? "baked" : alive ? "baking" : "pending"
194
+ : !baked
195
+ ? alive ? "baking" : "pending"
196
+ : existsSync(SETUP_DONE)
197
+ ? alive ? (startDone(worker) ? "running" : "setting-up") : "exited"
198
+ : alive ? "setting-up" : "exited"
199
+ return {
200
+ phase,
201
+ version: plan.version,
202
+ bakeKey: plan.bakeKey,
203
+ exitCode: project.exitCode,
204
+ worker,
205
+ logTail: tail(join(DIR, "project.log")),
206
+ at: new Date().toISOString(),
207
+ }
208
+ }
209
+
210
+ function reportProject() {
211
+ const state = projectState()
212
+ if (state) send({ type: "project_state", state })
213
+ }
214
+
215
+ // The member's git credential (RFC 0025), from a file this agent keeps current rather than from
216
+ // the env of the process asking. The setup's helper reads $SPUNTO_WORKER_TOKEN, frozen in every
217
+ // shell, code-server and session started since: in a fork or a member's model (RFC 0039 § 4.10),
218
+ // that is the source's token. A system helper comes before the user's: it answers with the token
219
+ // of the box it runs in, and says nothing when it has none (the setup's helper is asked next).
220
+ const GIT_HELPER = "/opt/spunto-sandbox/git-credential"
221
+ const GIT_HELPER_SOURCE = `#!/bin/sh
222
+ # Spunto sandbox: the member's git credential, with the token the agent keeps in ~/.spunto/git-token.
223
+ [ "$1" = get ] || exit 0
224
+ T=$(cat "$HOME/.spunto/git-token" 2>/dev/null); A=$(cat "$HOME/.spunto/api-url" 2>/dev/null)
225
+ [ -n "$T" ] && [ -n "$A" ] || exit 0
226
+ REPO=
227
+ while IFS= read -r line; do case "$line" in path=*) REPO="\${line#path=}" ;; "") break ;; esac; done
228
+ REPO="\${REPO%.git}"
229
+ R=$(curl -fsS -m 20 -X POST "$A/api/workers/git-credential" -H "Authorization: Bearer $T" -H "Content-Type: application/json" -d "{\\"repo\\":\\"$REPO\\"}" 2>/dev/null) || exit 0
230
+ P=$(printf %s "$R" | sed -n 's/.*"password":"\\([^"]*\\)".*/\\1/p')
231
+ [ -n "$P" ] || exit 0
232
+ echo "username=$(printf %s "$R" | sed -n 's/.*"username":"\\([^"]*\\)".*/\\1/p')"
233
+ echo "password=$P"
234
+ `
235
+ function refreshGitIdentity(plan) {
236
+ const token = plan.env?.SPUNTO_WORKER_TOKEN
237
+ if (!token || !runAs || process.getuid?.() !== 0 || !userExists(runAs)) return
238
+ try {
239
+ const dir = `/home/${runAs}/.spunto`
240
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
241
+ writeFileSync(`${dir}/git-token`, `${token}\n`, { mode: 0o600 })
242
+ writeFileSync(`${dir}/api-url`, `${plan.env.SPUNTO_API_URL ?? API}\n`, { mode: 0o600 })
243
+ execFileSync("chown", [`${runAs}:`, dir, `${dir}/git-token`, `${dir}/api-url`], { stdio: "ignore" })
244
+ mkdirSync("/opt/spunto-sandbox", { recursive: true })
245
+ writeFileSync(GIT_HELPER, GIT_HELPER_SOURCE, { mode: 0o755 })
246
+ if (!has("git")) return
247
+ const helpers = (() => {
248
+ try {
249
+ return execFileSync("git", ["config", "--system", "--get-all", "credential.helper"], { encoding: "utf8" })
250
+ } catch {
251
+ return ""
252
+ }
253
+ })()
254
+ if (!helpers.split("\n").includes(GIT_HELPER)) execFileSync("git", ["config", "--system", "--add", "credential.helper", GIT_HELPER], { stdio: "ignore" })
255
+ } catch (err) {
256
+ console.warn(`[sandbox-agent] git identity not written: ${err.message}`)
257
+ }
258
+ }
259
+
260
+ /**
261
+ * The image's own ENV (the plan's `imageEnv`), for a box whose provider booted the image's files
262
+ * without it (a microVM): its PATH in front of ours — `claude` in spunto-worker-base is in
263
+ * /usr/local/share/npm-global/bin, which only the image's ENV says —, anything else only if unset.
264
+ */
265
+ function underImageEnv(env, image) {
266
+ if (!image || typeof image !== "object") return {}
267
+ const out = {}
268
+ for (const [k, v] of Object.entries(image)) {
269
+ if (typeof v !== "string") continue
270
+ if (k === "PATH") {
271
+ const seen = new Set()
272
+ out.PATH = [...v.split(":"), ...(env.PATH ?? "").split(":")].filter((p) => p && !seen.has(p) && seen.add(p)).join(":")
273
+ } else if (env[k] == null) out[k] = v
274
+ }
275
+ return out
276
+ }
277
+
278
+ function applyProject(plan) {
279
+ mkdirSync(DIR, { recursive: true, mode: 0o700 })
280
+ writeFileSync(join(DIR, "bake.sh"), plan.bake, { mode: 0o700 })
281
+ writeFileSync(join(DIR, "worker.sh"), plan.worker, { mode: 0o700 })
282
+ writeFileSync(join(DIR, "runner.sh"), plan.runner, { mode: 0o700 })
283
+ childEnv = { ...baseEnv, ...underImageEnv(baseEnv, plan.imageEnv), ...plan.env }
284
+ runAs = plan.user || null
285
+ refreshGitIdentity(plan)
286
+ project = { plan, exitCode: project?.exitCode ?? null }
287
+
288
+ if (runnerPid()) {
289
+ // Already running (this agent restarted, or merely reconnected): leave the user's processes
290
+ // alone. A new project version is applied at the next start of the box.
291
+ reportProject()
292
+ return
293
+ }
294
+ // Not running, but already started in this very boot: its runner ended (postStart done, nothing
295
+ // left in the background) and the box was resumed with its RAM, or the agent merely reconnected.
296
+ // postStart runs once per boot — never at a resume (RFC 0039 § 20). Only a runner that is known to
297
+ // have failed is started again, as before.
298
+ const boot = bootId()
299
+ const last = lastStart()
300
+ if (boot && last?.boot === boot && !(last.exitCode != null && last.exitCode !== 0)) {
301
+ project.exitCode = last.exitCode ?? null
302
+ reportProject()
303
+ return
304
+ }
305
+ const log = openSync(join(DIR, "project.log"), "a")
306
+ // Before the spawn: the worker script's `ready` of this start must be newer than it (startDone).
307
+ writeFileSync(STARTED(), JSON.stringify({ boot, version: plan.version, exitCode: null, at: new Date().toISOString() }))
308
+ // Detached, in its own session: it outlives an agent restart, like a container's CMD would.
309
+ const child = spawn("bash", [join(DIR, "runner.sh"), DIR, plan.bakeKey, plan.bakeOnly ? "bake-only" : "full"], {
310
+ cwd: "/",
311
+ env: { ...childEnv, HOME: "/root" },
312
+ detached: true,
313
+ stdio: ["ignore", log, log],
314
+ })
315
+ writeFileSync(join(DIR, "runner.pid"), String(child.pid))
316
+ project.exitCode = null
317
+ child.on("exit", (code) => {
318
+ project.exitCode = code ?? 1
319
+ try {
320
+ writeFileSync(STARTED(), JSON.stringify({ ...lastStart(), exitCode: project.exitCode, endedAt: new Date().toISOString() }))
321
+ } catch {}
322
+ reportProject()
323
+ })
324
+ child.unref()
325
+ console.log(`[sandbox-agent] project ${plan.projectId} v${plan.version}: runner ${child.pid}`)
326
+ reportProject()
327
+ }
328
+
329
+ // A change of phase goes out at once — the platform waits on `running` to call the box ready, and
330
+ // riding the heartbeat cost ~14 s on every create and every start (measured on boat). Otherwise,
331
+ // every 5 s while something is happening, else the heartbeat cadence.
332
+ let reportedPhase = null
333
+ let reportedAt = 0
334
+ setInterval(() => {
335
+ const st = projectState()
336
+ if (!st) return
337
+ const busy = ["baking", "setting-up", "pending"].includes(st.phase)
338
+ if (st.phase !== reportedPhase || (busy && Date.now() - reportedAt >= 5_000)) {
339
+ reportedPhase = st.phase
340
+ reportedAt = Date.now()
341
+ send({ type: "project_state", state: st })
342
+ }
343
+ }, 1_000).unref()
344
+
345
+ let ws = null
346
+ let backoff = 1000
347
+ const terminals = new Map() // termId → child
348
+ const execs = new Map() // execId → child
349
+
350
+ function send(msg) {
351
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg))
352
+ }
353
+
354
+ function info() {
355
+ return {
356
+ agentVersion: VERSION,
357
+ hostname: os.hostname(),
358
+ platform: `${os.type()} ${os.release()}`,
359
+ arch: os.arch(),
360
+ node: process.versions.node,
361
+ cpus: boxResources().cpus,
362
+ memTotal: boxResources().memTotal,
363
+ shell: SHELL,
364
+ pty: HAS_PYTHON ? "python3" : HAS_SCRIPT ? "script" : "none",
365
+ workspace: CWD,
366
+ }
367
+ }
368
+
369
+ /**
370
+ * The box's own resources, not the host's: in a container `os.totalmem()` and the load average
371
+ * are the machine's (a 2 GB sandbox showed 22 GB used). cgroup v2 says what this box has and
372
+ * uses; a VM (boat, GCE) has no limit there and falls back to `os`, which is then right.
373
+ */
374
+ const CG = "/sys/fs/cgroup"
375
+ let lastCpu = null // { usec, at }
376
+ function readNum(file) {
377
+ try {
378
+ const v = readFileSync(file, "utf8").trim()
379
+ return v === "max" ? null : Number(v.split(/\s+/)[0])
380
+ } catch {
381
+ return undefined
382
+ }
383
+ }
384
+ function boxResources() {
385
+ const memCurrent = readNum(`${CG}/memory.current`)
386
+ const memMax = readNum(`${CG}/memory.max`)
387
+ let cpuPercent = null
388
+ let cpus = os.cpus().length
389
+ try {
390
+ const [quota, period] = readFileSync(`${CG}/cpu.max`, "utf8").trim().split(/\s+/)
391
+ if (quota !== "max") cpus = Number(quota) / Number(period)
392
+ } catch {}
393
+ try {
394
+ const usec = Number(/usage_usec (\d+)/.exec(readFileSync(`${CG}/cpu.stat`, "utf8"))?.[1])
395
+ const at = Date.now()
396
+ if (lastCpu && at > lastCpu.at) cpuPercent = Math.min(100, ((usec - lastCpu.usec) / 1000 / (at - lastCpu.at) / cpus) * 100)
397
+ lastCpu = { usec, at }
398
+ } catch {}
399
+ const inCgroup = typeof memCurrent === "number"
400
+ const memTotal = inCgroup && memMax ? memMax : os.totalmem()
401
+ const memUsed = inCgroup ? memCurrent : os.totalmem() - os.freemem()
402
+ return { memTotal, memFree: Math.max(0, memTotal - memUsed), cpuPercent: cpuPercent == null ? null : Math.round(cpuPercent), cpus: Math.round(cpus * 10) / 10 }
403
+ }
404
+
405
+ /**
406
+ * How full the system disk and /workspace are — `statfs`, no walk: cheap enough for every call. What
407
+ * a full `/` looked like before this (RFC 0039 § 4.12): a terminal saying a dtach socket did not
408
+ * exist, its `mkdir` failing with "No space left on device" out of sight.
409
+ */
410
+ function diskUsage() {
411
+ const out = []
412
+ for (const path of ["/", WORKSPACE]) {
413
+ try {
414
+ const st = statfsSync(path)
415
+ const sizeMb = Math.round((st.blocks * st.bsize) / 1024 ** 2)
416
+ // `bavail`, not `bfree`: what a user can still write (root's reserve excluded).
417
+ const freeMb = Math.round((st.bavail * st.bsize) / 1024 ** 2)
418
+ if (sizeMb > 0 && !out.some((d) => d.sizeMb === sizeMb && d.freeMb === freeMb)) out.push({ path, sizeMb, freeMb })
419
+ } catch {}
420
+ }
421
+ return out
422
+ }
423
+
424
+ function heartbeat() {
425
+ send({ type: "heartbeat" })
426
+ reportProject()
427
+ }
428
+
429
+ /**
430
+ * Gives the host back the page cache nobody reads any more (RFC 0039 § 15). In a microVM the
431
+ * guest's cache is RAM the VMM holds: the guest counts it as available and never drops it without
432
+ * pressure of its own, while the host sees it in the box's memory, its slice fills up, and the
433
+ * kernel kills a box (one showed 3 GB used inside, 8 GB outside). The balloon's free page reporting
434
+ * returns what the guest *frees*: so every minute, the cold half of the cache (`Inactive(file)`,
435
+ * pages read once and not since) is reclaimed through the root cgroup — the hot files stay cached,
436
+ * unlike `drop_caches`. Only with a balloon (Firecracker); a container has no business doing it.
437
+ *
438
+ * Freed is not enough: the balloon only reports free blocks of 2 MB and more, and a cache dropped
439
+ * page by page leaves holes between the pages still in use. Measured on Canada (2026-10-16): 550 MB
440
+ * of cache reclaimed in the guest, 40 MB back on the host; then a compaction, 377 MB (2.3 s of the
441
+ * kernel's CPU). So when the free memory in blocks under 2 MB (`/proc/buddyinfo`, orders 0 to 8)
442
+ * passes 256 MB, the guest is compacted, every 10 minutes at most.
443
+ */
444
+ const RECLAIM_MIN_BYTES = 256 * 1024 ** 2
445
+ const COMPACT_EVERY_MS = 10 * 60_000
446
+ let compactedAt = 0
447
+ const hasBalloon = (() => {
448
+ try {
449
+ return readdirSync("/sys/bus/virtio/devices").some((d) => readFileSync(`/sys/bus/virtio/devices/${d}/device`, "utf8").trim() === "0x0005")
450
+ } catch {
451
+ return false
452
+ }
453
+ })()
454
+ let reclaiming = false
455
+ async function reclaimColdCache() {
456
+ if (reclaiming) return
457
+ let bytes = 0
458
+ try {
459
+ bytes = Number(/^Inactive\(file\):\s+(\d+) kB$/m.exec(readFileSync("/proc/meminfo", "utf8"))?.[1]) * 1024
460
+ } catch {}
461
+ reclaiming = true
462
+ // On the thread pool: reclaiming a few GB takes the kernel a second or so. EAGAIN when it could
463
+ // not reach the whole amount (pages in use meanwhile): what it did reclaim is gone all the same.
464
+ if (bytes >= RECLAIM_MIN_BYTES) await writeFile(`${CG}/memory.reclaim`, String(bytes)).catch(() => {})
465
+ if (Date.now() - compactedAt >= COMPACT_EVERY_MS && fragmentedFreeBytes() >= RECLAIM_MIN_BYTES) {
466
+ compactedAt = Date.now()
467
+ await writeFile("/proc/sys/vm/compact_memory", "1").catch(() => {})
468
+ }
469
+ reclaiming = false
470
+ }
471
+
472
+ /** Free memory in blocks too small for the balloon to report (under 2 MB: orders 0 to 8 of 4 KB pages). */
473
+ function fragmentedFreeBytes() {
474
+ let pages = 0
475
+ try {
476
+ for (const line of readFileSync("/proc/buddyinfo", "utf8").split("\n")) {
477
+ const counts = line.split(/\s+/).slice(4).map(Number)
478
+ for (let order = 0; order < 9 && order < counts.length; order++) pages += counts[order] * 2 ** order
479
+ }
480
+ } catch {}
481
+ return pages * 4096
482
+ }
483
+
484
+ // ── exec ──────────────────────────────────────────────────────────────────────
485
+ function runExec(msg) {
486
+ const started = Date.now()
487
+ const [cmd, args] = asUser([SHELL, ["-lc", msg.command]])
488
+ const child = spawn(cmd, args, {
489
+ cwd: msg.cwd || CWD,
490
+ env: userEnv(msg.env ?? {}),
491
+ stdio: ["ignore", "pipe", "pipe"],
492
+ })
493
+ execs.set(msg.id, child)
494
+ const out = { stdout: [], stderr: [], size: { stdout: 0, stderr: 0 }, truncated: false }
495
+ const collect = (stream) => (chunk) => {
496
+ if (out.size[stream] >= MAX_OUTPUT) {
497
+ out.truncated = true
498
+ return
499
+ }
500
+ const room = MAX_OUTPUT - out.size[stream]
501
+ const piece = chunk.length > room ? chunk.subarray(0, room) : chunk
502
+ if (piece.length < chunk.length) out.truncated = true
503
+ out[stream].push(piece)
504
+ out.size[stream] += piece.length
505
+ }
506
+ child.stdout.on("data", collect("stdout"))
507
+ child.stderr.on("data", collect("stderr"))
508
+ let timedOut = false
509
+ const timer = setTimeout(() => {
510
+ timedOut = true
511
+ child.kill("SIGKILL")
512
+ }, Math.min(Math.max(Number(msg.timeoutMs) || 60_000, 1000), 30 * 60_000))
513
+ const finish = (exitCode, error) => {
514
+ clearTimeout(timer)
515
+ execs.delete(msg.id)
516
+ send({
517
+ type: "exec_result",
518
+ id: msg.id,
519
+ exitCode,
520
+ timedOut,
521
+ error,
522
+ stdout: Buffer.concat(out.stdout).toString("utf8"),
523
+ stderr: Buffer.concat(out.stderr).toString("utf8"),
524
+ truncated: out.truncated,
525
+ durationMs: Date.now() - started,
526
+ })
527
+ }
528
+ child.on("error", (err) => finish(null, err.message))
529
+ child.on("close", (code, signal) => finish(code ?? (signal ? 128 : null)))
530
+ }
531
+
532
+ // ── terminals ─────────────────────────────────────────────────────────────────
533
+ // A real PTY without native modules: python3's `pty` when present (it also handles resizes, via a
534
+ // tiny control protocol on fd 3), else util-linux `script`, else a plain interactive shell.
535
+ const PY_PTY = `
536
+ import os, pty, sys, select, struct, fcntl, termios, signal
537
+ cols, rows = int(sys.argv[1]), int(sys.argv[2])
538
+ # The control pipe is ours alone: a program started in the pty must not hold it open for life.
539
+ os.set_inheritable(3, False)
540
+ pid, fd = pty.fork()
541
+ if pid == 0:
542
+ os.execvp(sys.argv[3], sys.argv[3:])
543
+ def setsize(c, r):
544
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", r, c, 0, 0))
545
+ setsize(cols, rows)
546
+ ctl = os.fdopen(3, "rb", 0)
547
+ buf = b""
548
+ # Input is written to the pty without blocking, from a queue: a blocking write of a paste bigger
549
+ # than the pty's input queue stopped this loop reading the program's output, and the program,
550
+ # echoing the paste, blocked writing it — both stuck for good. Past 1 MB queued, stdin waits.
551
+ os.set_blocking(fd, False)
552
+ pending = b""
553
+ while True:
554
+ try:
555
+ r, w, _ = select.select([fd, ctl] + ([0] if len(pending) < 1048576 else []), [fd] if pending else [], [])
556
+ except InterruptedError:
557
+ continue
558
+ if fd in w:
559
+ try:
560
+ pending = pending[os.write(fd, pending[:65536]):]
561
+ except BlockingIOError:
562
+ pass
563
+ except OSError:
564
+ break
565
+ if fd in r:
566
+ try:
567
+ data = os.read(fd, 65536)
568
+ except BlockingIOError:
569
+ data = None
570
+ except OSError:
571
+ break
572
+ if data == b"":
573
+ break
574
+ if data:
575
+ os.write(1, data)
576
+ if 0 in r:
577
+ data = os.read(0, 65536)
578
+ if not data:
579
+ break
580
+ pending += data
581
+ if ctl in r:
582
+ data = ctl.read(64)
583
+ if not data:
584
+ continue
585
+ buf += data
586
+ while b"\\n" in buf:
587
+ line, buf = buf.split(b"\\n", 1)
588
+ try:
589
+ c, r2 = map(int, line.split())
590
+ setsize(c, r2)
591
+ os.kill(pid, signal.SIGWINCH)
592
+ except Exception:
593
+ pass
594
+ try:
595
+ _, status = os.waitpid(pid, 0)
596
+ sys.exit(os.waitstatus_to_exitcode(status))
597
+ except Exception:
598
+ sys.exit(0)
599
+ `
600
+
601
+ /** `script` fallback: find the pty its shell got, set the size on it, and tell the shell. */
602
+ function resizeScriptPty(scriptPid, cols, rows) {
603
+ try {
604
+ const child = readdirSync("/proc").find((d) => {
605
+ if (!/^\d+$/.test(d)) return false
606
+ try {
607
+ return readFileSync(`/proc/${d}/stat`, "utf8").split(") ")[1]?.split(" ")[1] === String(scriptPid)
608
+ } catch {
609
+ return false
610
+ }
611
+ })
612
+ if (!child) return
613
+ const pty = readlinkSync(`/proc/${child}/fd/0`)
614
+ if (!pty.startsWith("/dev/pts/")) return
615
+ execFileSync("stty", ["-F", pty, "cols", String(cols), "rows", String(rows)], { stdio: "ignore" })
616
+ process.kill(-Number(child), "SIGWINCH")
617
+ } catch {}
618
+ }
619
+
620
+ // What a terminal's environment says about the terminal: xterm.js, 256 colours and 24-bit ones.
621
+ const TERM_ENV = { TERM: "xterm-256color", COLORTERM: "truecolor" }
622
+
623
+ /**
624
+ * The program a session runs, as positional parameters (`"$@"`): the login shell under script(1),
625
+ * as in a worker's terminal (apps/agent terminal-dtach.ts). script records the session's output
626
+ * for the replay of a reattach, and it sits between dtach and the shell: straight over the shell,
627
+ * a paste bigger than the pty's input queue (~4 KB) deadlocked dtach's master, blocked writing the
628
+ * paste in, against the shell, blocked writing its echo out — the session was dead for good,
629
+ * Ctrl-C and reattach included (BAC-18). Without script: the shell, which works, minus both.
630
+ */
631
+ const SESSION_PROGRAM = [
632
+ '_SH=$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7); _SH=${_SH:-/bin/bash}',
633
+ // script runs `-c` through $SHELL, and what the session starts reads it too.
634
+ 'export SHELL="$_SH"',
635
+ 'LOG="$D/$MP_TERM_SESSION.log"',
636
+ 'if command -v script >/dev/null 2>&1; then set -- script -q -a -f "$LOG" -c "$_SH -l"; else set -- "$_SH" -l; fi',
637
+ ].join("\n")
638
+
639
+ /** Bytes of a session's recorded output replayed to a reattach, and the log's bounds (a worker's). */
640
+ const REPLAY_BYTES = 262144
641
+ const REPLAY_MIN_BYTES = 512 // a fresh "+" session has recorded its prompt: nothing worth a replay
642
+ const MAX_LOG_BYTES = 4194304
643
+ const TRIM_TO = 1048576
644
+
645
+ /**
646
+ * What runs inside the PTY: a named dtach session when dtach is there (spunto-pack bakes it in),
647
+ * so closing the tab or losing the network detaches instead of killing the shell — the worker
648
+ * terminal's behaviour (apps/agent terminal-dtach.ts, same flags). Without dtach: a plain shell.
649
+ * -A attach-or-create · -r winch repaint on attach · -E no detach key · -z Ctrl-Z to the program
650
+ */
651
+ const TERM_SCRIPT = [
652
+ "D=/tmp/spunto-term",
653
+ 'SOCK="$D/$MP_TERM_SESSION.sock"',
654
+ SESSION_PROGRAM,
655
+ // What goes wrong here is said, with the disk's state — then a plain shell rather than nothing:
656
+ // a full `/` showed only `dtach -a`'s "No such file or directory" (RFC 0039 § 4.12).
657
+ 'fail() { printf "\\r\\n\\033[1;31mspunto: %s\\033[0m\\r\\n" "$1"; df -h / /tmp 2>/dev/null | sed "s/$/\\r/"; printf "\\033[2m(a plain shell, without a session that survives the tab)\\033[0m\\r\\n\\r\\n"; exec "$_SH" -l; }',
658
+ 'err=$(mkdir -p "$D" 2>&1) || fail "cannot create $D: $err"',
659
+ // chmod is best effort, as before: a directory made by another user (root, an earlier session)
660
+ // cannot be chmodded but may well be writable — only a directory this user cannot write in fails.
661
+ 'chmod 700 "$D" 2>/dev/null; [ -w "$D" ] || fail "$D is not writable by $(id -un)"',
662
+ 'if ! command -v dtach >/dev/null 2>&1; then exec "$_SH" -l; fi',
663
+ // The session is created detached, like a "+" tab (createSession), then attached. Created by the
664
+ // attach itself (`-A`), it was the attach's child: the session kept this PTY's control pipe (fd 3)
665
+ // and its size variables for life, and the terminal that created it never reported its exit —
666
+ // the one thing "main", the tab every first visit opens, had that the others did not.
667
+ // A socket left by a dead session refuses connections: `-n` cannot bind it, so it goes first. If
668
+ // two attaches race to create it, the loser's `-n` fails and its `-a` joins the winner's.
669
+ // A session that is not live has nothing to replay: its log goes with it.
670
+ 'LIVE=0',
671
+ 'if [ -S "$SOCK" ] && dtach -p "$SOCK" </dev/null >/dev/null 2>&1; then LIVE=1; else rm -f "$SOCK"; : > "$LOG" 2>/dev/null; fi',
672
+ 'if [ ! -S "$SOCK" ]; then err=$(dtach -n "$SOCK" -E -z "$@" </dev/null 2>&1 >/dev/null 3>&-) || [ -S "$SOCK" ] || fail "cannot start the session: ${err:-dtach -n failed}"; fi',
673
+ '[ -S "$SOCK" ] || fail "the session socket $SOCK is not there"',
674
+ // The replay: dtach remembers nothing of the screen, `-r winch` only gets the program to repaint
675
+ // itself — so the tail of the log comes first, after a reset (the tail can start mid-sequence, on
676
+ // the alternate screen, with the mouse grabbed) and without its first, likely truncated, line.
677
+ 'if [ "$LIVE" = 1 ] && [ -f "$LOG" ]; then',
678
+ ' SZ=$(wc -c < "$LOG" 2>/dev/null || echo 0)',
679
+ ` if [ "$SZ" -gt ${MAX_LOG_BYTES} ] 2>/dev/null; then tail -c ${TRIM_TO} "$LOG" > "$LOG.trim" 2>/dev/null && cat "$LOG.trim" > "$LOG" 2>/dev/null; rm -f "$LOG.trim"; fi`,
680
+ ` if [ "$SZ" -gt ${REPLAY_MIN_BYTES} ] 2>/dev/null; then`,
681
+ " printf '\\033[?1049l\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1006l\\033c\\033[2m── replay ──\\033[0m\\r\\n'",
682
+ ` tail -c ${REPLAY_BYTES} "$LOG" 2>/dev/null | tail -n +2`,
683
+ " fi",
684
+ "fi",
685
+ 'exec dtach -a "$SOCK" -r winch -E -z',
686
+ ].join("\n")
687
+
688
+ /**
689
+ * dtach clears the screen as a client attaches (a bare `ESC[H ESC[J`), which would wipe the replay
690
+ * printed just before. Dropped once, in the first seconds of the attach — a worker's terminal does
691
+ * the same (makeDtachAttachClearStripper); `clear` writes other bytes, so it is never touched.
692
+ */
693
+ function attachClearStripper(windowMs = 5000) {
694
+ const CLEAR = Buffer.from("\x1b[H\x1b[J")
695
+ const deadline = Date.now() + windowMs
696
+ let done = false
697
+ return (buf) => {
698
+ if (done || Date.now() > deadline) {
699
+ done = true
700
+ return buf
701
+ }
702
+ const i = buf.indexOf(CLEAR)
703
+ if (i === -1) return buf
704
+ done = true
705
+ return Buffer.concat([buf.subarray(0, i), buf.subarray(i + CLEAR.length)])
706
+ }
707
+ }
708
+
709
+ // ── terminal sessions (the worker's tab strip) ────────────────────────────────
710
+ // A session is a dtach socket in /tmp/spunto-term, nothing more — like the worker agent's.
711
+ const TERM_DIR = "/tmp/spunto-term"
712
+ const sessionName = (n) => String(n || "").replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40)
713
+
714
+ function holders(sock) {
715
+ try {
716
+ return execFileSync("ps", ["-eo", "args="], { encoding: "utf8" }).split("\n").filter((l) => l.includes(sock)).length
717
+ } catch {
718
+ return 0
719
+ }
720
+ }
721
+
722
+ function listSessions() {
723
+ let names = []
724
+ try {
725
+ names = readdirSync(TERM_DIR).filter((f) => f.endsWith(".sock"))
726
+ } catch {}
727
+ return names
728
+ .map((f) => {
729
+ const sock = join(TERM_DIR, f)
730
+ const n = holders(sock)
731
+ if (n === 0) return null // a leftover socket, nobody behind it
732
+ let createdAt = Date.now()
733
+ try {
734
+ createdAt = statSync(sock).ctimeMs
735
+ } catch {}
736
+ // The dtach master holds the socket; any more process is an attached client.
737
+ return { name: f.slice(0, -5), windows: 1, attached: n > 1, createdAt: Math.round(createdAt) }
738
+ })
739
+ .filter(Boolean)
740
+ .sort((a, b) => a.createdAt - b.createdAt)
741
+ }
742
+
743
+ function createSession(requested) {
744
+ const taken = new Set(listSessions().map((s) => s.name))
745
+ let name = sessionName(requested)
746
+ if (!name) {
747
+ let i = 2
748
+ while (taken.has(`shell-${i}`)) i++
749
+ name = taken.has("main") ? `shell-${i}` : "main"
750
+ }
751
+ // Started detached (-n): it exists before anyone attaches, like a new tmux window.
752
+ const script = [
753
+ `D=${TERM_DIR}`,
754
+ 'mkdir -p "$D" || exit 4; chmod 700 "$D" 2>/dev/null; [ -w "$D" ] || exit 4',
755
+ SESSION_PROGRAM,
756
+ 'command -v dtach >/dev/null 2>&1 || exit 3',
757
+ ': > "$LOG" 2>/dev/null',
758
+ 'exec dtach -n "$D/$MP_TERM_SESSION.sock" -E -z "$@"',
759
+ ].join("\n")
760
+ const [cmd, args] = asUser(["sh", ["-c", script]])
761
+ try {
762
+ execFileSync(cmd, args, { cwd: CWD, env: userEnv({ ...TERM_ENV, MP_TERM_SESSION: name }), stdio: ["ignore", "ignore", "pipe"], timeout: 10_000 })
763
+ } catch (err) {
764
+ // Said in the agent's log with the disk's state: a full `/` is the usual cause.
765
+ const full = diskUsage().filter((d) => d.freeMb < 64)
766
+ console.error(`[sandbox-agent] session ${name} not created: ${String(err.stderr || err.message).trim()}${full.length ? ` — full: ${full.map((d) => d.path).join(", ")}` : ""}`)
767
+ }
768
+ return listSessions()
769
+ }
770
+
771
+ function killSession(requested) {
772
+ const sock = join(TERM_DIR, `${sessionName(requested)}.sock`)
773
+ try {
774
+ execFileSync("pkill", ["-f", sock], { stdio: "ignore" })
775
+ } catch {}
776
+ try {
777
+ execFileSync("rm", ["-f", sock, sock.replace(/\.sock$/, ".log")], { stdio: "ignore" })
778
+ } catch {}
779
+ return listSessions()
780
+ }
781
+
782
+ // ── logs: what a worker's "container logs" are — the project runner's output ─────
783
+ function openLogs(msg) {
784
+ const file = join(DIR, "project.log")
785
+ const forward = (chunk) => send({ type: "term_data", termId: msg.termId, data: chunk.toString("base64") })
786
+ if (!existsSync(file)) {
787
+ forward(Buffer.from("[spunto] No project runs in this sandbox (bare image): nothing logs here yet.\r\n"))
788
+ }
789
+ // -F: follows the file across a restart of the runner (it is reopened, not truncated).
790
+ const child = spawn("tail", ["-n", "400", "-F", file], { stdio: ["ignore", "pipe", "pipe"] })
791
+ terminals.set(msg.termId, child)
792
+ child.stdout.on("data", (c) => forward(Buffer.from(c.toString("utf8").replace(/\r?\n/g, "\r\n"))))
793
+ child.on("close", (code) => {
794
+ terminals.delete(msg.termId)
795
+ send({ type: "term_exit", termId: msg.termId, code })
796
+ })
797
+ }
798
+
799
+ function openTerminal(msg) {
800
+ const cols = Math.max(Number(msg.cols) || 120, 20)
801
+ const rows = Math.max(Number(msg.rows) || 30, 5)
802
+ const session = String(msg.session || "main").replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40) || "main"
803
+ // No COLUMNS/LINES: the pty has the size, and a shell that inherits them keeps them past a resize.
804
+ const env = userEnv({ ...TERM_ENV, MP_TERM_SESSION: session })
805
+ const [shCmd, shArgs] = asUser(["sh", ["-c", TERM_SCRIPT]])
806
+ let child
807
+ if (HAS_PYTHON) {
808
+ child = spawn("python3", ["-c", PY_PTY, String(cols), String(rows), shCmd, ...shArgs], {
809
+ cwd: CWD,
810
+ env,
811
+ stdio: ["pipe", "pipe", "pipe", "pipe"],
812
+ })
813
+ } else if (HAS_SCRIPT) {
814
+ // `script` has no size of its own: set it from inside, then resize through the pty (below).
815
+ const login = [shCmd, ...shArgs].map((a) => `'${a.replace(/'/g, `'\\''`)}'`).join(" ")
816
+ child = spawn("script", ["-qfc", `stty cols ${cols} rows ${rows} 2>/dev/null; exec ${login}`, "/dev/null"], {
817
+ cwd: CWD,
818
+ env,
819
+ stdio: ["pipe", "pipe", "pipe"],
820
+ })
821
+ } else {
822
+ child = spawn(SHELL, ["-i"], { cwd: CWD, env: { ...env, COLUMNS: String(cols), LINES: String(rows) }, stdio: ["pipe", "pipe", "pipe"] })
823
+ }
824
+ terminals.set(msg.termId, child)
825
+ const strip = attachClearStripper()
826
+ const forward = (chunk) => send({ type: "term_data", termId: msg.termId, data: strip(chunk).toString("base64") })
827
+ child.stdout.on("data", forward)
828
+ child.stderr.on("data", forward)
829
+ child.on("error", (err) => {
830
+ send({ type: "term_data", termId: msg.termId, data: Buffer.from(`\r\n[agent] ${err.message}\r\n`).toString("base64") })
831
+ })
832
+ child.on("close", (code) => {
833
+ terminals.delete(msg.termId)
834
+ send({ type: "term_exit", termId: msg.termId, code })
835
+ })
836
+ }
837
+
838
+ // ── ports: TCP tunnels to what listens inside the box ─────────────────────────────
839
+ // The platform's proxy reaches `sbx-<id>-<port>.<domain>` through here: the API asks for a tunnel,
840
+ // the agent connects to 127.0.0.1:<port> — it runs inside the box, so no container IP to resolve —
841
+ // and bytes flow both ways as binary frames: [1][id length][id][payload].
842
+ const tunnels = new Map()
843
+ const FRAME_DATA = 1
844
+ const HIGH_WATER = 8 * 1024 * 1024
845
+
846
+ function tunnelFrame(tunnelId, chunk) {
847
+ const id = Buffer.from(tunnelId)
848
+ return Buffer.concat([Buffer.from([FRAME_DATA, id.length]), id, chunk])
849
+ }
850
+
851
+ function openTunnel(msg) {
852
+ const port = Number(msg.port)
853
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return send({ type: "tunnel_close", tunnelId: msg.tunnelId, error: "bad port" })
854
+ const sock = net.connect({ host: "127.0.0.1", port })
855
+ tunnels.set(msg.tunnelId, sock)
856
+ sock.on("connect", () => send({ type: "tunnel_ready", tunnelId: msg.tunnelId }))
857
+ sock.on("data", (chunk) => {
858
+ if (!ws || ws.readyState !== WebSocket.OPEN) return sock.destroy()
859
+ ws.send(tunnelFrame(msg.tunnelId, chunk))
860
+ // Backpressure: a slow browser must not make the agent buffer a whole download.
861
+ if (ws.bufferedAmount > HIGH_WATER) {
862
+ sock.pause()
863
+ const wait = setInterval(() => {
864
+ if (!ws || ws.readyState !== WebSocket.OPEN || ws.bufferedAmount < HIGH_WATER / 4) {
865
+ clearInterval(wait)
866
+ sock.resume()
867
+ }
868
+ }, 20)
869
+ }
870
+ })
871
+ const close = (error) => {
872
+ if (!tunnels.has(msg.tunnelId)) return
873
+ tunnels.delete(msg.tunnelId)
874
+ send({ type: "tunnel_close", tunnelId: msg.tunnelId, ...(error ? { error } : {}) })
875
+ }
876
+ sock.on("error", (err) => close(err.code === "ECONNREFUSED" ? `nothing listens on port ${port}` : err.message))
877
+ sock.on("close", () => close())
878
+ }
879
+
880
+ function tunnelData(buf) {
881
+ if (buf[0] !== FRAME_DATA) return
882
+ const idLen = buf[1]
883
+ const tunnelId = buf.subarray(2, 2 + idLen).toString()
884
+ tunnels.get(tunnelId)?.write(buf.subarray(2 + idLen))
885
+ }
886
+
887
+ /** `node`, `python3`, `code-server`… — argv[0]'s basename; `comm` can be a thread name ("MainThread"). */
888
+ function processName(pid) {
889
+ try {
890
+ const argv = readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean)
891
+ // `node /usr/lib/code-server/out/node/entry.js` says more as "code-server" than as "node".
892
+ const script = argv.slice(1).find((a) => !a.startsWith("-"))
893
+ if (script?.includes("code-server")) return "code-server"
894
+ if (argv[0]) return argv[0].split("/").pop()
895
+ } catch {}
896
+ try {
897
+ return readFileSync(`/proc/${pid}/comm`, "utf8").trim()
898
+ } catch {
899
+ return undefined
900
+ }
901
+ }
902
+
903
+ /** What listens on TCP inside the box, with the process when it can be told: /proc, no `ss` needed. */
904
+ function listeningPorts() {
905
+ const byInode = new Map()
906
+ for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
907
+ let text = ""
908
+ try {
909
+ text = readFileSync(file, "utf8")
910
+ } catch {
911
+ continue
912
+ }
913
+ for (const line of text.split("\n").slice(1)) {
914
+ const f = line.trim().split(/\s+/)
915
+ if (f.length < 10 || f[3] !== "0A") continue // LISTEN
916
+ const port = parseInt(f[1].split(":")[1], 16)
917
+ byInode.set(f[9], port)
918
+ }
919
+ }
920
+ const procs = new Map()
921
+ for (const pid of readdirSync("/proc").filter((d) => /^\d+$/.test(d))) {
922
+ let fds = []
923
+ try {
924
+ fds = readdirSync(`/proc/${pid}/fd`)
925
+ } catch {
926
+ continue
927
+ }
928
+ for (const fd of fds) {
929
+ let target = ""
930
+ try {
931
+ target = readlinkSync(`/proc/${pid}/fd/${fd}`)
932
+ } catch {
933
+ continue
934
+ }
935
+ const m = /^socket:\[(\d+)\]$/.exec(target)
936
+ if (m && byInode.has(m[1]) && !procs.has(m[1])) {
937
+ procs.set(m[1], processName(pid))
938
+ }
939
+ }
940
+ }
941
+ const ports = new Map()
942
+ for (const [inode, port] of byInode) if (!ports.has(port)) ports.set(port, procs.get(inode))
943
+ return [...ports].sort((a, b) => a[0] - b[0]).map(([port, process]) => ({ port, ...(process ? { process } : {}) }))
944
+ }
945
+
946
+ // ── connection ────────────────────────────────────────────────────────────────
947
+ function handle(msg) {
948
+ switch (msg.type) {
949
+ case "welcome":
950
+ backoff = 1000
951
+ resumed = false
952
+ console.log(`[sandbox-agent] connected as sandbox ${msg.sandboxId}`)
953
+ heartbeat()
954
+ return
955
+ case "ping":
956
+ send({ type: "pong", at: msg.at })
957
+ return
958
+ case "project":
959
+ return applyProject(msg)
960
+ case "exec":
961
+ return runExec(msg)
962
+ case "exec_cancel":
963
+ execs.get(msg.id)?.kill("SIGKILL")
964
+ return
965
+ case "term_open":
966
+ return msg.mode === "logs" ? openLogs(msg) : openTerminal(msg)
967
+ case "sessions": {
968
+ const sessions = msg.op === "create" ? createSession(msg.name) : msg.op === "kill" ? killSession(msg.name) : listSessions()
969
+ send({ type: "reply", id: msg.id, result: sessions })
970
+ return
971
+ }
972
+ case "term_input":
973
+ terminals.get(msg.termId)?.stdin.write(Buffer.from(msg.data, "base64"))
974
+ return
975
+ case "term_resize": {
976
+ const t = terminals.get(msg.termId)
977
+ const cols = Math.max(Number(msg.cols) || 0, 20)
978
+ const rows = Math.max(Number(msg.rows) || 0, 5)
979
+ if (t?.stdio[3]) t.stdio[3].write(`${cols} ${rows}\n`)
980
+ else if (t) resizeScriptPty(t.pid, cols, rows)
981
+ return
982
+ }
983
+ case "term_close":
984
+ terminals.get(msg.termId)?.kill("SIGHUP")
985
+ terminals.delete(msg.termId)
986
+ return
987
+ case "tunnel_open":
988
+ return openTunnel(msg)
989
+ case "tunnel_close":
990
+ tunnels.get(msg.tunnelId)?.destroy()
991
+ tunnels.delete(msg.tunnelId)
992
+ return
993
+ case "ports":
994
+ send({ type: "reply", id: msg.id, result: listeningPorts() })
995
+ return
996
+ }
997
+ }
998
+
999
+ function connect() {
1000
+ const sock = (ws = new WebSocket(CONNECT_URL))
1001
+ wsDialedAt = uptime()
1002
+ ws.binaryType = "arraybuffer"
1003
+ ws.addEventListener("open", () => send({ type: "hello", token: TOKEN, info: info() }))
1004
+ ws.addEventListener("message", (ev) => {
1005
+ // A socket given up on after a resume (see SIGUSR1) may still deliver late: not ours anymore.
1006
+ if (ws !== sock) return
1007
+ lastMessageAt = Date.now()
1008
+ if (typeof ev.data !== "string") return tunnelData(Buffer.from(ev.data))
1009
+ let msg
1010
+ try {
1011
+ msg = JSON.parse(ev.data)
1012
+ } catch {
1013
+ return
1014
+ }
1015
+ try {
1016
+ handle(msg)
1017
+ } catch (err) {
1018
+ console.error("[sandbox-agent] handler failed:", err)
1019
+ }
1020
+ })
1021
+ ws.addEventListener("close", (ev) => {
1022
+ if (ws !== sock) return
1023
+ dropSessions()
1024
+ if (ev.code === 4401) console.error("[sandbox-agent] token refused")
1025
+ // The sandbox is stopped (a sleep, on some providers, that has not frozen us yet): ask again
1026
+ // every few seconds rather than backing off to 30 s — a start should find us in at once.
1027
+ if (ev.code === 4409) return void (reconnect = setTimeout(connect, 3_000))
1028
+ const wait = resumed ? 0 : Math.min(backoff, 30_000) * (0.5 + Math.random())
1029
+ resumed = false
1030
+ backoff = Math.min(backoff * 2, 30_000)
1031
+ reconnect = setTimeout(connect, wait)
1032
+ })
1033
+ ws.addEventListener("error", () => {})
1034
+ }
1035
+
1036
+ /** What lives on a connection ends with it: terminals detach (dtach keeps the shell), tunnels close. */
1037
+ function dropSessions() {
1038
+ for (const child of terminals.values()) child.kill("SIGHUP")
1039
+ terminals.clear()
1040
+ for (const t of tunnels.values()) t.destroy()
1041
+ tunnels.clear()
1042
+ }
1043
+
1044
+ // The box was frozen and comes back (a RAM snapshot loaded, RFC 0039). Its provider says so with
1045
+ // SIGUSR1, to the pid it asked us to write (SPUNTO_AGENT_PIDFILE) — never `pkill -f`, which would
1046
+ // hit any process with "agent.mjs" on its command line. Then:
1047
+ // - socket open: it may be dead (the platform let it go) or alive (a short stop). A `probe`, which
1048
+ // the platform acks, tells: nothing back within 1.5 s and the socket is given up for a new one.
1049
+ // Waiting for TCP to notice took 6 s after an 8 s stop (no RST through the tunnel, the peer's
1050
+ // retransmission instead). Only a socket that heard nothing since the signal is dropped: the
1051
+ // first version dropped blindly, and cut a connection already re-made with the exec just sent on it;
1052
+ // - socket closed, a reconnect waiting out its backoff (up to ~45 s): dial now;
1053
+ // - socket connecting: nothing to do.
1054
+ // The "skip the backoff" only holds for a few seconds: a later, unrelated drop backs off as usual.
1055
+ let resumed = false
1056
+ let resumedTimer = null
1057
+ let reconnect = null
1058
+ let lastMessageAt = 0
1059
+ // When the current socket was dialed, on the box's own clock (/proc/uptime: it stops while the box is
1060
+ // frozen, like the provider's record of the resume).
1061
+ let wsDialedAt = 0
1062
+ function uptime() {
1063
+ try {
1064
+ return Number(readFileSync("/proc/uptime", "utf8").split(" ")[0])
1065
+ } catch {
1066
+ return 0
1067
+ }
1068
+ }
1069
+ // SIGUSR2: frozen, and the platform let this connection go when it stopped the box (RFC 0039 § 4.4)
1070
+ // — a probe on it would only wait out its 1.5 s. A socket dialed before the resume (the provider
1071
+ // writes when, SPUNTO_AGENT_RESUMED_FILE) is dropped and a new one dialed now. One dialed since —
1072
+ // this agent already reconnected on its own — is the right one: probed, never cut.
1073
+ process.on("SIGUSR2", () => {
1074
+ // A fork: this is now another sandbox, its token in the env file the provider rewrote. A new
1075
+ // connection under it, at once; every process of the box stays (terminals, services, the setup).
1076
+ const forked = newIdentity()
1077
+ if (forked) {
1078
+ console.log(`[sandbox-agent] forked: now sandbox ${forked.SPUNTO_SANDBOX_ID ?? "?"}, dialing under its identity`)
1079
+ const before = process.env.SPUNTO_SANDBOX_ID ?? ""
1080
+ TOKEN = forked.SPUNTO_SANDBOX_TOKEN
1081
+ process.env.SPUNTO_SANDBOX_ID = forked.SPUNTO_SANDBOX_ID ?? before
1082
+ // What the commands it starts from now on see: this sandbox's id, not the source's. (The
1083
+ // processes already running keep the env they were started with.)
1084
+ for (const env of [baseEnv, childEnv]) {
1085
+ if (env.SPUNTO_SANDBOX_ID) env.SPUNTO_SANDBOX_ID = process.env.SPUNTO_SANDBOX_ID
1086
+ }
1087
+ const sock = ws
1088
+ dropSessions()
1089
+ clearTimeout(reconnect)
1090
+ resumed = false
1091
+ connect()
1092
+ try {
1093
+ sock?.close()
1094
+ } catch {}
1095
+ return
1096
+ }
1097
+ let resumedAt = NaN
1098
+ try {
1099
+ resumedAt = Number(readFileSync(process.env.SPUNTO_AGENT_RESUMED_FILE ?? "", "utf8").trim())
1100
+ } catch {}
1101
+ if (Number.isNaN(resumedAt) || (ws && ws.readyState !== WebSocket.CLOSED && wsDialedAt > resumedAt)) return onResume()
1102
+ console.log("[sandbox-agent] resumed, the platform dropped the connection: dialing now")
1103
+ const sock = ws
1104
+ dropSessions()
1105
+ clearTimeout(reconnect)
1106
+ resumed = false
1107
+ connect()
1108
+ try {
1109
+ sock?.close()
1110
+ } catch {}
1111
+ })
1112
+
1113
+ /** The env file's token when it is not ours any more (`K='v'` lines, as the provider writes them). */
1114
+ function newIdentity() {
1115
+ const file = process.env.SPUNTO_AGENT_ENV_FILE
1116
+ if (!file) return null
1117
+ let text = ""
1118
+ try {
1119
+ text = readFileSync(file, "utf8")
1120
+ } catch {
1121
+ return null
1122
+ }
1123
+ const env = {}
1124
+ for (const line of text.split("\n")) {
1125
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)='(.*)'$/.exec(line)
1126
+ if (m) env[m[1]] = m[2].replace(/'\\''/g, "'")
1127
+ }
1128
+ return env.SPUNTO_SANDBOX_TOKEN && env.SPUNTO_SANDBOX_TOKEN !== TOKEN ? env : null
1129
+ }
1130
+
1131
+ process.on("SIGUSR1", () => onResume())
1132
+ function onResume() {
1133
+ console.log("[sandbox-agent] resumed: checking the connection")
1134
+ resumed = true
1135
+ clearTimeout(resumedTimer)
1136
+ resumedTimer = setTimeout(() => (resumed = false), 5_000)
1137
+ if (ws?.readyState === WebSocket.OPEN) {
1138
+ const sock = ws
1139
+ const at = Date.now()
1140
+ send({ type: "probe", id: at })
1141
+ heartbeat()
1142
+ setTimeout(() => {
1143
+ if (ws !== sock || lastMessageAt >= at) return
1144
+ console.log("[sandbox-agent] no answer after the resume: reconnecting")
1145
+ resumed = false
1146
+ dropSessions()
1147
+ connect()
1148
+ try {
1149
+ sock.close()
1150
+ } catch {}
1151
+ }, 1_500)
1152
+ return
1153
+ }
1154
+ if (ws?.readyState === WebSocket.CONNECTING) return
1155
+ // Dialing now is the shortcut: if this one fails too, the backoff applies.
1156
+ resumed = false
1157
+ clearTimeout(reconnect)
1158
+ connect()
1159
+ }
1160
+ // After the handler: a SIGUSR1 before it is in place would start Node's inspector instead.
1161
+ if (process.env.SPUNTO_AGENT_PIDFILE) {
1162
+ try {
1163
+ writeFileSync(process.env.SPUNTO_AGENT_PIDFILE, `${process.pid}\n`)
1164
+ // What this agent understands, for a provider choosing between them (an older agent: USR1 only).
1165
+ if (process.env.SPUNTO_AGENT_SIGNALS_FILE) writeFileSync(process.env.SPUNTO_AGENT_SIGNALS_FILE, "USR1 USR2 REIDENTIFY\n")
1166
+ } catch (err) {
1167
+ console.error(`[sandbox-agent] cannot write ${process.env.SPUNTO_AGENT_PIDFILE}: ${err.message}`)
1168
+ }
1169
+ }
1170
+
1171
+ setInterval(heartbeat, 15_000).unref()
1172
+ if (hasBalloon && process.getuid?.() === 0 && process.env.SPUNTO_RECLAIM_CACHE !== "0") setInterval(reclaimColdCache, 60_000).unref()
1173
+ console.log(`[sandbox-agent] ${VERSION} → ${API} (shell ${SHELL}, cwd ${CWD})`)
1174
+ connect()