@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.
- package/README.md +61 -4
- package/package.json +22 -2
- package/src/agent-connection/index.ts +347 -0
- package/src/agent-protocol/index.ts +190 -0
- package/src/commands/index.ts +205 -0
- package/src/index.ts +3 -0
- package/src/sandbox-agent/agent.mjs +1174 -0
- package/src/sandbox-agent/bootstrap.sh +64 -0
- package/src/sandbox-agent/index.ts +31 -0
- package/src/sandbox-agent/recipe.ts +65 -0
- package/src/sandbox-agent/runner.sh +38 -0
|
@@ -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()
|