@namzu/sandbox 26.0.0 → 28.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 (83) hide show
  1. package/CHANGELOG.md +62 -7
  2. package/README.md +20 -7
  3. package/dist/backends/firecracker/protocol.d.ts +46 -0
  4. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  5. package/dist/backends/firecracker/protocol.js +31 -0
  6. package/dist/backends/firecracker/protocol.js.map +1 -1
  7. package/dist/backends/firecracker/transport.d.ts +15 -1
  8. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  9. package/dist/backends/firecracker/transport.js +16 -0
  10. package/dist/backends/firecracker/transport.js.map +1 -1
  11. package/dist/backends/kubernetes/allocation.d.ts +115 -0
  12. package/dist/backends/kubernetes/allocation.d.ts.map +1 -0
  13. package/dist/backends/kubernetes/allocation.js +130 -0
  14. package/dist/backends/kubernetes/allocation.js.map +1 -0
  15. package/dist/backends/kubernetes/egress-policy.d.ts +111 -35
  16. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
  17. package/dist/backends/kubernetes/egress-policy.js +81 -41
  18. package/dist/backends/kubernetes/egress-policy.js.map +1 -1
  19. package/dist/backends/kubernetes/identity.d.ts +129 -0
  20. package/dist/backends/kubernetes/identity.d.ts.map +1 -1
  21. package/dist/backends/kubernetes/identity.js +150 -0
  22. package/dist/backends/kubernetes/identity.js.map +1 -1
  23. package/dist/backends/kubernetes/index.d.ts +23 -1
  24. package/dist/backends/kubernetes/index.d.ts.map +1 -1
  25. package/dist/backends/kubernetes/index.js +22 -3
  26. package/dist/backends/kubernetes/index.js.map +1 -1
  27. package/dist/backends/kubernetes/objects.d.ts +76 -0
  28. package/dist/backends/kubernetes/objects.d.ts.map +1 -1
  29. package/dist/backends/kubernetes/objects.js +61 -0
  30. package/dist/backends/kubernetes/objects.js.map +1 -1
  31. package/dist/backends/kubernetes/per-sandbox-fence.d.ts +194 -0
  32. package/dist/backends/kubernetes/per-sandbox-fence.d.ts.map +1 -0
  33. package/dist/backends/kubernetes/per-sandbox-fence.js +522 -0
  34. package/dist/backends/kubernetes/per-sandbox-fence.js.map +1 -0
  35. package/dist/backends/kubernetes/per-sandbox-policy.d.ts +45 -3
  36. package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
  37. package/dist/backends/kubernetes/per-sandbox-policy.js +98 -8
  38. package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
  39. package/dist/backends/kubernetes/sandbox.d.ts +4 -9
  40. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
  41. package/dist/backends/kubernetes/sandbox.js +4 -9
  42. package/dist/backends/kubernetes/sandbox.js.map +1 -1
  43. package/dist/backends/kubernetes/transport.d.ts +108 -1
  44. package/dist/backends/kubernetes/transport.d.ts.map +1 -1
  45. package/dist/backends/kubernetes/transport.js +150 -6
  46. package/dist/backends/kubernetes/transport.js.map +1 -1
  47. package/dist/backends/kubernetes/workspace-allocate.d.ts +206 -0
  48. package/dist/backends/kubernetes/workspace-allocate.d.ts.map +1 -0
  49. package/dist/backends/kubernetes/workspace-allocate.js +332 -0
  50. package/dist/backends/kubernetes/workspace-allocate.js.map +1 -0
  51. package/dist/backends/kubernetes/workspace-observe.d.ts +187 -0
  52. package/dist/backends/kubernetes/workspace-observe.d.ts.map +1 -0
  53. package/dist/backends/kubernetes/workspace-observe.js +343 -0
  54. package/dist/backends/kubernetes/workspace-observe.js.map +1 -0
  55. package/dist/backends/kubernetes/workspace-per-sandbox.d.ts +94 -0
  56. package/dist/backends/kubernetes/workspace-per-sandbox.d.ts.map +1 -0
  57. package/dist/backends/kubernetes/workspace-per-sandbox.js +119 -0
  58. package/dist/backends/kubernetes/workspace-per-sandbox.js.map +1 -0
  59. package/dist/backends/kubernetes/workspace.d.ts +139 -5
  60. package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
  61. package/dist/backends/kubernetes/workspace.js +434 -36
  62. package/dist/backends/kubernetes/workspace.js.map +1 -1
  63. package/dist/index.d.ts +83 -7
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +100 -6
  66. package/dist/index.js.map +1 -1
  67. package/package.json +3 -3
  68. package/src/backends/firecracker/protocol.ts +47 -0
  69. package/src/backends/firecracker/transport.ts +31 -0
  70. package/src/backends/kubernetes/allocation.ts +199 -0
  71. package/src/backends/kubernetes/egress-policy.ts +165 -42
  72. package/src/backends/kubernetes/identity.ts +210 -0
  73. package/src/backends/kubernetes/index.ts +61 -2
  74. package/src/backends/kubernetes/objects.ts +98 -0
  75. package/src/backends/kubernetes/per-sandbox-fence.ts +800 -0
  76. package/src/backends/kubernetes/per-sandbox-policy.ts +152 -17
  77. package/src/backends/kubernetes/sandbox.ts +4 -9
  78. package/src/backends/kubernetes/transport.ts +203 -4
  79. package/src/backends/kubernetes/workspace-allocate.ts +607 -0
  80. package/src/backends/kubernetes/workspace-observe.ts +590 -0
  81. package/src/backends/kubernetes/workspace-per-sandbox.ts +179 -0
  82. package/src/backends/kubernetes/workspace.ts +655 -34
  83. package/src/index.ts +193 -3
@@ -159,17 +159,21 @@
159
159
  */
160
160
 
161
161
  import type {
162
+ OpenTerminalOptions,
162
163
  Sandbox,
163
164
  SandboxDestroyOptions,
164
165
  SandboxEnvironment,
166
+ SandboxExecOptions,
165
167
  SandboxExecResult,
166
168
  SandboxFileEntry,
167
169
  SandboxId,
170
+ SandboxNetworkPolicy,
168
171
  SandboxReadFileOptions,
169
172
  SandboxStatus,
170
173
  SandboxTcpConnectOptions,
171
174
  SandboxTcpConnection,
172
175
  SandboxWalkFilesOptions,
176
+ TerminalSession,
173
177
  } from '@namzu/sdk'
174
178
 
175
179
  import { OperationDeadline, OperationDeadlineExpired, runFailureCleanup } from '../readiness.js'
@@ -177,18 +181,24 @@ import type { SandboxRetirementObservation } from '../remote-execution-controlle
177
181
  import { RemoteCancellationUnknownError } from '../remote-execution-controller.js'
178
182
  import {
179
183
  type EgressProfileLabel,
184
+ type KubernetesEgressConfig,
185
+ type KubernetesPerSandboxEgressConfig,
180
186
  assertEgressPolicyIsEnforceable,
181
187
  assertEgressProfileIsUsable,
182
- assertWorkspaceCarriesNoPerSandboxEgress,
188
+ assertPerSandboxEgressIsUsable,
183
189
  composeAdditionalPodLabels,
184
190
  egressProfileLabel,
185
191
  } from './egress-policy.js'
186
192
  import {
187
193
  type KubernetesGuestEvidence,
194
+ KubernetesGuestPinRefusedError,
188
195
  type KubernetesGuestRestart,
196
+ type KubernetesPinnedGuestIdentity,
197
+ type KubernetesWorkspaceExpectedIdentity,
189
198
  KubernetesWorkspaceGuestGoneError,
190
199
  type KubernetesWorkspaceIdentity,
191
200
  KubernetesWorkspaceReplacedError,
201
+ freezePinnedIdentity,
192
202
  } from './identity.js'
193
203
  import {
194
204
  DEFAULT_AGENT_PORT,
@@ -196,6 +206,7 @@ import {
196
206
  type KubernetesAgentAddressMode,
197
207
  type KubernetesBackendInternalConfig,
198
208
  type KubernetesBoundPod,
209
+ type KubernetesEgressBoundary,
199
210
  type KubernetesSandboxBinding,
200
211
  ReadinessPollTimeout,
201
212
  type SandboxTemplateCopy,
@@ -226,6 +237,7 @@ import {
226
237
  import {
227
238
  HOLDER_EPOCH_ANNOTATION_KEY,
228
239
  type HolderEpochReading,
240
+ type JsonPatchOperation,
229
241
  OPERATING_MODE_CHANGED_AT_ANNOTATION_KEY,
230
242
  POD_TEMPLATE_HASH_ANNOTATION_KEY,
231
243
  type PodResource,
@@ -245,6 +257,12 @@ import {
245
257
  sandboxCollectionPath,
246
258
  sandboxPath,
247
259
  } from './objects.js'
260
+ import {
261
+ KubernetesOwnerUidMissingError,
262
+ type PerSandboxPolicyFence,
263
+ buildAdmissionFence,
264
+ buildPerSandboxPolicySetter,
265
+ } from './per-sandbox-policy.js'
248
266
  import {
249
267
  KubernetesSandboxDestroyedError,
250
268
  type KubernetesSandboxHandle,
@@ -271,8 +289,15 @@ import {
271
289
  type KubernetesStartDetachedOptions,
272
290
  type KubernetesWorkspaceTerminal,
273
291
  flushUnsupportedError,
292
+ guestMismatchOf,
274
293
  guestWhenReserved,
294
+ isUnauthorizedRefusal,
275
295
  } from './transport.js'
296
+ import {
297
+ perSandboxWorkspaceLabels,
298
+ reverifyPerSandboxEgress,
299
+ withoutPerSandboxLabel,
300
+ } from './workspace-per-sandbox.js'
276
301
 
277
302
  /**
278
303
  * Thrown when a workspace's `SandboxTemplate` does not describe a block disk
@@ -335,7 +360,11 @@ export class KubernetesWorkspaceMismatchError extends Error {
335
360
  /** The Sandbox found standing under this workspace's name. */
336
361
  readonly sandboxName: string,
337
362
  /** Which configured field the standing object disagrees with. */
338
- readonly field: 'sandboxTemplateName' | 'egressProfile' | 'runtimeClassName',
363
+ readonly field:
364
+ | 'sandboxTemplateName'
365
+ | 'egressProfile'
366
+ | 'perSandboxLabel'
367
+ | 'runtimeClassName',
339
368
  /**
340
369
  * What the configuration asked for. `key=value` for `egressProfile`,
341
370
  * because the KEY is configurable too and the value alone would not
@@ -487,7 +516,7 @@ export class KubernetesWorkspacePreconditionError extends Error {
487
516
  * as a decimal string, and an integer because a fractional epoch would
488
517
  * round-trip through the annotation as something other than what was passed.
489
518
  */
490
- function assertHolderEpoch(epoch: number | undefined, where: string): number | undefined {
519
+ export function assertHolderEpoch(epoch: number | undefined, where: string): number | undefined {
491
520
  if (epoch === undefined) return undefined
492
521
  if (!Number.isSafeInteger(epoch) || epoch < 0) {
493
522
  throw new Error(
@@ -662,6 +691,29 @@ export interface KubernetesWorkspaceTransitionOptions {
662
691
  * patch a failed start's cleanup sends.
663
692
  */
664
693
  readonly epoch?: number
694
+ /**
695
+ * The exact Sandbox this call is about, by `metadata.uid` — what a host
696
+ * retained from {@link KubernetesWorkspace.identity} or
697
+ * {@link inspectKubernetesWorkspace}.
698
+ *
699
+ * The workspace id is a deterministic NAME: if the Sandbox the host meant
700
+ * was deleted and another created under the same name, the name now
701
+ * addresses a stranger's disk. With this set, the JSON patch of
702
+ * {@link suspendKubernetesWorkspace} carries `test /metadata/uid` and the
703
+ * DELETE of {@link deleteKubernetesWorkspace} carries `preconditions.uid`,
704
+ * so the API server refuses the write itself. A different owner rejects
705
+ * with {@link KubernetesWorkspaceReplacedError}, changes nothing and is
706
+ * never retried against the new owner; an object already gone still counts
707
+ * as deleted.
708
+ *
709
+ * Read by the two standalone verbs. A handle's own verbs ignore it and
710
+ * fence every write with the uid they bound, which they do without being
711
+ * asked. Omitted, a standalone call is conditioned on nothing about
712
+ * identity, as before. `volumeClaimUids` is not read here: the claims are
713
+ * owned by the Sandbox, so the uid is the identity a write can be
714
+ * conditioned on — check claims with {@link openKubernetesWorkspace}.
715
+ */
716
+ readonly expectedIdentity?: KubernetesWorkspaceExpectedIdentity
665
717
  /**
666
718
  * Write the SandboxTemplate's CURRENT `spec.podTemplate` onto the
667
719
  * workspace as it wakes, keeping its disk — see
@@ -799,6 +851,25 @@ export interface KubernetesQuiesceOptions {
799
851
  * and still serving.
800
852
  */
801
853
  readonly graceMs?: number
854
+ /**
855
+ * Look, and touch nothing. With `true` the call resolves (with a report
856
+ * of `rounds: 0`, `stopped: []`, scope `pid-namespace`) only when the
857
+ * guest confirms its whole PID namespace holds nothing but init and the
858
+ * agent, and otherwise rejects with `KubernetesQuiesceBusyError` naming
859
+ * what was seen. It never signals a process, never marks an execution
860
+ * and never waits, so a busy guest is exactly as it was and the caller
861
+ * can retry later.
862
+ *
863
+ * Needs the guest feature `quiesce-idle`: against an older agent the
864
+ * call rejects with `KubernetesQuiesceUnsupportedError` and sends
865
+ * nothing, because that agent would ignore this field and run the
866
+ * stopping quiesce. Cannot be combined with `graceMs` (a `TypeError`).
867
+ * An agent that cannot see its whole namespace, or cannot classify a
868
+ * `/proc` entry, rejects with `KubernetesQuiesceUnconfirmedError`
869
+ * rather than reporting idle. Captured when the call is made, before
870
+ * any await.
871
+ */
872
+ readonly onlyIfIdle?: boolean
802
873
  readonly signal?: AbortSignal
803
874
  }
804
875
 
@@ -904,6 +975,23 @@ export type KubernetesWorkspaceOrigin = 'created' | 'adopted-running' | 'resumed
904
975
  * contract that every other backend would then have to answer for, and no
905
976
  * `deleteDisk` field on the shared `SandboxDestroyOptions`.
906
977
  */
978
+ /**
979
+ * What {@link KubernetesWorkspace.pinGuest} returns: the data-plane subset of
980
+ * a workspace, every call authenticated against one guest.
981
+ */
982
+ export interface KubernetesPinnedGuest {
983
+ /** The identity this view is pinned to, frozen. */
984
+ readonly expected: KubernetesPinnedGuestIdentity
985
+ exec(command: string, argv?: string[], options?: SandboxExecOptions): Promise<SandboxExecResult>
986
+ readFile(path: string, options?: SandboxReadFileOptions): Promise<Buffer>
987
+ writeFile(path: string, content: string | Buffer): Promise<void>
988
+ listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]>
989
+ openTerminal(options: OpenTerminalOptions): Promise<TerminalSession>
990
+ openTcpConnection(options: SandboxTcpConnectOptions): Promise<SandboxTcpConnection>
991
+ /** As {@link KubernetesWorkspace.quiesce}, without the lifecycle queue: it is the guest's, not the workspace's. */
992
+ quiesce(options?: KubernetesQuiesceOptions): Promise<KubernetesQuiesceReport>
993
+ }
994
+
907
995
  export interface KubernetesWorkspace extends Sandbox {
908
996
  /**
909
997
  * How this handle came by its workspace — see
@@ -1214,8 +1302,38 @@ export interface KubernetesWorkspace extends Sandbox {
1214
1302
  * `KubernetesQuiesceUnconfirmedError` when a process would not stop (the
1215
1303
  * message names its pid), and with `KubernetesQuiesceUnsupportedError`
1216
1304
  * against an image whose agent predates the op.
1305
+ *
1306
+ * `{ onlyIfIdle: true }` is the read-only variant: see
1307
+ * {@link KubernetesQuiesceOptions.onlyIfIdle}. It rejects with
1308
+ * `KubernetesQuiesceBusyError` when anything is running.
1217
1309
  */
1218
1310
  quiesce(options?: KubernetesQuiesceOptions): Promise<KubernetesQuiesceReport>
1311
+ /**
1312
+ * A non-owning view of this workspace that talks ONLY to the guest the
1313
+ * caller authenticated, and refuses (never rebinds) if it changed.
1314
+ *
1315
+ * `expected` names the Sandbox, the pod and the agent process
1316
+ * ({@link KubernetesPinnedGuestIdentity}); read them off `identity` after
1317
+ * a call the guest answered. A missing or empty field throws
1318
+ * `KubernetesPinIncompleteError` here, synchronously.
1319
+ *
1320
+ * Every call on the view first compares `identity` with `expected` and
1321
+ * refuses with `KubernetesGuestPinRefusedError` (`identity-changed`)
1322
+ * before dialing; then it sends the request inside the guest agent's
1323
+ * `pinned` envelope, which the agent compares with its own boot id BEFORE
1324
+ * dispatch, so a guest that is not the pinned one runs nothing
1325
+ * (`guest-replaced`, carrying the id of the agent that refused). A guest
1326
+ * agent that does not advertise `pinned-guest` refuses the call
1327
+ * (`guest-unsupported`) instead of serving it unpinned. A fenced agent
1328
+ * that IS the pinned one answers `KubernetesAgentRetiringError`, with its
1329
+ * id on `guestBootId`.
1330
+ *
1331
+ * The view owns nothing: it has no `destroy`, `suspend` or `resume`, never
1332
+ * retires the pod, and a later `suspend()` of this workspace makes its
1333
+ * calls refuse. It reads its pinned values once; mutating `expected`
1334
+ * afterwards changes nothing.
1335
+ */
1336
+ pinGuest(expected: KubernetesPinnedGuestIdentity): KubernetesPinnedGuest
1219
1337
  /**
1220
1338
  * Put everything this workspace has written onto its disk, now.
1221
1339
  *
@@ -1734,6 +1852,8 @@ export function assertAdoptedWorkspaceMatchesConfig(
1734
1852
  expected: {
1735
1853
  readonly sandboxTemplateName: string
1736
1854
  readonly profile?: EgressProfileLabel
1855
+ /** The per-sandbox selector label the configuration stamps, key and value. */
1856
+ readonly perSandboxLabel?: EgressProfileLabel
1737
1857
  readonly runtimeClassName?: string
1738
1858
  },
1739
1859
  ): void {
@@ -1760,6 +1880,19 @@ export function assertAdoptedWorkspaceMatchesConfig(
1760
1880
  )
1761
1881
  }
1762
1882
  }
1883
+ const perSandboxLabel = expected.perSandboxLabel
1884
+ if (perSandboxLabel !== undefined) {
1885
+ const carried = podTemplate?.metadata?.labels?.[perSandboxLabel.key]
1886
+ if (carried !== perSandboxLabel.value) {
1887
+ throw new KubernetesWorkspaceMismatchError(
1888
+ sandboxName,
1889
+ 'perSandboxLabel',
1890
+ `${perSandboxLabel.key}=${perSandboxLabel.value}`,
1891
+ carried === undefined ? undefined : `${perSandboxLabel.key}=${carried}`,
1892
+ `kubernetes: Sandbox ${sandboxName} in namespace ${namespace} already exists, and its spec.podTemplate carries ${perSandboxLabel.key}: ${carried === undefined ? '(absent)' : JSON.stringify(carried)} rather than ${JSON.stringify(perSandboxLabel.value)}. config.egress.perSandbox is configured, and every per-sandbox policy selects a pod by exactly that label — a workspace created before the option was set carries none, so a policy written for it would select nothing while setNetworkPolicy reported the grant applied. A standing workspace's pod labels are not rewritten underneath it by an ordinary adopt, so the mismatch is named instead. Reopen it with refreshPodTemplate: true, which rewrites spec.podTemplate — the label included — on the one Suspended → Running transition, or unset config.egress.perSandbox, or delete the Sandbox — which takes its disk with it — and create it again.`,
1893
+ )
1894
+ }
1895
+ }
1763
1896
  if (expected.runtimeClassName === undefined) return
1764
1897
  const declared = podTemplate?.spec?.runtimeClassName
1765
1898
  const actual = typeof declared === 'string' ? declared : undefined
@@ -1824,17 +1957,15 @@ export async function createKubernetesWorkspace(
1824
1957
  options: KubernetesWorkspaceOptions,
1825
1958
  ): Promise<KubernetesWorkspace> {
1826
1959
  options.signal?.throwIfAborted()
1827
- // `config.egress.perSandbox` is the one part of `config.egress` this path
1828
- // cannot honour at all, and it is refused FIRST — before readiness, before
1829
- // the client, before any request. It configures `setNetworkPolicy`, which
1830
- // a workspace handle never carries: nothing in the create path below
1831
- // composes a per-sandbox pod label or tracks an owner uid for one, and
1832
- // the option exists to make the method present on a TASK handle. Accepting
1833
- // it here and omitting the method is the silent downgrade every other
1834
- // refusal in `egress-policy.ts` exists to prevent, so it is refused by
1835
- // name rather than validated: `assertPerSandboxEgressIsUsable` would
1836
- // check a config this path serves no purpose for and report it as usable.
1837
- assertWorkspaceCarriesNoPerSandboxEgress(config.egress)
1960
+ // `config.egress.perSandbox` is honoured here exactly as on a task handle:
1961
+ // the workspace's pod template carries the per-sandbox selector label, the
1962
+ // Sandbox owns every policy written for it, and the handle serves
1963
+ // `setNetworkPolicy`. A value the backend can never honour — an engine
1964
+ // with no hostname concept, an unnamed fence, a selector key the API
1965
+ // server would refuse — is decidable from config alone and is refused
1966
+ // first, before readiness, before the client, before any request. With
1967
+ // `perSandbox` unset this is a no-op and nothing below changes.
1968
+ assertPerSandboxEgressIsUsable(config.egress)
1838
1969
  const readiness = resolveKubernetesReadiness(config)
1839
1970
  const namespace = config.namespace
1840
1971
  const name = workspaceSandboxName(options.workspaceId)
@@ -1889,6 +2020,19 @@ export async function createKubernetesWorkspace(
1889
2020
  )
1890
2021
  await egressBoundary?.verifyNamedObject(options.signal)
1891
2022
  }
2023
+ // Per-sandbox egress: the fence that bounds what this host may write is
2024
+ // proved BEFORE the POST and before any adopt patch, so a workspace is
2025
+ // never opened, woken or created under a configuration whose fence does
2026
+ // not enforce. `undefined` — and not one extra request — without it.
2027
+ const perSandboxEgress =
2028
+ config.egress?.perSandbox !== undefined
2029
+ ? { ...config.egress, perSandbox: config.egress.perSandbox }
2030
+ : undefined
2031
+ const openFence =
2032
+ perSandboxEgress !== undefined
2033
+ ? buildAdmissionFence(client, perSandboxEgress.perSandbox, namespace)
2034
+ : undefined
2035
+ await openFence?.assertApplied(options.signal)
1892
2036
 
1893
2037
  // Read and validate BEFORE anything is created, so a template that cannot
1894
2038
  // carry a workspace fails with nothing to clean up.
@@ -1921,7 +2065,8 @@ export async function createKubernetesWorkspace(
1921
2065
  // workspace onto a new profile is `refreshPodTemplate: true`, which
1922
2066
  // rewrites `/spec/podTemplate` with exactly these labels.
1923
2067
  const profile = egressProfileLabel(config.egress)
1924
- const profilePodLabels = composeAdditionalPodLabels(config.egress)
2068
+ const perSandboxLabels = perSandboxWorkspaceLabels(config.egress, name)
2069
+ const profilePodLabels = composeAdditionalPodLabels(config.egress, perSandboxLabels)
1925
2070
  const workspacePodLabels = sandboxPodLabels(template, templateName, profilePodLabels)
1926
2071
  const workspaceSubject = `to open workspace ${options.workspaceId} as Sandbox ${name} in namespace ${namespace}`
1927
2072
  const verifyIngress = buildIngressVerifier(client, config)
@@ -1935,7 +2080,14 @@ export async function createKubernetesWorkspace(
1935
2080
  // reason the ingress check is, and on the adopt path before any resume
1936
2081
  // patch — a workspace whose egress stopped being bounded is refused asleep
1937
2082
  // rather than woken up to be refused.
1938
- await egressBoundary?.verifyUnion(workspacePodLabels, workspaceSubject, options.signal)
2083
+ // Without the per-sandbox label when one is configured: the only policy
2084
+ // that selects a pod BY that key and value is this workspace's own, which
2085
+ // a standing workspace already has and which the post-write union check
2086
+ // (against the full labels) judged when it was written. Judged here
2087
+ // against the BASELINE it would read as a widening and refuse to reopen
2088
+ // every workspace that holds a grant.
2089
+ const unionLabels = withoutPerSandboxLabel(workspacePodLabels, perSandboxLabels)
2090
+ await egressBoundary?.verifyUnion(unionLabels, workspaceSubject, options.signal)
1939
2091
 
1940
2092
  // The pod template this call would write, and its revision — built once,
1941
2093
  // from the same overlays `buildSandboxBody` applies below, because the
@@ -1952,8 +2104,9 @@ export async function createKubernetesWorkspace(
1952
2104
  )
1953
2105
 
1954
2106
  let adopted: AdoptedWorkspace | undefined
2107
+ let createdUid: string | undefined
1955
2108
  try {
1956
- await client.request(
2109
+ const created = await client.request<SandboxResource>(
1957
2110
  'POST',
1958
2111
  sandboxCollectionPath(namespace),
1959
2112
  // No shutdownTime: a workspace carries no expiry — see the module
@@ -1981,6 +2134,7 @@ export async function createKubernetesWorkspace(
1981
2134
  }),
1982
2135
  options.signal,
1983
2136
  )
2137
+ createdUid = created?.metadata?.uid
1984
2138
  } catch (err) {
1985
2139
  if (!(err instanceof KubernetesConflictError)) throw err
1986
2140
  adopted = await adoptExistingWorkspace(
@@ -1994,6 +2148,14 @@ export async function createKubernetesWorkspace(
1994
2148
  // were built from, so the object is checked against exactly
1995
2149
  // the label this call would have stamped.
1996
2150
  ...(profile !== undefined ? { profile } : {}),
2151
+ ...(perSandboxLabels !== undefined
2152
+ ? {
2153
+ perSandboxLabel: {
2154
+ key: Object.keys(perSandboxLabels)[0] as string,
2155
+ value: name,
2156
+ },
2157
+ }
2158
+ : {}),
1997
2159
  ...(config.runtimeClassName !== undefined
1998
2160
  ? { runtimeClassName: config.runtimeClassName }
1999
2161
  : {}),
@@ -2001,8 +2163,43 @@ export async function createKubernetesWorkspace(
2001
2163
  epoch,
2002
2164
  options.refreshPodTemplate === true ? refresh : undefined,
2003
2165
  options.signal,
2166
+ perSandboxEgress !== undefined && openFence !== undefined
2167
+ ? async (uid) => {
2168
+ await reverifyPerSandboxEgress({
2169
+ client,
2170
+ namespace,
2171
+ sandboxName: name,
2172
+ ownerUid: uid,
2173
+ egress: perSandboxEgress,
2174
+ fence: openFence,
2175
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
2176
+ })
2177
+ }
2178
+ : undefined,
2004
2179
  )
2005
2180
  }
2181
+ // A workspace this call just created has no policy of its own yet, but a
2182
+ // same-named one deleted a moment ago may have left one the garbage
2183
+ // collector has not reached: it selects this label value, so it is
2184
+ // dropped now that the new uid is known. A reply with no uid skips it, and
2185
+ // the handle refuses `setNetworkPolicy` for the same reason.
2186
+ if (
2187
+ adopted === undefined &&
2188
+ perSandboxEgress !== undefined &&
2189
+ openFence !== undefined &&
2190
+ typeof createdUid === 'string' &&
2191
+ createdUid !== ''
2192
+ ) {
2193
+ await reverifyPerSandboxEgress({
2194
+ client,
2195
+ namespace,
2196
+ sandboxName: name,
2197
+ ownerUid: createdUid,
2198
+ egress: perSandboxEgress,
2199
+ fence: openFence,
2200
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
2201
+ })
2202
+ }
2006
2203
 
2007
2204
  return await openWorkspaceHandle({
2008
2205
  client,
@@ -2023,6 +2220,15 @@ export async function createKubernetesWorkspace(
2023
2220
  // would patch the egress profile off the very pod the configured
2024
2221
  // policy selects.
2025
2222
  ...(Object.keys(profilePodLabels).length > 0 ? { podLabels: profilePodLabels } : {}),
2223
+ ...(perSandboxEgress !== undefined
2224
+ ? {
2225
+ perSandboxEgress: {
2226
+ egress: perSandboxEgress,
2227
+ boundary: egressBoundary,
2228
+ podLabels: workspacePodLabels,
2229
+ },
2230
+ }
2231
+ : {}),
2026
2232
  // A create wrote the revision it just computed; an adopt reports
2027
2233
  // whatever the standing object carries, which is `undefined` for one
2028
2234
  // created before the annotation existed.
@@ -2086,11 +2292,13 @@ async function adoptExistingWorkspace(
2086
2292
  expected: {
2087
2293
  readonly sandboxTemplateName: string
2088
2294
  readonly profile?: EgressProfileLabel
2295
+ readonly perSandboxLabel?: EgressProfileLabel
2089
2296
  readonly runtimeClassName?: string
2090
2297
  },
2091
2298
  epoch: number | undefined,
2092
2299
  refresh: PodTemplateRefresh | undefined,
2093
2300
  signal?: AbortSignal,
2301
+ reverifyPerSandbox?: (ownerUid: string) => Promise<void>,
2094
2302
  ): Promise<AdoptedWorkspace> {
2095
2303
  const target: WorkspaceWriteTarget = { client, namespace, name, workspaceId }
2096
2304
  const existing = await readSandboxObject(target, signal)
@@ -2120,6 +2328,9 @@ async function adoptExistingWorkspace(
2120
2328
  ? {}
2121
2329
  : {
2122
2330
  ...(expected.profile !== undefined ? { profile: expected.profile } : {}),
2331
+ ...(expected.perSandboxLabel !== undefined
2332
+ ? { perSandboxLabel: expected.perSandboxLabel }
2333
+ : {}),
2123
2334
  ...(expected.runtimeClassName !== undefined
2124
2335
  ? { runtimeClassName: expected.runtimeClassName }
2125
2336
  : {}),
@@ -2131,6 +2342,17 @@ async function adoptExistingWorkspace(
2131
2342
  // superseded opener patches nothing and starts no pod.
2132
2343
  if (epoch !== undefined)
2133
2344
  assertHolderEpochAllows(target, 'createKubernetesWorkspace', epoch, reading)
2345
+ // Per-sandbox egress, before any patch for the reason the refusals above
2346
+ // are: the fence is proved again and every policy this Sandbox does not
2347
+ // own is dropped, so a workspace is never woken under a grant it did not
2348
+ // consent to. See `workspace-per-sandbox.ts` for the rule.
2349
+ if (reverifyPerSandbox !== undefined) {
2350
+ const uid = existing?.metadata?.uid
2351
+ if (typeof uid !== 'string' || uid === '') {
2352
+ throw new KubernetesOwnerUidMissingError(name, namespace)
2353
+ }
2354
+ await reverifyPerSandbox(uid)
2355
+ }
2134
2356
  if (refreshing && refresh !== undefined) {
2135
2357
  // Both BEFORE the patch, and both against the SANDBOX's
2136
2358
  // volumeClaimTemplates rather than the template's: those are
@@ -2337,6 +2559,66 @@ interface WorkspaceWriteTarget {
2337
2559
  readonly namespace: string
2338
2560
  readonly name: string
2339
2561
  readonly workspaceId: string
2562
+ /**
2563
+ * The Sandbox `metadata.uid` every write to this target must land on, when
2564
+ * the caller knows one: a handle's is the uid it bound, the standalone
2565
+ * verbs' is `expectedIdentity.sandboxUid`.
2566
+ *
2567
+ * The workspace NAME is deterministic, so it identifies whatever Sandbox
2568
+ * stands under it now — including a stranger that replaced the one the
2569
+ * caller meant. With a uid here, a patch carries `test /metadata/uid` and
2570
+ * a DELETE carries `preconditions.uid`, so the API server refuses the
2571
+ * write itself rather than this process hoping its last read is current.
2572
+ * Read through a getter by a handle, because the handle learns its uid at
2573
+ * its first bind and not before; absent, a write is conditioned on
2574
+ * nothing about identity, exactly as it always was.
2575
+ */
2576
+ readonly expectedSandboxUid?: string
2577
+ }
2578
+
2579
+ /** The `expectedSandboxUid` a standalone verb's options carry, validated. */
2580
+ function expectedUidOf(options?: KubernetesWorkspaceTransitionOptions): {
2581
+ readonly expectedSandboxUid?: string
2582
+ } {
2583
+ const identity = options?.expectedIdentity
2584
+ if (identity === undefined) return {}
2585
+ if (typeof identity.sandboxUid !== 'string' || identity.sandboxUid === '') {
2586
+ throw new TypeError(
2587
+ 'kubernetes: expectedIdentity.sandboxUid must be a non-empty string — an empty uid would be a fence that fences nothing.',
2588
+ )
2589
+ }
2590
+ return { expectedSandboxUid: identity.sandboxUid }
2591
+ }
2592
+
2593
+ /**
2594
+ * Refuse a write whose target is not the Sandbox the caller meant.
2595
+ *
2596
+ * Decided off the read the write is conditioned on, so the refusal is raised
2597
+ * BEFORE any request that could mutate. The same uid then rides in the write
2598
+ * itself, which is what makes the check hold against a replacement landing
2599
+ * between this read and that write.
2600
+ */
2601
+ function assertSandboxUidStands(
2602
+ target: WorkspaceWriteTarget,
2603
+ standing: string | undefined,
2604
+ options?: ErrorOptions,
2605
+ ): void {
2606
+ const expected = target.expectedSandboxUid
2607
+ if (expected === undefined || standing === expected) return
2608
+ throw new KubernetesWorkspaceReplacedError(
2609
+ target.workspaceId,
2610
+ target.name,
2611
+ expected,
2612
+ standing,
2613
+ options,
2614
+ )
2615
+ }
2616
+
2617
+ /** The `test` clause that pins a JSON patch to the Sandbox the caller meant. */
2618
+ function sandboxUidTests(target: WorkspaceWriteTarget): readonly JsonPatchOperation[] {
2619
+ return target.expectedSandboxUid === undefined
2620
+ ? []
2621
+ : [{ op: 'test', path: '/metadata/uid', value: target.expectedSandboxUid }]
2340
2622
  }
2341
2623
 
2342
2624
  /** One GET of the Sandbox, which is where every condition is read from. */
@@ -2436,17 +2718,24 @@ async function writeOperatingMode(
2436
2718
  gate?: HolderEpochReading,
2437
2719
  ): Promise<void> {
2438
2720
  const path = sandboxPath(target.namespace, target.name)
2439
- if (epoch === undefined) {
2721
+ // Nothing to write is nothing to condition: the stamp-only write exists to
2722
+ // move the epoch, and without one it has no mutation in it at all.
2723
+ if (epoch === undefined && mode === undefined) return
2724
+ if (epoch === undefined && target.expectedSandboxUid === undefined) {
2440
2725
  if (mode === undefined) return
2441
2726
  await target.client.request('PATCH', path, operatingModePatch(mode), signal)
2442
2727
  return
2443
2728
  }
2444
2729
  let reading = gate ?? readHolderEpoch((await readSandboxObject(target, signal))?.metadata)
2445
2730
  for (let attempt = 1; ; attempt += 1) {
2446
- assertHolderEpochAllows(target, operation, epoch, reading)
2731
+ // Identity before epoch: a stranger under this name is not this
2732
+ // caller's workspace, whatever epoch it happens to carry.
2733
+ assertSandboxUidStands(target, reading.sandboxUid)
2734
+ if (epoch !== undefined) assertHolderEpochAllows(target, operation, epoch, reading)
2447
2735
  const patch = buildHolderEpochPatch({
2448
2736
  reading,
2449
- epoch,
2737
+ ...(epoch !== undefined ? { epoch } : {}),
2738
+ tests: sandboxUidTests(target),
2450
2739
  ...(mode !== undefined
2451
2740
  ? { operatingMode: mode, operatingModeChangedAt: new Date().toISOString() }
2452
2741
  : {}),
@@ -2458,6 +2747,10 @@ async function writeOperatingMode(
2458
2747
  if (!(err instanceof KubernetesPatchNotAppliedError)) throw err
2459
2748
  if (attempt >= HOLDER_EPOCH_WRITE_ATTEMPTS) throw err
2460
2749
  const next = readHolderEpoch((await readSandboxObject(target, signal))?.metadata)
2750
+ // A replacement that landed between the read and the write is what
2751
+ // the uid `test` refused. It is never retried against the new
2752
+ // owner: this is the refusal, not a race to win.
2753
+ assertSandboxUidStands(target, next.sandboxUid)
2461
2754
  const unmoved =
2462
2755
  next.annotation === reading.annotation &&
2463
2756
  next.resourceVersion === reading.resourceVersion &&
@@ -2477,7 +2770,7 @@ async function writeOperatingMode(
2477
2770
  * but next to the object it describes is how `templateCurrent` starts
2478
2771
  * reporting drift that does not exist.
2479
2772
  */
2480
- interface PodTemplateRefresh {
2773
+ export interface PodTemplateRefresh {
2481
2774
  readonly podTemplate: SandboxPodTemplate
2482
2775
  readonly hash: string
2483
2776
  /**
@@ -2501,7 +2794,7 @@ interface PodTemplateRefresh {
2501
2794
  * every create POSTs, so `templateCurrent` would report drift that no refresh
2502
2795
  * could clear.
2503
2796
  */
2504
- function buildPodTemplateRefresh(
2797
+ export function buildPodTemplateRefresh(
2505
2798
  template: SandboxTemplateCopy,
2506
2799
  sandboxTemplateName: string,
2507
2800
  runtimeClassName?: string,
@@ -2576,11 +2869,15 @@ async function writeRefreshedPodTemplate(
2576
2869
  const path = sandboxPath(target.namespace, target.name)
2577
2870
  let reading = gate
2578
2871
  for (let attempt = 1; ; attempt += 1) {
2872
+ assertSandboxUidStands(target, reading.sandboxUid)
2579
2873
  if (epoch !== undefined) assertHolderEpochAllows(target, operation, epoch, reading)
2580
2874
  const patch = buildHolderEpochPatch({
2581
2875
  reading,
2582
2876
  ...(epoch !== undefined ? { epoch } : {}),
2583
- tests: [{ op: 'test', path: '/spec/operatingMode', value: 'Suspended' }],
2877
+ tests: [
2878
+ ...sandboxUidTests(target),
2879
+ { op: 'test', path: '/spec/operatingMode', value: 'Suspended' },
2880
+ ],
2584
2881
  annotations: { [POD_TEMPLATE_HASH_ANNOTATION_KEY]: refresh.hash },
2585
2882
  podTemplate: refresh.podTemplate,
2586
2883
  operatingMode: 'Running',
@@ -2605,6 +2902,7 @@ async function writeRefreshedPodTemplate(
2605
2902
  // — the patch did not apply and nothing changed.
2606
2903
  if (sandbox === undefined) throw err
2607
2904
  const next = readHolderEpoch(sandbox.metadata)
2905
+ assertSandboxUidStands(target, next.sandboxUid)
2608
2906
  if (operatingModeOf(sandbox) !== 'Suspended') {
2609
2907
  // Somebody resumed it first. If they also took the workspace
2610
2908
  // over, this caller hears THAT rather than being handed a pod
@@ -2646,7 +2944,8 @@ async function deleteSandboxObject(
2646
2944
  gate?: HolderEpochReading,
2647
2945
  ): Promise<void> {
2648
2946
  const path = sandboxPath(target.namespace, target.name)
2649
- if (epoch === undefined) {
2947
+ const expectedUid = target.expectedSandboxUid
2948
+ if (epoch === undefined && expectedUid === undefined) {
2650
2949
  try {
2651
2950
  await target.client.request('DELETE', path, undefined, signal)
2652
2951
  } catch (err) {
@@ -2656,7 +2955,9 @@ async function deleteSandboxObject(
2656
2955
  }
2657
2956
  let reading = gate
2658
2957
  for (let attempt = 1; ; attempt += 1) {
2659
- if (reading === undefined) {
2958
+ // The epoch needs the object's current version for its precondition;
2959
+ // the uid alone does not, because it is the precondition.
2960
+ if (reading === undefined && epoch !== undefined) {
2660
2961
  let sandbox: SandboxResource | undefined
2661
2962
  try {
2662
2963
  sandbox = await readSandboxObject(target, signal)
@@ -2666,8 +2967,11 @@ async function deleteSandboxObject(
2666
2967
  }
2667
2968
  reading = readHolderEpoch(sandbox?.metadata)
2668
2969
  }
2669
- assertHolderEpochAllows(target, operation, epoch, reading)
2670
- if (reading.resourceVersion === undefined) {
2970
+ if (reading !== undefined) {
2971
+ assertSandboxUidStands(target, reading.sandboxUid)
2972
+ if (epoch !== undefined) assertHolderEpochAllows(target, operation, epoch, reading)
2973
+ }
2974
+ if (epoch !== undefined && reading?.resourceVersion === undefined) {
2671
2975
  // Unreachable against a real API server, which sets it on every
2672
2976
  // object it serves — and a bodyless DELETE here would be an
2673
2977
  // UNFENCED delete of somebody's disk, which is the one thing this
@@ -2683,7 +2987,12 @@ async function deleteSandboxObject(
2683
2987
  {
2684
2988
  apiVersion: 'v1',
2685
2989
  kind: 'DeleteOptions',
2686
- preconditions: { resourceVersion: reading.resourceVersion },
2990
+ preconditions: {
2991
+ ...(reading?.resourceVersion !== undefined
2992
+ ? { resourceVersion: reading.resourceVersion }
2993
+ : {}),
2994
+ ...(expectedUid !== undefined ? { uid: expectedUid } : {}),
2995
+ },
2687
2996
  },
2688
2997
  signal,
2689
2998
  )
@@ -2691,8 +3000,21 @@ async function deleteSandboxObject(
2691
3000
  } catch (err) {
2692
3001
  if (err instanceof KubernetesAlreadyGoneError) return
2693
3002
  if (!(err instanceof KubernetesConflictError)) throw err
3003
+ // A 409 is either the version moving (epoch path: re-read and try
3004
+ // again) or the uid precondition refusing a stranger. Look, and
3005
+ // let the uid decide: a different owner is the refusal and is
3006
+ // never retried against.
3007
+ let standing: SandboxResource | undefined
3008
+ try {
3009
+ standing = await readSandboxObject(target, signal)
3010
+ } catch (readErr) {
3011
+ if (readErr instanceof KubernetesAlreadyGoneError) return
3012
+ throw readErr
3013
+ }
3014
+ assertSandboxUidStands(target, standing?.metadata?.uid, { cause: err })
3015
+ if (epoch === undefined) throw err
2694
3016
  if (attempt >= HOLDER_EPOCH_WRITE_ATTEMPTS) throw err
2695
- reading = undefined
3017
+ reading = readHolderEpoch(standing?.metadata)
2696
3018
  }
2697
3019
  }
2698
3020
  }
@@ -3017,7 +3339,7 @@ export async function deleteKubernetesWorkspace(
3017
3339
  // workspace and calling this would otherwise take the new holder's disk,
3018
3340
  // and nothing brings a disk back.
3019
3341
  await deleteSandboxObject(
3020
- { client, namespace, name, workspaceId },
3342
+ { client, namespace, name, workspaceId, ...expectedUidOf(options) },
3021
3343
  'deleteKubernetesWorkspace',
3022
3344
  epoch,
3023
3345
  options?.signal,
@@ -3065,7 +3387,7 @@ export async function suspendKubernetesWorkspace(
3065
3387
  const readiness = resolveKubernetesReadiness(config)
3066
3388
  const client = createKubernetesClient(clientAccess(config), clientOptions(config))
3067
3389
  await writeOperatingMode(
3068
- { client, namespace, name, workspaceId },
3390
+ { client, namespace, name, workspaceId, ...expectedUidOf(options) },
3069
3391
  'suspendKubernetesWorkspace',
3070
3392
  'Suspended',
3071
3393
  epoch,
@@ -3115,6 +3437,19 @@ interface WorkspaceHandleOptions {
3115
3437
  * with nothing on that path to notice.
3116
3438
  */
3117
3439
  readonly podLabels?: Readonly<Record<string, string>>
3440
+ /**
3441
+ * Present exactly when `config.egress.perSandbox` is configured: what the
3442
+ * handle needs to serve `setNetworkPolicy` and to re-verify on a resume.
3443
+ * `podLabels` is the FULL label set the pod carries, per-sandbox label
3444
+ * included, which is what the post-write union check selects with.
3445
+ */
3446
+ readonly perSandboxEgress?: {
3447
+ readonly egress: KubernetesEgressConfig & {
3448
+ readonly perSandbox: KubernetesPerSandboxEgressConfig
3449
+ }
3450
+ readonly boundary: KubernetesEgressBoundary | undefined
3451
+ readonly podLabels: Readonly<Record<string, string>>
3452
+ }
3118
3453
  /** The revision the bound object carries — see `templateRevision`. */
3119
3454
  readonly templateRevision?: string
3120
3455
  /** The revision the template read at open would produce — see `templateCurrent`. */
@@ -3218,7 +3553,18 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
3218
3553
  const { client, namespace, name, workspaceId, readiness } = options
3219
3554
  const id = name as SandboxId
3220
3555
  /** What every fenced write addresses, and what a refusal names. */
3221
- const target: WorkspaceWriteTarget = { client, namespace, name, workspaceId }
3556
+ const target: WorkspaceWriteTarget = {
3557
+ client,
3558
+ namespace,
3559
+ name,
3560
+ workspaceId,
3561
+ // A getter, because the uid is learned at the first bind and every
3562
+ // write after it must carry it. Before that the handle has no uid to
3563
+ // pin, which is the only window it writes unpinned.
3564
+ get expectedSandboxUid() {
3565
+ return sandboxUid
3566
+ },
3567
+ }
3222
3568
  /**
3223
3569
  * The epoch this handle writes under when a call passes none.
3224
3570
  *
@@ -4479,6 +4825,88 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
4479
4825
  state = 'suspended'
4480
4826
  }
4481
4827
 
4828
+ /**
4829
+ * Per-sandbox egress on this handle — present exactly when
4830
+ * `config.egress.perSandbox` is configured, which is what makes
4831
+ * `setNetworkPolicy` PRESENT here and absent otherwise (presence follows
4832
+ * configuration, never a probe).
4833
+ *
4834
+ * The fence is replaced, not reused, on every resume: its memo is "proved
4835
+ * once", and a resume is precisely the moment that is no longer enough.
4836
+ * The setter delegates to whichever one is current. Setters are cached per
4837
+ * Sandbox uid, because a policy is NAMED after the uid and its write queue
4838
+ * has to outlive one call.
4839
+ */
4840
+ const perSandboxEgress = options.perSandboxEgress
4841
+ let currentFence: PerSandboxPolicyFence | undefined =
4842
+ perSandboxEgress !== undefined
4843
+ ? buildAdmissionFence(client, perSandboxEgress.egress.perSandbox, namespace)
4844
+ : undefined
4845
+ const delegatingFence: PerSandboxPolicyFence = {
4846
+ assertApplied: async (signal) => {
4847
+ await currentFence?.assertApplied(signal)
4848
+ },
4849
+ }
4850
+ const policySetters = new Map<string, (policy: SandboxNetworkPolicy) => Promise<void>>()
4851
+ /**
4852
+ * The Sandbox that owns every policy this handle writes, as the cluster
4853
+ * reports it NOW. A different uid than the one this handle bound is a
4854
+ * workspace deleted and created again under the same name: refused, the
4855
+ * way every other call on such a handle is, rather than writing a grant
4856
+ * the new disk's owner never consented to.
4857
+ */
4858
+ const resolvePolicyOwner = async (signal?: AbortSignal): Promise<string> => {
4859
+ const standing = await readSandboxObject(target, signal)
4860
+ const uid = standing?.metadata?.uid
4861
+ if (standing === undefined) {
4862
+ throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, undefined)
4863
+ }
4864
+ if (typeof uid !== 'string' || uid === '') {
4865
+ throw new KubernetesOwnerUidMissingError(name, namespace)
4866
+ }
4867
+ if (sandboxUid !== undefined && uid !== sandboxUid) {
4868
+ throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, uid)
4869
+ }
4870
+ return uid
4871
+ }
4872
+ const setNetworkPolicy =
4873
+ perSandboxEgress === undefined
4874
+ ? undefined
4875
+ : async (policy: SandboxNetworkPolicy): Promise<void> => {
4876
+ // Serialised with the transitions: a write racing a resume would
4877
+ // run under the fence the resume is about to replace, and one
4878
+ // racing a destroy would name an owner that is going away.
4879
+ await serialise(async () => {
4880
+ if (state === 'deleted')
4881
+ throw new KubernetesSandboxDestroyedError('setNetworkPolicy', name)
4882
+ const uid = await resolvePolicyOwner()
4883
+ let setter = policySetters.get(uid)
4884
+ if (setter === undefined) {
4885
+ setter = buildPerSandboxPolicySetter({
4886
+ client,
4887
+ fence: delegatingFence,
4888
+ namespace,
4889
+ egress: perSandboxEgress.egress,
4890
+ owner: { kind: 'Sandbox', name, uid },
4891
+ selectorValue: name,
4892
+ ...(perSandboxEgress.boundary !== undefined
4893
+ ? {
4894
+ verifyUnion: (written, signal) =>
4895
+ perSandboxEgress.boundary?.verifyUnionWith(
4896
+ perSandboxEgress.podLabels,
4897
+ `the per-sandbox policy ${written.name} written for Sandbox ${name} in namespace ${namespace}`,
4898
+ written,
4899
+ signal,
4900
+ ) ?? Promise.resolve(),
4901
+ }
4902
+ : {}),
4903
+ })
4904
+ policySetters.set(uid, setter)
4905
+ }
4906
+ await setter(policy)
4907
+ })
4908
+ }
4909
+
4482
4910
  /**
4483
4911
  * Wake the workspace if it is asleep, then bring a session up on it.
4484
4912
  *
@@ -4531,6 +4959,31 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
4531
4959
  // the object says it was built from, including a refresh another
4532
4960
  // process applied while this handle slept.
4533
4961
  templateRevision = readPodTemplateHash(current?.metadata)
4962
+ // Per-sandbox egress, before ANY patch: a fresh fence, then every
4963
+ // policy this Sandbox does not own dropped — see
4964
+ // `workspace-per-sandbox.ts` for the rule. A fence that does not
4965
+ // verify refuses the resume with the workspace still suspended, and
4966
+ // nothing written; a policy this Sandbox owns is kept.
4967
+ if (perSandboxEgress !== undefined && current !== undefined) {
4968
+ const uid = current.metadata?.uid
4969
+ if (typeof uid !== 'string' || uid === '') {
4970
+ throw new KubernetesOwnerUidMissingError(name, namespace)
4971
+ }
4972
+ if (sandboxUid !== undefined && uid !== sandboxUid) {
4973
+ throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, uid)
4974
+ }
4975
+ const fence = buildAdmissionFence(client, perSandboxEgress.egress.perSandbox, namespace)
4976
+ await reverifyPerSandboxEgress({
4977
+ client,
4978
+ namespace,
4979
+ sandboxName: name,
4980
+ ownerUid: uid,
4981
+ egress: perSandboxEgress.egress,
4982
+ fence,
4983
+ ...(signal !== undefined ? { signal } : {}),
4984
+ })
4985
+ currentFence = fence
4986
+ }
4534
4987
  if (woke && refreshPodTemplate) {
4535
4988
  // The template is re-read HERE rather than remembered from the
4536
4989
  // open: the whole point of the option is to pick up an edit made
@@ -5058,11 +5511,157 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
5058
5511
  'quiesce',
5059
5512
  async (handle) =>
5060
5513
  await admittedTransport('quiesce', handle).quiesce(
5061
- quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {},
5514
+ {
5515
+ ...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
5516
+ ...(quiesceOptions?.onlyIfIdle === true ? { idleOnly: true } : {}),
5517
+ },
5062
5518
  quiesceOptions?.signal,
5063
5519
  ),
5064
5520
  )
5065
5521
 
5522
+ /**
5523
+ * The pinned view over this handle — see {@link KubernetesWorkspace.pinGuest}.
5524
+ *
5525
+ * It owns a sibling transport (built by `pinnedTo`) for the live session's
5526
+ * pod and a Sandbox around it, made on first use and reused while the live
5527
+ * session's transport is the one it was built from. The sibling has no
5528
+ * `refreshHandle`, so nothing it does can follow a replaced pod, and the
5529
+ * Sandbox's `release` does nothing and its unconfirmed-cancellation hook
5530
+ * keeps the pod: a view is not allowed to retire what it does not own.
5531
+ */
5532
+ const buildPinnedGuest = (expected: KubernetesPinnedGuestIdentity): KubernetesPinnedGuest => {
5533
+ let built:
5534
+ | {
5535
+ readonly parent: KubernetesAgentTransport
5536
+ readonly transport: KubernetesAgentTransport
5537
+ readonly sandbox: KubernetesSandboxHandle
5538
+ }
5539
+ | undefined
5540
+ const refuse = (
5541
+ reason: ConstructorParameters<typeof KubernetesGuestPinRefusedError>[0],
5542
+ actual: KubernetesWorkspaceIdentity | undefined,
5543
+ field: ConstructorParameters<typeof KubernetesGuestPinRefusedError>[4],
5544
+ fresh?: string,
5545
+ cause?: unknown,
5546
+ ): KubernetesGuestPinRefusedError =>
5547
+ new KubernetesGuestPinRefusedError(
5548
+ reason,
5549
+ workspaceId,
5550
+ expected,
5551
+ actual,
5552
+ field,
5553
+ fresh,
5554
+ cause !== undefined ? { cause } : undefined,
5555
+ )
5556
+ /** Compared before anything is dialed. The guest's own check is the authority; this one is free. */
5557
+ const resolve = (operation: string) => {
5558
+ const actual = identityNow()
5559
+ if (state !== 'running' || session === undefined || actual.podUid === undefined) {
5560
+ throw refuse('identity-changed', actual, 'podUid')
5561
+ }
5562
+ if (actual.sandboxUid !== expected.sandboxUid) {
5563
+ throw refuse('identity-changed', actual, 'sandboxUid')
5564
+ }
5565
+ if (actual.podUid !== expected.podUid) throw refuse('identity-changed', actual, 'podUid')
5566
+ // Not yet heard from is not "changed": the guest compares it itself.
5567
+ if (actual.guestBootId !== undefined && actual.guestBootId !== expected.guestBootId) {
5568
+ throw refuse('identity-changed', actual, 'guestBootId')
5569
+ }
5570
+ for (const [claim, uid] of Object.entries(expected.volumeClaimUids ?? {})) {
5571
+ if (actual.volumeClaimUids[claim] !== uid) {
5572
+ throw refuse('identity-changed', actual, 'volumeClaimUids')
5573
+ }
5574
+ }
5575
+ const parent = admittedTransport(operation, session)
5576
+ if (built === undefined || built.parent !== parent) {
5577
+ const transport = parent.pinnedTo(expected.guestBootId)
5578
+ built = {
5579
+ parent,
5580
+ transport,
5581
+ sandbox: buildKubernetesSandbox({
5582
+ name,
5583
+ rootDir: options.rootDir,
5584
+ transport,
5585
+ release: async () => {},
5586
+ onUnconfirmedCancellation: async () => ({
5587
+ accepted: false,
5588
+ reason: 'pinned-view-keeps-the-pod',
5589
+ }),
5590
+ }),
5591
+ }
5592
+ }
5593
+ return built
5594
+ }
5595
+ const translate = (err: unknown): unknown => {
5596
+ if (err instanceof KubernetesGuestPinRefusedError) return err
5597
+ const replaced = guestMismatchOf(err)
5598
+ if (replaced !== undefined) {
5599
+ return refuse('guest-replaced', undefined, undefined, replaced.guestBootId, err)
5600
+ }
5601
+ if (isUnauthorizedRefusal(err))
5602
+ return refuse('pod-replaced', undefined, undefined, undefined, err)
5603
+ return err
5604
+ }
5605
+ const pinned = async <T>(
5606
+ operation: string,
5607
+ run: (view: {
5608
+ transport: KubernetesAgentTransport
5609
+ sandbox: KubernetesSandboxHandle
5610
+ }) => Promise<T>,
5611
+ signal?: AbortSignal,
5612
+ ): Promise<T> => {
5613
+ const view = resolve(operation)
5614
+ try {
5615
+ if (!(await view.transport.supportsPinnedGuest(signal))) {
5616
+ throw refuse('guest-unsupported', undefined, undefined)
5617
+ }
5618
+ return await run(view)
5619
+ } catch (err) {
5620
+ throw translate(err)
5621
+ }
5622
+ }
5623
+ return Object.freeze({
5624
+ expected,
5625
+ exec: async (command: string, argv?: string[], execOptions?: SandboxExecOptions) =>
5626
+ await pinned('exec', async (view) => await view.sandbox.exec(command, argv, execOptions)),
5627
+ readFile: async (path: string, readOptions?: SandboxReadFileOptions) =>
5628
+ await pinned('readFile', async (view) => await view.sandbox.readFile(path, readOptions)),
5629
+ writeFile: async (path: string, content: string | Buffer) =>
5630
+ await pinned('writeFile', async (view) => await view.sandbox.writeFile(path, content)),
5631
+ listFiles: async (rootPath: string) =>
5632
+ await pinned('listFiles', async (view) => await view.sandbox.listFiles(rootPath)),
5633
+ openTerminal: async (terminalOptions: OpenTerminalOptions) =>
5634
+ await pinned(
5635
+ 'openTerminal',
5636
+ async (view) => await view.sandbox.openTerminal(terminalOptions),
5637
+ ),
5638
+ openTcpConnection: async (connectOptions: SandboxTcpConnectOptions) =>
5639
+ await pinned(
5640
+ 'openTcpConnection',
5641
+ async (view) => await view.sandbox.openTcpConnection(connectOptions),
5642
+ ),
5643
+ quiesce: async (quiesceOptions?: KubernetesQuiesceOptions) => {
5644
+ // Copied before any await, like the workspace's own.
5645
+ if (
5646
+ quiesceOptions?.onlyIfIdle !== undefined &&
5647
+ typeof quiesceOptions.onlyIfIdle !== 'boolean'
5648
+ ) {
5649
+ throw new TypeError('kubernetes: quiesce({ onlyIfIdle }) must be a boolean')
5650
+ }
5651
+ const request = {
5652
+ ...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
5653
+ ...(quiesceOptions?.onlyIfIdle === true ? { idleOnly: true } : {}),
5654
+ }
5655
+ const signal = quiesceOptions?.signal
5656
+ return await pinned(
5657
+ 'quiesce',
5658
+ async (view) => await view.transport.quiesce(request, signal),
5659
+ signal,
5660
+ )
5661
+ },
5662
+ })
5663
+ }
5664
+
5066
5665
  /**
5067
5666
  * Tell the host about a gap a suspend went ahead over.
5068
5667
  *
@@ -5307,6 +5906,9 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
5307
5906
  get identity(): KubernetesWorkspaceIdentity {
5308
5907
  return identityNow()
5309
5908
  },
5909
+ pinGuest(input: KubernetesPinnedGuestIdentity): KubernetesPinnedGuest {
5910
+ return buildPinnedGuest(freezePinnedIdentity(input))
5911
+ },
5310
5912
  onGuestRestart(listener: (event: KubernetesGuestRestart) => void): () => void {
5311
5913
  restartListeners.add(listener)
5312
5914
  return () => {
@@ -5329,6 +5931,7 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
5329
5931
  },
5330
5932
  rootDir: options.rootDir,
5331
5933
  environment,
5934
+ ...(setNetworkPolicy !== undefined ? { setNetworkPolicy } : {}),
5332
5935
 
5333
5936
  async exec(
5334
5937
  command: string,
@@ -5568,7 +6171,25 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
5568
6171
  // racing the suspend that follows it would be a quiesce of a pod the
5569
6172
  // patch has already taken away, and one racing a resume would ask
5570
6173
  // the old pod to stop the new one's processes.
5571
- return await serialise(async () => await quiesceNow(quiesceOptions))
6174
+ //
6175
+ // The options are copied HERE, synchronously, before the queue is
6176
+ // awaited: a caller that mutates its object after calling cannot
6177
+ // change what is sent, in particular cannot turn an idle-only
6178
+ // request into a stopping one.
6179
+ if (
6180
+ quiesceOptions?.onlyIfIdle !== undefined &&
6181
+ typeof quiesceOptions.onlyIfIdle !== 'boolean'
6182
+ ) {
6183
+ throw new TypeError('kubernetes: quiesce({ onlyIfIdle }) must be a boolean')
6184
+ }
6185
+ const request: KubernetesQuiesceOptions = Object.freeze({
6186
+ ...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
6187
+ ...(quiesceOptions?.onlyIfIdle !== undefined
6188
+ ? { onlyIfIdle: quiesceOptions.onlyIfIdle }
6189
+ : {}),
6190
+ ...(quiesceOptions?.signal !== undefined ? { signal: quiesceOptions.signal } : {}),
6191
+ })
6192
+ return await serialise(async () => await quiesceNow(request))
5572
6193
  },
5573
6194
 
5574
6195
  async flush(flushOptions?: KubernetesFlushOptions): Promise<KubernetesFlushReport> {