@namzu/sandbox 24.0.0 → 26.0.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/CHANGELOG.md +284 -0
- package/dist/backends/http-worker-client.d.ts +3 -1
- package/dist/backends/http-worker-client.d.ts.map +1 -1
- package/dist/backends/http-worker-client.js +15 -4
- package/dist/backends/http-worker-client.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/local-virtual-computer/client.d.ts +21 -0
- package/dist/local-virtual-computer/client.d.ts.map +1 -0
- package/dist/local-virtual-computer/client.js +503 -0
- package/dist/local-virtual-computer/client.js.map +1 -0
- package/dist/local-virtual-computer/detached-process.d.ts +15 -0
- package/dist/local-virtual-computer/detached-process.d.ts.map +1 -0
- package/dist/local-virtual-computer/detached-process.js +111 -0
- package/dist/local-virtual-computer/detached-process.js.map +1 -0
- package/dist/local-virtual-computer/detached-worker.d.ts +11 -0
- package/dist/local-virtual-computer/detached-worker.d.ts.map +1 -0
- package/dist/local-virtual-computer/detached-worker.js +97 -0
- package/dist/local-virtual-computer/detached-worker.js.map +1 -0
- package/dist/local-virtual-computer/dialect.d.ts +26 -0
- package/dist/local-virtual-computer/dialect.d.ts.map +1 -0
- package/dist/local-virtual-computer/dialect.js +61 -0
- package/dist/local-virtual-computer/dialect.js.map +1 -0
- package/dist/local-virtual-computer/engine.d.ts +63 -0
- package/dist/local-virtual-computer/engine.d.ts.map +1 -0
- package/dist/local-virtual-computer/engine.js +125 -0
- package/dist/local-virtual-computer/engine.js.map +1 -0
- package/dist/local-virtual-computer/guest-stdio.d.ts +15 -0
- package/dist/local-virtual-computer/guest-stdio.d.ts.map +1 -0
- package/dist/local-virtual-computer/guest-stdio.js +330 -0
- package/dist/local-virtual-computer/guest-stdio.js.map +1 -0
- package/dist/local-virtual-computer/index.d.ts +36 -0
- package/dist/local-virtual-computer/index.d.ts.map +1 -0
- package/dist/local-virtual-computer/index.js +341 -0
- package/dist/local-virtual-computer/index.js.map +1 -0
- package/dist/local-virtual-computer/podman-machine.d.ts +17 -0
- package/dist/local-virtual-computer/podman-machine.d.ts.map +1 -0
- package/dist/local-virtual-computer/podman-machine.js +181 -0
- package/dist/local-virtual-computer/podman-machine.js.map +1 -0
- package/dist/local-virtual-computer/setup/contract.d.ts +360 -0
- package/dist/local-virtual-computer/setup/contract.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/contract.js +3 -0
- package/dist/local-virtual-computer/setup/contract.js.map +1 -0
- package/dist/local-virtual-computer/setup/elevated.d.ts +78 -0
- package/dist/local-virtual-computer/setup/elevated.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/elevated.js +548 -0
- package/dist/local-virtual-computer/setup/elevated.js.map +1 -0
- package/dist/local-virtual-computer/setup/host.d.ts +12 -0
- package/dist/local-virtual-computer/setup/host.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/host.js +93 -0
- package/dist/local-virtual-computer/setup/host.js.map +1 -0
- package/dist/local-virtual-computer/setup/idle.d.ts +18 -0
- package/dist/local-virtual-computer/setup/idle.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/idle.js +38 -0
- package/dist/local-virtual-computer/setup/idle.js.map +1 -0
- package/dist/local-virtual-computer/setup/image.d.ts +7 -0
- package/dist/local-virtual-computer/setup/image.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/image.js +124 -0
- package/dist/local-virtual-computer/setup/image.js.map +1 -0
- package/dist/local-virtual-computer/setup/index.d.ts +17 -0
- package/dist/local-virtual-computer/setup/index.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/index.js +17 -0
- package/dist/local-virtual-computer/setup/index.js.map +1 -0
- package/dist/local-virtual-computer/setup/limits.d.ts +30 -0
- package/dist/local-virtual-computer/setup/limits.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/limits.js +58 -0
- package/dist/local-virtual-computer/setup/limits.js.map +1 -0
- package/dist/local-virtual-computer/setup/plan.d.ts +8 -0
- package/dist/local-virtual-computer/setup/plan.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/plan.js +131 -0
- package/dist/local-virtual-computer/setup/plan.js.map +1 -0
- package/dist/local-virtual-computer/setup/readiness.d.ts +11 -0
- package/dist/local-virtual-computer/setup/readiness.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/readiness.js +320 -0
- package/dist/local-virtual-computer/setup/readiness.js.map +1 -0
- package/dist/local-virtual-computer/setup/registry.d.ts +18 -0
- package/dist/local-virtual-computer/setup/registry.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/registry.js +50 -0
- package/dist/local-virtual-computer/setup/registry.js.map +1 -0
- package/dist/local-virtual-computer/setup/restart.d.ts +8 -0
- package/dist/local-virtual-computer/setup/restart.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/restart.js +60 -0
- package/dist/local-virtual-computer/setup/restart.js.map +1 -0
- package/dist/local-virtual-computer/setup/resume.d.ts +14 -0
- package/dist/local-virtual-computer/setup/resume.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/resume.js +58 -0
- package/dist/local-virtual-computer/setup/resume.js.map +1 -0
- package/dist/local-virtual-computer/setup/run.d.ts +8 -0
- package/dist/local-virtual-computer/setup/run.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/run.js +200 -0
- package/dist/local-virtual-computer/setup/run.js.map +1 -0
- package/dist/local-virtual-computer/setup/windows-folder.d.ts +11 -0
- package/dist/local-virtual-computer/setup/windows-folder.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/windows-folder.js +14 -0
- package/dist/local-virtual-computer/setup/windows-folder.js.map +1 -0
- package/dist/local-virtual-computer/setup/wsl-package.d.ts +19 -0
- package/dist/local-virtual-computer/setup/wsl-package.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/wsl-package.js +242 -0
- package/dist/local-virtual-computer/setup/wsl-package.js.map +1 -0
- package/dist/local-virtual-computer/setup/wslc-shared.d.ts +27 -0
- package/dist/local-virtual-computer/setup/wslc-shared.d.ts.map +1 -0
- package/dist/local-virtual-computer/setup/wslc-shared.js +137 -0
- package/dist/local-virtual-computer/setup/wslc-shared.js.map +1 -0
- package/dist/local-virtual-computer/ssh-forward.d.ts +19 -0
- package/dist/local-virtual-computer/ssh-forward.d.ts.map +1 -0
- package/dist/local-virtual-computer/ssh-forward.js +179 -0
- package/dist/local-virtual-computer/ssh-forward.js.map +1 -0
- package/dist/local-virtual-computer/wslc-engine.d.ts +27 -0
- package/dist/local-virtual-computer/wslc-engine.d.ts.map +1 -0
- package/dist/local-virtual-computer/wslc-engine.js +98 -0
- package/dist/local-virtual-computer/wslc-engine.js.map +1 -0
- package/local-computer/Dockerfile +99 -0
- package/local-computer/app-catalog.cjs +240 -0
- package/local-computer/app-native-host.cjs +144 -0
- package/local-computer/browser-home.cjs +249 -0
- package/local-computer/build-image.mjs +310 -0
- package/local-computer/desktop-worker.cjs +477 -0
- package/local-computer/entrypoint.sh +161 -0
- package/local-computer/home.html +28 -0
- package/local-computer/new-tab/home.css +255 -0
- package/local-computer/new-tab/home.js +65 -0
- package/local-computer/new-tab/manifest.json +12 -0
- package/local-computer/new-tab/wordmark.svg +33 -0
- package/local-computer/openbox-rc.xml +41 -0
- package/local-computer/prepare-home.cjs +121 -0
- package/local-computer/tint2rc +44 -0
- package/package.json +13 -4
- package/src/backends/http-worker-client.ts +31 -3
- package/src/index.ts +13 -0
- package/src/local-virtual-computer/client.ts +573 -0
- package/src/local-virtual-computer/detached-process.ts +133 -0
- package/src/local-virtual-computer/detached-worker.ts +124 -0
- package/src/local-virtual-computer/dialect.ts +83 -0
- package/src/local-virtual-computer/engine.ts +197 -0
- package/src/local-virtual-computer/guest-stdio.ts +332 -0
- package/src/local-virtual-computer/index.ts +447 -0
- package/src/local-virtual-computer/podman-machine.ts +264 -0
- package/src/local-virtual-computer/setup/contract.ts +395 -0
- package/src/local-virtual-computer/setup/elevated.ts +655 -0
- package/src/local-virtual-computer/setup/host.ts +100 -0
- package/src/local-virtual-computer/setup/idle.ts +39 -0
- package/src/local-virtual-computer/setup/image.ts +159 -0
- package/src/local-virtual-computer/setup/index.ts +16 -0
- package/src/local-virtual-computer/setup/limits.ts +68 -0
- package/src/local-virtual-computer/setup/plan.ts +144 -0
- package/src/local-virtual-computer/setup/readiness.ts +386 -0
- package/src/local-virtual-computer/setup/registry.ts +50 -0
- package/src/local-virtual-computer/setup/restart.ts +64 -0
- package/src/local-virtual-computer/setup/resume.ts +65 -0
- package/src/local-virtual-computer/setup/run.ts +225 -0
- package/src/local-virtual-computer/setup/windows-folder.ts +13 -0
- package/src/local-virtual-computer/setup/wsl-package.ts +242 -0
- package/src/local-virtual-computer/setup/wslc-shared.ts +147 -0
- package/src/local-virtual-computer/ssh-forward.ts +195 -0
- package/src/local-virtual-computer/wslc-engine.ts +124 -0
- package/worker/server.js +1499 -0
package/worker/server.js
ADDED
|
@@ -0,0 +1,1499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @namzu/sandbox container-backend worker.
|
|
3
|
+
*
|
|
4
|
+
* Lives inside the per-task Docker container; talks HTTP on
|
|
5
|
+
* loopback to the host-side `DockerSandboxBackend` adapter. The
|
|
6
|
+
* host spawns one container per `Sandbox` instance and tears it
|
|
7
|
+
* down on `destroy()`.
|
|
8
|
+
*
|
|
9
|
+
* Why HTTP and not stdin/stdout: a long-running container with a
|
|
10
|
+
* stable HTTP surface lets the host issue many `exec` /
|
|
11
|
+
* `read-file` / `write-file` calls per task without spawning a
|
|
12
|
+
* new container for each (cold-start kills latency). namzu's
|
|
13
|
+
* protocol is deliberately minimal because the namzu backend is
|
|
14
|
+
* trusted-tenant by default. (For adversarial multi-tenant the
|
|
15
|
+
* host picks the `microvm` tier instead.)
|
|
16
|
+
*
|
|
17
|
+
* Endpoints:
|
|
18
|
+
* GET /healthz — exact-protocol readiness probe.
|
|
19
|
+
* POST /execute — run a command inside the workspace.
|
|
20
|
+
* body: { executionId?, command, args, cwd, env, stdin,
|
|
21
|
+
* timeoutMs, maxOutputBytes }
|
|
22
|
+
* response: NDJSON stream of
|
|
23
|
+
* { type: 'stdout_delta'|'stderr_delta'
|
|
24
|
+
* |'result'|'error', ... }
|
|
25
|
+
* POST /executions/reserve
|
|
26
|
+
* — reserve an inert, expiring execution lease.
|
|
27
|
+
* response: { ok, executionId, leaseExpiresAt }
|
|
28
|
+
* POST /cancel — cancel one reserved or running execution.
|
|
29
|
+
* body: { executionId }
|
|
30
|
+
* a successful response is sent only after terminal
|
|
31
|
+
* state is known; an unconfirmed stop retires worker.
|
|
32
|
+
* POST /read-file — read a file from the workspace.
|
|
33
|
+
* body: { path, encoding?, range?: { version: 1, offset?, length? } }
|
|
34
|
+
* response: { ok, content, sizeBytes, range? }
|
|
35
|
+
* { capabilitiesOnly: true } probes bounded-read support.
|
|
36
|
+
* POST /write-file — write a file inside the workspace.
|
|
37
|
+
* body: { path, content, encoding? }
|
|
38
|
+
* response: { ok, bytesWritten }
|
|
39
|
+
*
|
|
40
|
+
* Authn: `Authorization: Bearer <token>` on every route but `/healthz`,
|
|
41
|
+
* where the token is `NAMZU_SANDBOX_TOKEN` — minted per instance by
|
|
42
|
+
* whoever starts this worker, read here at startup, never baked into an
|
|
43
|
+
* image, never written to disk, and dead with the container. Absent or
|
|
44
|
+
* wrong is a `401` with the reason and nothing else; the request never
|
|
45
|
+
* reaches a handler. The variable carries `WORKER_CONFIG_PREFIX`, which is
|
|
46
|
+
* what keeps it out of every command the sandbox runs — see
|
|
47
|
+
* {@link childEnvironment}.
|
|
48
|
+
*
|
|
49
|
+
* A worker with no token is only allowed to LISTEN if it is bound to
|
|
50
|
+
* loopback; on anything routable it refuses to start. It listens on every
|
|
51
|
+
* interface by default and has to, for the reasons recorded at the `BIND`
|
|
52
|
+
* constant — which is exactly why the credential rather than the bind
|
|
53
|
+
* address is what makes the default defensible.
|
|
54
|
+
*
|
|
55
|
+
* This paragraph used to say the worker "only listens on loopback" and,
|
|
56
|
+
* later, that authn was "none" and the network the container sits on was
|
|
57
|
+
* the only boundary. The first was false ten lines from a comment that
|
|
58
|
+
* said so correctly, which is how a reader who found the true half stopped
|
|
59
|
+
* looking. The second was true and no longer is. What is still true and
|
|
60
|
+
* worth saying: the transport is plain HTTP, so this is a bearer token —
|
|
61
|
+
* replayable by anything on the path — and network placement remains the
|
|
62
|
+
* boundary it defends BEHIND, not something it replaces. The egress proxy
|
|
63
|
+
* still checks nothing inbound of any kind; it stamps credentials on the
|
|
64
|
+
* way out and has no opinion about what comes back.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
const http = require('node:http')
|
|
68
|
+
const { spawn } = require('node:child_process')
|
|
69
|
+
const { createHash, randomUUID, timingSafeEqual } = require('node:crypto')
|
|
70
|
+
const fs = require('node:fs/promises')
|
|
71
|
+
const { constants: fileConstants } = require('node:fs')
|
|
72
|
+
const path = require('node:path')
|
|
73
|
+
|
|
74
|
+
const REMOTE_EXECUTION_PROTOCOL_VERSION = 2
|
|
75
|
+
const FILE_RANGE_BYTES = 32 * 1024 * 1024
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Prefix every variable this worker reads its own configuration from.
|
|
79
|
+
*
|
|
80
|
+
* Load-bearing: {@link childEnvironment} strips it, so the prefix is the
|
|
81
|
+
* boundary between "the worker's configuration" and "the environment the
|
|
82
|
+
* sandbox is supposed to run commands in". A new setting that does not use
|
|
83
|
+
* it is handed to untrusted code automatically.
|
|
84
|
+
*/
|
|
85
|
+
const WORKER_CONFIG_PREFIX = 'NAMZU_SANDBOX_'
|
|
86
|
+
|
|
87
|
+
// Only an exclusive, long-lived computer may accept applications that outlive
|
|
88
|
+
// a successful launcher. Each foreground execution must also opt in; registered
|
|
89
|
+
// background jobs and ordinary container workers retain strict process ownership.
|
|
90
|
+
const NORMAL_EXIT_POLICY = process.env.NAMZU_SANDBOX_NORMAL_EXIT_POLICY ?? 'strict'
|
|
91
|
+
|
|
92
|
+
const PORT = Number(process.env.NAMZU_SANDBOX_PORT || 2024)
|
|
93
|
+
// Bind address picks `0.0.0.0` by default so a sibling container
|
|
94
|
+
// (a host app talking to a sandbox spawned via docker.sock on
|
|
95
|
+
// the same host) can reach the worker over a docker bridge network.
|
|
96
|
+
// Overridable via `NAMZU_SANDBOX_BIND` for the dev case where the
|
|
97
|
+
// SDK consumer runs on the docker host itself and prefers loopback.
|
|
98
|
+
//
|
|
99
|
+
// Narrowing this default is not the fix and was measured as a
|
|
100
|
+
// regression: a published container port translates to the container's
|
|
101
|
+
// bridge address, so a worker bound to the container's own loopback is
|
|
102
|
+
// unreachable through it. The container backend's reachability modes
|
|
103
|
+
// both need a non-loopback bind. What makes an every-interface listener
|
|
104
|
+
// defensible is the credential every route but `/healthz` requires and
|
|
105
|
+
// the refusal below when there is none — not the network placement alone,
|
|
106
|
+
// which is a property of the deployment rather than of this file.
|
|
107
|
+
const BIND = process.env.NAMZU_SANDBOX_BIND || '0.0.0.0'
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The per-instance credential, and the one escape from requiring it.
|
|
111
|
+
*
|
|
112
|
+
* `NAMZU_SANDBOX_TOKEN` is minted by whoever starts this worker — the
|
|
113
|
+
* container backend at `docker run` time — and handed over in the
|
|
114
|
+
* environment, which is the only channel this process can READ from. That
|
|
115
|
+
* it is minted per instance rather than baked into the image is the whole
|
|
116
|
+
* point: an image-level secret is shared by every container ever built
|
|
117
|
+
* from it, readable by anything that can pull the image, and rotated by
|
|
118
|
+
* rebuilding and redeploying every deployment.
|
|
119
|
+
*
|
|
120
|
+
* A worker the host did NOT start — a warm pool claimed by address — has
|
|
121
|
+
* no such channel and must be provisioned with the token by whoever builds
|
|
122
|
+
* the profile or image it comes from. That is a real gap, not a hand-wave:
|
|
123
|
+
* see `docs/sdk/container-sandbox-worker.md`.
|
|
124
|
+
*
|
|
125
|
+
* Presence and contents are read separately on purpose. `= ""` is
|
|
126
|
+
* REFUSED at startup in every mode, because that is the shape an injected
|
|
127
|
+
* secret takes when it resolved to nothing — honouring it would open
|
|
128
|
+
* exactly the hole the variable exists to close. (The microVM guest agent
|
|
129
|
+
* refuses the same value for the same reason at its own startup.) So is
|
|
130
|
+
* any value that differs from its own trim, for a subtler version of the
|
|
131
|
+
* same reason: the presentation path reads the token out of a trimmed
|
|
132
|
+
* header, so a padded value can never be presented and would leave the
|
|
133
|
+
* worker booted, authenticated and refusing every caller — including its
|
|
134
|
+
* host — for the life of the container.
|
|
135
|
+
*/
|
|
136
|
+
const TOKEN_WAS_SET = Object.prototype.hasOwnProperty.call(process.env, 'NAMZU_SANDBOX_TOKEN')
|
|
137
|
+
const TOKEN = process.env.NAMZU_SANDBOX_TOKEN
|
|
138
|
+
const AUTH_ENABLED = TOKEN_WAS_SET && TOKEN !== '' && TOKEN === TOKEN.trim()
|
|
139
|
+
/**
|
|
140
|
+
* The third way a token can be set and useless: one nobody can present.
|
|
141
|
+
*
|
|
142
|
+
* The credential travels as an HTTP header value, and a header value
|
|
143
|
+
* carries ONE BYTE per character. Two mechanisms enforce that and both are
|
|
144
|
+
* outside this process. The client's `fetch` throws `Cannot convert
|
|
145
|
+
* argument to a ByteString` for any code point above U+00FF before the
|
|
146
|
+
* request leaves the host, and the HTTP parser this server runs drops the
|
|
147
|
+
* connection on the C0 controls it will not accept — everything below
|
|
148
|
+
* U+0020 except HTAB, plus DEL. Either way the worker boots authenticated
|
|
149
|
+
* and refuses every caller including its host, for the life of the
|
|
150
|
+
* container: the same failure the padded value above is refused for, from
|
|
151
|
+
* the other end of the same header.
|
|
152
|
+
*
|
|
153
|
+
* The set below is therefore exactly what survives the round trip, mapped
|
|
154
|
+
* rather than guessed: HTAB, printable ASCII, and U+0080–U+00FF, which
|
|
155
|
+
* both sides carry as latin-1. Anything else is refused at startup, where
|
|
156
|
+
* the reason can still be read.
|
|
157
|
+
*/
|
|
158
|
+
const TOKEN_IS_PRESENTABLE = /^[\t\u0020-\u007e\u0080-\u00ff]*$/.test(TOKEN ?? '')
|
|
159
|
+
/**
|
|
160
|
+
* The explicit escape from the refusal below, and what it gives up.
|
|
161
|
+
*
|
|
162
|
+
* Set `NAMZU_SANDBOX_ALLOW_UNAUTHENTICATED=1` and a worker with no token
|
|
163
|
+
* will listen on a routable address, which is the pre-token behaviour and
|
|
164
|
+
* is unauthenticated `execute` for whoever can route to it. It exists so
|
|
165
|
+
* that an existing deployment keeps working while it is provisioned with a
|
|
166
|
+
* credential, and it is named rather than implied so that accepting the
|
|
167
|
+
* exposure is a decision someone made on purpose.
|
|
168
|
+
*
|
|
169
|
+
* A configured token WINS over this flag: the escape can only mean "serve
|
|
170
|
+
* without a credential", never "ignore the one I was given".
|
|
171
|
+
*
|
|
172
|
+
* Presence of the variable is not enough — `= 0` and `= false` are read as
|
|
173
|
+
* "not set", because a flag whose off-spelling turns it on is a trap. It is
|
|
174
|
+
* matched UNTRIMMED for the same reason: `= " yes "` is someone's editor
|
|
175
|
+
* adding a space to a value they meant to write, and a security escape that
|
|
176
|
+
* accepts an unrecognised spelling is exactly the trap the sentence above
|
|
177
|
+
* is about. An unrecognised value therefore fails CLOSED — the worker
|
|
178
|
+
* refuses to start — and the refusal names this variable and the accepted
|
|
179
|
+
* spellings.
|
|
180
|
+
*/
|
|
181
|
+
const ALLOW_UNAUTHENTICATED = ['1', 'true', 'yes', 'on'].includes(
|
|
182
|
+
(process.env.NAMZU_SANDBOX_ALLOW_UNAUTHENTICATED || '').toLowerCase(),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A bind address only this container's own loopback can reach.
|
|
187
|
+
*
|
|
188
|
+
* Deliberately a narrow allowlist rather than "anything that resolves
|
|
189
|
+
* locally": `0.0.0.0` and `::` reach every interface and are what this
|
|
190
|
+
* worker binds by default, which is the case the refusal exists for. An
|
|
191
|
+
* address this predicate does not recognise is treated as routable, so a
|
|
192
|
+
* spelling nobody thought of fails closed.
|
|
193
|
+
*/
|
|
194
|
+
function isLoopbackBind(bind) {
|
|
195
|
+
const host = String(bind)
|
|
196
|
+
.trim()
|
|
197
|
+
.replace(/^\[|\]$/g, '')
|
|
198
|
+
.toLowerCase()
|
|
199
|
+
return host === 'localhost' || host === '::1' || host.startsWith('127.')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function refuseToStart(reason) {
|
|
203
|
+
console.error(`[namzu-sandbox-worker] refusing to start: ${reason}`)
|
|
204
|
+
process.exit(1)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The startup decision, taken before a socket exists.
|
|
209
|
+
*
|
|
210
|
+
* Two asymmetries, both deliberate:
|
|
211
|
+
*
|
|
212
|
+
* - No token on LOOPBACK may start. Nothing outside this container's
|
|
213
|
+
* network namespace can open that socket — inside the container the
|
|
214
|
+
* only listener is this one, and the host reaches it through Docker's
|
|
215
|
+
* port-forward, which is a different address. The exposure this issue
|
|
216
|
+
* is about is a routable listener, and a loopback bind is not one, so
|
|
217
|
+
* refusing here would break the dev case (a host running the SDK
|
|
218
|
+
* beside the docker host) without closing anything.
|
|
219
|
+
* - No token on anything ROUTABLE refuses. That is the default bind, so
|
|
220
|
+
* this is where the honest default costs something: a deployment that
|
|
221
|
+
* has not been given a credential stops working rather than continuing
|
|
222
|
+
* to run unauthenticated. The escape above is how it keeps working on
|
|
223
|
+
* purpose.
|
|
224
|
+
*/
|
|
225
|
+
function assertListenableConfiguration() {
|
|
226
|
+
if (NORMAL_EXIT_POLICY !== 'strict' && NORMAL_EXIT_POLICY !== 'computer-lifetime')
|
|
227
|
+
refuseToStart(
|
|
228
|
+
'NAMZU_SANDBOX_NORMAL_EXIT_POLICY must be strict or computer-lifetime, with no surrounding whitespace.',
|
|
229
|
+
)
|
|
230
|
+
// A token that was SET but cannot be used is refused before the bind
|
|
231
|
+
// address is considered: `""` is the empty injection, a padded value is
|
|
232
|
+
// one the trimmed header can never match, and a value outside what a
|
|
233
|
+
// header can carry is one no client can send at all.
|
|
234
|
+
if (TOKEN_WAS_SET && !AUTH_ENABLED) {
|
|
235
|
+
refuseToStart(
|
|
236
|
+
'NAMZU_SANDBOX_TOKEN is set but is empty after trimming, or carries leading or trailing whitespace. An empty value is the shape a per-instance secret takes when the injection that was supposed to supply it resolved to nothing, and honouring it would open exactly the hole the variable exists to close. A PADDED value is refused for the other half of the same reason: the token is read out of a trimmed `Authorization` header, so a value with whitespace around it can never be presented, and this worker would boot authenticated and refuse every caller including its host until the container is gone. Set it to a per-instance value with no surrounding whitespace, or leave it unset entirely and let the bind-address rule below decide.',
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
if (TOKEN_WAS_SET && !TOKEN_IS_PRESENTABLE) {
|
|
240
|
+
refuseToStart(
|
|
241
|
+
'NAMZU_SANDBOX_TOKEN carries a character that cannot be presented in an HTTP header value. A header carries one byte per character, so a code point above U+00FF is refused by the client before the request leaves it (`fetch` rejects it as a ByteString conversion), and a C0 control other than HTAB, or DEL, is refused by the HTTP parser on this side — the connection is dropped before the router sees it. Either way the token would be unanswerable: this worker would boot authenticated and refuse every caller including its host until the container is gone, which is the failure the padded-value refusal above exists to prevent and this is the same failure one rung further out. Mint the token from random bytes encoded as base64url (or any ASCII), which is what the container backend does.',
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
if (AUTH_ENABLED) return
|
|
245
|
+
if (isLoopbackBind(BIND)) return
|
|
246
|
+
if (ALLOW_UNAUTHENTICATED) return
|
|
247
|
+
refuseToStart(
|
|
248
|
+
`no NAMZU_SANDBOX_TOKEN is configured and NAMZU_SANDBOX_BIND=${BIND} would accept connections from anything that can route to this container, where every route but /healthz runs a command, reads a file, or writes a file on the caller's behalf with no credential of any kind. Set NAMZU_SANDBOX_TOKEN to a per-instance secret (the starter of this container mints one; a worker from a warm pool must be provisioned with one) and present it as \`Authorization: Bearer <token>\`; or bind 127.0.0.1 if only this container can be the caller; or set NAMZU_SANDBOX_ALLOW_UNAUTHENTICATED=1 (exactly that spelling: 1, true, yes or on, in any case, with no surrounding whitespace) to serve unauthenticated on a routable address, which gives up the credential entirely rather than deferring it.`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The digest compared against, precomputed once.
|
|
254
|
+
*
|
|
255
|
+
* Comparing fixed-width digests rather than the strings means the
|
|
256
|
+
* comparison is constant-time whatever the presented length and that
|
|
257
|
+
* `timingSafeEqual` cannot throw on a length mismatch — so neither the
|
|
258
|
+
* token's value nor its length is learnable by probing.
|
|
259
|
+
*/
|
|
260
|
+
function digest(value) {
|
|
261
|
+
return createHash('sha256').update(value).digest()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const TOKEN_DIGEST = AUTH_ENABLED ? digest(TOKEN) : undefined
|
|
265
|
+
|
|
266
|
+
/** The bearer token a request presents, or `undefined` if it presents none. */
|
|
267
|
+
function presentedToken(req) {
|
|
268
|
+
const header = req.headers.authorization
|
|
269
|
+
if (typeof header !== 'string') return undefined
|
|
270
|
+
const match = /^Bearer[ ]+(.+)$/i.exec(header.trim())
|
|
271
|
+
return match ? match[1] : undefined
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isAuthorized(req) {
|
|
275
|
+
if (!AUTH_ENABLED) return true
|
|
276
|
+
const presented = presentedToken(req)
|
|
277
|
+
if (presented === undefined) return false
|
|
278
|
+
return timingSafeEqual(TOKEN_DIGEST, digest(presented))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const WORKSPACE_ROOT = process.env.NAMZU_SANDBOX_WORKSPACE || '/workspace'
|
|
282
|
+
const READ_ROOTS = normalizeRoots(
|
|
283
|
+
[WORKSPACE_ROOT, ...(process.env.NAMZU_SANDBOX_READ_ROOTS || '').split(path.delimiter)].filter(
|
|
284
|
+
Boolean,
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
// Writable roots: WORKSPACE_ROOT is always writable; NAMZU_SANDBOX_WRITE_ROOTS
|
|
288
|
+
// adds extra RW mounts (e.g. `/mnt/user-data/outputs`, `/mnt/user-data/scratch`)
|
|
289
|
+
// so the agent's `write`/`append` tools can land in the sibling mounts the
|
|
290
|
+
// host chose, not just inside `/workspace`. This must be a strict subset of
|
|
291
|
+
// READ_ROOTS or read-only mounts (uploads, skills) would silently become
|
|
292
|
+
// writable.
|
|
293
|
+
const WRITE_ROOTS = normalizeRoots(
|
|
294
|
+
[WORKSPACE_ROOT, ...(process.env.NAMZU_SANDBOX_WRITE_ROOTS || '').split(path.delimiter)].filter(
|
|
295
|
+
Boolean,
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
const MAX_BODY_BYTES = Number(process.env.NAMZU_SANDBOX_MAX_BODY_BYTES || 8 * 1024 * 1024)
|
|
299
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 100 * 1024 * 1024
|
|
300
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000
|
|
301
|
+
// Ceiling on the caller-supplied `timeoutMs`. `/execute`'s `timeoutMs` sets
|
|
302
|
+
// how long the spawned child (and the container resources it holds — CPU,
|
|
303
|
+
// memory, the open NDJSON response) stays alive; upstream this value comes
|
|
304
|
+
// straight from the `bash` tool's `timeout` argument, a model-chosen number
|
|
305
|
+
// with no cap of its own (see `resolveTimeoutMs` in the firecracker guest
|
|
306
|
+
// agent, `agent/agent.cjs`, which guards the same unbounded value on that
|
|
307
|
+
// transport). Left unbounded here, a single call can pin a container for as
|
|
308
|
+
// long as Node's timer will honor (`setTimeout` clamps at ~24.8 days rather
|
|
309
|
+
// than firing early), which is a resource-exhaustion vector, not a
|
|
310
|
+
// convenience knob. A request above the cap is REFUSED, not quietly
|
|
311
|
+
// clamped: silently running under a different deadline than the caller
|
|
312
|
+
// asked for is the "accepted and not applied" shape this codebase treats
|
|
313
|
+
// as worse than never offering the control — the caller stops looking,
|
|
314
|
+
// and a timeout it believes it set is not the one in force.
|
|
315
|
+
const MAX_TIMEOUT_MS = Number(process.env.NAMZU_SANDBOX_MAX_TIMEOUT_MS || 30 * 60 * 1000)
|
|
316
|
+
// Cancellation is a second control request, not an aborted `/execute`
|
|
317
|
+
// transport. The worker owns a finite execution lease before it owns a
|
|
318
|
+
// process, then a finite TERM -> KILL confirmation window once it does.
|
|
319
|
+
// Unknown ids are never admitted by `/execute`, so no tombstone has to make
|
|
320
|
+
// an unbounded promise about a request that may arrive later.
|
|
321
|
+
const EXECUTION_LEASE_TTL_MS = Number(process.env.NAMZU_SANDBOX_EXECUTION_LEASE_TTL_MS || 30_000)
|
|
322
|
+
const EXECUTION_TERMINAL_TTL_MS = Number(
|
|
323
|
+
process.env.NAMZU_SANDBOX_EXECUTION_TERMINAL_TTL_MS || 60_000,
|
|
324
|
+
)
|
|
325
|
+
const MAX_TRACKED_EXECUTIONS = Number(process.env.NAMZU_SANDBOX_MAX_TRACKED_EXECUTIONS || 1_024)
|
|
326
|
+
// `terminateAndConfirm` only escalates SIGTERM to SIGKILL when the owned
|
|
327
|
+
// process group is STILL alive at the end of this window — a group that
|
|
328
|
+
// goes quiet before then is read as "the signal worked," with no check
|
|
329
|
+
// that the signal was the reason. A command that ignores SIGTERM but
|
|
330
|
+
// happens to finish on its own before this elapses therefore runs to
|
|
331
|
+
// completion untouched, and is reported back as a clean, unaborted-looking
|
|
332
|
+
// result: exactly the outcome `SandboxExecOptions.signal`'s contract
|
|
333
|
+
// forbids ("must terminate the owned process ... never silently ignore the
|
|
334
|
+
// signal and let the command run to completion"). This was 2000ms until
|
|
335
|
+
// issue #469's kind conformance run caught the identical mechanism in
|
|
336
|
+
// `agent/agent.cjs` (the Firecracker/kubernetes guest agent this worker's
|
|
337
|
+
// spawn/jail/framing logic is deliberately kept parallel with) — every test
|
|
338
|
+
// in THIS file that drives this path shortens it (80ms, 700ms, ...) "so the
|
|
339
|
+
// abort case proves the kill in milliseconds, not the production window",
|
|
340
|
+
// which hid this default's own behaviour from every one of them exactly as
|
|
341
|
+
// it did on the other transport. Kept short enough to leave the shared
|
|
342
|
+
// SIGTERM-ignoring fixture (~400ms to finish on its own) a wide margin; a
|
|
343
|
+
// deployment that genuinely needs longer for cooperative cleanup sets
|
|
344
|
+
// `NAMZU_SANDBOX_CANCEL_GRACE_MS` explicitly.
|
|
345
|
+
const CANCEL_GRACE_MS = Number(process.env.NAMZU_SANDBOX_CANCEL_GRACE_MS || 250)
|
|
346
|
+
const CANCEL_CONFIRM_TIMEOUT_MS = Number(
|
|
347
|
+
process.env.NAMZU_SANDBOX_CANCEL_CONFIRM_TIMEOUT_MS || 5_000,
|
|
348
|
+
)
|
|
349
|
+
const POISON_EXIT_DELAY_MS = 50
|
|
350
|
+
const EXECUTION_ID_PATTERN = /^exec_[0-9a-f-]{36}$/
|
|
351
|
+
// Idle timeout: if the worker sees no `/execute`, `/read-file`, or
|
|
352
|
+
// `/write-file` request for this many ms, it `process.exit(0)`s. The
|
|
353
|
+
// container is spawned `--rm` so the daemon collects the corpse
|
|
354
|
+
// automatically; that's the cheap layer-2 defense against orphaned
|
|
355
|
+
// sandboxes when the host's TTL or the supervisor's `finally`
|
|
356
|
+
// block both fail. `0` disables.
|
|
357
|
+
//
|
|
358
|
+
// Default 5 min: a supervised agent's median tool-call → tool-call
|
|
359
|
+
// gap is well under a minute, so 5 min is a comfortable buffer that
|
|
360
|
+
// still bounds runaway lifetime to a single-digit-minute scale. Hosts
|
|
361
|
+
// that run longer interactive turns (heavy data-prep, slow LLMs)
|
|
362
|
+
// override via env.
|
|
363
|
+
const IDLE_TIMEOUT_MS = Number(process.env.NAMZU_SANDBOX_IDLE_TIMEOUT_MS ?? 5 * 60 * 1000)
|
|
364
|
+
|
|
365
|
+
function readBody(req) {
|
|
366
|
+
return new Promise((resolve, reject) => {
|
|
367
|
+
const chunks = []
|
|
368
|
+
let total = 0
|
|
369
|
+
req.on('data', (chunk) => {
|
|
370
|
+
total += chunk.length
|
|
371
|
+
if (total > MAX_BODY_BYTES) {
|
|
372
|
+
reject(new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`))
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
chunks.push(chunk)
|
|
376
|
+
})
|
|
377
|
+
req.on('end', () => {
|
|
378
|
+
try {
|
|
379
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'))
|
|
380
|
+
} catch (err) {
|
|
381
|
+
reject(err)
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
req.on('error', reject)
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function writeJson(res, status, payload) {
|
|
389
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
390
|
+
res.end(JSON.stringify(payload))
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The one answer an unauthenticated caller gets.
|
|
395
|
+
*
|
|
396
|
+
* The body is the reason and nothing else — no "expected"/"got", no prefix
|
|
397
|
+
* of either value, no length, no hint about which routes exist. The
|
|
398
|
+
* `www-authenticate` header is the standard challenge and carries only the
|
|
399
|
+
* scheme and a realm name.
|
|
400
|
+
*
|
|
401
|
+
* The request body is deliberately NOT read here. Nothing downstream can
|
|
402
|
+
* act on it, and reading it would mean an unauthenticated peer could make
|
|
403
|
+
* this process parse bytes and hold a lease. Node discards the remainder
|
|
404
|
+
* of the request itself once the response is finished, and its own
|
|
405
|
+
* `requestTimeout` bounds how long that may take.
|
|
406
|
+
*/
|
|
407
|
+
function writeUnauthorized(res) {
|
|
408
|
+
res.setHeader('www-authenticate', 'Bearer realm="namzu-sandbox-worker"')
|
|
409
|
+
writeJson(res, 401, { error: 'unauthorized' })
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function writeEvent(res, event) {
|
|
413
|
+
res.write(`${JSON.stringify(event)}\n`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function resolveWithinWorkspace(p, base) {
|
|
417
|
+
const resolved = path.resolve(base, p)
|
|
418
|
+
const baseResolved = path.resolve(base)
|
|
419
|
+
if (!resolved.startsWith(`${baseResolved}${path.sep}`) && resolved !== baseResolved) {
|
|
420
|
+
throw new Error('path escapes the workspace')
|
|
421
|
+
}
|
|
422
|
+
return resolved
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function normalizeRoots(roots) {
|
|
426
|
+
const seen = new Set()
|
|
427
|
+
const normalized = []
|
|
428
|
+
for (const root of roots) {
|
|
429
|
+
const trimmed = String(root || '').trim()
|
|
430
|
+
if (!trimmed) continue
|
|
431
|
+
const resolved = path.resolve(trimmed)
|
|
432
|
+
if (seen.has(resolved)) continue
|
|
433
|
+
seen.add(resolved)
|
|
434
|
+
normalized.push(resolved)
|
|
435
|
+
}
|
|
436
|
+
return normalized
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isWithinRoot(resolved, root) {
|
|
440
|
+
return resolved === root || resolved.startsWith(`${root}${path.sep}`)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function resolveReadablePath(p) {
|
|
444
|
+
return resolveAgainstRoots(p, READ_ROOTS)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function resolveWritablePath(p) {
|
|
448
|
+
return resolveAgainstRoots(p, WRITE_ROOTS)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function resolveAgainstRoots(p, roots) {
|
|
452
|
+
if (!path.isAbsolute(p)) {
|
|
453
|
+
return {
|
|
454
|
+
target: resolveWithinWorkspace(p, WORKSPACE_ROOT),
|
|
455
|
+
root: path.resolve(WORKSPACE_ROOT),
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const target = path.resolve(p)
|
|
459
|
+
const root = roots.find((candidate) => isWithinRoot(target, candidate))
|
|
460
|
+
if (!root) {
|
|
461
|
+
throw new Error('path escapes the workspace')
|
|
462
|
+
}
|
|
463
|
+
return { target, root }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* After lexical resolution proves the requested path doesn't ESCAPE
|
|
468
|
+
* `/workspace` via `..`, we still have to defend against symlinks
|
|
469
|
+
* inside the workspace pointing OUTSIDE it (e.g. `/workspace/leak ->
|
|
470
|
+
* /etc/passwd`). The lexical check only inspects the string; the
|
|
471
|
+
* actual `fs.readFile` follows symlinks. Resolve via `realpath` and
|
|
472
|
+
* verify the resolved target is still inside the workspace before
|
|
473
|
+
* touching the file.
|
|
474
|
+
*
|
|
475
|
+
* For writes the parent directory's realpath is what matters — the
|
|
476
|
+
* file itself may not exist yet, so realpath the parent and
|
|
477
|
+
* reconstruct the final path. If the parent contains a symlink
|
|
478
|
+
* jumping out of the workspace, this rejects the write.
|
|
479
|
+
*/
|
|
480
|
+
async function realpathWithinWorkspace(target, base) {
|
|
481
|
+
const baseReal = await fs.realpath(path.resolve(base))
|
|
482
|
+
let real
|
|
483
|
+
try {
|
|
484
|
+
real = await fs.realpath(target)
|
|
485
|
+
} catch (err) {
|
|
486
|
+
if (err && err.code === 'ENOENT') {
|
|
487
|
+
const parentReal = await fs.realpath(path.dirname(target))
|
|
488
|
+
real = path.join(parentReal, path.basename(target))
|
|
489
|
+
} else {
|
|
490
|
+
throw err
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (!real.startsWith(`${baseReal}${path.sep}`) && real !== baseReal) {
|
|
494
|
+
throw new Error('symlink escapes the workspace')
|
|
495
|
+
}
|
|
496
|
+
return real
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Mirrors `resolveTimeoutMs` in `agent/agent.cjs` — same bound, same
|
|
500
|
+
// refusal, same unbounded upstream input, different transport. Throws so
|
|
501
|
+
// the caller learns its request was rejected; the handler turns that into
|
|
502
|
+
// a 400 beside `invalid_cwd`.
|
|
503
|
+
function resolveTimeoutMs(rawTimeoutMs) {
|
|
504
|
+
const timeoutMs = rawTimeoutMs === undefined ? DEFAULT_TIMEOUT_MS : Number(rawTimeoutMs)
|
|
505
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) {
|
|
506
|
+
throw new Error(`timeoutMs must be a finite number in (0, ${MAX_TIMEOUT_MS}]`)
|
|
507
|
+
}
|
|
508
|
+
return timeoutMs
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* The environment a spawned command runs in.
|
|
513
|
+
*
|
|
514
|
+
* This used to be `{ ...process.env, ...body.env }`, which handed the
|
|
515
|
+
* worker's ENTIRE environment to every command the agent runs — by
|
|
516
|
+
* construction, on every call, visible in a bare `env` in any shell
|
|
517
|
+
* transcript. That is a stronger exposure than "untrusted code could read
|
|
518
|
+
* `/proc/self/environ` if it thought to": it is active propagation, and the
|
|
519
|
+
* agent does not have to go looking.
|
|
520
|
+
*
|
|
521
|
+
* What rode along: `NAMZU_SANDBOX_WORKSPACE`, `_READ_ROOTS` and
|
|
522
|
+
* `_WRITE_ROOTS` — the confinement layout itself, handed to the code being
|
|
523
|
+
* confined — plus every other setting below. So the boundary announced its
|
|
524
|
+
* own shape to the thing it was drawn around.
|
|
525
|
+
*
|
|
526
|
+
* Stripping by prefix rather than by an allowlist of known-safe names is
|
|
527
|
+
* deliberate, and it is the difference between this working and this
|
|
528
|
+
* breaking egress:
|
|
529
|
+
*
|
|
530
|
+
* - `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` are set on the container ON
|
|
531
|
+
* PURPOSE, so that tooling inside routes through the egress boundary. An
|
|
532
|
+
* allowlist assembled from first principles drops them and every
|
|
533
|
+
* proxied workload silently stops being proxied — which would look
|
|
534
|
+
* exactly like the policy working.
|
|
535
|
+
* - A host's own `options.env` arrives on this same channel and is meant
|
|
536
|
+
* to reach commands. It is indistinguishable from the worker's config
|
|
537
|
+
* once both are in `process.env`; the prefix is the only thing that
|
|
538
|
+
* tells them apart.
|
|
539
|
+
*
|
|
540
|
+
* `body.env` is applied AFTER the strip and is not filtered. Inheritance is
|
|
541
|
+
* implicit and gets the default; an explicit per-call value is a caller
|
|
542
|
+
* deciding, including a caller that deliberately sets a prefixed name.
|
|
543
|
+
*/
|
|
544
|
+
function childEnvironment(requested) {
|
|
545
|
+
const inherited = {}
|
|
546
|
+
for (const key of Object.keys(process.env)) {
|
|
547
|
+
if (key.startsWith(WORKER_CONFIG_PREFIX)) continue
|
|
548
|
+
inherited[key] = process.env[key]
|
|
549
|
+
}
|
|
550
|
+
return { ...inherited, ...(requested || {}) }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Execution ownership registry.
|
|
555
|
+
*
|
|
556
|
+
* A reservation is deliberately inert: losing its response can leak one
|
|
557
|
+
* bounded map entry, never a process. `/execute` may transition ONLY a live
|
|
558
|
+
* reservation, which is the admission barrier that makes cancel-before-start
|
|
559
|
+
* sound without an immortal unknown-id tombstone.
|
|
560
|
+
*/
|
|
561
|
+
const executions = new Map()
|
|
562
|
+
let activeWorkCount = 0
|
|
563
|
+
let workerPoisoned = false
|
|
564
|
+
|
|
565
|
+
function acquireWorkerActivity() {
|
|
566
|
+
let released = false
|
|
567
|
+
activeWorkCount += 1
|
|
568
|
+
disarmIdleTimer()
|
|
569
|
+
return () => {
|
|
570
|
+
if (released) return
|
|
571
|
+
released = true
|
|
572
|
+
activeWorkCount = Math.max(0, activeWorkCount - 1)
|
|
573
|
+
if (activeWorkCount === 0) resetIdleTimer()
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function whileWorkerActive(operation) {
|
|
578
|
+
const release = acquireWorkerActivity()
|
|
579
|
+
try {
|
|
580
|
+
return await operation()
|
|
581
|
+
} finally {
|
|
582
|
+
release()
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function pruneExecutions(now = Date.now()) {
|
|
587
|
+
for (const [executionId, execution] of executions) {
|
|
588
|
+
if (
|
|
589
|
+
(execution.state === 'reserved' || execution.state === 'terminal') &&
|
|
590
|
+
execution.expiresAt <= now
|
|
591
|
+
) {
|
|
592
|
+
executions.delete(executionId)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function makeRoomForReservation() {
|
|
598
|
+
if (executions.size < MAX_TRACKED_EXECUTIONS) return
|
|
599
|
+
const terminal = [...executions.entries()]
|
|
600
|
+
.filter(([, execution]) => execution.state === 'terminal')
|
|
601
|
+
.sort(([, left], [, right]) => left.expiresAt - right.expiresAt)
|
|
602
|
+
for (const [executionId] of terminal) {
|
|
603
|
+
executions.delete(executionId)
|
|
604
|
+
if (executions.size < MAX_TRACKED_EXECUTIONS) return
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function validateExecutionId(executionId) {
|
|
609
|
+
return typeof executionId === 'string' && EXECUTION_ID_PATTERN.test(executionId)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function syntheticCancelledResult(start = Date.now()) {
|
|
613
|
+
return {
|
|
614
|
+
exitCode: 1,
|
|
615
|
+
timedOut: false,
|
|
616
|
+
durationMs: Math.max(0, Date.now() - start),
|
|
617
|
+
stdoutTruncated: false,
|
|
618
|
+
stderrTruncated: false,
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function rememberTerminal(execution, outcome, result, error) {
|
|
623
|
+
execution.started = execution.started ?? Boolean(execution.child)
|
|
624
|
+
execution.state = 'terminal'
|
|
625
|
+
execution.outcome = outcome
|
|
626
|
+
execution.result = result
|
|
627
|
+
execution.error = error
|
|
628
|
+
execution.expiresAt = Date.now() + EXECUTION_TERMINAL_TTL_MS
|
|
629
|
+
execution.child = undefined
|
|
630
|
+
execution.processGroupId = undefined
|
|
631
|
+
execution.done = undefined
|
|
632
|
+
execution.resolveDone = undefined
|
|
633
|
+
execution.terminationPromise = undefined
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function terminalPayload(execution) {
|
|
637
|
+
return {
|
|
638
|
+
ok: true,
|
|
639
|
+
state: execution.outcome,
|
|
640
|
+
started: execution.started === true,
|
|
641
|
+
...(execution.result ? { result: execution.result } : {}),
|
|
642
|
+
...(execution.error ? { error: execution.error } : {}),
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function handleReserveExecution(_req, res) {
|
|
647
|
+
pruneExecutions()
|
|
648
|
+
makeRoomForReservation()
|
|
649
|
+
if (executions.size >= MAX_TRACKED_EXECUTIONS) {
|
|
650
|
+
writeJson(res, 503, {
|
|
651
|
+
error: 'execution_capacity',
|
|
652
|
+
message: `worker already tracks ${MAX_TRACKED_EXECUTIONS} execution leases`,
|
|
653
|
+
})
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const executionId = `exec_${randomUUID()}`
|
|
658
|
+
const leaseExpiresAt = Date.now() + EXECUTION_LEASE_TTL_MS
|
|
659
|
+
executions.set(executionId, {
|
|
660
|
+
executionId,
|
|
661
|
+
state: 'reserved',
|
|
662
|
+
expiresAt: leaseExpiresAt,
|
|
663
|
+
})
|
|
664
|
+
writeJson(res, 201, {
|
|
665
|
+
ok: true,
|
|
666
|
+
protocolVersion: REMOTE_EXECUTION_PROTOCOL_VERSION,
|
|
667
|
+
executionId,
|
|
668
|
+
leaseExpiresAt,
|
|
669
|
+
normalExitPolicy: NORMAL_EXIT_POLICY,
|
|
670
|
+
stdio: { version: 1, maxLifetimeMs: MAX_TIMEOUT_MS },
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function processGroupAlive(processGroupId) {
|
|
675
|
+
if (!processGroupId) return false
|
|
676
|
+
if (process.platform === 'win32') return true
|
|
677
|
+
try {
|
|
678
|
+
process.kill(-processGroupId, 0)
|
|
679
|
+
return true
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if (error?.code === 'ESRCH') return false
|
|
682
|
+
// EPERM still proves that the group exists. Any other result is not
|
|
683
|
+
// evidence that it is gone, so keep waiting until the hard bound.
|
|
684
|
+
return true
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function signalProcessGroup(execution, signal) {
|
|
689
|
+
const processGroupId = execution.processGroupId
|
|
690
|
+
if (!processGroupId || process.platform === 'win32') {
|
|
691
|
+
try {
|
|
692
|
+
execution.child?.kill(signal)
|
|
693
|
+
} catch {}
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
process.kill(-processGroupId, signal)
|
|
698
|
+
} catch (error) {
|
|
699
|
+
if (error?.code !== 'ESRCH') throw error
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function delay(ms) {
|
|
704
|
+
return new Promise((resolve) => {
|
|
705
|
+
const timer = setTimeout(resolve, ms)
|
|
706
|
+
timer.unref?.()
|
|
707
|
+
})
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function waitForGroupExit(processGroupId, deadlineAt) {
|
|
711
|
+
while (processGroupAlive(processGroupId)) {
|
|
712
|
+
const remaining = deadlineAt - Date.now()
|
|
713
|
+
if (remaining <= 0) return false
|
|
714
|
+
await delay(Math.min(25, remaining))
|
|
715
|
+
}
|
|
716
|
+
return true
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function waitForDone(execution, deadlineAt) {
|
|
720
|
+
const remaining = deadlineAt - Date.now()
|
|
721
|
+
if (remaining <= 0) throw new Error('execution close was not observed before the deadline')
|
|
722
|
+
let timer
|
|
723
|
+
try {
|
|
724
|
+
return await Promise.race([
|
|
725
|
+
execution.done,
|
|
726
|
+
new Promise((_, reject) => {
|
|
727
|
+
timer = setTimeout(
|
|
728
|
+
() => reject(new Error('execution close was not observed before the deadline')),
|
|
729
|
+
remaining,
|
|
730
|
+
)
|
|
731
|
+
timer.unref?.()
|
|
732
|
+
}),
|
|
733
|
+
])
|
|
734
|
+
} finally {
|
|
735
|
+
if (timer) clearTimeout(timer)
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function terminateAndConfirm(execution, cause) {
|
|
740
|
+
if (execution.state === 'terminal') return terminalPayload(execution)
|
|
741
|
+
if (execution.state === 'exited') {
|
|
742
|
+
// A Pal launcher has not transferred its descendants until normal close
|
|
743
|
+
// commits that handoff. Cancellation or timeout before it wins the fence.
|
|
744
|
+
// Observe the surviving group, but never signal its reusable numeric ID.
|
|
745
|
+
if (
|
|
746
|
+
execution.normalExitPolicy === 'computer-lifetime' &&
|
|
747
|
+
!execution.computerLifetimeHandoff &&
|
|
748
|
+
execution.terminationCause === undefined &&
|
|
749
|
+
processGroupAlive(execution.processGroupId)
|
|
750
|
+
)
|
|
751
|
+
execution.terminationCause = cause
|
|
752
|
+
// `exit` proves that the owned leader has gone; `close` may lag while
|
|
753
|
+
// inherited stdio drains. Do not relabel a naturally completed command,
|
|
754
|
+
// and do not signal a numeric process-group id after its leader exited.
|
|
755
|
+
await waitForDone(execution, Date.now() + CANCEL_CONFIRM_TIMEOUT_MS)
|
|
756
|
+
return terminalPayload(execution)
|
|
757
|
+
}
|
|
758
|
+
if (execution.state !== 'running') {
|
|
759
|
+
throw new Error(`execution is not running (state=${execution.state})`)
|
|
760
|
+
}
|
|
761
|
+
if (execution.terminationCause === undefined) execution.terminationCause = cause
|
|
762
|
+
|
|
763
|
+
const deadlineAt = Date.now() + CANCEL_CONFIRM_TIMEOUT_MS
|
|
764
|
+
signalProcessGroup(execution, 'SIGTERM')
|
|
765
|
+
const termDeadline = Math.min(deadlineAt, Date.now() + CANCEL_GRACE_MS)
|
|
766
|
+
let groupGone = await waitForGroupExit(execution.processGroupId, termDeadline)
|
|
767
|
+
if (!groupGone) {
|
|
768
|
+
if (execution.state === 'exited') {
|
|
769
|
+
throw new Error(
|
|
770
|
+
`process group ${execution.processGroupId} outlived its leader during cancellation; refusing to signal a reusable numeric process-group id`,
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
signalProcessGroup(execution, 'SIGKILL')
|
|
774
|
+
groupGone = await waitForGroupExit(execution.processGroupId, deadlineAt)
|
|
775
|
+
}
|
|
776
|
+
if (!groupGone) {
|
|
777
|
+
throw new Error(`process group ${execution.processGroupId} remained live after SIGKILL`)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
await waitForDone(execution, deadlineAt)
|
|
781
|
+
return terminalPayload(execution)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function ensureTermination(execution, cause) {
|
|
785
|
+
if (!execution.terminationPromise) {
|
|
786
|
+
execution.terminationPromise = terminateAndConfirm(execution, cause).catch((error) => {
|
|
787
|
+
// A later idempotent cancel is allowed to retry observation/signalling.
|
|
788
|
+
// Retaining a rejected promise would turn a lost first confirmation
|
|
789
|
+
// into a permanent false negative even after the process has exited.
|
|
790
|
+
execution.terminationPromise = undefined
|
|
791
|
+
throw error
|
|
792
|
+
})
|
|
793
|
+
}
|
|
794
|
+
return execution.terminationPromise
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function confirmExitedProcessGroup(execution) {
|
|
798
|
+
if (!processGroupAlive(execution.processGroupId)) return
|
|
799
|
+
if (
|
|
800
|
+
execution.normalExitPolicy === 'computer-lifetime' &&
|
|
801
|
+
execution.terminationCause === undefined &&
|
|
802
|
+
execution.exitCode === 0 &&
|
|
803
|
+
!execution.exitSignal
|
|
804
|
+
) {
|
|
805
|
+
// The foreground command succeeded. Its surviving applications now belong
|
|
806
|
+
// to this exclusive computer allocation, like applications opened from the
|
|
807
|
+
// desktop dock. This is not a claim that a cancelled job/tree has stopped.
|
|
808
|
+
execution.computerLifetimeHandoff = true
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
if (execution.terminationCause !== undefined) {
|
|
812
|
+
const groupGone = await waitForGroupExit(
|
|
813
|
+
execution.processGroupId,
|
|
814
|
+
Date.now() + CANCEL_CONFIRM_TIMEOUT_MS,
|
|
815
|
+
)
|
|
816
|
+
if (groupGone) return
|
|
817
|
+
} else {
|
|
818
|
+
// `close` and kernel process-table cleanup can be adjacent but not
|
|
819
|
+
// perfectly simultaneous. Observe one short no-signal grace before
|
|
820
|
+
// treating the surviving numeric group id as unsafe to reuse.
|
|
821
|
+
await delay(25)
|
|
822
|
+
if (!processGroupAlive(execution.processGroupId)) return
|
|
823
|
+
}
|
|
824
|
+
throw new Error(
|
|
825
|
+
`process group ${execution.processGroupId} remained live after its leader exited; refusing to signal a reusable numeric process-group id`,
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function poisonWorker(error) {
|
|
830
|
+
if (workerPoisoned) return
|
|
831
|
+
workerPoisoned = true
|
|
832
|
+
disarmIdleTimer()
|
|
833
|
+
console.error(
|
|
834
|
+
`[namzu-sandbox-worker] termination could not be confirmed; retiring worker: ${error instanceof Error ? error.message : String(error)}`,
|
|
835
|
+
)
|
|
836
|
+
try {
|
|
837
|
+
server.close()
|
|
838
|
+
} catch {}
|
|
839
|
+
// This worker is the container's ownership boundary. If it cannot prove
|
|
840
|
+
// that a command is gone, keeping the container reusable would allow an
|
|
841
|
+
// unowned process to overlap the next call. Exiting PID 1 asks the runtime
|
|
842
|
+
// to tear down the whole container and its remaining process namespace.
|
|
843
|
+
setTimeout(() => process.exit(1), POISON_EXIT_DELAY_MS)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function handleCancelExecution(req, res) {
|
|
847
|
+
let body
|
|
848
|
+
try {
|
|
849
|
+
body = await readBody(req)
|
|
850
|
+
} catch (error) {
|
|
851
|
+
writeJson(res, 400, { error: 'invalid_body', message: error.message })
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
if (!validateExecutionId(body.executionId)) {
|
|
855
|
+
writeJson(res, 400, { error: 'invalid_execution_id' })
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
pruneExecutions()
|
|
860
|
+
const execution = executions.get(body.executionId)
|
|
861
|
+
if (!execution) {
|
|
862
|
+
writeJson(res, 404, { error: 'unknown_execution' })
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (execution.state === 'reserved' || execution.state === 'starting') {
|
|
867
|
+
const result = syntheticCancelledResult(execution.startedAt)
|
|
868
|
+
rememberTerminal(execution, 'cancelled', result)
|
|
869
|
+
writeJson(res, 200, terminalPayload(execution))
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
if (execution.state === 'terminal') {
|
|
873
|
+
writeJson(res, 200, terminalPayload(execution))
|
|
874
|
+
return
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
try {
|
|
878
|
+
writeJson(res, 200, await ensureTermination(execution, 'cancelled'))
|
|
879
|
+
} catch (error) {
|
|
880
|
+
writeJson(res, 504, {
|
|
881
|
+
error: 'cancellation_unconfirmed',
|
|
882
|
+
message: error instanceof Error ? error.message : String(error),
|
|
883
|
+
})
|
|
884
|
+
poisonWorker(error)
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function handleExecute(req, res) {
|
|
889
|
+
let body
|
|
890
|
+
try {
|
|
891
|
+
body = await readBody(req)
|
|
892
|
+
} catch (err) {
|
|
893
|
+
writeJson(res, 400, { error: 'invalid_body', message: err.message })
|
|
894
|
+
return
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (!body.command || typeof body.command !== 'string') {
|
|
898
|
+
writeJson(res, 400, { error: 'missing_command' })
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
if (body.stdio !== undefined && body.stdio !== true) {
|
|
902
|
+
writeJson(res, 400, { error: 'invalid_stdio' })
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
if (
|
|
906
|
+
body.stdio &&
|
|
907
|
+
(!body.executionId || body.stdin !== undefined || body.normalExitPolicy === 'computer-lifetime')
|
|
908
|
+
) {
|
|
909
|
+
writeJson(res, 400, { error: 'stdio_requires_reserved_strict_execution' })
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
const normalExitPolicy = body.normalExitPolicy === undefined ? 'strict' : body.normalExitPolicy
|
|
913
|
+
if (normalExitPolicy !== 'strict' && normalExitPolicy !== 'computer-lifetime') {
|
|
914
|
+
writeJson(res, 400, { error: 'invalid_normal_exit_policy' })
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
if (normalExitPolicy === 'computer-lifetime' && NORMAL_EXIT_POLICY !== 'computer-lifetime') {
|
|
918
|
+
writeJson(res, 400, { error: 'computer_lifetime_not_enabled' })
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
let trackedExecution
|
|
923
|
+
if (body.executionId !== undefined) {
|
|
924
|
+
if (!validateExecutionId(body.executionId)) {
|
|
925
|
+
writeJson(res, 400, { error: 'invalid_execution_id' })
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Resolve `cwd` and pre-create it BEFORE we commit to the streaming
|
|
931
|
+
// 200 NDJSON response: both calls can throw (`resolveWithinWorkspace`
|
|
932
|
+
// rejects a host path that escapes the container workspace, and
|
|
933
|
+
// `fs.mkdir` can fail for permission / EROFS / ENOSPC). Without this
|
|
934
|
+
// guard the rejection bubbles out of the http callback, becomes an
|
|
935
|
+
// unhandled promise rejection, and on Node ≥ 15 with the default
|
|
936
|
+
// `unhandledRejection: throw` policy it terminates the worker
|
|
937
|
+
// process. The container exits 1 (`--rm` GCs it), the host's next
|
|
938
|
+
// `fetch` gets `UND_ERR_SOCKET` ("other side closed") and reports
|
|
939
|
+
// it as the bare "fetch failed" real transcripts surfaced —
|
|
940
|
+
// every subsequent tool call in the same supervisor.run() then
|
|
941
|
+
// hits the same dead DNS name and looks like a sandbox-runtime bug
|
|
942
|
+
// when the trigger was a single bad input on a single endpoint.
|
|
943
|
+
let cwd
|
|
944
|
+
try {
|
|
945
|
+
cwd = body.cwd ? resolveWithinWorkspace(body.cwd, WORKSPACE_ROOT) : WORKSPACE_ROOT
|
|
946
|
+
} catch (err) {
|
|
947
|
+
writeJson(res, 400, { error: 'invalid_cwd', message: err.message })
|
|
948
|
+
return
|
|
949
|
+
}
|
|
950
|
+
let timeoutMs
|
|
951
|
+
try {
|
|
952
|
+
timeoutMs = resolveTimeoutMs(body.timeoutMs)
|
|
953
|
+
} catch (err) {
|
|
954
|
+
writeJson(res, 400, { error: 'invalid_timeout', message: err.message })
|
|
955
|
+
return
|
|
956
|
+
}
|
|
957
|
+
const maxOutputBytes = Number(body.maxOutputBytes) || DEFAULT_MAX_OUTPUT_BYTES
|
|
958
|
+
const start = Date.now()
|
|
959
|
+
|
|
960
|
+
if (body.executionId !== undefined) {
|
|
961
|
+
pruneExecutions()
|
|
962
|
+
trackedExecution = executions.get(body.executionId)
|
|
963
|
+
if (!trackedExecution) {
|
|
964
|
+
writeJson(res, 404, { error: 'unknown_execution' })
|
|
965
|
+
return
|
|
966
|
+
}
|
|
967
|
+
if (trackedExecution.state !== 'reserved') {
|
|
968
|
+
writeJson(res, 409, {
|
|
969
|
+
error: 'execution_not_reserved',
|
|
970
|
+
state:
|
|
971
|
+
trackedExecution.state === 'terminal' ? trackedExecution.outcome : trackedExecution.state,
|
|
972
|
+
})
|
|
973
|
+
return
|
|
974
|
+
}
|
|
975
|
+
// Validation above is synchronous, so this remains an atomic admission
|
|
976
|
+
// transition. Invalid input leaves an expiring reservation rather than
|
|
977
|
+
// manufacturing an immortal `starting` entry.
|
|
978
|
+
trackedExecution.state = 'starting'
|
|
979
|
+
trackedExecution.startedAt = start
|
|
980
|
+
trackedExecution.expiresAt = undefined
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
try {
|
|
984
|
+
await fs.mkdir(cwd, { recursive: true })
|
|
985
|
+
} catch (err) {
|
|
986
|
+
if (trackedExecution?.state === 'starting') {
|
|
987
|
+
rememberTerminal(trackedExecution, 'failed', undefined, err.message)
|
|
988
|
+
}
|
|
989
|
+
writeJson(res, 400, { error: 'mkdir_failed', message: err.message })
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
if (trackedExecution?.state === 'terminal') {
|
|
993
|
+
writeJson(res, 409, {
|
|
994
|
+
error: 'execution_cancelled',
|
|
995
|
+
state: trackedExecution.outcome,
|
|
996
|
+
})
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
let child
|
|
1001
|
+
try {
|
|
1002
|
+
child = spawn(body.command, Array.isArray(body.args) ? body.args : [], {
|
|
1003
|
+
cwd,
|
|
1004
|
+
env: childEnvironment(body.env),
|
|
1005
|
+
stdio: [body.stdio || body.stdin !== undefined ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
1006
|
+
// The worker owns the process group, not only the command's outer
|
|
1007
|
+
// wrapper. Cancellation can therefore address the wrapper and ordinary
|
|
1008
|
+
// descendants with the same signal. A descendant that deliberately
|
|
1009
|
+
// creates a new session is outside this narrower process-group claim.
|
|
1010
|
+
detached: process.platform !== 'win32',
|
|
1011
|
+
})
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
if (trackedExecution?.state === 'starting') {
|
|
1014
|
+
rememberTerminal(trackedExecution, 'failed', undefined, err.message)
|
|
1015
|
+
}
|
|
1016
|
+
writeJson(res, 400, { error: 'spawn_failed', message: err.message })
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
res.writeHead(200, {
|
|
1021
|
+
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
1022
|
+
'cache-control': 'no-store',
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
if (body.stdin !== undefined && child.stdin) {
|
|
1026
|
+
child.stdin.end(String(body.stdin))
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const stdout = { chunks: [], bytes: 0, truncated: false }
|
|
1030
|
+
const stderr = { chunks: [], bytes: 0, truncated: false }
|
|
1031
|
+
let settled = false
|
|
1032
|
+
let resolveDone
|
|
1033
|
+
const done = new Promise((resolve) => {
|
|
1034
|
+
resolveDone = resolve
|
|
1035
|
+
})
|
|
1036
|
+
const execution = trackedExecution ?? { state: 'starting', startedAt: start }
|
|
1037
|
+
Object.assign(execution, {
|
|
1038
|
+
state: 'running',
|
|
1039
|
+
child,
|
|
1040
|
+
processGroupId: child.pid,
|
|
1041
|
+
done,
|
|
1042
|
+
resolveDone,
|
|
1043
|
+
terminationCause: undefined,
|
|
1044
|
+
normalExitPolicy,
|
|
1045
|
+
computerLifetimeHandoff: false,
|
|
1046
|
+
stdio: body.stdio === true,
|
|
1047
|
+
})
|
|
1048
|
+
// `whileWorkerActive` owns request parsing and preparation. The spawned
|
|
1049
|
+
// command outlives this handler, so take a second activity lease before the
|
|
1050
|
+
// request lease can be released; `settle` transfers it back exactly once.
|
|
1051
|
+
const releaseActivity = acquireWorkerActivity()
|
|
1052
|
+
let stdioHeartbeat
|
|
1053
|
+
function stopStdioHeartbeat() {
|
|
1054
|
+
clearInterval(stdioHeartbeat)
|
|
1055
|
+
stdioHeartbeat = undefined
|
|
1056
|
+
}
|
|
1057
|
+
if (body.stdio) {
|
|
1058
|
+
child.stdin.on('error', () => {})
|
|
1059
|
+
child.once('spawn', () => {
|
|
1060
|
+
if (settled || res.destroyed || res.writableEnded) return
|
|
1061
|
+
writeEvent(res, { type: 'stdio_started', version: 1 })
|
|
1062
|
+
// Quiet MCP services still need response-body bytes: HTTP clients and
|
|
1063
|
+
// intermediaries may expire an otherwise healthy idle response. These
|
|
1064
|
+
// frames neither feed stdin nor acknowledge an application operation.
|
|
1065
|
+
stdioHeartbeat = setInterval(() => {
|
|
1066
|
+
if (!settled && !res.destroyed && !res.writableEnded && !res.writableNeedDrain)
|
|
1067
|
+
writeEvent(res, { type: 'stdio_heartbeat', version: 1 })
|
|
1068
|
+
}, 30_000)
|
|
1069
|
+
stdioHeartbeat.unref()
|
|
1070
|
+
})
|
|
1071
|
+
res.once('close', () => {
|
|
1072
|
+
stopStdioHeartbeat()
|
|
1073
|
+
if (!settled) void ensureTermination(execution, 'cancelled').catch(poisonWorker)
|
|
1074
|
+
})
|
|
1075
|
+
}
|
|
1076
|
+
function streamStdio(stream, chunk) {
|
|
1077
|
+
// Lossless byte framing with backpressure, without accumulating lifetime output.
|
|
1078
|
+
if (
|
|
1079
|
+
!res.write(
|
|
1080
|
+
`${JSON.stringify({ type: 'stdio_data', stream, data: chunk.toString('base64') })}\n`,
|
|
1081
|
+
)
|
|
1082
|
+
) {
|
|
1083
|
+
child.stdout.pause()
|
|
1084
|
+
child.stderr.pause()
|
|
1085
|
+
res.once('drain', () => {
|
|
1086
|
+
child.stdout.resume()
|
|
1087
|
+
child.stderr.resume()
|
|
1088
|
+
})
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function appendChunk(target, chunk) {
|
|
1093
|
+
if (target.truncated) return null
|
|
1094
|
+
const remaining = maxOutputBytes - target.bytes
|
|
1095
|
+
if (remaining <= 0) {
|
|
1096
|
+
target.truncated = true
|
|
1097
|
+
return null
|
|
1098
|
+
}
|
|
1099
|
+
const clipped = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk
|
|
1100
|
+
target.chunks.push(clipped)
|
|
1101
|
+
target.bytes += clipped.length
|
|
1102
|
+
if (clipped.length < chunk.length) target.truncated = true
|
|
1103
|
+
return clipped
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
child.stdout.on('data', (chunk) => {
|
|
1107
|
+
if (body.stdio) return streamStdio('stdout', chunk)
|
|
1108
|
+
const clipped = appendChunk(stdout, chunk)
|
|
1109
|
+
if (clipped) writeEvent(res, { type: 'stdout_delta', data: clipped.toString('utf8') })
|
|
1110
|
+
})
|
|
1111
|
+
child.stderr.on('data', (chunk) => {
|
|
1112
|
+
if (body.stdio) return streamStdio('stderr', chunk)
|
|
1113
|
+
const clipped = appendChunk(stderr, chunk)
|
|
1114
|
+
if (clipped) writeEvent(res, { type: 'stderr_delta', data: clipped.toString('utf8') })
|
|
1115
|
+
})
|
|
1116
|
+
|
|
1117
|
+
const timeout = setTimeout(() => {
|
|
1118
|
+
void ensureTermination(execution, 'timeout').catch((error) => {
|
|
1119
|
+
console.error(
|
|
1120
|
+
`[namzu-sandbox-worker] timed out execution could not be confirmed stopped: ${error instanceof Error ? error.message : String(error)}`,
|
|
1121
|
+
)
|
|
1122
|
+
poisonWorker(error)
|
|
1123
|
+
})
|
|
1124
|
+
}, timeoutMs)
|
|
1125
|
+
timeout.unref()
|
|
1126
|
+
|
|
1127
|
+
function settle(error, result) {
|
|
1128
|
+
if (settled) return
|
|
1129
|
+
settled = true
|
|
1130
|
+
clearTimeout(timeout)
|
|
1131
|
+
stopStdioHeartbeat()
|
|
1132
|
+
releaseActivity()
|
|
1133
|
+
if (trackedExecution) {
|
|
1134
|
+
rememberTerminal(
|
|
1135
|
+
trackedExecution,
|
|
1136
|
+
execution.terminationCause === 'cancelled' ? 'cancelled' : error ? 'failed' : 'completed',
|
|
1137
|
+
result,
|
|
1138
|
+
error?.message,
|
|
1139
|
+
)
|
|
1140
|
+
}
|
|
1141
|
+
resolveDone({ error, result })
|
|
1142
|
+
try {
|
|
1143
|
+
if (error) writeEvent(res, { type: 'error', error: error.message })
|
|
1144
|
+
else writeEvent(res, { type: 'result', ...result })
|
|
1145
|
+
res.end()
|
|
1146
|
+
} catch {}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
child.on('error', (error) => {
|
|
1150
|
+
settle(error)
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
child.on('exit', (exitCode, signal) => {
|
|
1154
|
+
if (execution.state !== 'running') return
|
|
1155
|
+
execution.state = 'exited'
|
|
1156
|
+
execution.exitCode = exitCode
|
|
1157
|
+
execution.exitSignal = signal
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
child.on('close', (exitCode, signal) => {
|
|
1161
|
+
void confirmExitedProcessGroup(execution)
|
|
1162
|
+
.then(() => {
|
|
1163
|
+
settle(undefined, {
|
|
1164
|
+
exitCode: typeof exitCode === 'number' ? exitCode : -1,
|
|
1165
|
+
timedOut: execution.terminationCause === 'timeout',
|
|
1166
|
+
durationMs: Date.now() - start,
|
|
1167
|
+
...(signal ? { signal } : {}),
|
|
1168
|
+
stdoutTruncated: stdout.truncated,
|
|
1169
|
+
stderrTruncated: stderr.truncated,
|
|
1170
|
+
})
|
|
1171
|
+
})
|
|
1172
|
+
.catch((error) => poisonWorker(error))
|
|
1173
|
+
})
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function handleStdioWrite(req, res) {
|
|
1177
|
+
const body = await readBody(req)
|
|
1178
|
+
if (
|
|
1179
|
+
!validateExecutionId(body.executionId) ||
|
|
1180
|
+
typeof body.data !== 'string' ||
|
|
1181
|
+
body.data.length > 2 * 1024 * 1024
|
|
1182
|
+
) {
|
|
1183
|
+
writeJson(res, 400, { error: 'invalid_stdio_write' })
|
|
1184
|
+
return
|
|
1185
|
+
}
|
|
1186
|
+
const bytes = Buffer.from(body.data, 'base64')
|
|
1187
|
+
if (bytes.toString('base64') !== body.data) {
|
|
1188
|
+
writeJson(res, 400, { error: 'invalid_stdio_bytes' })
|
|
1189
|
+
return
|
|
1190
|
+
}
|
|
1191
|
+
const execution = executions.get(body.executionId)
|
|
1192
|
+
if (!execution?.stdio || execution.state !== 'running' || !execution.child?.stdin?.writable) {
|
|
1193
|
+
writeJson(res, 409, { error: 'stdio_not_running' })
|
|
1194
|
+
return
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
await new Promise((resolve, reject) =>
|
|
1198
|
+
execution.child.stdin.write(bytes, (error) => (error ? reject(error) : resolve())),
|
|
1199
|
+
)
|
|
1200
|
+
writeJson(res, 200, { ok: true, bytesWritten: bytes.length })
|
|
1201
|
+
} catch {
|
|
1202
|
+
writeJson(res, 500, { error: 'stdio_delivery_unknown' })
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function handleReadFile(req, res) {
|
|
1207
|
+
let body
|
|
1208
|
+
try {
|
|
1209
|
+
body = await readBody(req)
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
writeJson(res, 400, { error: 'invalid_body', message: err.message })
|
|
1212
|
+
return
|
|
1213
|
+
}
|
|
1214
|
+
if (body.capabilitiesOnly === true) {
|
|
1215
|
+
writeJson(res, 200, {
|
|
1216
|
+
ok: true,
|
|
1217
|
+
readFileRanges: { version: 1, maxBytes: FILE_RANGE_BYTES },
|
|
1218
|
+
})
|
|
1219
|
+
return
|
|
1220
|
+
}
|
|
1221
|
+
if (!body.path) {
|
|
1222
|
+
writeJson(res, 400, { error: 'missing_path' })
|
|
1223
|
+
return
|
|
1224
|
+
}
|
|
1225
|
+
try {
|
|
1226
|
+
const { target, root } = resolveReadablePath(body.path)
|
|
1227
|
+
const real = await realpathWithinWorkspace(target, root)
|
|
1228
|
+
if (body.range !== undefined) {
|
|
1229
|
+
const range = body.range
|
|
1230
|
+
if (
|
|
1231
|
+
!range ||
|
|
1232
|
+
typeof range !== 'object' ||
|
|
1233
|
+
Array.isArray(range) ||
|
|
1234
|
+
range.version !== 1 ||
|
|
1235
|
+
(range.offset !== undefined && (!Number.isSafeInteger(range.offset) || range.offset < 0)) ||
|
|
1236
|
+
(range.length !== undefined &&
|
|
1237
|
+
(!Number.isSafeInteger(range.length) ||
|
|
1238
|
+
range.length < 0 ||
|
|
1239
|
+
range.length > FILE_RANGE_BYTES))
|
|
1240
|
+
)
|
|
1241
|
+
throw new Error('Invalid or oversized file read range')
|
|
1242
|
+
const offset = range.offset ?? 0
|
|
1243
|
+
const file = await fs.open(
|
|
1244
|
+
real,
|
|
1245
|
+
fileConstants.O_RDONLY | (fileConstants.O_NOFOLLOW ?? 0) | (fileConstants.O_NONBLOCK ?? 0),
|
|
1246
|
+
)
|
|
1247
|
+
try {
|
|
1248
|
+
// The opened descriptor pins the object throughout the read. Recheck
|
|
1249
|
+
// its path and identity before consuming bytes after an ancestor swap.
|
|
1250
|
+
const confirmed = await realpathWithinWorkspace(target, root)
|
|
1251
|
+
const info = await file.stat()
|
|
1252
|
+
const current = await fs.stat(confirmed)
|
|
1253
|
+
if (confirmed !== real || info.dev !== current.dev || info.ino !== current.ino)
|
|
1254
|
+
throw new Error('File identity changed before its bounded read')
|
|
1255
|
+
if (!info.isFile()) throw new Error('Bounded reads require a regular file')
|
|
1256
|
+
const requested = range.length ?? Math.max(0, info.size - offset)
|
|
1257
|
+
if (!Number.isSafeInteger(requested) || requested > FILE_RANGE_BYTES)
|
|
1258
|
+
throw new Error('File read range exceeds the worker limit; specify a bounded length')
|
|
1259
|
+
const length = Math.min(requested, Math.max(0, info.size - offset))
|
|
1260
|
+
const bytes = Buffer.alloc(length)
|
|
1261
|
+
let read = 0
|
|
1262
|
+
while (read < length) {
|
|
1263
|
+
const next = await file.read(bytes, read, length - read, offset + read)
|
|
1264
|
+
if (!next.bytesRead) break
|
|
1265
|
+
read += next.bytesRead
|
|
1266
|
+
}
|
|
1267
|
+
const buf = bytes.subarray(0, read)
|
|
1268
|
+
const encoding = body.encoding === 'base64' ? 'base64' : 'utf8'
|
|
1269
|
+
writeJson(res, 200, {
|
|
1270
|
+
ok: true,
|
|
1271
|
+
content: buf.toString(encoding),
|
|
1272
|
+
sizeBytes: buf.length,
|
|
1273
|
+
encoding,
|
|
1274
|
+
range: { version: 1, offset, length: range.length ?? null },
|
|
1275
|
+
})
|
|
1276
|
+
} finally {
|
|
1277
|
+
await file.close()
|
|
1278
|
+
}
|
|
1279
|
+
return
|
|
1280
|
+
}
|
|
1281
|
+
const buf = await fs.readFile(real)
|
|
1282
|
+
const encoding = body.encoding === 'base64' ? 'base64' : 'utf8'
|
|
1283
|
+
writeJson(res, 200, {
|
|
1284
|
+
ok: true,
|
|
1285
|
+
content: buf.toString(encoding),
|
|
1286
|
+
sizeBytes: buf.length,
|
|
1287
|
+
encoding,
|
|
1288
|
+
})
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
writeJson(res, 400, { ok: false, error: err.message })
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
async function handleWriteFile(req, res) {
|
|
1295
|
+
let body
|
|
1296
|
+
try {
|
|
1297
|
+
body = await readBody(req)
|
|
1298
|
+
} catch (err) {
|
|
1299
|
+
writeJson(res, 400, { error: 'invalid_body', message: err.message })
|
|
1300
|
+
return
|
|
1301
|
+
}
|
|
1302
|
+
if (!body.path || body.content === undefined) {
|
|
1303
|
+
writeJson(res, 400, { error: 'missing_path_or_content' })
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1306
|
+
try {
|
|
1307
|
+
const { target, root } = resolveWritablePath(body.path)
|
|
1308
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
1309
|
+
const real = await realpathWithinWorkspace(target, root)
|
|
1310
|
+
const buf =
|
|
1311
|
+
body.encoding === 'base64'
|
|
1312
|
+
? Buffer.from(String(body.content), 'base64')
|
|
1313
|
+
: Buffer.from(String(body.content), 'utf8')
|
|
1314
|
+
// flag 'wx' rejects existing symlinks pointing out of the workspace
|
|
1315
|
+
// only when the target doesn't exist; for existing files we already
|
|
1316
|
+
// confirmed via realpath that they resolve inside one of WRITE_ROOTS,
|
|
1317
|
+
// so a plain writeFile is safe.
|
|
1318
|
+
await fs.writeFile(real, buf)
|
|
1319
|
+
writeJson(res, 200, { ok: true, bytesWritten: buf.length })
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
writeJson(res, 400, { ok: false, error: err.message })
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Idle-exit timer. Every "real work" request (`/execute`,
|
|
1326
|
+
// `/executions/reserve`, `/cancel`, `/read-file`, `/write-file`) holds an
|
|
1327
|
+
// activity lease for its whole handler; a spawned command transfers that lease
|
|
1328
|
+
// through process close. `/healthz` deliberately holds none — heartbeat
|
|
1329
|
+
// liveness pings should not extend a sandbox that's otherwise idle. When the timer fires, exit cleanly so the
|
|
1330
|
+
// container's `--rm` flag triggers daemon-side cleanup. `0` disables
|
|
1331
|
+
// the layer entirely (testing, hosts that don't want it).
|
|
1332
|
+
let idleTimer
|
|
1333
|
+
function disarmIdleTimer() {
|
|
1334
|
+
if (!idleTimer) return
|
|
1335
|
+
clearTimeout(idleTimer)
|
|
1336
|
+
idleTimer = undefined
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function resetIdleTimer() {
|
|
1340
|
+
if (!IDLE_TIMEOUT_MS || IDLE_TIMEOUT_MS <= 0) return
|
|
1341
|
+
disarmIdleTimer()
|
|
1342
|
+
// Active HTTP work or an execution owns the worker. The idle policy starts
|
|
1343
|
+
// only after the final owner releases, so it cannot tear down request
|
|
1344
|
+
// parsing, file I/O, command preparation, or a live execution stream.
|
|
1345
|
+
if (activeWorkCount > 0) return
|
|
1346
|
+
idleTimer = setTimeout(() => {
|
|
1347
|
+
console.log(
|
|
1348
|
+
`[namzu-sandbox-worker] idle for ${IDLE_TIMEOUT_MS}ms — exiting (container --rm cleans up)`,
|
|
1349
|
+
)
|
|
1350
|
+
// Exit code 0: this is intentional shutdown, not a crash. The
|
|
1351
|
+
// host's docker logs see a clean exit; the `--rm` flag (set by
|
|
1352
|
+
// `@namzu/sandbox` when spawning) collects the container body.
|
|
1353
|
+
process.exit(0)
|
|
1354
|
+
}, IDLE_TIMEOUT_MS)
|
|
1355
|
+
// `unref()` so this timer doesn't keep the event loop alive on its
|
|
1356
|
+
// own — process exits naturally if everything else (HTTP server,
|
|
1357
|
+
// pending children) settles first.
|
|
1358
|
+
idleTimer.unref?.()
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const server = http.createServer(async (req, res) => {
|
|
1362
|
+
try {
|
|
1363
|
+
// The gate is the FIRST thing in the router, ahead of the poison
|
|
1364
|
+
// check and ahead of every dispatch, so an unauthenticated caller
|
|
1365
|
+
// cannot tell an existing route from a missing one, or a bad token
|
|
1366
|
+
// from a missing one: both are the same `401` with the same body.
|
|
1367
|
+
//
|
|
1368
|
+
// `/healthz` is the exception and the whole of it: it is what a
|
|
1369
|
+
// host probes BEFORE it has any other business with the worker —
|
|
1370
|
+
// the readiness poll runs many times per create — so a worker that
|
|
1371
|
+
// required a token for it would need the token plumbed into the
|
|
1372
|
+
// readiness path and would still answer a liveness question with a
|
|
1373
|
+
// credential error. It reveals liveness and the protocol version
|
|
1374
|
+
// and nothing else; the body is unchanged by any of this.
|
|
1375
|
+
//
|
|
1376
|
+
// Two consequences of that exemption, named rather than discovered.
|
|
1377
|
+
//
|
|
1378
|
+
// The poison check below answers BEFORE the `/healthz` dispatch, so
|
|
1379
|
+
// an unauthenticated caller CAN tell a retiring worker from a
|
|
1380
|
+
// serving one on that one route: `503 {"error":"worker_retiring"}`
|
|
1381
|
+
// where a healthy worker answers `200`. This used to be written
|
|
1382
|
+
// down as impossible. It is the drain signal, and it is the host's
|
|
1383
|
+
// readiness probe that has to read it — a probe with no credential
|
|
1384
|
+
// to present. What it discloses is that the worker is going away,
|
|
1385
|
+
// and nothing about what it holds, what it has run, or which routes
|
|
1386
|
+
// exist. Every route that does anything is behind the token, and
|
|
1387
|
+
// there a retiring worker and a serving one are the same `401` to
|
|
1388
|
+
// anyone without one.
|
|
1389
|
+
//
|
|
1390
|
+
// The exemption compares the WHOLE of `req.url`, so `POST /healthz`,
|
|
1391
|
+
// `GET /healthz?x=1` and `GET /healthz/` are not it: they are gated,
|
|
1392
|
+
// and with a token they are `404`s like any other route that does
|
|
1393
|
+
// not exist.
|
|
1394
|
+
if (!(req.method === 'GET' && req.url === '/healthz') && !isAuthorized(req)) {
|
|
1395
|
+
writeUnauthorized(res)
|
|
1396
|
+
return
|
|
1397
|
+
}
|
|
1398
|
+
if (workerPoisoned) {
|
|
1399
|
+
writeJson(res, 503, { error: 'worker_retiring' })
|
|
1400
|
+
return
|
|
1401
|
+
}
|
|
1402
|
+
if (req.method === 'GET' && req.url === '/healthz') {
|
|
1403
|
+
writeJson(res, 200, {
|
|
1404
|
+
ok: true,
|
|
1405
|
+
protocolVersion: REMOTE_EXECUTION_PROTOCOL_VERSION,
|
|
1406
|
+
})
|
|
1407
|
+
return
|
|
1408
|
+
}
|
|
1409
|
+
if (req.method === 'POST' && req.url === '/execute') {
|
|
1410
|
+
await whileWorkerActive(() => handleExecute(req, res))
|
|
1411
|
+
return
|
|
1412
|
+
}
|
|
1413
|
+
if (req.method === 'POST' && req.url === '/executions/reserve') {
|
|
1414
|
+
await whileWorkerActive(() => handleReserveExecution(req, res))
|
|
1415
|
+
return
|
|
1416
|
+
}
|
|
1417
|
+
if (req.method === 'POST' && req.url === '/cancel') {
|
|
1418
|
+
await whileWorkerActive(() => handleCancelExecution(req, res))
|
|
1419
|
+
return
|
|
1420
|
+
}
|
|
1421
|
+
if (req.method === 'POST' && req.url === '/executions/write') {
|
|
1422
|
+
await whileWorkerActive(() => handleStdioWrite(req, res))
|
|
1423
|
+
return
|
|
1424
|
+
}
|
|
1425
|
+
if (req.method === 'POST' && req.url === '/read-file') {
|
|
1426
|
+
await whileWorkerActive(() => handleReadFile(req, res))
|
|
1427
|
+
return
|
|
1428
|
+
}
|
|
1429
|
+
if (req.method === 'POST' && req.url === '/write-file') {
|
|
1430
|
+
await whileWorkerActive(() => handleWriteFile(req, res))
|
|
1431
|
+
return
|
|
1432
|
+
}
|
|
1433
|
+
writeJson(res, 404, { error: 'not_found' })
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
// Last-line-of-defence: ANY async path that throws past the
|
|
1436
|
+
// per-handler try/catch must not be allowed to crash the
|
|
1437
|
+
// worker. The container is single-tenant per task; a process
|
|
1438
|
+
// exit kills every in-flight supervisor + child agent that
|
|
1439
|
+
// shared the cached sandbox handle, and they all fail with
|
|
1440
|
+
// the misleading bare `fetch failed`. Respond if the headers
|
|
1441
|
+
// are still inflight; otherwise log and drop — the host will
|
|
1442
|
+
// see a socket close on that one request and retry whatever
|
|
1443
|
+
// it was doing, but the next request lands on a still-alive
|
|
1444
|
+
// worker.
|
|
1445
|
+
console.error('[namzu-sandbox-worker] uncaught handler error:', err?.stack ? err.stack : err)
|
|
1446
|
+
try {
|
|
1447
|
+
if (!res.headersSent) {
|
|
1448
|
+
writeJson(res, 500, {
|
|
1449
|
+
error: 'internal',
|
|
1450
|
+
message: err?.message ? err.message : String(err),
|
|
1451
|
+
})
|
|
1452
|
+
} else {
|
|
1453
|
+
try {
|
|
1454
|
+
res.end()
|
|
1455
|
+
} catch {}
|
|
1456
|
+
}
|
|
1457
|
+
} catch {}
|
|
1458
|
+
}
|
|
1459
|
+
})
|
|
1460
|
+
|
|
1461
|
+
// Defence-in-depth process-level handlers: log loudly if something
|
|
1462
|
+
// slips past every try/catch, but DO NOT exit the worker. A caller's
|
|
1463
|
+
// retry path treats a single 500 / 502 as transient, while a process
|
|
1464
|
+
// exit produces the catastrophic "every subsequent tool call fetch
|
|
1465
|
+
// fails because the container is gone" pattern.
|
|
1466
|
+
process.on('unhandledRejection', (err) => {
|
|
1467
|
+
console.error('[namzu-sandbox-worker] unhandledRejection:', err?.stack ? err.stack : err)
|
|
1468
|
+
})
|
|
1469
|
+
process.on('uncaughtException', (err) => {
|
|
1470
|
+
console.error('[namzu-sandbox-worker] uncaughtException:', err?.stack ? err.stack : err)
|
|
1471
|
+
})
|
|
1472
|
+
|
|
1473
|
+
// The startup decision comes before `listen`, so a worker that cannot
|
|
1474
|
+
// authenticate on a routable address refuses while refusal is still free:
|
|
1475
|
+
// nothing has answered yet, and the host sees an exited container rather
|
|
1476
|
+
// than an open one it must notice for itself.
|
|
1477
|
+
assertListenableConfiguration()
|
|
1478
|
+
// A failed `listen` must not look like a clean shutdown. Without this
|
|
1479
|
+
// handler the `error` event is unhandled, the process-level handler below
|
|
1480
|
+
// logs it and does not exit, and the worker then has nothing keeping its
|
|
1481
|
+
// event loop alive — so it exits 0, having served nothing, and a host (or a
|
|
1482
|
+
// test) reading an exit code calls that an intentional shutdown. That is
|
|
1483
|
+
// the one thing an operator cannot act on: the container is gone, the
|
|
1484
|
+
// reason is in a log they may not be reading, and the code says success.
|
|
1485
|
+
server.on('error', (error) => {
|
|
1486
|
+
console.error(
|
|
1487
|
+
`[namzu-sandbox-worker] could not listen on ${BIND}:${PORT}: ${error?.message ?? error}`,
|
|
1488
|
+
)
|
|
1489
|
+
process.exit(1)
|
|
1490
|
+
})
|
|
1491
|
+
server.listen(PORT, BIND, () => {
|
|
1492
|
+
console.log(
|
|
1493
|
+
`[namzu-sandbox-worker] listening on ${BIND}:${server.address().port} workspace=${WORKSPACE_ROOT} idleTimeoutMs=${IDLE_TIMEOUT_MS} auth=${AUTH_ENABLED ? 'bearer' : 'none'}`,
|
|
1494
|
+
)
|
|
1495
|
+
// Arm the idle timer at boot. If the host never sends a single
|
|
1496
|
+
// `/execute` (e.g. supervisor hangs before its first tool call),
|
|
1497
|
+
// the sandbox still bounds its own lifetime.
|
|
1498
|
+
resetIdleTimer()
|
|
1499
|
+
})
|