@namzu/sandbox 14.0.0 → 16.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 (100) hide show
  1. package/CHANGELOG.md +924 -0
  2. package/README.md +369 -14
  3. package/dist/backends/aci-standby-pool/index.d.ts.map +1 -1
  4. package/dist/backends/aci-standby-pool/index.js +13 -1
  5. package/dist/backends/aci-standby-pool/index.js.map +1 -1
  6. package/dist/backends/docker/index.d.ts +169 -6
  7. package/dist/backends/docker/index.d.ts.map +1 -1
  8. package/dist/backends/docker/index.js +499 -85
  9. package/dist/backends/docker/index.js.map +1 -1
  10. package/dist/backends/firecracker/index.d.ts.map +1 -1
  11. package/dist/backends/firecracker/index.js +12 -2
  12. package/dist/backends/firecracker/index.js.map +1 -1
  13. package/dist/backends/firecracker/protocol.d.ts +459 -8
  14. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  15. package/dist/backends/firecracker/protocol.js +136 -0
  16. package/dist/backends/firecracker/protocol.js.map +1 -1
  17. package/dist/backends/firecracker/transport.d.ts +539 -6
  18. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  19. package/dist/backends/firecracker/transport.js +1171 -24
  20. package/dist/backends/firecracker/transport.js.map +1 -1
  21. package/dist/backends/kubernetes/egress-policy.d.ts +1181 -13
  22. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  23. package/dist/backends/kubernetes/egress-policy.js +2350 -31
  24. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  25. package/dist/backends/kubernetes/identity.d.ts +193 -0
  26. package/dist/backends/kubernetes/identity.d.ts.map +1 -0
  27. package/dist/backends/kubernetes/identity.js +147 -0
  28. package/dist/backends/kubernetes/identity.js.map +1 -0
  29. package/dist/backends/kubernetes/index.d.ts +678 -33
  30. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  31. package/dist/backends/kubernetes/index.js +1180 -95
  32. package/dist/backends/kubernetes/index.js.map +1 -1
  33. package/dist/backends/kubernetes/ingress-policy.d.ts +375 -0
  34. package/dist/backends/kubernetes/ingress-policy.d.ts.map +1 -0
  35. package/dist/backends/kubernetes/ingress-policy.js +1050 -0
  36. package/dist/backends/kubernetes/ingress-policy.js.map +1 -0
  37. package/dist/backends/kubernetes/k8s-client.d.ts +213 -4
  38. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -1
  39. package/dist/backends/kubernetes/k8s-client.js +359 -52
  40. package/dist/backends/kubernetes/k8s-client.js.map +1 -1
  41. package/dist/backends/kubernetes/lease.d.ts +40 -14
  42. package/dist/backends/kubernetes/lease.d.ts.map +1 -1
  43. package/dist/backends/kubernetes/lease.js +68 -18
  44. package/dist/backends/kubernetes/lease.js.map +1 -1
  45. package/dist/backends/kubernetes/objects.d.ts +423 -3
  46. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  47. package/dist/backends/kubernetes/objects.js +364 -2
  48. package/dist/backends/kubernetes/objects.js.map +1 -1
  49. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +219 -0
  50. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -0
  51. package/dist/backends/kubernetes/per-sandbox-policy.js +375 -0
  52. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -0
  53. package/dist/backends/kubernetes/rbac.d.ts +153 -0
  54. package/dist/backends/kubernetes/rbac.d.ts.map +1 -0
  55. package/dist/backends/kubernetes/rbac.js +177 -0
  56. package/dist/backends/kubernetes/rbac.js.map +1 -0
  57. package/dist/backends/kubernetes/sandbox.d.ts +81 -14
  58. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  59. package/dist/backends/kubernetes/sandbox.js +149 -15
  60. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  61. package/dist/backends/kubernetes/transport.d.ts +935 -9
  62. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  63. package/dist/backends/kubernetes/transport.js +1958 -62
  64. package/dist/backends/kubernetes/transport.js.map +1 -1
  65. package/dist/backends/kubernetes/workspace.d.ts +1149 -18
  66. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  67. package/dist/backends/kubernetes/workspace.js +2825 -186
  68. package/dist/backends/kubernetes/workspace.js.map +1 -1
  69. package/dist/backends/remote-execution-controller.d.ts +14 -0
  70. package/dist/backends/remote-execution-controller.d.ts.map +1 -1
  71. package/dist/backends/remote-execution-controller.js.map +1 -1
  72. package/dist/index.d.ts +294 -18
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +280 -10
  75. package/dist/index.js.map +1 -1
  76. package/dist/testing/sandbox-conformance.d.ts +39 -5
  77. package/dist/testing/sandbox-conformance.d.ts.map +1 -1
  78. package/dist/testing/sandbox-conformance.js +436 -5
  79. package/dist/testing/sandbox-conformance.js.map +1 -1
  80. package/package.json +3 -3
  81. package/src/backends/aci-standby-pool/index.ts +16 -1
  82. package/src/backends/docker/index.ts +617 -100
  83. package/src/backends/firecracker/index.ts +14 -2
  84. package/src/backends/firecracker/protocol.ts +514 -6
  85. package/src/backends/firecracker/transport.ts +1492 -40
  86. package/src/backends/kubernetes/egress-policy.ts +3334 -55
  87. package/src/backends/kubernetes/identity.ts +261 -0
  88. package/src/backends/kubernetes/index.ts +1785 -127
  89. package/src/backends/kubernetes/ingress-policy.ts +1344 -0
  90. package/src/backends/kubernetes/k8s-client.ts +444 -54
  91. package/src/backends/kubernetes/lease.ts +75 -19
  92. package/src/backends/kubernetes/objects.ts +626 -6
  93. package/src/backends/kubernetes/per-sandbox-policy.ts +497 -0
  94. package/src/backends/kubernetes/rbac.ts +192 -0
  95. package/src/backends/kubernetes/sandbox.ts +218 -20
  96. package/src/backends/kubernetes/transport.ts +2733 -124
  97. package/src/backends/kubernetes/workspace.ts +4476 -222
  98. package/src/backends/remote-execution-controller.ts +14 -0
  99. package/src/index.ts +668 -19
  100. package/src/testing/sandbox-conformance.ts +540 -5
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The verbs a CLAIM-ONLY host issues — the pool-only path, written down once
3
+ * so `k8s/manifests/rbac-claimant.yaml`, and the test that compares it to both
4
+ * this list and the call sites behind it, cannot drift from the code they
5
+ * describe.
6
+ *
7
+ * A host that sets `warmPoolName` and never creates a `Sandbox` itself needs
8
+ * a strictly smaller grant than `k8s/manifests/rbac.yaml`'s, and the smaller
9
+ * it is the more it matters that it is EXACT. Two failure directions, and
10
+ * this constant is the one place both are decided:
11
+ *
12
+ * - **Too few verbs** is a `403` on a flow nobody ran in review. The four
13
+ * `list` verbs are the load-bearing ones, and the two a host can go a long
14
+ * time without noticing are the sharp ones: `sandboxclaims: list` is the
15
+ * crash-recovery read (`releaseKubernetesTaskSandboxes`) and the
16
+ * pool-membership read (`readKubernetesTaskCapacity`), and `pods: list` is
17
+ * `readBoundPod`'s fallback read by the bound Sandbox's own selector and
18
+ * `readKubernetesTaskCapacity`'s count of Pending pods. The ACQUIRE path
19
+ * never notices either is missing — `create()` never lists either
20
+ * collection, so a claim is still created, bound, renewed and released
21
+ * exactly as before — which makes the failure DEFERRED rather than quiet:
22
+ * the `await client.request` inside each of those two functions rejects
23
+ * with a `KubernetesCredentialError` (`403`), and neither call catches one,
24
+ * so the first release or capacity read a host makes fails loudly, with the
25
+ * refused verb and the collection's path in the error. The other two are
26
+ * load-bearing SOONER: `list` is what both policy checks need, and the
27
+ * ingress one runs by default before every create.
28
+ * - **One verb too many** is the vulnerability class issue #500 is about: a
29
+ * `sandboxes: create` grant against a namespace running a privileged
30
+ * workspace template is an arbitrary privileged pod for anyone holding
31
+ * it. Every verb below has a call site named against it in the list.
32
+ *
33
+ * The path this describes is the CLAIM path, not the task tier as a whole:
34
+ * with `warmPoolName` unset the same backend POSTs a `Sandbox` instead of a
35
+ * claim, reads its `SandboxTemplate` first, and patches and deletes that
36
+ * object — none of which is granted here, deliberately. See the manifest's
37
+ * own header for what it declines to grant and why.
38
+ *
39
+ * Kept honest by a test rather than by review, and the test reaches the CODE
40
+ * rather than only this list: `k8s/__tests__/manifests.test.ts` reads the
41
+ * pool-only path's own files — `index.ts`, `objects.ts`, `ingress-policy.ts`,
42
+ * `egress-policy.ts` — as text, resolves every literal `client.request(
43
+ * '<METHOD>', <path>, …)` in them to the `(apiGroup, resource, verb)` triple
44
+ * the builder that path goes through addresses, and FAILS on anything it
45
+ * cannot resolve — an unknown builder, a path it cannot follow, a builder
46
+ * call with anything appended to it (`podPath(ns, name) + '/log'` is the
47
+ * `pods/log` subresource, which RBAC authorizes separately), a name the source
48
+ * writes a second path to — the guard reads four spellings, wherever each is
49
+ * written rather than only where it opens a statement: a plain assignment
50
+ * (`path = …`, the one-line branch included), a compound one (`path += …`), a
51
+ * `for (path of …)` binding, and a destructuring target (`({ path } = …)`) — as
52
+ * a `const`/`let`/`var`-declared name, whose initializer is then not what a
53
+ * request through it sends, or as a wrapper's own path parameter, whose call
54
+ * sites then do not say what it sends — a path
55
+ * that is a parameter of a body whose call sites the scan has not been told
56
+ * about, a wrapper declared as such in a file the scan does not read — so a new
57
+ * request has to declare itself instead of escaping. WHICH files those are
58
+ * is not a list that can quietly stop covering the path: every `.ts` under
59
+ * `src/backends/kubernetes/` that reaches the client — calling
60
+ * `client.request(…)` itself in either spelling the scan reads, the literal
61
+ * one or a computed `client['request']`, or naming `listPolicies`, the one
62
+ * wrapper the scan knows, which takes its path as an argument; every half read
63
+ * on text with its comments blanked — is either scanned or declared off the
64
+ * path with its reason, so a file that reaches the client fails that test
65
+ * until someone decides which of the two it is. The triples it
66
+ * observes must be exactly this list, plus the four the pool-LESS branch
67
+ * issues and this grant declines by design. A request added to the pool-only
68
+ * path therefore fails that test naming the resource, the file and the line,
69
+ * instead of surfacing as a `403` in a host's log — and so does a rule here
70
+ * with no call site, which is a grant wider than the code needs.
71
+ *
72
+ * What that test still cannot see, so none of the above reads stronger than
73
+ * it is: the files it declares off the path — `workspace.ts`, `k8s-client.ts`,
74
+ * `transport.ts` — are never resolved to triples, a wrapper call site in one
75
+ * of them included, since call sites are read from the scanned files only; and
76
+ * that a claimant host never reaches `workspace.ts` is a claim about the
77
+ * DEPLOYMENT (`warmPoolName` set, no `Sandbox` created by that host), which
78
+ * nothing in this repository measures. A wrapper reached by ANOTHER name is outside that file predicate,
79
+ * which matches the wrapper's name and not its identity: a file importing a
80
+ * re-export of it, or calling a helper one level further out that calls it, is
81
+ * neither scanned nor required to be declared. A request whose path is a
82
+ * parameter of an expression-bodied arrow is likewise outside what the scan
83
+ * matches, and a body the scan could not register — a class method, or an
84
+ * object literal's shorthand method, declared INSIDE one of the bodies it
85
+ * reads — is attributed to the body that encloses it, so its own parameters
86
+ * are invisible. The assignment guard is by name in the file it resolves in,
87
+ * not by scope, so a BINDING it registers nowhere shadows nothing there: a
88
+ * destructuring parameter (`function f({ path })`) keeps its positional slot
89
+ * without a name this scan can match, a `catch (path)` is not read at all, and
90
+ * a write made to an exported binding from ANOTHER module is not in that file's
91
+ * text — which is also to say that an assignment to a same-named local in an
92
+ * unrelated function of the same file refuses the request too, a false refusal
93
+ * rather than an escape. A parameter carrying a DEFAULT is written with an `=`
94
+ * (`function f(path = …)`, `function f({ path } = {})`), so the guard reads it
95
+ * and refuses the request rather than resolving it — a refusal, where the bare
96
+ * pattern would have resolved or failed; and a destructuring pattern nested
97
+ * inside another (`({ a: { path } } = …)`) is read by neither spelling, which
98
+ * is measured green.
99
+ *
100
+ * **That list is the SHORT form, not the list.** It is carried in full in the
101
+ * header of `k8s/__tests__/manifests.test.ts`, which also holds two bullets
102
+ * this paragraph does not: `a path assembled by an operation the scan does not
103
+ * model`, and `the cluster` (nothing there contacts an API server, so no live
104
+ * `Role` is measured).
105
+ *
106
+ * Exported from the package root because the grant is a property of the
107
+ * backend, not of a file this package does not publish: `k8s/` is outside
108
+ * the `files` array, so a consumer cannot read the YAML out of `node_modules`
109
+ * — it can, however, compare its own applied `Role` against this.
110
+ */
111
+
112
+ /** An RBAC verb this backend issues. No wildcard, and none is ever added. */
113
+ export type KubernetesRbacVerb = 'create' | 'get' | 'list' | 'patch' | 'delete'
114
+
115
+ /**
116
+ * One `rules[]` entry, in the shape RBAC itself uses — one resource per
117
+ * entry, which is also how the shipped manifests are written, so a rule here
118
+ * and a rule there compare field for field.
119
+ */
120
+ export interface KubernetesRbacRule {
121
+ /** The `apiGroups` entry, verbatim: `''` for the core group. */
122
+ readonly apiGroup: string
123
+ readonly resource: string
124
+ readonly verbs: readonly KubernetesRbacVerb[]
125
+ }
126
+
127
+ /**
128
+ * Every verb the pool-only path issues, each pinned to its call site in
129
+ * `./index.ts` (and, for the policy checks, `./ingress-policy.ts` /
130
+ * `./egress-policy.ts`):
131
+ *
132
+ * - `sandboxclaims` — `create` is the acquire's POST (`createPath`), `get`
133
+ * the readiness poll that waits for the controller to bind one, `patch`
134
+ * the lease renewal that keeps a long run's claim alive, `delete` the
135
+ * release `destroy()` and a failed acquire's cleanup both send, and
136
+ * `list` the two host-invoked reads named above.
137
+ * - `sandboxes: get` — read for `status.selector` when the pod-by-name GET
138
+ * comes back gone, which on the claim path is the ONLY way to find the
139
+ * bound pod: a claim has no `podSelector` of its own to fall back on.
140
+ * - `sandboxwarmpools: get` — `readKubernetesTaskCapacity` reads the
141
+ * configured pool's own `status.readyReplicas`/`spec.replicas` before a
142
+ * host admits more work.
143
+ * - `pods` — `get` by name for the agent's bind token and, under
144
+ * `agentAddress: 'pod-ip'`, its address, plus the whole-namespace `list`
145
+ * that counts in-flight scale-up. The two policy checks use NO pod
146
+ * subresource; the agent's protocol is a socket the guest serves, not
147
+ * `pods/exec`.
148
+ * - `networkpolicies` / `ciliumnetworkpolicies` — `list` is the ingress
149
+ * coverage check (on by default before every create) and the egress UNION
150
+ * check (on by default whenever `config.egress` is set); `get` is the
151
+ * egress named-object check, also only under `config.egress`. Under
152
+ * `engine: 'core'` the Cilium rule simply never matches, and a cluster
153
+ * with no Cilium CRDs installed never has an object to match it.
154
+ */
155
+ export const KUBERNETES_CLAIMANT_RBAC_RULES: readonly KubernetesRbacRule[] = Object.freeze([
156
+ // Every RULE is frozen too, not just the array and the verb lists: a
157
+ // frozen array still hands out mutable elements, so a plain-JS consumer
158
+ // could rewrite `rule.resource` and compare its live `Role` against a
159
+ // grant this module never declared. The test pins that (see
160
+ // `k8s/__tests__/manifests.test.ts`), because a `readonly` modifier is
161
+ // TypeScript's promise and not the runtime's.
162
+ Object.freeze({
163
+ apiGroup: 'extensions.agents.x-k8s.io',
164
+ resource: 'sandboxclaims',
165
+ verbs: Object.freeze(['create', 'get', 'list', 'patch', 'delete'] as const),
166
+ }),
167
+ Object.freeze({
168
+ apiGroup: 'agents.x-k8s.io',
169
+ resource: 'sandboxes',
170
+ verbs: Object.freeze(['get'] as const),
171
+ }),
172
+ Object.freeze({
173
+ apiGroup: 'extensions.agents.x-k8s.io',
174
+ resource: 'sandboxwarmpools',
175
+ verbs: Object.freeze(['get'] as const),
176
+ }),
177
+ Object.freeze({
178
+ apiGroup: '',
179
+ resource: 'pods',
180
+ verbs: Object.freeze(['get', 'list'] as const),
181
+ }),
182
+ Object.freeze({
183
+ apiGroup: 'networking.k8s.io',
184
+ resource: 'networkpolicies',
185
+ verbs: Object.freeze(['get', 'list'] as const),
186
+ }),
187
+ Object.freeze({
188
+ apiGroup: 'cilium.io',
189
+ resource: 'ciliumnetworkpolicies',
190
+ verbs: Object.freeze(['get', 'list'] as const),
191
+ }),
192
+ ])
@@ -11,20 +11,36 @@
11
11
  *
12
12
  * Implemented: `exec` (through the shared {@link RemoteExecutionController},
13
13
  * so an `AbortSignal` terminates the guest process rather than abandoning
14
- * the wait), `writeFile`, `readFile`, `listFiles`, `openTerminal`,
15
- * `openTcpConnection`, `destroy`.
14
+ * the wait), `writeFile`, `readFile`, `listFiles`, `walkFiles`,
15
+ * `openTerminal`, `openTcpConnection`, `destroy`.
16
+ *
17
+ * Conditionally present, on the same contract read the other way:
18
+ *
19
+ * - `setNetworkPolicy` — present on a TASK handle exactly when the backend
20
+ * was configured with `egress.perSandbox`, which is what gives this host
21
+ * an object to write (one `CiliumNetworkPolicy` per sandbox, owned by the
22
+ * claim), an admission fence bounding what it may write, and the RBAC to
23
+ * write it. Without that configuration there is still no per-running-pod
24
+ * knob to turn, so the method is ABSENT rather than present and throwing —
25
+ * the same rule, applied to a capability that now sometimes exists.
26
+ * Presence follows configuration alone, never a probe of the cluster. See
27
+ * `per-sandbox-policy.ts`.
28
+ *
29
+ * A WORKSPACE handle never carries it: `KubernetesWorkspace`'s create path
30
+ * builds its handle through `buildKubernetesSandbox` without this option,
31
+ * because it composes no per-sandbox pod label and tracks no owner uid for
32
+ * one. That is a property of the PATH and not of the configuration, so it
33
+ * is not answered by omission: `createKubernetesWorkspace` refuses a
34
+ * config carrying `egress.perSandbox` outright
35
+ * (`KubernetesWorkspacePerSandboxEgressConfigError`, before any request)
36
+ * rather than letting the option declare a capability the path never
37
+ * serves — see `workspace.ts`.
16
38
  *
17
39
  * Absent on purpose, because the SDK's contract says a backend that cannot
18
40
  * honour an optional method must omit it rather than accept and ignore:
19
41
  *
20
- * - `setNetworkPolicy` — egress here is a `NetworkPolicy` attached to the
21
- * pool's `SandboxTemplate`. There is no per-running-pod knob to turn, and
22
- * a policy accepted and not applied is worse than one never offered: the
23
- * caller stops looking.
24
42
  * - `spawnDetached` — the guest agent has no op that starts a process and
25
43
  * returns it running. A host asking for background jobs must be told no.
26
- * - `walkFiles` — not in this batch. A host that requires bounded search
27
- * refuses an absent method, which is the honest answer today.
28
44
  *
29
45
  * ## Terminals are owned
30
46
  *
@@ -52,11 +68,15 @@ import type {
52
68
  SandboxExecResult,
53
69
  SandboxFileEntry,
54
70
  SandboxId,
71
+ SandboxNetworkPolicy,
72
+ SandboxReadFileOptions,
55
73
  SandboxStatus,
56
74
  SandboxTcpConnectOptions,
57
75
  SandboxTcpConnection,
76
+ SandboxWalkFilesOptions,
58
77
  TerminalSession,
59
78
  } from '@namzu/sdk'
79
+ import { walkFilesViaExec } from '@namzu/sdk'
60
80
 
61
81
  import { OperationDeadline } from '../readiness.js'
62
82
  import {
@@ -116,6 +136,58 @@ interface KubernetesSandboxBaseOptions {
116
136
  readonly transport: KubernetesAgentTransport
117
137
  /** DELETE the object this backend created. Already-gone counts as done. */
118
138
  readonly release: (signal?: AbortSignal) => Promise<void>
139
+ /**
140
+ * Decide what an execution whose cancellation could not be CONFIRMED
141
+ * does to this sandbox — called instead of retiring it, and answering
142
+ * the {@link SandboxRetirementObservation} that goes onto the error the
143
+ * caller is about to receive.
144
+ *
145
+ * Unset (the task path, and the Firecracker tier through its own
146
+ * transport) keeps the shared controller's rule verbatim: a command of
147
+ * unknown state is still in that pod, the pod stops being reusable, and
148
+ * the handle retires it through {@link release}. That is right for a
149
+ * disposable object whose disk is scratch.
150
+ *
151
+ * It is wrong for an object that is not disposable. On a workspace
152
+ * `release` is an `operatingMode: Suspended` patch, which makes the
153
+ * controller delete the pod — so eight seconds of network loss under one
154
+ * `exec()` would take every other holder's terminals, dev servers and
155
+ * running commands with it, and no host-side lock can prevent it because
156
+ * no caller issued it. The workspace passes a hook that keeps the pod,
157
+ * diagnoses the agent and says `accepted: false` with a `reason` rather
158
+ * than letting a decision that large be made from inside a failing call.
159
+ *
160
+ * It must not reject; one that does is reported as an unaccepted
161
+ * retirement carrying its own error, so a broken hook cannot replace the
162
+ * error the caller asked about.
163
+ */
164
+ readonly onUnconfirmedCancellation?: (
165
+ error: RemoteCancellationUnknownError,
166
+ ) => Promise<SandboxRetirementObservation>
167
+ /**
168
+ * Narrow this sandbox's egress while it runs — present on the handle
169
+ * EXACTLY when this is passed, and passed by the TASK acquire exactly when
170
+ * `config.egress.perSandbox` is configured.
171
+ *
172
+ * That conditional presence is what the SDK's omit-or-throw contract
173
+ * licenses and what makes it honest here: without the configuration there
174
+ * is no policy object to write, no admission fence bounding what this
175
+ * host may write, and no RBAC grant to write it with, so the method is
176
+ * ABSENT rather than present and throwing. With it, `per-sandbox-policy.ts`
177
+ * writes one `CiliumNetworkPolicy` per sandbox, owned by the object the
178
+ * acquire created.
179
+ *
180
+ * The workspace passes NO such option whatever the config says — its
181
+ * create path composes no per-sandbox pod label and tracks no owner uid
182
+ * for one — and `createKubernetesWorkspace` refuses
183
+ * `config.egress.perSandbox` rather than silently omitting the method the
184
+ * option asks for.
185
+ *
186
+ * Presence depends only on configuration — never on a runtime probe of
187
+ * the cluster — so a caller's capability detection cannot come out
188
+ * differently depending on when it asked.
189
+ */
190
+ readonly setNetworkPolicy?: (policy: SandboxNetworkPolicy) => Promise<void>
119
191
  }
120
192
 
121
193
  /**
@@ -163,13 +235,13 @@ function detectEnvironment(): SandboxEnvironment {
163
235
  }
164
236
 
165
237
  /**
166
- * What this backend hands back: the SDK contract, with the two optional
167
- * members it DOES implement narrowed to present, so a caller that composes
168
- * one — `workspace.ts` wraps this handle — does not have to re-check for a
169
- * method this file always defines.
238
+ * What this backend hands back: the SDK contract, with the optional members
239
+ * it DOES implement narrowed to present, so a caller that composes one —
240
+ * `workspace.ts` wraps this handle — does not have to re-check for a method
241
+ * this file always defines.
170
242
  */
171
243
  export type KubernetesSandboxHandle = Sandbox &
172
- Required<Pick<Sandbox, 'openTerminal' | 'openTcpConnection'>>
244
+ Required<Pick<Sandbox, 'openTerminal' | 'openTcpConnection' | 'walkFiles' | 'readFileStream'>>
173
245
 
174
246
  /**
175
247
  * Build the handle. It does NOT run the acquire-time privilege probe — that
@@ -183,6 +255,9 @@ export function buildKubernetesSandbox(options: KubernetesSandboxOptions): Kuber
183
255
  // line carrying an id is also a `kubectl get sandbox` argument.
184
256
  const id = options.name as SandboxId
185
257
  const transport = options.transport
258
+ // Captured as a const so the conditional member below narrows: the method
259
+ // is on the handle if and only if this is defined, decided once, here.
260
+ const setNetworkPolicy = options.setNetworkPolicy
186
261
 
187
262
  type Lifecycle = 'active' | 'retiring' | 'destroyed' | 'gone'
188
263
  let lifecycle: Lifecycle = 'active'
@@ -278,6 +353,29 @@ export function buildKubernetesSandbox(options: KubernetesSandboxOptions): Kuber
278
353
  return retirementPromise
279
354
  }
280
355
 
356
+ /**
357
+ * What an unconfirmed cancellation does to THIS sandbox: retire it, or
358
+ * whatever the owner's hook decided instead — see
359
+ * {@link KubernetesSandboxBaseOptions.onUnconfirmedCancellation}.
360
+ */
361
+ const observeUnconfirmedCancellation = async (
362
+ error: RemoteCancellationUnknownError,
363
+ ): Promise<SandboxRetirementObservation> => {
364
+ const decide = options.onUnconfirmedCancellation
365
+ if (decide === undefined) return await retire()
366
+ try {
367
+ return await decide(error)
368
+ } catch (hookError: unknown) {
369
+ // The caller is already receiving `error`; a hook that threw must
370
+ // not replace it, and must not be reported as a teardown that was
371
+ // attempted either.
372
+ return {
373
+ accepted: false,
374
+ error: hookError instanceof Error ? hookError : new Error(String(hookError)),
375
+ }
376
+ }
377
+ }
378
+
281
379
  const runExecution = async <T>(operation: string, run: () => Promise<T>): Promise<T> => {
282
380
  assertAdmissible(operation)
283
381
  activeExecutions += 1
@@ -285,7 +383,7 @@ export function buildKubernetesSandbox(options: KubernetesSandboxOptions): Kuber
285
383
  return await run()
286
384
  } catch (error) {
287
385
  if (error instanceof RemoteCancellationUnknownError) {
288
- error.retirement = await retire()
386
+ error.retirement = await observeUnconfirmedCancellation(error)
289
387
  }
290
388
  throw error
291
389
  } finally {
@@ -317,10 +415,15 @@ export function buildKubernetesSandbox(options: KubernetesSandboxOptions): Kuber
317
415
  * Every `tcp` request dials a fresh connection, so its envelope is
318
416
  * also that connection's first, not-yet-authenticated frame and is
319
417
  * bounded by the guest's pre-auth frame ceiling (8 MiB by default).
320
- * The transport checks that BEFORE dialing and throws
321
- * `AgentPreauthFrameTooLargeError` naming the limit; it is passed
322
- * through unwrapped so a caller can catch that class and chunk,
323
- * rather than having to pattern-match a message.
418
+ * A body above it is no longer a refusal: the transport splits it
419
+ * into parts that each fit, writes them to a temporary sibling of
420
+ * the target and finishes with an atomic rename, so this method
421
+ * takes a body of any size the transport's `maxWriteFileBytes`
422
+ * admits (1 GiB by default). The named refusals that remain are
423
+ * passed through unwrapped so a caller can catch them BY CLASS:
424
+ * `AgentWriteFileTooLargeError` for a body above that bound, and
425
+ * `AgentPreauthFrameTooLargeError` for an oversized body against a
426
+ * guest too old to advertise the part protocol.
324
427
  */
325
428
  async writeFile(path: string, content: string | Buffer): Promise<void> {
326
429
  assertAdmissible('writeFile')
@@ -328,11 +431,47 @@ export function buildKubernetesSandbox(options: KubernetesSandboxOptions): Kuber
328
431
  await transport.writeFile(path, buf)
329
432
  },
330
433
 
331
- async readFile(path: string): Promise<Buffer> {
434
+ /**
435
+ * A whole-file read is served by the guest's streamed op when the
436
+ * guest advertises it, so neither side holds the file's base64 form
437
+ * or its JSON envelope in one piece and a file of any size this
438
+ * workspace's disk holds can be read. `offset`/`length` ask for one
439
+ * slice instead; a guest too old to honour them is refused with
440
+ * `AgentReadFileStreamUnsupportedError` rather than answering with
441
+ * the whole file.
442
+ */
443
+ async readFile(path: string, readOptions?: SandboxReadFileOptions): Promise<Buffer> {
332
444
  assertAdmissible('readFile')
333
- return await transport.readFile(path)
445
+ return await transport.readFile(path, readOptions)
446
+ },
447
+
448
+ /**
449
+ * Chunks, in order, with nothing whole at either end — what a host
450
+ * draining a large output file before `destroy()` needs. The
451
+ * admissibility check runs at the call, not per chunk: a workspace
452
+ * suspended mid-stream takes its pod's connection with it, which is
453
+ * what ends the iteration.
454
+ */
455
+ readFileStream(path: string, readOptions?: SandboxReadFileOptions): AsyncIterable<Buffer> {
456
+ assertAdmissible('readFileStream')
457
+ return transport.readFileStream(path, readOptions)
334
458
  },
335
459
 
460
+ // Present only when the backend was configured for per-sandbox egress
461
+ // — see {@link KubernetesSandboxBaseOptions.setNetworkPolicy}. The
462
+ // admissibility gate is this file's, not the writer's, so a destroyed
463
+ // or cluster-removed sandbox refuses BY NAME here, exactly as every
464
+ // other method does, rather than failing at the API server one round
465
+ // trip later.
466
+ ...(setNetworkPolicy !== undefined
467
+ ? {
468
+ setNetworkPolicy: async (policy: SandboxNetworkPolicy): Promise<void> => {
469
+ assertAdmissible('setNetworkPolicy')
470
+ await setNetworkPolicy(policy)
471
+ },
472
+ }
473
+ : {}),
474
+
336
475
  async openTerminal(terminalOptions: OpenTerminalOptions): Promise<TerminalSession> {
337
476
  assertAdmissible('openTerminal')
338
477
  const terminal = await transport.openTerminal(terminalOptions)
@@ -369,6 +508,65 @@ export function buildKubernetesSandbox(options: KubernetesSandboxOptions): Kuber
369
508
  })
370
509
  },
371
510
 
511
+ /**
512
+ * Bounded, lazy file discovery — the method the SDK's `glob` and
513
+ * `grep` builtins refuse a sandbox for not having.
514
+ *
515
+ * Built on {@link walkFilesViaExec}, the same host-side enumerator the
516
+ * Firecracker and docker backends use, over this transport's `exec`:
517
+ * the guest needs no new agent op, because the walk IS an execution —
518
+ * `node -e` running the SDK's own walk program and streaming one JSONL
519
+ * record per match. The guest image is `node:22-bookworm-slim` (see
520
+ * `k8s/Dockerfile`) and the agent is itself node, so node on the
521
+ * guest's PATH is a precondition of the agent existing rather than a
522
+ * new requirement this method introduces.
523
+ *
524
+ * Ownership is the same as `exec`'s, and deliberately NOT
525
+ * `runExecution`'s: that helper wraps one awaited call, and a walk is a
526
+ * sequence of them. `activeExecutions` is therefore held for the whole
527
+ * walk rather than per entry — `status` reads `busy` from the first
528
+ * `next()` to the last, never flapping between yields — and an
529
+ * unconfirmed cancellation retires this handle exactly as a failed
530
+ * `exec` cancel does, on the same error class and through the same
531
+ * `retire()`.
532
+ *
533
+ * Cancellation: `options.signal` and the consumer's own
534
+ * `iterator.return()` both abort the underlying `exec`, which sends
535
+ * the guest a `cancel-execution` and kills the walk's process group —
536
+ * so breaking out of the loop after five entries leaves nothing
537
+ * running in the pod.
538
+ */
539
+ async *walkFiles(
540
+ rootPath: string,
541
+ walkOptions: SandboxWalkFilesOptions,
542
+ ): AsyncIterable<SandboxFileEntry> {
543
+ assertAdmissible('walkFiles')
544
+ activeExecutions += 1
545
+ try {
546
+ yield* walkFilesViaExec(
547
+ async (command, argv, execOpts) => await transport.exec(command, argv, execOpts),
548
+ rootPath,
549
+ walkOptions,
550
+ )
551
+ } catch (error) {
552
+ // The same rule `runExecution` applies, inlined because a
553
+ // generator cannot be wrapped by it: a command whose
554
+ // cancellation the guest could not confirm may still be running
555
+ // in that pod, so the pod stops being reusable.
556
+ //
557
+ // TWO SITES, ONE RULE. This block and `runExecution`'s must
558
+ // change together — #480, which owns the unconfirmed-cancel
559
+ // rule for this backend, is the next change to both, and a
560
+ // change that lands in one of them is a bug in the other.
561
+ if (error instanceof RemoteCancellationUnknownError) {
562
+ error.retirement = await retire()
563
+ }
564
+ throw error
565
+ } finally {
566
+ activeExecutions = Math.max(0, activeExecutions - 1)
567
+ }
568
+ },
569
+
372
570
  async destroy(destroyOptions?: SandboxDestroyOptions): Promise<void> {
373
571
  if (retirementPromise) {
374
572
  const observation = await retirementPromise