@namzu/sandbox 18.1.1 → 19.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 +85 -0
- package/README.md +35 -5
- package/dist/backends/docker/index.d.ts +46 -1
- package/dist/backends/docker/index.d.ts.map +1 -1
- package/dist/backends/docker/index.js +187 -51
- package/dist/backends/docker/index.js.map +1 -1
- package/dist/egress/index.d.ts +1 -0
- package/dist/egress/index.d.ts.map +1 -1
- package/dist/egress/index.js +1 -0
- package/dist/egress/index.js.map +1 -1
- package/dist/egress/profile-wiring.d.ts +40 -0
- package/dist/egress/profile-wiring.d.ts.map +1 -0
- package/dist/egress/profile-wiring.js +75 -0
- package/dist/egress/profile-wiring.js.map +1 -0
- package/dist/egress/profile.d.ts +153 -0
- package/dist/egress/profile.d.ts.map +1 -0
- package/dist/egress/profile.js +244 -0
- package/dist/egress/profile.js.map +1 -0
- package/dist/egress/proxy.d.ts +16 -0
- package/dist/egress/proxy.d.ts.map +1 -1
- package/dist/egress/proxy.js +37 -3
- package/dist/egress/proxy.js.map +1 -1
- package/dist/index.d.ts +19 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -4
- package/dist/index.js.map +1 -1
- package/dist/seed/index.d.ts +165 -0
- package/dist/seed/index.d.ts.map +1 -0
- package/dist/seed/index.js +505 -0
- package/dist/seed/index.js.map +1 -0
- package/dist/testing/sandbox-conformance.d.ts +30 -1
- package/dist/testing/sandbox-conformance.d.ts.map +1 -1
- package/dist/testing/sandbox-conformance.js +18 -0
- package/dist/testing/sandbox-conformance.js.map +1 -1
- package/package.json +3 -3
- package/src/backends/docker/index.ts +246 -51
- package/src/egress/index.ts +1 -0
- package/src/egress/profile-wiring.ts +125 -0
- package/src/egress/profile.ts +380 -0
- package/src/egress/proxy.ts +53 -3
- package/src/index.ts +54 -5
- package/src/seed/index.ts +710 -0
- package/src/testing/sandbox-conformance.ts +52 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,90 @@
|
|
|
1
1
|
# @namzu/sandbox
|
|
2
2
|
|
|
3
|
+
## 19.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- 3e7a97b: No API change. The peer dependency on `@namzu/sdk` moves to the 44 major,
|
|
8
|
+
because the SDK itself had a major release (its run became a turn inside a
|
|
9
|
+
session). `@namzu/sandbox` reads no renamed field, but its peer range is
|
|
10
|
+
published as a caret on the SDK version it was built with, so the SDK major
|
|
11
|
+
takes it out of range and forces this bump.
|
|
12
|
+
|
|
13
|
+
What to do: upgrade `@namzu/sdk` and `@namzu/sandbox` together. Nothing in your
|
|
14
|
+
code changes.
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- 0a89444: New: egress profiles. `defineEgressProfile({ name, hosts: [{ host, ports? }] })`
|
|
19
|
+
validates one named host allowlist, and `createSandboxProvider` takes it as
|
|
20
|
+
`egressProfile` instead of `defaultEgress`. On docker, runsc and firecracker a
|
|
21
|
+
profile becomes `deny-all` (no hosts) or a `static` allowlist. Whatever a
|
|
22
|
+
backend cannot honour is refused at construction with
|
|
23
|
+
`SandboxEgressProfileError`: ports on firecracker, any profile on the ACI
|
|
24
|
+
standby pool or beside `defaultEgress`, and a
|
|
25
|
+
`brokeredCredentials` host the profile does not allow. On docker and runsc a
|
|
26
|
+
live `setNetworkPolicy` under a profile may only name hosts the profile covers.
|
|
27
|
+
On kubernetes, `createSandboxProvider` refuses `egressProfile`; put
|
|
28
|
+
`kubernetesEgressFromProfile(profile, { engine: 'cilium' })` in
|
|
29
|
+
`backend.egress` instead, which also bounds workspaces and writes the profile
|
|
30
|
+
label only with `profileLabel: true`.
|
|
31
|
+
|
|
32
|
+
Nothing changes for a config without `egressProfile`.
|
|
33
|
+
|
|
34
|
+
- aee81a6: The docker and runsc egress proxy now enforces the `ports` of an egress
|
|
35
|
+
profile, on the port it actually dials: an upgraded `http://host/` and a
|
|
36
|
+
`CONNECT` with no port are checked on 443. A host may use the union of the
|
|
37
|
+
ports of every rule that matches it. The same check is available on
|
|
38
|
+
`EgressProxy` as `allowedPorts`, and `egressPortsForRules(profile.hosts)`
|
|
39
|
+
builds that option from a profile's rules with the same union rule.
|
|
40
|
+
|
|
41
|
+
**Rebuild `egressProxyImage` from `packages/sandbox/egress-proxy/Dockerfile`
|
|
42
|
+
before using `ports`.** A profile with ports sends the proxy a new
|
|
43
|
+
configuration variable, `NAMZU_EGRESS_PROXY_CONFIG_V2`, and the backend first
|
|
44
|
+
reads the image's `ai.namzu.egress-proxy.config` label with
|
|
45
|
+
`docker image inspect`, refusing an image that does not declare version 2 (or
|
|
46
|
+
that is not on the daemon: `docker image inspect` does not pull). Profiles
|
|
47
|
+
without ports, and every policy without a profile, need nothing: the proxy
|
|
48
|
+
gets the same configuration and argv as before.
|
|
49
|
+
|
|
50
|
+
- c366ca7: New: sandbox seeds. `ensureSandboxSeed(sandbox, seed, { root })` makes the git
|
|
51
|
+
repositories of a `defineSandboxSeed({ name, repositories })` present under
|
|
52
|
+
`root` inside any sandbox, through `exec` only. Every call checks each
|
|
53
|
+
repository (origin URL, and that its pinned or recorded commit is an ancestor
|
|
54
|
+
of HEAD; a `ref` changed since the clone, or a pin dropped, is drift unless
|
|
55
|
+
the ref's commit is exactly HEAD) and clones only what is missing, so running it after each create or
|
|
56
|
+
workspace resume costs one check when nothing changed. Drift is refused before
|
|
57
|
+
anything is cloned (`onDrift: 'report'` records it instead), and nothing is
|
|
58
|
+
ever deleted or re-cloned. `root` is required; on docker, use
|
|
59
|
+
`layout.scratch`, not the outputs root. A repository directory may not sit
|
|
60
|
+
inside another's, nor under `.namzu/`. URLs with a user name or password,
|
|
61
|
+
`ssh://` and `git@host:path` are refused, so no credential enters the guest.
|
|
62
|
+
The guest needs `sh`, `git`, `find`, `mkdir`, `mktemp`, `rm` and GNU `mv`.
|
|
63
|
+
|
|
64
|
+
Nothing changes for code that does not call it.
|
|
65
|
+
|
|
66
|
+
### Patch Changes
|
|
67
|
+
|
|
68
|
+
- 251d815: On the docker backend, two overlapping `setNetworkPolicy()` calls on one
|
|
69
|
+
sandbox could leave no egress proxy running while a caller was told its policy
|
|
70
|
+
was in force, or resolve one call while the other call's policy was the one
|
|
71
|
+
applied. Calls on one sandbox now run one at a time, in the order they were
|
|
72
|
+
made, and each resolves only once its own policy is running. A call that asks
|
|
73
|
+
for the allowlist already in force no longer restarts the proxy. No action is
|
|
74
|
+
needed.
|
|
75
|
+
- Updated dependencies [8805360]
|
|
76
|
+
- Updated dependencies [9355755]
|
|
77
|
+
- Updated dependencies [755a81a]
|
|
78
|
+
- Updated dependencies [cb1f00c]
|
|
79
|
+
- Updated dependencies [933ba6d]
|
|
80
|
+
- Updated dependencies [3e7a97b]
|
|
81
|
+
- Updated dependencies [a84dc1c]
|
|
82
|
+
- Updated dependencies [b064cea]
|
|
83
|
+
- Updated dependencies [9238347]
|
|
84
|
+
- Updated dependencies [a14b013]
|
|
85
|
+
- Updated dependencies [3641102]
|
|
86
|
+
- @namzu/sdk@44.0.0
|
|
87
|
+
|
|
3
88
|
## 18.1.1
|
|
4
89
|
|
|
5
90
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "@namzu/sandbox"
|
|
4
4
|
description: >-
|
|
5
|
-
Container and process isolation for Namzu
|
|
6
|
-
four backends, a bounded filesystem view, and an egress boundary the
|
|
5
|
+
Container and process isolation for Namzu agents. Two isolation tiers over
|
|
6
|
+
four backends, a bounded filesystem view, and an egress boundary the agent
|
|
7
7
|
cannot talk its way past.
|
|
8
8
|
tags: [readme, package, sandbox, isolation]
|
|
9
9
|
status: stable
|
|
@@ -14,7 +14,7 @@ generated: { by: human:bahadirarda, at: 2026-08-30T00:00:00Z }
|
|
|
14
14
|
|
|
15
15
|
<h1>@namzu/sandbox</h1>
|
|
16
16
|
|
|
17
|
-
**Container and process isolation for Namzu
|
|
17
|
+
**Container and process isolation for Namzu agents.**
|
|
18
18
|
|
|
19
19
|
[](https://www.npmjs.com/package/@namzu/sandbox)
|
|
20
20
|
[](https://github.com/cogitave/namzu/actions/workflows/ci.yml)
|
|
@@ -28,7 +28,7 @@ generated: { by: human:bahadirarda, at: 2026-08-30T00:00:00Z }
|
|
|
28
28
|
|
|
29
29
|
Runs a tool call somewhere that is not your process. Two isolation tiers
|
|
30
30
|
over four backends, a bounded filesystem view, and an egress boundary the
|
|
31
|
-
|
|
31
|
+
agent cannot talk its way past.
|
|
32
32
|
|
|
33
33
|
## Install
|
|
34
34
|
|
|
@@ -98,7 +98,7 @@ off nothing else: `--cap-drop=ALL`, `--security-opt=no-new-privileges` and
|
|
|
98
98
|
Because those four paths are tmpfs, they are RAM, not the container's writable
|
|
99
99
|
layer: scratch larger than half the host's RAM (or than `--memory`, the tighter
|
|
100
100
|
of the two when the host sets one) fails with `ENOSPC` rather than spilling onto
|
|
101
|
-
the host's disk. A
|
|
101
|
+
the host's disk. A workload that writes temp files bigger than its memory budget does
|
|
102
102
|
not have to give up the baseline over it: `layout.scratch` is a bind to a host
|
|
103
103
|
directory and stays disk-backed, so a host with room on disk mounts one there
|
|
104
104
|
and points the workload at it — `TMPDIR` set to that container path through the
|
|
@@ -761,6 +761,36 @@ Both methods are capability-checked. A backend that cannot preserve the same
|
|
|
761
761
|
isolation and ownership boundary omits them; callers must not fall back to a
|
|
762
762
|
host process or a different sandbox.
|
|
763
763
|
|
|
764
|
+
## Egress profiles
|
|
765
|
+
|
|
766
|
+
`defineEgressProfile({ name, hosts: [{ host, ports? }] })` is one named,
|
|
767
|
+
validated allowlist a host can give any backend. `createSandboxProvider({
|
|
768
|
+
egressProfile })` turns it into `deny-all` (no hosts) or a `static` allowlist
|
|
769
|
+
on docker, runsc and firecracker, and refuses at construction, before any I/O,
|
|
770
|
+
what a backend cannot honour: ports on firecracker, any profile on the ACI
|
|
771
|
+
standby pool, a profile beside `defaultEgress`, and a brokered credential for a
|
|
772
|
+
host outside the profile. On kubernetes, put
|
|
773
|
+
`kubernetesEgressFromProfile(profile, { engine: 'cilium' })` in
|
|
774
|
+
`backend.egress`, which also covers workspaces. On docker and runsc the egress
|
|
775
|
+
proxy enforces a rule's `ports` on the port it dials; rebuild the proxy image
|
|
776
|
+
from `egress-proxy/Dockerfile` first, since the backend refuses an image
|
|
777
|
+
without its `ai.namzu.egress-proxy.config="2"` label. A profile carries no
|
|
778
|
+
credentials and has no wildcard. See
|
|
779
|
+
[docs/sdk/sandbox-egress-profiles.md](../../docs/sdk/sandbox-egress-profiles.md).
|
|
780
|
+
|
|
781
|
+
## Sandbox seeds
|
|
782
|
+
|
|
783
|
+
`ensureSandboxSeed(sandbox, defineSandboxSeed({ name, repositories }), { root })`
|
|
784
|
+
makes git repositories present under `root`, doing only what is missing: every
|
|
785
|
+
call checks each repository (origin, and that its pinned or recorded commit is
|
|
786
|
+
an ancestor of HEAD; a changed `ref` is drift unless the checkout already holds
|
|
787
|
+
it), refuses drift without touching anything, clones what is
|
|
788
|
+
missing into a partial directory and moves it into place. `root` is required:
|
|
789
|
+
put it on a kubernetes workspace's disk mount, or `layout.scratch` on docker,
|
|
790
|
+
never the docker outputs root. URLs with credentials, `ssh://` and `git@` are
|
|
791
|
+
refused, so nothing secret enters the guest. See
|
|
792
|
+
[docs/sdk/sandbox-seeds.md](../../docs/sdk/sandbox-seeds.md).
|
|
793
|
+
|
|
764
794
|
## Firecracker network policy
|
|
765
795
|
|
|
766
796
|
At microVM creation, the Firecracker backend maps the resolved egress decision
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { type ContainerSandboxLayout, type ResolvedContainerSandboxLayout } from '@namzu/sdk';
|
|
44
44
|
import type { BrokeredCredential } from '../../egress/index.js';
|
|
45
|
+
import { type SandboxEgressProfile } from '../../egress/profile.js';
|
|
45
46
|
import { type EgressPolicy, type SandboxBackend, type SandboxBackendOptions } from '../../index.js';
|
|
46
47
|
/**
|
|
47
48
|
* Backend-specific tuning. Most hosts use the defaults; advanced
|
|
@@ -248,6 +249,15 @@ export interface DockerBackendInternalConfig {
|
|
|
248
249
|
* credentials with a route to the internet.
|
|
249
250
|
*/
|
|
250
251
|
readonly labels?: Readonly<Record<string, string>>;
|
|
252
|
+
/**
|
|
253
|
+
* The egress profile the provider was built with, already validated by
|
|
254
|
+
* `defineEgressProfile`. The create-time policy is derived from it by the
|
|
255
|
+
* provider; the backend reads it for what a policy cannot carry. Under a
|
|
256
|
+
* profile, a brokered credential for a host outside it is refused at
|
|
257
|
+
* construction, and a live `setNetworkPolicy` may only name hosts the
|
|
258
|
+
* profile covers.
|
|
259
|
+
*/
|
|
260
|
+
readonly egressProfile?: SandboxEgressProfile;
|
|
251
261
|
}
|
|
252
262
|
/**
|
|
253
263
|
* Build a {@link SandboxBackend} backed by Docker. Construction is
|
|
@@ -378,6 +388,17 @@ export interface EgressProxyContainerConfig {
|
|
|
378
388
|
* network alias, which is the name the sandbox actually dials.
|
|
379
389
|
*/
|
|
380
390
|
readonly selfNames?: readonly string[];
|
|
391
|
+
/**
|
|
392
|
+
* The egress profile's rules, every one of them, when the profile carries
|
|
393
|
+
* ports. Present only then, and only in the V2 configuration
|
|
394
|
+
* ({@link EGRESS_PROXY_CONFIG_V2_ENV}): the proxy computes a host's
|
|
395
|
+
* allowed ports as the union over every rule that matches it, so a rule
|
|
396
|
+
* without ports is listed too.
|
|
397
|
+
*/
|
|
398
|
+
readonly hostPorts?: readonly {
|
|
399
|
+
readonly host: string;
|
|
400
|
+
readonly ports?: readonly number[];
|
|
401
|
+
}[];
|
|
381
402
|
}
|
|
382
403
|
/**
|
|
383
404
|
* Build the proxy container's configuration.
|
|
@@ -391,7 +412,31 @@ export interface EgressProxyContainerConfig {
|
|
|
391
412
|
* is one the sandbox can reach too, which would let the sandbox ask for its
|
|
392
413
|
* own allowlist to be widened. See `docs/sdk/sandbox-egress.md`.
|
|
393
414
|
*/
|
|
394
|
-
export declare function egressProxyContainerConfig(config: Pick<DockerBackendInternalConfig, 'brokeredCredentials' | 'allowInwardFor'>, allowedHosts: readonly string[], port: number): EgressProxyContainerConfig;
|
|
415
|
+
export declare function egressProxyContainerConfig(config: Pick<DockerBackendInternalConfig, 'brokeredCredentials' | 'allowInwardFor' | 'egressProfile'>, allowedHosts: readonly string[], port: number): EgressProxyContainerConfig;
|
|
416
|
+
/**
|
|
417
|
+
* Whether the proxy has port rules to enforce: a profile with at least one
|
|
418
|
+
* rule that carries `ports`. Only then is the configuration sent as V2, and
|
|
419
|
+
* only then is the image's label checked, so every other policy starts the
|
|
420
|
+
* proxy with exactly the V1 configuration and argv it always had.
|
|
421
|
+
*/
|
|
422
|
+
export declare function usesPortRules(config: Pick<DockerBackendInternalConfig, 'egressProfile'>): boolean;
|
|
423
|
+
/** The environment variable the proxy's configuration travels in. See {@link usesPortRules}. */
|
|
424
|
+
export declare function egressProxyConfigEnvName(config: Pick<DockerBackendInternalConfig, 'egressProfile'>): string;
|
|
425
|
+
/**
|
|
426
|
+
* The V2 configuration, which adds port rules. Sent INSTEAD of the V1 one,
|
|
427
|
+
* never beside it: an image that predates port rules reads only V1, finds it
|
|
428
|
+
* unset and exits, so it fails closed rather than enforcing hosts without
|
|
429
|
+
* ports. `egress-proxy/server.mjs` is the reader.
|
|
430
|
+
*/
|
|
431
|
+
export declare const EGRESS_PROXY_CONFIG_V2_ENV = "NAMZU_EGRESS_PROXY_CONFIG_V2";
|
|
432
|
+
/**
|
|
433
|
+
* The image label saying which configuration version the proxy image reads.
|
|
434
|
+
* `egress-proxy/Dockerfile` sets it to `2`. Checked with `docker image
|
|
435
|
+
* inspect` before a proxy with port rules is started, because the other
|
|
436
|
+
* signal, the container exiting, can arrive after the readiness check has
|
|
437
|
+
* already passed.
|
|
438
|
+
*/
|
|
439
|
+
export declare const EGRESS_PROXY_IMAGE_CONFIG_LABEL = "ai.namzu.egress-proxy.config";
|
|
395
440
|
/** Everything {@link renderEgressProxyRunArgs} renders, as a value. */
|
|
396
441
|
export interface EgressProxyArgvInput {
|
|
397
442
|
readonly config: DockerBackendInternalConfig;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/backends/docker/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAKH,OAAO,EACN,KAAK,sBAAsB,EAE3B,KAAK,8BAA8B,EAmBnC,MAAM,YAAY,CAAA;AACnB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAE/D,OAAO,EAEN,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,MAAM,gBAAgB,CAAA;AAkBvB;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,2BAA2B;IAC3C,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,8BAA8B,CAAA;IAC/C,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAA;IAE9B;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAE3B;;;;;;;;;;;;;;;;;;;OAmBG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAE1B;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,CAAA;IAEjC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAEhD;;;;;;;;;OASG;IACH,QAAQ,CAAC,mBAAmB,CAAC,EAAE,SAAS,kBAAkB,EAAE,CAAA;IAE5D;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAE3C;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;IAElC;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,QAAQ,CAAC,0BAA0B,CAAC,EAAE,MAAM,CAAA;IAE5C,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAA;IAC7C,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAA;IAChC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAAA;IAC5C;;;;;;;;;;;;;;;;OAgBG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,WAAW,GAAG,mBAAmB,CAAA;IAC7D;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/backends/docker/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
import { spawn } from 'node:child_process';
|
|
44
44
|
import { randomBytes } from 'node:crypto';
|
|
45
45
|
import { SANDBOX_DEFAULT_OUTPUTS_PATH, SANDBOX_DEFAULT_SCRATCH_PATH, SANDBOX_DEFAULT_SKILLS_PARENT, SANDBOX_DEFAULT_TOOL_RESULTS_PATH, SANDBOX_DEFAULT_TRANSCRIPTS_PATH, SANDBOX_DEFAULT_UPLOADS_PATH, generateSandboxId, walkFilesViaExec, withHint, } from '@namzu/sdk';
|
|
46
|
+
import { assertBrokeredCredentialsFitProfile } from '../../egress/profile-wiring.js';
|
|
47
|
+
import { SandboxEgressProfileError, egressProfileCoversEntry, egressProfileHasPorts, } from '../../egress/profile.js';
|
|
46
48
|
import { ContainerSandboxLayoutValidationError, } from '../../index.js';
|
|
47
49
|
import { HttpWorkerClient, WORKER_UNAUTHORIZED_HINT, workerAuthorization, } from '../http-worker-client.js';
|
|
48
50
|
import { OperationDeadline, OperationDeadlineExpired, probeHttpHealth, resolveReadinessOptions, runFailureCleanup, } from '../readiness.js';
|
|
@@ -64,6 +66,9 @@ export function buildDockerBackend(config) {
|
|
|
64
66
|
// argv is built, because that is the only place a caller cannot skip them.
|
|
65
67
|
assertCpuLimitIsRenderable(config.cpuLimit);
|
|
66
68
|
assertRootfsOptionsAreCoherent(config);
|
|
69
|
+
if (config.egressProfile !== undefined) {
|
|
70
|
+
assertBrokeredCredentialsFitProfile(config.egressProfile, config.brokeredCredentials, config.runtime === 'runsc' ? 'runsc' : 'docker');
|
|
71
|
+
}
|
|
67
72
|
const readiness = resolveReadinessOptions('docker', config.readyTimeoutMs, config.readyPollIntervalMs, {
|
|
68
73
|
timeoutMs: DEFAULT_READY_TIMEOUT_MS,
|
|
69
74
|
pollIntervalMs: DEFAULT_READY_POLL_MS,
|
|
@@ -134,6 +139,15 @@ export async function resolveAllowedHosts(egress) {
|
|
|
134
139
|
return await egress.resolve();
|
|
135
140
|
throw new Error(`Egress policy of kind "${egress.kind}" does not describe a host allowlist and must not be routed through the proxy.`);
|
|
136
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Whether two allowlists are the same list: same hosts, same order, duplicates
|
|
144
|
+
* kept. Deliberately not a set comparison. The proxy is handed the list as
|
|
145
|
+
* written, and a reordered list is a different configuration even when it
|
|
146
|
+
* permits the same hosts; the only cost of calling it different is one swap.
|
|
147
|
+
*/
|
|
148
|
+
function sameHostList(a, b) {
|
|
149
|
+
return a.length === b.length && a.every((host, index) => host === b[index]);
|
|
150
|
+
}
|
|
137
151
|
/** Whether a policy needs a boundary before it can be enforced at all. */
|
|
138
152
|
export function needsEgressProxy(egress) {
|
|
139
153
|
return egress?.kind === 'static' || egress?.kind === 'resolver';
|
|
@@ -228,8 +242,24 @@ export function egressProxyContainerConfig(config, allowedHosts, port) {
|
|
|
228
242
|
credentials: config.brokeredCredentials ?? [],
|
|
229
243
|
...(config.allowInwardFor ? { allowInwardFor: config.allowInwardFor } : {}),
|
|
230
244
|
selfNames: [PROXY_HOST_ALIAS],
|
|
245
|
+
...(usesPortRules(config) && config.egressProfile
|
|
246
|
+
? { hostPorts: config.egressProfile.hosts }
|
|
247
|
+
: {}),
|
|
231
248
|
};
|
|
232
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Whether the proxy has port rules to enforce: a profile with at least one
|
|
252
|
+
* rule that carries `ports`. Only then is the configuration sent as V2, and
|
|
253
|
+
* only then is the image's label checked, so every other policy starts the
|
|
254
|
+
* proxy with exactly the V1 configuration and argv it always had.
|
|
255
|
+
*/
|
|
256
|
+
export function usesPortRules(config) {
|
|
257
|
+
return config.egressProfile !== undefined && egressProfileHasPorts(config.egressProfile);
|
|
258
|
+
}
|
|
259
|
+
/** The environment variable the proxy's configuration travels in. See {@link usesPortRules}. */
|
|
260
|
+
export function egressProxyConfigEnvName(config) {
|
|
261
|
+
return usesPortRules(config) ? EGRESS_PROXY_CONFIG_V2_ENV : EGRESS_PROXY_CONFIG_ENV;
|
|
262
|
+
}
|
|
233
263
|
/**
|
|
234
264
|
* `--label key=value` flags, validated before they reach the daemon.
|
|
235
265
|
*
|
|
@@ -258,6 +288,21 @@ function renderLabelArgs(labels) {
|
|
|
258
288
|
* that pair rather than a literal written twice.
|
|
259
289
|
*/
|
|
260
290
|
const EGRESS_PROXY_CONFIG_ENV = 'NAMZU_EGRESS_PROXY_CONFIG';
|
|
291
|
+
/**
|
|
292
|
+
* The V2 configuration, which adds port rules. Sent INSTEAD of the V1 one,
|
|
293
|
+
* never beside it: an image that predates port rules reads only V1, finds it
|
|
294
|
+
* unset and exits, so it fails closed rather than enforcing hosts without
|
|
295
|
+
* ports. `egress-proxy/server.mjs` is the reader.
|
|
296
|
+
*/
|
|
297
|
+
export const EGRESS_PROXY_CONFIG_V2_ENV = 'NAMZU_EGRESS_PROXY_CONFIG_V2';
|
|
298
|
+
/**
|
|
299
|
+
* The image label saying which configuration version the proxy image reads.
|
|
300
|
+
* `egress-proxy/Dockerfile` sets it to `2`. Checked with `docker image
|
|
301
|
+
* inspect` before a proxy with port rules is started, because the other
|
|
302
|
+
* signal, the container exiting, can arrive after the readiness check has
|
|
303
|
+
* already passed.
|
|
304
|
+
*/
|
|
305
|
+
export const EGRESS_PROXY_IMAGE_CONFIG_LABEL = 'ai.namzu.egress-proxy.config';
|
|
261
306
|
/**
|
|
262
307
|
* The `docker run` argv for the egress proxy container, as a value.
|
|
263
308
|
*
|
|
@@ -329,7 +374,7 @@ export function renderEgressProxyRunArgs(input) {
|
|
|
329
374
|
// anything with access to the daemon, through `docker inspect`, and
|
|
330
375
|
// `docs/sdk/sandbox-egress.md` says so.
|
|
331
376
|
'--env',
|
|
332
|
-
|
|
377
|
+
egressProxyConfigEnvName(config),
|
|
333
378
|
config.egressProxyImage,
|
|
334
379
|
];
|
|
335
380
|
}
|
|
@@ -950,6 +995,11 @@ async function spawnDockerSandbox(config, options, readiness) {
|
|
|
950
995
|
const containerName = `namzu-sandbox-${id}`;
|
|
951
996
|
/** The proxy container's name, once it is being started. */
|
|
952
997
|
let egressProxyContainer;
|
|
998
|
+
/**
|
|
999
|
+
* The allowlist the running proxy container was started with, or
|
|
1000
|
+
* `undefined` when that is not known. See `setNetworkPolicy`.
|
|
1001
|
+
*/
|
|
1002
|
+
let appliedHosts;
|
|
953
1003
|
// All bind sources come from the consumer-supplied layout. The
|
|
954
1004
|
// backend never allocates host directories and never removes them
|
|
955
1005
|
// — that pre-existing single-mount mkdtemp path was the source of
|
|
@@ -1016,6 +1066,12 @@ async function spawnDockerSandbox(config, options, readiness) {
|
|
|
1016
1066
|
// does that itself — so an abort before this point reaches the same
|
|
1017
1067
|
// place by the same route.
|
|
1018
1068
|
if (proxyPlanned && options.egress) {
|
|
1069
|
+
// Before anything is started, and only when there are port rules:
|
|
1070
|
+
// an image that cannot read them is refused by name here, rather
|
|
1071
|
+
// than started and left to exit after the readiness check passed.
|
|
1072
|
+
if (usesPortRules(config)) {
|
|
1073
|
+
await assertEgressProxyImageReadsPortRules(docker, config.egressProxyImage, options.signal);
|
|
1074
|
+
}
|
|
1019
1075
|
egressProxyContainer = egressProxyContainerName(id);
|
|
1020
1076
|
// Resolved once, here, rather than per request — see
|
|
1021
1077
|
// `egressProxyContainerConfig` for what that costs and why it is paid.
|
|
@@ -1029,6 +1085,7 @@ async function spawnDockerSandbox(config, options, readiness) {
|
|
|
1029
1085
|
allowedHosts,
|
|
1030
1086
|
signal: options.signal,
|
|
1031
1087
|
});
|
|
1088
|
+
appliedHosts = Object.freeze([...allowedHosts]);
|
|
1032
1089
|
options.signal?.throwIfAborted();
|
|
1033
1090
|
}
|
|
1034
1091
|
const args = buildDockerRunArgs({
|
|
@@ -1063,6 +1120,11 @@ async function spawnDockerSandbox(config, options, readiness) {
|
|
|
1063
1120
|
let teardownPromise;
|
|
1064
1121
|
let teardownComplete = false;
|
|
1065
1122
|
const workerClient = new HttpWorkerClient(baseUrl, workerToken);
|
|
1123
|
+
/**
|
|
1124
|
+
* The tail of this sandbox's `setNetworkPolicy` calls. Each call chains
|
|
1125
|
+
* onto it, so at most one swap is in flight. See `setNetworkPolicy`.
|
|
1126
|
+
*/
|
|
1127
|
+
let policyQueue = Promise.resolve();
|
|
1066
1128
|
const assertActive = () => {
|
|
1067
1129
|
if (lifecycle !== 'active') {
|
|
1068
1130
|
throw new Error(`Sandbox ${id} is ${lifecycle}; no new worker operation can be admitted`);
|
|
@@ -1192,58 +1254,103 @@ async function spawnDockerSandbox(config, options, readiness) {
|
|
|
1192
1254
|
// raced a failing create would otherwise call `assertActive()` and
|
|
1193
1255
|
// then name `undefined` in the removal.
|
|
1194
1256
|
const proxyContainer = egressProxyContainer;
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1257
|
+
// Copied now, so a caller that mutates its array while the call waits
|
|
1258
|
+
// its turn does not change what the call applies.
|
|
1259
|
+
const requested = Object.freeze([...policy.allowedHosts]);
|
|
1260
|
+
// Under a profile, a live change narrows within it and never widens
|
|
1261
|
+
// past it: the profile is what the host declared this sandbox may
|
|
1262
|
+
// reach, and a call that names more is refused before it is queued.
|
|
1263
|
+
const profile = config.egressProfile;
|
|
1264
|
+
if (profile !== undefined) {
|
|
1265
|
+
requested.forEach((entry, index) => {
|
|
1266
|
+
if (egressProfileCoversEntry(profile, entry))
|
|
1267
|
+
return;
|
|
1268
|
+
throw new SandboxEgressProfileError('invalid-host', `allowedHosts[${index}]`, `${JSON.stringify(entry)} is outside egress profile ${JSON.stringify(profile.name)}; setNetworkPolicy may narrow within the profile, not widen past it`, config.runtime === 'runsc' ? 'runsc' : 'docker');
|
|
1202
1269
|
});
|
|
1203
|
-
// A teardown that landed while the replacement was starting
|
|
1204
|
-
// leaves a container nothing else will ever remove: `destroy()`
|
|
1205
|
-
// is running or has run, `egressProxyContainer` is only removed
|
|
1206
|
-
// from `teardownSandbox` and `cleanupOnFailure`, and the
|
|
1207
|
-
// `assertActive()` above ran BEFORE the first `await` — before
|
|
1208
|
-
// the swap suspended inside `restartEgressProxyContainer`, which
|
|
1209
|
-
// is exactly when a teardown lands. So the swap fails here
|
|
1210
|
-
// rather than reporting a policy change on a sandbox that no
|
|
1211
|
-
// longer exists.
|
|
1212
|
-
assertActive();
|
|
1213
1270
|
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
//
|
|
1228
|
-
//
|
|
1229
|
-
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1271
|
+
// Calls are SERIALIZED per sandbox, first in first out, and each one
|
|
1272
|
+
// applies and verifies its OWN policy. Overlapping swaps used to
|
|
1273
|
+
// interleave remove, run, attach and inspect against one container
|
|
1274
|
+
// name: one call's pre-start removal, or a failure path's removal by
|
|
1275
|
+
// name, could land on the other call's container after that call had
|
|
1276
|
+
// resolved, leaving no proxy at all; and one call's readiness inspect
|
|
1277
|
+
// could read the other's container as running and resolve claiming a
|
|
1278
|
+
// policy that was not in force. There is no coalescing ("last writer
|
|
1279
|
+
// wins") on purpose: a call replaced by a later one would then resolve
|
|
1280
|
+
// while a different policy was in force, which is the same misreport.
|
|
1281
|
+
// The kubernetes backend's per-sandbox setter follows the same rule
|
|
1282
|
+
// (`per-sandbox-policy.ts`).
|
|
1283
|
+
const step = policyQueue.then(async () => {
|
|
1284
|
+
// Again, because a teardown may have landed while this call waited
|
|
1285
|
+
// behind another.
|
|
1286
|
+
assertActive();
|
|
1287
|
+
// A repeat of the policy the running container already enforces is a
|
|
1288
|
+
// no-op. This backend has no adopt path, so the sandbox handle is the
|
|
1289
|
+
// only owner of its proxy and this record is authoritative. It is
|
|
1290
|
+
// cleared before every swap and set again only when the swap
|
|
1291
|
+
// succeeds, because after a failure the state is unknown and the next
|
|
1292
|
+
// call must swap.
|
|
1293
|
+
if (appliedHosts !== undefined && sameHostList(appliedHosts, requested))
|
|
1294
|
+
return;
|
|
1295
|
+
appliedHosts = undefined;
|
|
1296
|
+
try {
|
|
1297
|
+
await restartEgressProxyContainer({
|
|
1298
|
+
docker,
|
|
1299
|
+
config,
|
|
1300
|
+
containerName: proxyContainer,
|
|
1301
|
+
internalNetwork: network,
|
|
1302
|
+
allowedHosts: requested,
|
|
1303
|
+
});
|
|
1304
|
+
// A teardown that landed while the replacement was starting
|
|
1305
|
+
// leaves a container nothing else will ever remove: `destroy()`
|
|
1306
|
+
// is running or has run, `egressProxyContainer` is only removed
|
|
1307
|
+
// from `teardownSandbox` and `cleanupOnFailure`, and the
|
|
1308
|
+
// `assertActive()` above ran BEFORE the first `await` — before
|
|
1309
|
+
// the swap suspended inside `restartEgressProxyContainer`, which
|
|
1310
|
+
// is exactly when a teardown lands. So the swap fails here
|
|
1311
|
+
// rather than reporting a policy change on a sandbox that no
|
|
1312
|
+
// longer exists.
|
|
1313
|
+
assertActive();
|
|
1245
1314
|
}
|
|
1246
|
-
|
|
1315
|
+
finally {
|
|
1316
|
+
// ... and the replacement is removed even so, because the check
|
|
1317
|
+
// above cannot be where the guarantee lives. It sits AFTER the
|
|
1318
|
+
// container comes into existence, and that ordering is what
|
|
1319
|
+
// makes it airtight rather than merely narrower than the check
|
|
1320
|
+
// at entry. A generation counter read before the `docker run`
|
|
1321
|
+
// has the opposite shape: it can only refuse a start it already
|
|
1322
|
+
// knows about, and a teardown that begins between that refusal
|
|
1323
|
+
// being evaluated and the daemon committing the container is in
|
|
1324
|
+
// no check's view — the container exists and nothing has looked
|
|
1325
|
+
// since. Here the two orderings partition the space instead. A
|
|
1326
|
+
// teardown that began before this point has already set
|
|
1327
|
+
// `lifecycle`, synchronously (`teardownSandbox` and `retire()`
|
|
1328
|
+
// both do, before their first `await`), so this removal runs. A
|
|
1329
|
+
// teardown that begins after it issues its own `rm -f` for this
|
|
1330
|
+
// same name — `teardownSandbox` reads `egressProxyContainer`,
|
|
1331
|
+
// which is this container — against a container that, at this
|
|
1332
|
+
// point, exists. Whichever of the two runs second finds the
|
|
1333
|
+
// container and removes it, and both are idempotent.
|
|
1334
|
+
//
|
|
1335
|
+
// A signal-less remover, and not `runOnce` on some signal, for
|
|
1336
|
+
// the reason `removeEgressProxyContainer` exists: the teardown
|
|
1337
|
+
// this is racing may be one whose own signal was already
|
|
1338
|
+
// aborted, and it removes nothing at all in that case (the whole
|
|
1339
|
+
// container set is left, which is what `Sandbox.destroy`
|
|
1340
|
+
// promises to settle promptly over). That is the caller's
|
|
1341
|
+
// contract and not something to defeat — but a proxy container
|
|
1342
|
+
// holding brokered credentials and a live route to the internet
|
|
1343
|
+
// is not something to leave with it either.
|
|
1344
|
+
if (lifecycle !== 'active') {
|
|
1345
|
+
await removeEgressProxyContainer(docker, proxyContainer);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
appliedHosts = requested;
|
|
1349
|
+
});
|
|
1350
|
+
// The chain continues past a rejection: one caller's failed swap is
|
|
1351
|
+
// that caller's error, not a reason to refuse every later call.
|
|
1352
|
+
policyQueue = step.then(() => undefined, () => undefined);
|
|
1353
|
+
await step;
|
|
1247
1354
|
},
|
|
1248
1355
|
async writeFile(path, content) {
|
|
1249
1356
|
assertActive();
|
|
@@ -1432,7 +1539,7 @@ async function startEgressProxyContainer(input) {
|
|
|
1432
1539
|
// `docker` CLI's own. See `renderEgressProxyRunArgs` for why it does not
|
|
1433
1540
|
// travel in the argv.
|
|
1434
1541
|
const proxyEnvironment = {
|
|
1435
|
-
[
|
|
1542
|
+
[egressProxyConfigEnvName(config)]: JSON.stringify(egressProxyContainerConfig(config, allowedHosts, EGRESS_PROXY_PORT_INSIDE_CONTAINER)),
|
|
1436
1543
|
};
|
|
1437
1544
|
try {
|
|
1438
1545
|
await runOnce(docker, renderEgressProxyRunArgs(argvInput), signal, proxyEnvironment);
|
|
@@ -1511,6 +1618,35 @@ async function assertEgressProxyContainerIsRunning(docker, containerName, signal
|
|
|
1511
1618
|
return;
|
|
1512
1619
|
throw withHint(new Error(`The egress proxy container '${containerName}' is not running after being started, so this sandbox has no boundary to reach and no other route out.`), 'Almost always the image: it either lacks the compiled module (build the package before the image — `pnpm --filter @namzu/sandbox build`) or the entrypoint refused its configuration. `docker logs <container>` has the line the entrypoint wrote; the container is started with --rm, so an already-exited one is gone and its output with it.');
|
|
1513
1620
|
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Refuse an egress proxy image that does not declare it reads port rules.
|
|
1623
|
+
*
|
|
1624
|
+
* Reads {@link EGRESS_PROXY_IMAGE_CONFIG_LABEL} off the image with `docker
|
|
1625
|
+
* image inspect`, which needs the image to be present on the daemon: `docker
|
|
1626
|
+
* run` would pull a missing image, and this check does not, so an image that
|
|
1627
|
+
* has never been pulled is refused with that in the hint. Anything other than
|
|
1628
|
+
* `2` or higher, including an unreadable answer, is a refusal.
|
|
1629
|
+
*/
|
|
1630
|
+
async function assertEgressProxyImageReadsPortRules(docker, image, signal) {
|
|
1631
|
+
let label = '';
|
|
1632
|
+
try {
|
|
1633
|
+
label = await runOnce(docker, [
|
|
1634
|
+
'image',
|
|
1635
|
+
'inspect',
|
|
1636
|
+
'--format',
|
|
1637
|
+
`{{ index .Config.Labels "${EGRESS_PROXY_IMAGE_CONFIG_LABEL}" }}`,
|
|
1638
|
+
image,
|
|
1639
|
+
], signal);
|
|
1640
|
+
}
|
|
1641
|
+
catch {
|
|
1642
|
+
signal?.throwIfAborted();
|
|
1643
|
+
label = '';
|
|
1644
|
+
}
|
|
1645
|
+
const version = Number(label.trim());
|
|
1646
|
+
if (Number.isInteger(version) && version >= 2)
|
|
1647
|
+
return;
|
|
1648
|
+
throw withHint(new Error(`The egress proxy image '${image}' does not declare that it reads port rules (label ${EGRESS_PROXY_IMAGE_CONFIG_LABEL} is ${JSON.stringify(label.trim())}, and 2 is needed), and this sandbox's egress profile carries ports. Refusing rather than starting a proxy that would exit on a configuration it cannot read.`), 'Rebuild the image from packages/sandbox/egress-proxy/Dockerfile (`pnpm --filter @namzu/sandbox build`, then `docker build -f packages/sandbox/egress-proxy/Dockerfile -t <tag> packages/sandbox`), and pull it onto the daemon first if it lives in a registry. A profile without ports needs no rebuild.');
|
|
1649
|
+
}
|
|
1514
1650
|
/**
|
|
1515
1651
|
* Ask Docker which host port it bound to the worker port. Used
|
|
1516
1652
|
* instead of the pre-reserve-then-publish pattern (which had a
|