@namzu/sandbox 15.0.0 → 17.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 (47) hide show
  1. package/CHANGELOG.md +324 -0
  2. package/README.md +223 -0
  3. package/dist/backends/aci-standby-pool/index.d.ts +22 -4
  4. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  5. package/dist/backends/aci-standby-pool/index.js +31 -7
  6. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  7. package/dist/backends/docker/index.d.ts +408 -26
  8. package/dist/backends/docker/index.d.ts.map +1 -1
  9. package/dist/backends/docker/index.js +1173 -168
  10. package/dist/backends/docker/index.js.map +1 -1
  11. package/dist/backends/firecracker/transport.d.ts +156 -1
  12. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  13. package/dist/backends/firecracker/transport.js +223 -29
  14. package/dist/backends/firecracker/transport.js.map +1 -1
  15. package/dist/backends/http-worker-client.d.ts +64 -2
  16. package/dist/backends/http-worker-client.d.ts.map +1 -1
  17. package/dist/backends/http-worker-client.js +78 -7
  18. package/dist/backends/http-worker-client.js.map +1 -1
  19. package/dist/backends/kubernetes/egress-policy.d.ts +193 -102
  20. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  21. package/dist/backends/kubernetes/egress-policy.js +321 -146
  22. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  23. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +6 -6
  24. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
  25. package/dist/backends/kubernetes/per-sandbox-policy.js +21 -53
  26. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
  27. package/dist/backends/kubernetes/transport.d.ts +7 -0
  28. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  29. package/dist/backends/kubernetes/transport.js.map +1 -1
  30. package/dist/egress/proxy.d.ts +47 -2
  31. package/dist/egress/proxy.d.ts.map +1 -1
  32. package/dist/egress/proxy.js +31 -7
  33. package/dist/egress/proxy.js.map +1 -1
  34. package/dist/index.d.ts +130 -6
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +55 -5
  37. package/dist/index.js.map +1 -1
  38. package/package.json +4 -4
  39. package/src/backends/aci-standby-pool/index.ts +37 -7
  40. package/src/backends/docker/index.ts +1475 -196
  41. package/src/backends/firecracker/transport.ts +387 -36
  42. package/src/backends/http-worker-client.ts +89 -5
  43. package/src/backends/kubernetes/egress-policy.ts +455 -187
  44. package/src/backends/kubernetes/per-sandbox-policy.ts +21 -66
  45. package/src/backends/kubernetes/transport.ts +7 -0
  46. package/src/egress/proxy.ts +65 -8
  47. package/src/index.ts +162 -5
@@ -21,6 +21,68 @@ type WorkerEvent =
21
21
  }
22
22
  | { readonly type: 'error'; readonly error: string }
23
23
 
24
+ /**
25
+ * What every call to a worker's control API carries, if the worker has a
26
+ * credential at all.
27
+ *
28
+ * The worker (`packages/sandbox/worker/server.js`) requires
29
+ * `Authorization: Bearer <token>` on every route but `/healthz`, where the
30
+ * token is the per-instance `NAMZU_SANDBOX_TOKEN` it was started with. An
31
+ * absent token here sends no header, which is exactly right for a worker
32
+ * that has none: a loopback-bound dev worker, or a warm-pool worker whose
33
+ * profile authenticates nothing.
34
+ *
35
+ * A worker the host did NOT create is the case this cannot solve by
36
+ * itself. The token is minted by whoever starts the container and travels
37
+ * in its environment, so a warm pool has to be provisioned with the same
38
+ * token before a host can claim it — there is no channel back. Constructing
39
+ * this client with no token against such a worker fails at the first call
40
+ * with a `401`, not silently.
41
+ */
42
+ export function workerAuthorization(token: string | undefined): Record<string, string> {
43
+ return token ? { authorization: `Bearer ${token}` } : {}
44
+ }
45
+
46
+ /**
47
+ * The `401` a worker answers with, and what to do about it.
48
+ *
49
+ * Hung on every path that can be a caller's FIRST request to a worker —
50
+ * `reserve`, `execute`, and the container backend's direct `read-file` /
51
+ * `write-file` — because the bare status is the one failure whose cause is
52
+ * never visible from the sandbox the caller thinks it is talking to: the
53
+ * container is up, the port is open, and every command fails.
54
+ */
55
+ export const WORKER_UNAUTHORIZED_HINT =
56
+ 'The worker requires the per-instance token it was started with, as `Authorization: Bearer <token>`. If this host created the worker, the token it minted and the token the client sends have diverged. If it did not — a warm pool, a shared profile, a container someone else started — that worker must be provisioned with the token by whoever builds it: the worker has no channel back to hand one over.'
57
+
58
+ /**
59
+ * The same failure, for the one backend that can never fix it.
60
+ *
61
+ * The standby pool has no way to present a token. The claim API admits
62
+ * exactly one property override, and it is not `env`: it is a config map,
63
+ * and a config map reaches the container as a FILE MOUNT under
64
+ * `/mnt/configmap/<containername>/<key>`, not as an environment variable.
65
+ * This worker reads its credential from `process.env` once at startup, so
66
+ * a value delivered that way is not read at all — and Microsoft's own
67
+ * guidance is that config map values are not validated by the runtime and
68
+ * that a value affecting application security belongs in an environment
69
+ * variable instead. The channel exists; this credential is declined for
70
+ * it, at both ends. See `docs/sdk/container-sandbox-worker.md` for the
71
+ * answer, the reason, and the change that would close the gap.
72
+ *
73
+ * A token on the shared profile would make the worker boot and then refuse
74
+ * every call the backend makes — an unrecoverable loop that looks like a
75
+ * broken worker. So what works there is the address first and the
76
+ * worker-side escape second, and the hint says which of the two situations
77
+ * the caller is in.
78
+ *
79
+ * `HttpWorkerClient` uses {@link WORKER_UNAUTHORIZED_HINT} rather than this
80
+ * one: the client is shared by two backends, and a 401 it sees could be
81
+ * either situation.
82
+ */
83
+ export const STANDBY_POOL_UNAUTHORIZED_HINT =
84
+ 'The worker requires a per-instance token, and this backend cannot present one: the claim API admits a config map and nothing else, and a config map arrives as a file mount under /mnt/configmap, not as an environment variable this worker reads. Putting a token on the shared container group profile does NOT fix this — the worker boots, the backend sends no header, and every call 401s. What works today is the address and then the worker-side escape: claim the group with `subnetId` so it sits on a private network, and set `NAMZU_SANDBOX_ALLOW_UNAUTHENTICATED=1` on that group profile, which is the only configuration in which a pooled worker starts and this backend can talk to it. Any other workload should run on a backend that can carry a credential. See `docs/sdk/container-sandbox-worker.md`.'
85
+
24
86
  function parseWorkerEvent(line: string): WorkerEvent {
25
87
  let parsed: unknown
26
88
  try {
@@ -57,6 +119,7 @@ function parseWorkerEvent(line: string): WorkerEvent {
57
119
 
58
120
  async function readExecution(
59
121
  baseUrl: string,
122
+ token: string | undefined,
60
123
  executionId: string | undefined,
61
124
  command: string,
62
125
  argv: string[] | undefined,
@@ -67,7 +130,7 @@ async function readExecution(
67
130
  try {
68
131
  response = await fetch(`${baseUrl}/execute`, {
69
132
  method: 'POST',
70
- headers: { 'content-type': 'application/json' },
133
+ headers: { 'content-type': 'application/json', ...workerAuthorization(token) },
71
134
  signal: transportSignal,
72
135
  body: JSON.stringify({
73
136
  ...(executionId ? { executionId } : {}),
@@ -94,6 +157,12 @@ async function readExecution(
94
157
  'The worker was reachable when the sandbox started, so it has most likely exited, been killed, or become unreachable since. Check the container logs and runtime exit state.',
95
158
  )
96
159
  }
160
+ if (response.status === 401) {
161
+ throw withHint(
162
+ new Error(`execute failed: HTTP 401 ${await response.text()}`),
163
+ WORKER_UNAUTHORIZED_HINT,
164
+ )
165
+ }
97
166
  if (!response.ok || !response.body) {
98
167
  throw new Error(`execute failed: HTTP ${response.status} ${await response.text()}`)
99
168
  }
@@ -163,16 +232,24 @@ async function readExecution(
163
232
  /**
164
233
  * A per-sandbox HTTP worker client. Every command must reserve an identity
165
234
  * through the exact worker protocol before it can be admitted.
235
+ *
236
+ * `token` is the per-instance credential the worker was started with, and
237
+ * it rides on every request this client makes. It is optional because a
238
+ * worker that was never given one — a loopback dev worker — requires none;
239
+ * say what happens when it is missing from the wrong side rather than
240
+ * sending nothing quietly: the worker answers `401` and the failure names
241
+ * the reason.
166
242
  */
167
243
  export class HttpWorkerClient {
168
244
  private readonly controller: RemoteExecutionController
169
245
 
170
- constructor(baseUrl: string) {
246
+ constructor(baseUrl: string, token?: string) {
171
247
  const adapter: RemoteExecutionAdapter = {
172
248
  label: 'HTTP worker',
173
249
  reserve: async (signal) => {
174
250
  const response = await fetch(`${baseUrl}/executions/reserve`, {
175
251
  method: 'POST',
252
+ headers: workerAuthorization(token),
176
253
  signal,
177
254
  })
178
255
  if (response.status === 404) {
@@ -180,6 +257,12 @@ export class HttpWorkerClient {
180
257
  'The sandbox worker does not implement the required execution protocol. Rebuild the worker image or standby-pool profile from the same Namzu release before admitting commands.',
181
258
  )
182
259
  }
260
+ if (response.status === 401) {
261
+ throw withHint(
262
+ new Error(`execution reservation failed: HTTP 401 ${await response.text()}`),
263
+ WORKER_UNAUTHORIZED_HINT,
264
+ )
265
+ }
183
266
  if (!response.ok) {
184
267
  throw new Error(
185
268
  `execution reservation failed: HTTP ${response.status} ${await response.text()}`,
@@ -190,7 +273,7 @@ export class HttpWorkerClient {
190
273
  cancel: async (executionId, signal) => {
191
274
  const response = await fetch(`${baseUrl}/cancel`, {
192
275
  method: 'POST',
193
- headers: { 'content-type': 'application/json' },
276
+ headers: { 'content-type': 'application/json', ...workerAuthorization(token) },
194
277
  body: JSON.stringify({ executionId }),
195
278
  signal,
196
279
  })
@@ -200,7 +283,7 @@ export class HttpWorkerClient {
200
283
  return await response.json()
201
284
  },
202
285
  execute: async (executionId, command, argv, opts, signal) =>
203
- await readExecution(baseUrl, executionId, command, argv, opts, signal),
286
+ await readExecution(baseUrl, token, executionId, command, argv, opts, signal),
204
287
  }
205
288
  this.controller = new RemoteExecutionController(adapter)
206
289
  }
@@ -220,6 +303,7 @@ export async function execViaHttpWorker(
220
303
  command: string,
221
304
  argv: string[] | undefined,
222
305
  opts: SandboxExecOptions | undefined,
306
+ token?: string,
223
307
  ): Promise<SandboxExecResult> {
224
- return await new HttpWorkerClient(baseUrl).exec(command, argv, opts)
308
+ return await new HttpWorkerClient(baseUrl, token).exec(command, argv, opts)
225
309
  }