@namzu/sandbox 26.0.0 → 27.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 +47 -0
  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,13 +159,15 @@
159
159
  */
160
160
  import { OperationDeadline, OperationDeadlineExpired, runFailureCleanup } from '../readiness.js';
161
161
  import { RemoteCancellationUnknownError } from '../remote-execution-controller.js';
162
- import { assertEgressPolicyIsEnforceable, assertEgressProfileIsUsable, assertWorkspaceCarriesNoPerSandboxEgress, composeAdditionalPodLabels, egressProfileLabel, } from './egress-policy.js';
163
- import { KubernetesWorkspaceGuestGoneError, KubernetesWorkspaceReplacedError, } from './identity.js';
162
+ import { assertEgressPolicyIsEnforceable, assertEgressProfileIsUsable, assertPerSandboxEgressIsUsable, composeAdditionalPodLabels, egressProfileLabel, } from './egress-policy.js';
163
+ import { KubernetesGuestPinRefusedError, KubernetesWorkspaceGuestGoneError, KubernetesWorkspaceReplacedError, freezePinnedIdentity, } from './identity.js';
164
164
  import { DEFAULT_AGENT_PORT, ReadinessPollTimeout, bindingFromSandbox, buildEgressBoundary, buildIngressVerifier, buildSandboxBody, clientAccess, clientOptions, pollForBinding, probeSandboxPrivileges, readBoundPod, readSandboxTemplate, resolveAgentAddress, resolveKubernetesReadiness, resolveProbeTimeoutMs, resolveStreamHeartbeatMs, sandboxPodLabels, sandboxPodTemplate, } from './index.js';
165
165
  import { KubernetesAlreadyGoneError, KubernetesConflictError, KubernetesPatchNotAppliedError, createKubernetesClient, } from './k8s-client.js';
166
166
  import { HOLDER_EPOCH_ANNOTATION_KEY, OPERATING_MODE_CHANGED_AT_ANNOTATION_KEY, POD_TEMPLATE_HASH_ANNOTATION_KEY, SANDBOX_TEMPLATE_LABEL_KEY, buildHolderEpochPatch, holderEpochAllows, isPodStopped, persistentVolumeClaimPath, podPath, podTemplateHash, readHolderEpoch, readPodTemplateHash, sandboxCollectionPath, sandboxPath, } from './objects.js';
167
+ import { KubernetesOwnerUidMissingError, buildAdmissionFence, buildPerSandboxPolicySetter, } from './per-sandbox-policy.js';
167
168
  import { KubernetesSandboxDestroyedError, buildKubernetesSandbox, } from './sandbox.js';
168
- import { KubernetesAgentTransport, KubernetesFlushUnconfirmedError, KubernetesFlushUnreachableError, KubernetesFlushUnsupportedError, KubernetesQuiesceUnconfirmedError, KubernetesQuiesceUnsupportedError, flushUnsupportedError, guestWhenReserved, } from './transport.js';
169
+ import { KubernetesAgentTransport, KubernetesFlushUnconfirmedError, KubernetesFlushUnreachableError, KubernetesFlushUnsupportedError, KubernetesQuiesceUnconfirmedError, KubernetesQuiesceUnsupportedError, flushUnsupportedError, guestMismatchOf, guestWhenReserved, isUnauthorizedRefusal, } from './transport.js';
170
+ import { perSandboxWorkspaceLabels, reverifyPerSandboxEgress, withoutPerSandboxLabel, } from './workspace-per-sandbox.js';
169
171
  /**
170
172
  * Thrown when a workspace's `SandboxTemplate` does not describe a block disk
171
173
  * this backend is willing to build a workspace on.
@@ -368,7 +370,7 @@ export class KubernetesWorkspacePreconditionError extends Error {
368
370
  * as a decimal string, and an integer because a fractional epoch would
369
371
  * round-trip through the annotation as something other than what was passed.
370
372
  */
371
- function assertHolderEpoch(epoch, where) {
373
+ export function assertHolderEpoch(epoch, where) {
372
374
  if (epoch === undefined)
373
375
  return undefined;
374
376
  if (!Number.isSafeInteger(epoch) || epoch < 0) {
@@ -611,6 +613,13 @@ export function assertAdoptedWorkspaceMatchesConfig(sandboxName, namespace, podT
611
613
  throw new KubernetesWorkspaceMismatchError(sandboxName, 'egressProfile', `${profile.key}=${profile.value}`, carried === undefined ? undefined : `${profile.key}=${carried}`, `kubernetes: Sandbox ${sandboxName} in namespace ${namespace} already exists, and its spec.podTemplate carries ${profile.key}: ${carried === undefined ? '(absent)' : JSON.stringify(carried)} rather than the configured egress profile ${JSON.stringify(profile.value)}. The translated policy's podSelector matches that label as well as the template one, so this object's pod is not selected by the policy this call just verified — and since each profile needs its own policy object, a pod carrying no profile label at all is selected by none of them, while create() would have reported the boundary verified. A standing workspace's pod labels are not rewritten underneath it by an ordinary adopt, so the mismatch is named instead. Three ways out, and only the last one costs the disk: reopen it with refreshPodTemplate: true, which rewrites spec.podTemplate — profile label and all — on the one Suspended → Running transition and is the supported way to move a workspace between profiles; or point config.egress.profile at ${carried === undefined ? 'the profile this object was built under (none was)' : JSON.stringify(carried)}; or delete the Sandbox — which takes its disk with it — and create it again under ${JSON.stringify(profile.value)}.`);
612
614
  }
613
615
  }
616
+ const perSandboxLabel = expected.perSandboxLabel;
617
+ if (perSandboxLabel !== undefined) {
618
+ const carried = podTemplate?.metadata?.labels?.[perSandboxLabel.key];
619
+ if (carried !== perSandboxLabel.value) {
620
+ throw new KubernetesWorkspaceMismatchError(sandboxName, 'perSandboxLabel', `${perSandboxLabel.key}=${perSandboxLabel.value}`, carried === undefined ? undefined : `${perSandboxLabel.key}=${carried}`, `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.`);
621
+ }
622
+ }
614
623
  if (expected.runtimeClassName === undefined)
615
624
  return;
616
625
  const declared = podTemplate?.spec?.runtimeClassName;
@@ -644,17 +653,15 @@ export function assertAdoptedWorkspaceMatchesConfig(sandboxName, namespace, podT
644
653
  */
645
654
  export async function createKubernetesWorkspace(config, options) {
646
655
  options.signal?.throwIfAborted();
647
- // `config.egress.perSandbox` is the one part of `config.egress` this path
648
- // cannot honour at all, and it is refused FIRST — before readiness, before
649
- // the client, before any request. It configures `setNetworkPolicy`, which
650
- // a workspace handle never carries: nothing in the create path below
651
- // composes a per-sandbox pod label or tracks an owner uid for one, and
652
- // the option exists to make the method present on a TASK handle. Accepting
653
- // it here and omitting the method is the silent downgrade every other
654
- // refusal in `egress-policy.ts` exists to prevent, so it is refused by
655
- // name rather than validated: `assertPerSandboxEgressIsUsable` would
656
- // check a config this path serves no purpose for and report it as usable.
657
- assertWorkspaceCarriesNoPerSandboxEgress(config.egress);
656
+ // `config.egress.perSandbox` is honoured here exactly as on a task handle:
657
+ // the workspace's pod template carries the per-sandbox selector label, the
658
+ // Sandbox owns every policy written for it, and the handle serves
659
+ // `setNetworkPolicy`. A value the backend can never honour — an engine
660
+ // with no hostname concept, an unnamed fence, a selector key the API
661
+ // server would refuse — is decidable from config alone and is refused
662
+ // first, before readiness, before the client, before any request. With
663
+ // `perSandbox` unset this is a no-op and nothing below changes.
664
+ assertPerSandboxEgressIsUsable(config.egress);
658
665
  const readiness = resolveKubernetesReadiness(config);
659
666
  const namespace = config.namespace;
660
667
  const name = workspaceSandboxName(options.workspaceId);
@@ -704,6 +711,17 @@ export async function createKubernetesWorkspace(config, options) {
704
711
  assertEgressPolicyIsEnforceable(config.egress.policy, config.egress.engine ?? 'core', config.egress.ciliumNarrowing);
705
712
  await egressBoundary?.verifyNamedObject(options.signal);
706
713
  }
714
+ // Per-sandbox egress: the fence that bounds what this host may write is
715
+ // proved BEFORE the POST and before any adopt patch, so a workspace is
716
+ // never opened, woken or created under a configuration whose fence does
717
+ // not enforce. `undefined` — and not one extra request — without it.
718
+ const perSandboxEgress = config.egress?.perSandbox !== undefined
719
+ ? { ...config.egress, perSandbox: config.egress.perSandbox }
720
+ : undefined;
721
+ const openFence = perSandboxEgress !== undefined
722
+ ? buildAdmissionFence(client, perSandboxEgress.perSandbox, namespace)
723
+ : undefined;
724
+ await openFence?.assertApplied(options.signal);
707
725
  // Read and validate BEFORE anything is created, so a template that cannot
708
726
  // carry a workspace fails with nothing to clean up.
709
727
  const template = await readSandboxTemplate(client, namespace, templateName, options.signal);
@@ -730,7 +748,8 @@ export async function createKubernetesWorkspace(config, options) {
730
748
  // workspace onto a new profile is `refreshPodTemplate: true`, which
731
749
  // rewrites `/spec/podTemplate` with exactly these labels.
732
750
  const profile = egressProfileLabel(config.egress);
733
- const profilePodLabels = composeAdditionalPodLabels(config.egress);
751
+ const perSandboxLabels = perSandboxWorkspaceLabels(config.egress, name);
752
+ const profilePodLabels = composeAdditionalPodLabels(config.egress, perSandboxLabels);
734
753
  const workspacePodLabels = sandboxPodLabels(template, templateName, profilePodLabels);
735
754
  const workspaceSubject = `to open workspace ${options.workspaceId} as Sandbox ${name} in namespace ${namespace}`;
736
755
  const verifyIngress = buildIngressVerifier(client, config);
@@ -744,7 +763,14 @@ export async function createKubernetesWorkspace(config, options) {
744
763
  // reason the ingress check is, and on the adopt path before any resume
745
764
  // patch — a workspace whose egress stopped being bounded is refused asleep
746
765
  // rather than woken up to be refused.
747
- await egressBoundary?.verifyUnion(workspacePodLabels, workspaceSubject, options.signal);
766
+ // Without the per-sandbox label when one is configured: the only policy
767
+ // that selects a pod BY that key and value is this workspace's own, which
768
+ // a standing workspace already has and which the post-write union check
769
+ // (against the full labels) judged when it was written. Judged here
770
+ // against the BASELINE it would read as a widening and refuse to reopen
771
+ // every workspace that holds a grant.
772
+ const unionLabels = withoutPerSandboxLabel(workspacePodLabels, perSandboxLabels);
773
+ await egressBoundary?.verifyUnion(unionLabels, workspaceSubject, options.signal);
748
774
  // The pod template this call would write, and its revision — built once,
749
775
  // from the same overlays `buildSandboxBody` applies below, because the
750
776
  // hash has to be taken over exactly the object that lands on the cluster.
@@ -754,8 +780,9 @@ export async function createKubernetesWorkspace(config, options) {
754
780
  // by the POST would be taken over a template the POST never wrote.
755
781
  const refresh = buildPodTemplateRefresh(template, templateName, config.runtimeClassName, profilePodLabels);
756
782
  let adopted;
783
+ let createdUid;
757
784
  try {
758
- await client.request('POST', sandboxCollectionPath(namespace),
785
+ const created = await client.request('POST', sandboxCollectionPath(namespace),
759
786
  // No shutdownTime: a workspace carries no expiry — see the module
760
787
  // comment.
761
788
  buildSandboxBody({
@@ -779,6 +806,7 @@ export async function createKubernetesWorkspace(config, options) {
779
806
  ? { runtimeClassName: config.runtimeClassName }
780
807
  : {}),
781
808
  }), options.signal);
809
+ createdUid = created?.metadata?.uid;
782
810
  }
783
811
  catch (err) {
784
812
  if (!(err instanceof KubernetesConflictError))
@@ -789,10 +817,50 @@ export async function createKubernetesWorkspace(config, options) {
789
817
  // were built from, so the object is checked against exactly
790
818
  // the label this call would have stamped.
791
819
  ...(profile !== undefined ? { profile } : {}),
820
+ ...(perSandboxLabels !== undefined
821
+ ? {
822
+ perSandboxLabel: {
823
+ key: Object.keys(perSandboxLabels)[0],
824
+ value: name,
825
+ },
826
+ }
827
+ : {}),
792
828
  ...(config.runtimeClassName !== undefined
793
829
  ? { runtimeClassName: config.runtimeClassName }
794
830
  : {}),
795
- }, epoch, options.refreshPodTemplate === true ? refresh : undefined, options.signal);
831
+ }, epoch, options.refreshPodTemplate === true ? refresh : undefined, options.signal, perSandboxEgress !== undefined && openFence !== undefined
832
+ ? async (uid) => {
833
+ await reverifyPerSandboxEgress({
834
+ client,
835
+ namespace,
836
+ sandboxName: name,
837
+ ownerUid: uid,
838
+ egress: perSandboxEgress,
839
+ fence: openFence,
840
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
841
+ });
842
+ }
843
+ : undefined);
844
+ }
845
+ // A workspace this call just created has no policy of its own yet, but a
846
+ // same-named one deleted a moment ago may have left one the garbage
847
+ // collector has not reached: it selects this label value, so it is
848
+ // dropped now that the new uid is known. A reply with no uid skips it, and
849
+ // the handle refuses `setNetworkPolicy` for the same reason.
850
+ if (adopted === undefined &&
851
+ perSandboxEgress !== undefined &&
852
+ openFence !== undefined &&
853
+ typeof createdUid === 'string' &&
854
+ createdUid !== '') {
855
+ await reverifyPerSandboxEgress({
856
+ client,
857
+ namespace,
858
+ sandboxName: name,
859
+ ownerUid: createdUid,
860
+ egress: perSandboxEgress,
861
+ fence: openFence,
862
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
863
+ });
796
864
  }
797
865
  return await openWorkspaceHandle({
798
866
  client,
@@ -813,6 +881,15 @@ export async function createKubernetesWorkspace(config, options) {
813
881
  // would patch the egress profile off the very pod the configured
814
882
  // policy selects.
815
883
  ...(Object.keys(profilePodLabels).length > 0 ? { podLabels: profilePodLabels } : {}),
884
+ ...(perSandboxEgress !== undefined
885
+ ? {
886
+ perSandboxEgress: {
887
+ egress: perSandboxEgress,
888
+ boundary: egressBoundary,
889
+ podLabels: workspacePodLabels,
890
+ },
891
+ }
892
+ : {}),
816
893
  // A create wrote the revision it just computed; an adopt reports
817
894
  // whatever the standing object carries, which is `undefined` for one
818
895
  // created before the annotation existed.
@@ -867,7 +944,7 @@ export async function createKubernetesWorkspace(config, options) {
867
944
  * been created yet, and the first bind attempt has to WAIT for it rather than
868
945
  * fail on the read that finds no live pod — see {@link PodBindPolicy}.
869
946
  */
870
- async function adoptExistingWorkspace(client, namespace, name, workspaceId, expected, epoch, refresh, signal) {
947
+ async function adoptExistingWorkspace(client, namespace, name, workspaceId, expected, epoch, refresh, signal, reverifyPerSandbox) {
871
948
  const target = { client, namespace, name, workspaceId };
872
949
  const existing = await readSandboxObject(target, signal);
873
950
  assertBlockModeWorkspaceDisk(`Sandbox ${name} in namespace ${namespace}`, existing?.spec?.podTemplate, existing?.spec?.volumeClaimTemplates);
@@ -892,6 +969,9 @@ async function adoptExistingWorkspace(client, namespace, name, workspaceId, expe
892
969
  ? {}
893
970
  : {
894
971
  ...(expected.profile !== undefined ? { profile: expected.profile } : {}),
972
+ ...(expected.perSandboxLabel !== undefined
973
+ ? { perSandboxLabel: expected.perSandboxLabel }
974
+ : {}),
895
975
  ...(expected.runtimeClassName !== undefined
896
976
  ? { runtimeClassName: expected.runtimeClassName }
897
977
  : {}),
@@ -903,6 +983,17 @@ async function adoptExistingWorkspace(client, namespace, name, workspaceId, expe
903
983
  // superseded opener patches nothing and starts no pod.
904
984
  if (epoch !== undefined)
905
985
  assertHolderEpochAllows(target, 'createKubernetesWorkspace', epoch, reading);
986
+ // Per-sandbox egress, before any patch for the reason the refusals above
987
+ // are: the fence is proved again and every policy this Sandbox does not
988
+ // own is dropped, so a workspace is never woken under a grant it did not
989
+ // consent to. See `workspace-per-sandbox.ts` for the rule.
990
+ if (reverifyPerSandbox !== undefined) {
991
+ const uid = existing?.metadata?.uid;
992
+ if (typeof uid !== 'string' || uid === '') {
993
+ throw new KubernetesOwnerUidMissingError(name, namespace);
994
+ }
995
+ await reverifyPerSandbox(uid);
996
+ }
906
997
  if (refreshing && refresh !== undefined) {
907
998
  // Both BEFORE the patch, and both against the SANDBOX's
908
999
  // volumeClaimTemplates rather than the template's: those are
@@ -1041,6 +1132,36 @@ function operatingModePatch(mode) {
1041
1132
  * re-read and then succeeds.
1042
1133
  */
1043
1134
  const HOLDER_EPOCH_WRITE_ATTEMPTS = 3;
1135
+ /** The `expectedSandboxUid` a standalone verb's options carry, validated. */
1136
+ function expectedUidOf(options) {
1137
+ const identity = options?.expectedIdentity;
1138
+ if (identity === undefined)
1139
+ return {};
1140
+ if (typeof identity.sandboxUid !== 'string' || identity.sandboxUid === '') {
1141
+ throw new TypeError('kubernetes: expectedIdentity.sandboxUid must be a non-empty string — an empty uid would be a fence that fences nothing.');
1142
+ }
1143
+ return { expectedSandboxUid: identity.sandboxUid };
1144
+ }
1145
+ /**
1146
+ * Refuse a write whose target is not the Sandbox the caller meant.
1147
+ *
1148
+ * Decided off the read the write is conditioned on, so the refusal is raised
1149
+ * BEFORE any request that could mutate. The same uid then rides in the write
1150
+ * itself, which is what makes the check hold against a replacement landing
1151
+ * between this read and that write.
1152
+ */
1153
+ function assertSandboxUidStands(target, standing, options) {
1154
+ const expected = target.expectedSandboxUid;
1155
+ if (expected === undefined || standing === expected)
1156
+ return;
1157
+ throw new KubernetesWorkspaceReplacedError(target.workspaceId, target.name, expected, standing, options);
1158
+ }
1159
+ /** The `test` clause that pins a JSON patch to the Sandbox the caller meant. */
1160
+ function sandboxUidTests(target) {
1161
+ return target.expectedSandboxUid === undefined
1162
+ ? []
1163
+ : [{ op: 'test', path: '/metadata/uid', value: target.expectedSandboxUid }];
1164
+ }
1044
1165
  /** One GET of the Sandbox, which is where every condition is read from. */
1045
1166
  async function readSandboxObject(target, signal) {
1046
1167
  return await target.client.request('GET', sandboxPath(target.namespace, target.name), undefined, signal);
@@ -1104,7 +1225,11 @@ async function readHolderEpochGate(target, operation, epoch, signal) {
1104
1225
  */
1105
1226
  async function writeOperatingMode(target, operation, mode, epoch, signal, gate) {
1106
1227
  const path = sandboxPath(target.namespace, target.name);
1107
- if (epoch === undefined) {
1228
+ // Nothing to write is nothing to condition: the stamp-only write exists to
1229
+ // move the epoch, and without one it has no mutation in it at all.
1230
+ if (epoch === undefined && mode === undefined)
1231
+ return;
1232
+ if (epoch === undefined && target.expectedSandboxUid === undefined) {
1108
1233
  if (mode === undefined)
1109
1234
  return;
1110
1235
  await target.client.request('PATCH', path, operatingModePatch(mode), signal);
@@ -1112,10 +1237,15 @@ async function writeOperatingMode(target, operation, mode, epoch, signal, gate)
1112
1237
  }
1113
1238
  let reading = gate ?? readHolderEpoch((await readSandboxObject(target, signal))?.metadata);
1114
1239
  for (let attempt = 1;; attempt += 1) {
1115
- assertHolderEpochAllows(target, operation, epoch, reading);
1240
+ // Identity before epoch: a stranger under this name is not this
1241
+ // caller's workspace, whatever epoch it happens to carry.
1242
+ assertSandboxUidStands(target, reading.sandboxUid);
1243
+ if (epoch !== undefined)
1244
+ assertHolderEpochAllows(target, operation, epoch, reading);
1116
1245
  const patch = buildHolderEpochPatch({
1117
1246
  reading,
1118
- epoch,
1247
+ ...(epoch !== undefined ? { epoch } : {}),
1248
+ tests: sandboxUidTests(target),
1119
1249
  ...(mode !== undefined
1120
1250
  ? { operatingMode: mode, operatingModeChangedAt: new Date().toISOString() }
1121
1251
  : {}),
@@ -1130,6 +1260,10 @@ async function writeOperatingMode(target, operation, mode, epoch, signal, gate)
1130
1260
  if (attempt >= HOLDER_EPOCH_WRITE_ATTEMPTS)
1131
1261
  throw err;
1132
1262
  const next = readHolderEpoch((await readSandboxObject(target, signal))?.metadata);
1263
+ // A replacement that landed between the read and the write is what
1264
+ // the uid `test` refused. It is never retried against the new
1265
+ // owner: this is the refusal, not a race to win.
1266
+ assertSandboxUidStands(target, next.sandboxUid);
1133
1267
  const unmoved = next.annotation === reading.annotation &&
1134
1268
  next.resourceVersion === reading.resourceVersion &&
1135
1269
  next.hasAnnotations === reading.hasAnnotations;
@@ -1151,7 +1285,7 @@ async function writeOperatingMode(target, operation, mode, epoch, signal, gate)
1151
1285
  * every create POSTs, so `templateCurrent` would report drift that no refresh
1152
1286
  * could clear.
1153
1287
  */
1154
- function buildPodTemplateRefresh(template, sandboxTemplateName, runtimeClassName, podLabels) {
1288
+ export function buildPodTemplateRefresh(template, sandboxTemplateName, runtimeClassName, podLabels) {
1155
1289
  const podTemplate = sandboxPodTemplate(template, sandboxTemplateName, runtimeClassName, podLabels);
1156
1290
  return {
1157
1291
  podTemplate,
@@ -1196,12 +1330,16 @@ async function writeRefreshedPodTemplate(target, operation, refresh, epoch, gate
1196
1330
  const path = sandboxPath(target.namespace, target.name);
1197
1331
  let reading = gate;
1198
1332
  for (let attempt = 1;; attempt += 1) {
1333
+ assertSandboxUidStands(target, reading.sandboxUid);
1199
1334
  if (epoch !== undefined)
1200
1335
  assertHolderEpochAllows(target, operation, epoch, reading);
1201
1336
  const patch = buildHolderEpochPatch({
1202
1337
  reading,
1203
1338
  ...(epoch !== undefined ? { epoch } : {}),
1204
- tests: [{ op: 'test', path: '/spec/operatingMode', value: 'Suspended' }],
1339
+ tests: [
1340
+ ...sandboxUidTests(target),
1341
+ { op: 'test', path: '/spec/operatingMode', value: 'Suspended' },
1342
+ ],
1205
1343
  annotations: { [POD_TEMPLATE_HASH_ANNOTATION_KEY]: refresh.hash },
1206
1344
  podTemplate: refresh.podTemplate,
1207
1345
  operatingMode: 'Running',
@@ -1229,6 +1367,7 @@ async function writeRefreshedPodTemplate(target, operation, refresh, epoch, gate
1229
1367
  if (sandbox === undefined)
1230
1368
  throw err;
1231
1369
  const next = readHolderEpoch(sandbox.metadata);
1370
+ assertSandboxUidStands(target, next.sandboxUid);
1232
1371
  if (operatingModeOf(sandbox) !== 'Suspended') {
1233
1372
  // Somebody resumed it first. If they also took the workspace
1234
1373
  // over, this caller hears THAT rather than being handed a pod
@@ -1266,7 +1405,8 @@ async function writeRefreshedPodTemplate(target, operation, refresh, epoch, gate
1266
1405
  */
1267
1406
  async function deleteSandboxObject(target, operation, epoch, signal, gate) {
1268
1407
  const path = sandboxPath(target.namespace, target.name);
1269
- if (epoch === undefined) {
1408
+ const expectedUid = target.expectedSandboxUid;
1409
+ if (epoch === undefined && expectedUid === undefined) {
1270
1410
  try {
1271
1411
  await target.client.request('DELETE', path, undefined, signal);
1272
1412
  }
@@ -1278,7 +1418,9 @@ async function deleteSandboxObject(target, operation, epoch, signal, gate) {
1278
1418
  }
1279
1419
  let reading = gate;
1280
1420
  for (let attempt = 1;; attempt += 1) {
1281
- if (reading === undefined) {
1421
+ // The epoch needs the object's current version for its precondition;
1422
+ // the uid alone does not, because it is the precondition.
1423
+ if (reading === undefined && epoch !== undefined) {
1282
1424
  let sandbox;
1283
1425
  try {
1284
1426
  sandbox = await readSandboxObject(target, signal);
@@ -1290,8 +1432,12 @@ async function deleteSandboxObject(target, operation, epoch, signal, gate) {
1290
1432
  }
1291
1433
  reading = readHolderEpoch(sandbox?.metadata);
1292
1434
  }
1293
- assertHolderEpochAllows(target, operation, epoch, reading);
1294
- if (reading.resourceVersion === undefined) {
1435
+ if (reading !== undefined) {
1436
+ assertSandboxUidStands(target, reading.sandboxUid);
1437
+ if (epoch !== undefined)
1438
+ assertHolderEpochAllows(target, operation, epoch, reading);
1439
+ }
1440
+ if (epoch !== undefined && reading?.resourceVersion === undefined) {
1295
1441
  // Unreachable against a real API server, which sets it on every
1296
1442
  // object it serves — and a bodyless DELETE here would be an
1297
1443
  // UNFENCED delete of somebody's disk, which is the one thing this
@@ -1302,7 +1448,12 @@ async function deleteSandboxObject(target, operation, epoch, signal, gate) {
1302
1448
  await target.client.request('DELETE', path, {
1303
1449
  apiVersion: 'v1',
1304
1450
  kind: 'DeleteOptions',
1305
- preconditions: { resourceVersion: reading.resourceVersion },
1451
+ preconditions: {
1452
+ ...(reading?.resourceVersion !== undefined
1453
+ ? { resourceVersion: reading.resourceVersion }
1454
+ : {}),
1455
+ ...(expectedUid !== undefined ? { uid: expectedUid } : {}),
1456
+ },
1306
1457
  }, signal);
1307
1458
  return;
1308
1459
  }
@@ -1311,9 +1462,25 @@ async function deleteSandboxObject(target, operation, epoch, signal, gate) {
1311
1462
  return;
1312
1463
  if (!(err instanceof KubernetesConflictError))
1313
1464
  throw err;
1465
+ // A 409 is either the version moving (epoch path: re-read and try
1466
+ // again) or the uid precondition refusing a stranger. Look, and
1467
+ // let the uid decide: a different owner is the refusal and is
1468
+ // never retried against.
1469
+ let standing;
1470
+ try {
1471
+ standing = await readSandboxObject(target, signal);
1472
+ }
1473
+ catch (readErr) {
1474
+ if (readErr instanceof KubernetesAlreadyGoneError)
1475
+ return;
1476
+ throw readErr;
1477
+ }
1478
+ assertSandboxUidStands(target, standing?.metadata?.uid, { cause: err });
1479
+ if (epoch === undefined)
1480
+ throw err;
1314
1481
  if (attempt >= HOLDER_EPOCH_WRITE_ATTEMPTS)
1315
1482
  throw err;
1316
- reading = undefined;
1483
+ reading = readHolderEpoch(standing?.metadata);
1317
1484
  }
1318
1485
  }
1319
1486
  }
@@ -1489,7 +1656,7 @@ export async function deleteKubernetesWorkspace(config, workspaceId, options) {
1489
1656
  // matters most on: a retention job superseded between deciding to delete a
1490
1657
  // workspace and calling this would otherwise take the new holder's disk,
1491
1658
  // and nothing brings a disk back.
1492
- await deleteSandboxObject({ client, namespace, name, workspaceId }, 'deleteKubernetesWorkspace', epoch, options?.signal);
1659
+ await deleteSandboxObject({ client, namespace, name, workspaceId, ...expectedUidOf(options) }, 'deleteKubernetesWorkspace', epoch, options?.signal);
1493
1660
  }
1494
1661
  /**
1495
1662
  * Suspend a workspace by id, without ever adopting it: send the patch, then
@@ -1527,7 +1694,7 @@ export async function suspendKubernetesWorkspace(config, workspaceId, options) {
1527
1694
  assertNoFlushHere(options?.flush, 'suspendKubernetesWorkspace');
1528
1695
  const readiness = resolveKubernetesReadiness(config);
1529
1696
  const client = createKubernetesClient(clientAccess(config), clientOptions(config));
1530
- await writeOperatingMode({ client, namespace, name, workspaceId }, 'suspendKubernetesWorkspace', 'Suspended', epoch, options?.signal);
1697
+ await writeOperatingMode({ client, namespace, name, workspaceId, ...expectedUidOf(options) }, 'suspendKubernetesWorkspace', 'Suspended', epoch, options?.signal);
1531
1698
  await awaitPodRetired(client, namespace, name, workspaceId, readiness, options?.signal);
1532
1699
  }
1533
1700
  /**
@@ -1568,7 +1735,18 @@ async function openWorkspaceHandle(options) {
1568
1735
  const { client, namespace, name, workspaceId, readiness } = options;
1569
1736
  const id = name;
1570
1737
  /** What every fenced write addresses, and what a refusal names. */
1571
- const target = { client, namespace, name, workspaceId };
1738
+ const target = {
1739
+ client,
1740
+ namespace,
1741
+ name,
1742
+ workspaceId,
1743
+ // A getter, because the uid is learned at the first bind and every
1744
+ // write after it must carry it. Before that the handle has no uid to
1745
+ // pin, which is the only window it writes unpinned.
1746
+ get expectedSandboxUid() {
1747
+ return sandboxUid;
1748
+ },
1749
+ };
1572
1750
  /**
1573
1751
  * The epoch this handle writes under when a call passes none.
1574
1752
  *
@@ -2744,6 +2922,79 @@ async function openWorkspaceHandle(options) {
2744
2922
  await awaitPodRetired(client, namespace, name, workspaceId, readiness, signal);
2745
2923
  state = 'suspended';
2746
2924
  };
2925
+ /**
2926
+ * Per-sandbox egress on this handle — present exactly when
2927
+ * `config.egress.perSandbox` is configured, which is what makes
2928
+ * `setNetworkPolicy` PRESENT here and absent otherwise (presence follows
2929
+ * configuration, never a probe).
2930
+ *
2931
+ * The fence is replaced, not reused, on every resume: its memo is "proved
2932
+ * once", and a resume is precisely the moment that is no longer enough.
2933
+ * The setter delegates to whichever one is current. Setters are cached per
2934
+ * Sandbox uid, because a policy is NAMED after the uid and its write queue
2935
+ * has to outlive one call.
2936
+ */
2937
+ const perSandboxEgress = options.perSandboxEgress;
2938
+ let currentFence = perSandboxEgress !== undefined
2939
+ ? buildAdmissionFence(client, perSandboxEgress.egress.perSandbox, namespace)
2940
+ : undefined;
2941
+ const delegatingFence = {
2942
+ assertApplied: async (signal) => {
2943
+ await currentFence?.assertApplied(signal);
2944
+ },
2945
+ };
2946
+ const policySetters = new Map();
2947
+ /**
2948
+ * The Sandbox that owns every policy this handle writes, as the cluster
2949
+ * reports it NOW. A different uid than the one this handle bound is a
2950
+ * workspace deleted and created again under the same name: refused, the
2951
+ * way every other call on such a handle is, rather than writing a grant
2952
+ * the new disk's owner never consented to.
2953
+ */
2954
+ const resolvePolicyOwner = async (signal) => {
2955
+ const standing = await readSandboxObject(target, signal);
2956
+ const uid = standing?.metadata?.uid;
2957
+ if (standing === undefined) {
2958
+ throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, undefined);
2959
+ }
2960
+ if (typeof uid !== 'string' || uid === '') {
2961
+ throw new KubernetesOwnerUidMissingError(name, namespace);
2962
+ }
2963
+ if (sandboxUid !== undefined && uid !== sandboxUid) {
2964
+ throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, uid);
2965
+ }
2966
+ return uid;
2967
+ };
2968
+ const setNetworkPolicy = perSandboxEgress === undefined
2969
+ ? undefined
2970
+ : async (policy) => {
2971
+ // Serialised with the transitions: a write racing a resume would
2972
+ // run under the fence the resume is about to replace, and one
2973
+ // racing a destroy would name an owner that is going away.
2974
+ await serialise(async () => {
2975
+ if (state === 'deleted')
2976
+ throw new KubernetesSandboxDestroyedError('setNetworkPolicy', name);
2977
+ const uid = await resolvePolicyOwner();
2978
+ let setter = policySetters.get(uid);
2979
+ if (setter === undefined) {
2980
+ setter = buildPerSandboxPolicySetter({
2981
+ client,
2982
+ fence: delegatingFence,
2983
+ namespace,
2984
+ egress: perSandboxEgress.egress,
2985
+ owner: { kind: 'Sandbox', name, uid },
2986
+ selectorValue: name,
2987
+ ...(perSandboxEgress.boundary !== undefined
2988
+ ? {
2989
+ verifyUnion: (written, signal) => perSandboxEgress.boundary?.verifyUnionWith(perSandboxEgress.podLabels, `the per-sandbox policy ${written.name} written for Sandbox ${name} in namespace ${namespace}`, written, signal) ?? Promise.resolve(),
2990
+ }
2991
+ : {}),
2992
+ });
2993
+ policySetters.set(uid, setter);
2994
+ }
2995
+ await setter(policy);
2996
+ });
2997
+ };
2747
2998
  /**
2748
2999
  * Wake the workspace if it is asleep, then bring a session up on it.
2749
3000
  *
@@ -2794,6 +3045,31 @@ async function openWorkspaceHandle(options) {
2794
3045
  // the object says it was built from, including a refresh another
2795
3046
  // process applied while this handle slept.
2796
3047
  templateRevision = readPodTemplateHash(current?.metadata);
3048
+ // Per-sandbox egress, before ANY patch: a fresh fence, then every
3049
+ // policy this Sandbox does not own dropped — see
3050
+ // `workspace-per-sandbox.ts` for the rule. A fence that does not
3051
+ // verify refuses the resume with the workspace still suspended, and
3052
+ // nothing written; a policy this Sandbox owns is kept.
3053
+ if (perSandboxEgress !== undefined && current !== undefined) {
3054
+ const uid = current.metadata?.uid;
3055
+ if (typeof uid !== 'string' || uid === '') {
3056
+ throw new KubernetesOwnerUidMissingError(name, namespace);
3057
+ }
3058
+ if (sandboxUid !== undefined && uid !== sandboxUid) {
3059
+ throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, uid);
3060
+ }
3061
+ const fence = buildAdmissionFence(client, perSandboxEgress.egress.perSandbox, namespace);
3062
+ await reverifyPerSandboxEgress({
3063
+ client,
3064
+ namespace,
3065
+ sandboxName: name,
3066
+ ownerUid: uid,
3067
+ egress: perSandboxEgress.egress,
3068
+ fence,
3069
+ ...(signal !== undefined ? { signal } : {}),
3070
+ });
3071
+ currentFence = fence;
3072
+ }
2797
3073
  if (woke && refreshPodTemplate) {
2798
3074
  // The template is re-read HERE rather than remembered from the
2799
3075
  // open: the whole point of the option is to pick up an edit made
@@ -3265,7 +3541,109 @@ async function openWorkspaceHandle(options) {
3265
3541
  * the transition queue — the public verb takes it, and `suspendNow` runs
3266
3542
  * inside it.
3267
3543
  */
3268
- const quiesceNow = async (quiesceOptions) => await admitted('quiesce', async (handle) => await admittedTransport('quiesce', handle).quiesce(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}, quiesceOptions?.signal));
3544
+ const quiesceNow = async (quiesceOptions) => await admitted('quiesce', async (handle) => await admittedTransport('quiesce', handle).quiesce({
3545
+ ...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
3546
+ ...(quiesceOptions?.onlyIfIdle === true ? { idleOnly: true } : {}),
3547
+ }, quiesceOptions?.signal));
3548
+ /**
3549
+ * The pinned view over this handle — see {@link KubernetesWorkspace.pinGuest}.
3550
+ *
3551
+ * It owns a sibling transport (built by `pinnedTo`) for the live session's
3552
+ * pod and a Sandbox around it, made on first use and reused while the live
3553
+ * session's transport is the one it was built from. The sibling has no
3554
+ * `refreshHandle`, so nothing it does can follow a replaced pod, and the
3555
+ * Sandbox's `release` does nothing and its unconfirmed-cancellation hook
3556
+ * keeps the pod: a view is not allowed to retire what it does not own.
3557
+ */
3558
+ const buildPinnedGuest = (expected) => {
3559
+ let built;
3560
+ const refuse = (reason, actual, field, fresh, cause) => new KubernetesGuestPinRefusedError(reason, workspaceId, expected, actual, field, fresh, cause !== undefined ? { cause } : undefined);
3561
+ /** Compared before anything is dialed. The guest's own check is the authority; this one is free. */
3562
+ const resolve = (operation) => {
3563
+ const actual = identityNow();
3564
+ if (state !== 'running' || session === undefined || actual.podUid === undefined) {
3565
+ throw refuse('identity-changed', actual, 'podUid');
3566
+ }
3567
+ if (actual.sandboxUid !== expected.sandboxUid) {
3568
+ throw refuse('identity-changed', actual, 'sandboxUid');
3569
+ }
3570
+ if (actual.podUid !== expected.podUid)
3571
+ throw refuse('identity-changed', actual, 'podUid');
3572
+ // Not yet heard from is not "changed": the guest compares it itself.
3573
+ if (actual.guestBootId !== undefined && actual.guestBootId !== expected.guestBootId) {
3574
+ throw refuse('identity-changed', actual, 'guestBootId');
3575
+ }
3576
+ for (const [claim, uid] of Object.entries(expected.volumeClaimUids ?? {})) {
3577
+ if (actual.volumeClaimUids[claim] !== uid) {
3578
+ throw refuse('identity-changed', actual, 'volumeClaimUids');
3579
+ }
3580
+ }
3581
+ const parent = admittedTransport(operation, session);
3582
+ if (built === undefined || built.parent !== parent) {
3583
+ const transport = parent.pinnedTo(expected.guestBootId);
3584
+ built = {
3585
+ parent,
3586
+ transport,
3587
+ sandbox: buildKubernetesSandbox({
3588
+ name,
3589
+ rootDir: options.rootDir,
3590
+ transport,
3591
+ release: async () => { },
3592
+ onUnconfirmedCancellation: async () => ({
3593
+ accepted: false,
3594
+ reason: 'pinned-view-keeps-the-pod',
3595
+ }),
3596
+ }),
3597
+ };
3598
+ }
3599
+ return built;
3600
+ };
3601
+ const translate = (err) => {
3602
+ if (err instanceof KubernetesGuestPinRefusedError)
3603
+ return err;
3604
+ const replaced = guestMismatchOf(err);
3605
+ if (replaced !== undefined) {
3606
+ return refuse('guest-replaced', undefined, undefined, replaced.guestBootId, err);
3607
+ }
3608
+ if (isUnauthorizedRefusal(err))
3609
+ return refuse('pod-replaced', undefined, undefined, undefined, err);
3610
+ return err;
3611
+ };
3612
+ const pinned = async (operation, run, signal) => {
3613
+ const view = resolve(operation);
3614
+ try {
3615
+ if (!(await view.transport.supportsPinnedGuest(signal))) {
3616
+ throw refuse('guest-unsupported', undefined, undefined);
3617
+ }
3618
+ return await run(view);
3619
+ }
3620
+ catch (err) {
3621
+ throw translate(err);
3622
+ }
3623
+ };
3624
+ return Object.freeze({
3625
+ expected,
3626
+ exec: async (command, argv, execOptions) => await pinned('exec', async (view) => await view.sandbox.exec(command, argv, execOptions)),
3627
+ readFile: async (path, readOptions) => await pinned('readFile', async (view) => await view.sandbox.readFile(path, readOptions)),
3628
+ writeFile: async (path, content) => await pinned('writeFile', async (view) => await view.sandbox.writeFile(path, content)),
3629
+ listFiles: async (rootPath) => await pinned('listFiles', async (view) => await view.sandbox.listFiles(rootPath)),
3630
+ openTerminal: async (terminalOptions) => await pinned('openTerminal', async (view) => await view.sandbox.openTerminal(terminalOptions)),
3631
+ openTcpConnection: async (connectOptions) => await pinned('openTcpConnection', async (view) => await view.sandbox.openTcpConnection(connectOptions)),
3632
+ quiesce: async (quiesceOptions) => {
3633
+ // Copied before any await, like the workspace's own.
3634
+ if (quiesceOptions?.onlyIfIdle !== undefined &&
3635
+ typeof quiesceOptions.onlyIfIdle !== 'boolean') {
3636
+ throw new TypeError('kubernetes: quiesce({ onlyIfIdle }) must be a boolean');
3637
+ }
3638
+ const request = {
3639
+ ...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
3640
+ ...(quiesceOptions?.onlyIfIdle === true ? { idleOnly: true } : {}),
3641
+ };
3642
+ const signal = quiesceOptions?.signal;
3643
+ return await pinned('quiesce', async (view) => await view.transport.quiesce(request, signal), signal);
3644
+ },
3645
+ });
3646
+ };
3269
3647
  /**
3270
3648
  * Tell the host about a gap a suspend went ahead over.
3271
3649
  *
@@ -3489,6 +3867,9 @@ async function openWorkspaceHandle(options) {
3489
3867
  get identity() {
3490
3868
  return identityNow();
3491
3869
  },
3870
+ pinGuest(input) {
3871
+ return buildPinnedGuest(freezePinnedIdentity(input));
3872
+ },
3492
3873
  onGuestRestart(listener) {
3493
3874
  restartListeners.add(listener);
3494
3875
  return () => {
@@ -3512,6 +3893,7 @@ async function openWorkspaceHandle(options) {
3512
3893
  },
3513
3894
  rootDir: options.rootDir,
3514
3895
  environment,
3896
+ ...(setNetworkPolicy !== undefined ? { setNetworkPolicy } : {}),
3515
3897
  async exec(command, argv, execOptions) {
3516
3898
  // The default path, untouched: the SDK's exec through the inner
3517
3899
  // handle, the shared execution controller, and the retirement
@@ -3652,7 +4034,23 @@ async function openWorkspaceHandle(options) {
3652
4034
  // racing the suspend that follows it would be a quiesce of a pod the
3653
4035
  // patch has already taken away, and one racing a resume would ask
3654
4036
  // the old pod to stop the new one's processes.
3655
- return await serialise(async () => await quiesceNow(quiesceOptions));
4037
+ //
4038
+ // The options are copied HERE, synchronously, before the queue is
4039
+ // awaited: a caller that mutates its object after calling cannot
4040
+ // change what is sent, in particular cannot turn an idle-only
4041
+ // request into a stopping one.
4042
+ if (quiesceOptions?.onlyIfIdle !== undefined &&
4043
+ typeof quiesceOptions.onlyIfIdle !== 'boolean') {
4044
+ throw new TypeError('kubernetes: quiesce({ onlyIfIdle }) must be a boolean');
4045
+ }
4046
+ const request = Object.freeze({
4047
+ ...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
4048
+ ...(quiesceOptions?.onlyIfIdle !== undefined
4049
+ ? { onlyIfIdle: quiesceOptions.onlyIfIdle }
4050
+ : {}),
4051
+ ...(quiesceOptions?.signal !== undefined ? { signal: quiesceOptions.signal } : {}),
4052
+ });
4053
+ return await serialise(async () => await quiesceNow(request));
3656
4054
  },
3657
4055
  async flush(flushOptions) {
3658
4056
  // Serialised for the reason `quiesce()` is: a flush racing the