@namzu/sandbox 24.0.0 → 25.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.
Files changed (158) hide show
  1. package/CHANGELOG.md +274 -0
  2. package/dist/backends/http-worker-client.d.ts +3 -1
  3. package/dist/backends/http-worker-client.d.ts.map +1 -1
  4. package/dist/backends/http-worker-client.js +15 -4
  5. package/dist/backends/http-worker-client.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/local-virtual-computer/client.d.ts +21 -0
  11. package/dist/local-virtual-computer/client.d.ts.map +1 -0
  12. package/dist/local-virtual-computer/client.js +503 -0
  13. package/dist/local-virtual-computer/client.js.map +1 -0
  14. package/dist/local-virtual-computer/detached-process.d.ts +15 -0
  15. package/dist/local-virtual-computer/detached-process.d.ts.map +1 -0
  16. package/dist/local-virtual-computer/detached-process.js +111 -0
  17. package/dist/local-virtual-computer/detached-process.js.map +1 -0
  18. package/dist/local-virtual-computer/detached-worker.d.ts +11 -0
  19. package/dist/local-virtual-computer/detached-worker.d.ts.map +1 -0
  20. package/dist/local-virtual-computer/detached-worker.js +97 -0
  21. package/dist/local-virtual-computer/detached-worker.js.map +1 -0
  22. package/dist/local-virtual-computer/dialect.d.ts +26 -0
  23. package/dist/local-virtual-computer/dialect.d.ts.map +1 -0
  24. package/dist/local-virtual-computer/dialect.js +61 -0
  25. package/dist/local-virtual-computer/dialect.js.map +1 -0
  26. package/dist/local-virtual-computer/engine.d.ts +63 -0
  27. package/dist/local-virtual-computer/engine.d.ts.map +1 -0
  28. package/dist/local-virtual-computer/engine.js +125 -0
  29. package/dist/local-virtual-computer/engine.js.map +1 -0
  30. package/dist/local-virtual-computer/guest-stdio.d.ts +15 -0
  31. package/dist/local-virtual-computer/guest-stdio.d.ts.map +1 -0
  32. package/dist/local-virtual-computer/guest-stdio.js +330 -0
  33. package/dist/local-virtual-computer/guest-stdio.js.map +1 -0
  34. package/dist/local-virtual-computer/index.d.ts +36 -0
  35. package/dist/local-virtual-computer/index.d.ts.map +1 -0
  36. package/dist/local-virtual-computer/index.js +341 -0
  37. package/dist/local-virtual-computer/index.js.map +1 -0
  38. package/dist/local-virtual-computer/podman-machine.d.ts +17 -0
  39. package/dist/local-virtual-computer/podman-machine.d.ts.map +1 -0
  40. package/dist/local-virtual-computer/podman-machine.js +181 -0
  41. package/dist/local-virtual-computer/podman-machine.js.map +1 -0
  42. package/dist/local-virtual-computer/setup/contract.d.ts +360 -0
  43. package/dist/local-virtual-computer/setup/contract.d.ts.map +1 -0
  44. package/dist/local-virtual-computer/setup/contract.js +3 -0
  45. package/dist/local-virtual-computer/setup/contract.js.map +1 -0
  46. package/dist/local-virtual-computer/setup/elevated.d.ts +78 -0
  47. package/dist/local-virtual-computer/setup/elevated.d.ts.map +1 -0
  48. package/dist/local-virtual-computer/setup/elevated.js +548 -0
  49. package/dist/local-virtual-computer/setup/elevated.js.map +1 -0
  50. package/dist/local-virtual-computer/setup/host.d.ts +12 -0
  51. package/dist/local-virtual-computer/setup/host.d.ts.map +1 -0
  52. package/dist/local-virtual-computer/setup/host.js +93 -0
  53. package/dist/local-virtual-computer/setup/host.js.map +1 -0
  54. package/dist/local-virtual-computer/setup/idle.d.ts +18 -0
  55. package/dist/local-virtual-computer/setup/idle.d.ts.map +1 -0
  56. package/dist/local-virtual-computer/setup/idle.js +38 -0
  57. package/dist/local-virtual-computer/setup/idle.js.map +1 -0
  58. package/dist/local-virtual-computer/setup/image.d.ts +7 -0
  59. package/dist/local-virtual-computer/setup/image.d.ts.map +1 -0
  60. package/dist/local-virtual-computer/setup/image.js +124 -0
  61. package/dist/local-virtual-computer/setup/image.js.map +1 -0
  62. package/dist/local-virtual-computer/setup/index.d.ts +17 -0
  63. package/dist/local-virtual-computer/setup/index.d.ts.map +1 -0
  64. package/dist/local-virtual-computer/setup/index.js +17 -0
  65. package/dist/local-virtual-computer/setup/index.js.map +1 -0
  66. package/dist/local-virtual-computer/setup/limits.d.ts +30 -0
  67. package/dist/local-virtual-computer/setup/limits.d.ts.map +1 -0
  68. package/dist/local-virtual-computer/setup/limits.js +58 -0
  69. package/dist/local-virtual-computer/setup/limits.js.map +1 -0
  70. package/dist/local-virtual-computer/setup/plan.d.ts +8 -0
  71. package/dist/local-virtual-computer/setup/plan.d.ts.map +1 -0
  72. package/dist/local-virtual-computer/setup/plan.js +131 -0
  73. package/dist/local-virtual-computer/setup/plan.js.map +1 -0
  74. package/dist/local-virtual-computer/setup/readiness.d.ts +11 -0
  75. package/dist/local-virtual-computer/setup/readiness.d.ts.map +1 -0
  76. package/dist/local-virtual-computer/setup/readiness.js +320 -0
  77. package/dist/local-virtual-computer/setup/readiness.js.map +1 -0
  78. package/dist/local-virtual-computer/setup/registry.d.ts +18 -0
  79. package/dist/local-virtual-computer/setup/registry.d.ts.map +1 -0
  80. package/dist/local-virtual-computer/setup/registry.js +50 -0
  81. package/dist/local-virtual-computer/setup/registry.js.map +1 -0
  82. package/dist/local-virtual-computer/setup/restart.d.ts +8 -0
  83. package/dist/local-virtual-computer/setup/restart.d.ts.map +1 -0
  84. package/dist/local-virtual-computer/setup/restart.js +60 -0
  85. package/dist/local-virtual-computer/setup/restart.js.map +1 -0
  86. package/dist/local-virtual-computer/setup/resume.d.ts +14 -0
  87. package/dist/local-virtual-computer/setup/resume.d.ts.map +1 -0
  88. package/dist/local-virtual-computer/setup/resume.js +58 -0
  89. package/dist/local-virtual-computer/setup/resume.js.map +1 -0
  90. package/dist/local-virtual-computer/setup/run.d.ts +8 -0
  91. package/dist/local-virtual-computer/setup/run.d.ts.map +1 -0
  92. package/dist/local-virtual-computer/setup/run.js +200 -0
  93. package/dist/local-virtual-computer/setup/run.js.map +1 -0
  94. package/dist/local-virtual-computer/setup/windows-folder.d.ts +11 -0
  95. package/dist/local-virtual-computer/setup/windows-folder.d.ts.map +1 -0
  96. package/dist/local-virtual-computer/setup/windows-folder.js +14 -0
  97. package/dist/local-virtual-computer/setup/windows-folder.js.map +1 -0
  98. package/dist/local-virtual-computer/setup/wsl-package.d.ts +19 -0
  99. package/dist/local-virtual-computer/setup/wsl-package.d.ts.map +1 -0
  100. package/dist/local-virtual-computer/setup/wsl-package.js +242 -0
  101. package/dist/local-virtual-computer/setup/wsl-package.js.map +1 -0
  102. package/dist/local-virtual-computer/setup/wslc-shared.d.ts +27 -0
  103. package/dist/local-virtual-computer/setup/wslc-shared.d.ts.map +1 -0
  104. package/dist/local-virtual-computer/setup/wslc-shared.js +137 -0
  105. package/dist/local-virtual-computer/setup/wslc-shared.js.map +1 -0
  106. package/dist/local-virtual-computer/ssh-forward.d.ts +19 -0
  107. package/dist/local-virtual-computer/ssh-forward.d.ts.map +1 -0
  108. package/dist/local-virtual-computer/ssh-forward.js +179 -0
  109. package/dist/local-virtual-computer/ssh-forward.js.map +1 -0
  110. package/dist/local-virtual-computer/wslc-engine.d.ts +27 -0
  111. package/dist/local-virtual-computer/wslc-engine.d.ts.map +1 -0
  112. package/dist/local-virtual-computer/wslc-engine.js +98 -0
  113. package/dist/local-virtual-computer/wslc-engine.js.map +1 -0
  114. package/local-computer/Dockerfile +99 -0
  115. package/local-computer/app-catalog.cjs +240 -0
  116. package/local-computer/app-native-host.cjs +144 -0
  117. package/local-computer/browser-home.cjs +249 -0
  118. package/local-computer/build-image.mjs +310 -0
  119. package/local-computer/desktop-worker.cjs +477 -0
  120. package/local-computer/entrypoint.sh +161 -0
  121. package/local-computer/home.html +28 -0
  122. package/local-computer/new-tab/home.css +255 -0
  123. package/local-computer/new-tab/home.js +65 -0
  124. package/local-computer/new-tab/manifest.json +12 -0
  125. package/local-computer/new-tab/wordmark.svg +33 -0
  126. package/local-computer/openbox-rc.xml +41 -0
  127. package/local-computer/prepare-home.cjs +121 -0
  128. package/local-computer/tint2rc +44 -0
  129. package/package.json +13 -4
  130. package/src/backends/http-worker-client.ts +31 -3
  131. package/src/index.ts +13 -0
  132. package/src/local-virtual-computer/client.ts +573 -0
  133. package/src/local-virtual-computer/detached-process.ts +133 -0
  134. package/src/local-virtual-computer/detached-worker.ts +124 -0
  135. package/src/local-virtual-computer/dialect.ts +83 -0
  136. package/src/local-virtual-computer/engine.ts +197 -0
  137. package/src/local-virtual-computer/guest-stdio.ts +332 -0
  138. package/src/local-virtual-computer/index.ts +447 -0
  139. package/src/local-virtual-computer/podman-machine.ts +264 -0
  140. package/src/local-virtual-computer/setup/contract.ts +395 -0
  141. package/src/local-virtual-computer/setup/elevated.ts +655 -0
  142. package/src/local-virtual-computer/setup/host.ts +100 -0
  143. package/src/local-virtual-computer/setup/idle.ts +39 -0
  144. package/src/local-virtual-computer/setup/image.ts +159 -0
  145. package/src/local-virtual-computer/setup/index.ts +16 -0
  146. package/src/local-virtual-computer/setup/limits.ts +68 -0
  147. package/src/local-virtual-computer/setup/plan.ts +144 -0
  148. package/src/local-virtual-computer/setup/readiness.ts +386 -0
  149. package/src/local-virtual-computer/setup/registry.ts +50 -0
  150. package/src/local-virtual-computer/setup/restart.ts +64 -0
  151. package/src/local-virtual-computer/setup/resume.ts +65 -0
  152. package/src/local-virtual-computer/setup/run.ts +225 -0
  153. package/src/local-virtual-computer/setup/windows-folder.ts +13 -0
  154. package/src/local-virtual-computer/setup/wsl-package.ts +242 -0
  155. package/src/local-virtual-computer/setup/wslc-shared.ts +147 -0
  156. package/src/local-virtual-computer/ssh-forward.ts +195 -0
  157. package/src/local-virtual-computer/wslc-engine.ts +124 -0
  158. package/worker/server.js +1499 -0
@@ -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
+ })