@opengeni/runtime 0.13.0 → 0.13.4

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.
@@ -65,3 +65,18 @@ export function parseExecBannerExitCode(raw: string): number | null {
65
65
  const banner = parseExecResponseBanner(raw);
66
66
  return banner.kind === "exited" ? banner.exitCode : null;
67
67
  }
68
+
69
+ // Detect the Modal "the exec-session you're writing to no longer exists" fact.
70
+ // This is process-lifetime authority, so classify only the complete known
71
+ // banner (or its bare provider form) carrying the exact tracked numeric id.
72
+ export function isExecSessionLostBanner(out: string, execSessionId: number): boolean {
73
+ if (!out || !Number.isSafeInteger(execSessionId) || execSessionId < 0) return false;
74
+ const match = out.match(/^(?:write_stdin failed: )?session not found: (\d+)$/);
75
+ // JavaScript's `$` also matches immediately before one final line terminator.
76
+ // Requiring the regex match to consume the entire string keeps even that
77
+ // otherwise-special case fail-closed.
78
+ if (!match || match[0] !== out) return false;
79
+ // String equality is intentional: parseInt would normalize malformed facts
80
+ // such as `01` and can round an out-of-range integer into another identity.
81
+ return match[1] === String(execSessionId);
82
+ }
@@ -32,7 +32,14 @@ import type {
32
32
  } from "@openai/agents/sandbox";
33
33
  import { PROVIDER_REGISTRY } from "./providers";
34
34
  import { SandboxConfigError, SandboxResumeStateUnavailableError } from "./errors";
35
- import { isSelfhostedProviderNotFoundError } from "./selfhosted/session";
35
+ import { isProviderSandboxNotFoundError } from "./provider-errors";
36
+ import {
37
+ readVerifiedWorkspaceArchive,
38
+ verifyRestoredWorkspace,
39
+ WorkspaceArchiveIntegrityError,
40
+ type VerifiedWorkspaceArchive,
41
+ type WorkspaceArchiveDescriptor,
42
+ } from "./workspace-archive";
36
43
  import type { RuntimeMetricsHooks } from "../metrics";
37
44
 
38
45
  // Re-export the config-owned environment/port helpers from the leaf so the
@@ -62,6 +69,27 @@ export {
62
69
  type ProviderRegistration,
63
70
  type ProviderConstructionContext,
64
71
  } from "./providers";
72
+ export {
73
+ classifyProviderSandboxFailure,
74
+ isProviderSandboxGoneDuringRoutedOperation,
75
+ isProviderSandboxNotFoundError,
76
+ isProviderSandboxTransientError,
77
+ type ProviderSandboxFailure,
78
+ type ProviderSandboxFailureKind,
79
+ } from "./provider-errors";
80
+ export {
81
+ WORKSPACE_ARCHIVE_DESCRIPTOR_VERSION,
82
+ WorkspaceArchiveIntegrityError,
83
+ captureVerifiedWorkspaceArchive,
84
+ fingerprintSandboxWorkspace,
85
+ parseWorkspaceArchiveDescriptor,
86
+ readVerifiedWorkspaceArchive,
87
+ verifyRestoredWorkspace,
88
+ type VerifiedWorkspaceArchive,
89
+ type WorkspaceArchiveDescriptor,
90
+ type WorkspaceArchiveIntegrityCode,
91
+ type WorkspaceTreeFingerprint,
92
+ } from "./workspace-archive";
65
93
  export {
66
94
  ensureModalRegistryImage,
67
95
  modalSandboxAttributionEnvironment,
@@ -342,11 +370,18 @@ export {
342
370
  // currently-active backend (Modal or selfhosted) — flippable mid-turn, single
343
371
  // active at a time, fence-retrying on a swap race.
344
372
  export {
373
+ RoutingBackendRecoveryRequiredError,
374
+ RoutingMutationOutcomeUnknownError,
375
+ RoutingRetainedProcessNotFoundError,
345
376
  RoutingSandboxSession,
346
377
  RoutingUnsupportedError,
347
378
  type ActivePointer,
379
+ type DefaultBackendLossResult,
348
380
  type RoutableBackendSession,
349
381
  type ResolvedActiveBackend,
382
+ type RoutingRetainedProcess,
383
+ type RoutingRetainedProcessAdoption,
384
+ type RoutingRetainedProcessTerminalProof,
350
385
  type RoutingSandboxSessionDeps,
351
386
  type RoutingTransitionEvent,
352
387
  } from "./routing/routing-session";
@@ -592,24 +627,6 @@ export function readWorkspaceArchiveFromEnvelopeSessionState(
592
627
  }
593
628
  }
594
629
 
595
- function readWorkspaceArchivePairFromEnvelopeSessionState(sessionState: unknown): {
596
- current?: Uint8Array;
597
- previous?: Uint8Array;
598
- } {
599
- if (!sessionState || typeof sessionState !== "object") {
600
- return {};
601
- }
602
- const state = sessionState as { workspaceArchivePrev?: unknown };
603
- const current = readWorkspaceArchiveFromEnvelopeSessionState(sessionState);
604
- const previous = readWorkspaceArchiveFromEnvelopeSessionState({
605
- workspaceArchive: state.workspaceArchivePrev,
606
- });
607
- return {
608
- ...(current ? { current } : {}),
609
- ...(previous ? { previous } : {}),
610
- };
611
- }
612
-
613
630
  // The native snapshot-ref prefixes the @openai/agents-extensions modal client
614
631
  // encodes (snapshots.mjs `NATIVE_SNAPSHOT_PREFIXES`). The ref is
615
632
  // `<PREFIX>\n{"snapshot_id":"...",...}`. We re-implement the decode here because
@@ -764,8 +781,113 @@ export type EstablishedSandboxSession = {
764
781
  /** Set when a create-authorized reattach found the envelope's box GONE
765
782
  * (provider NotFound) and fell through to cold-restore. */
766
783
  lostInstanceId?: string;
784
+ /** Exact durable archive revision verified byte-for-byte after hydration. */
785
+ restoredArchive?: WorkspaceArchiveDescriptor;
767
786
  };
768
787
 
788
+ export class SandboxExecReadinessError extends Error {
789
+ readonly name = "SandboxExecReadinessError";
790
+
791
+ constructor(
792
+ public readonly backend: string,
793
+ public readonly code: "exec_probe_unavailable" | "exec_probe_timeout" | "exec_probe_failed",
794
+ public readonly timeoutMs: number,
795
+ public readonly exitCode: number | null = null,
796
+ ) {
797
+ super(
798
+ code === "exec_probe_timeout"
799
+ ? `sandbox creation timed out waiting for ${backend} command readiness after ${timeoutMs}ms`
800
+ : code === "exec_probe_unavailable"
801
+ ? `${backend} sandbox session does not expose an exec readiness probe`
802
+ : exitCode === null
803
+ ? `${backend} sandbox exec readiness probe did not return a command exit code`
804
+ : `${backend} sandbox exec readiness probe failed with exit code ${exitCode}`,
805
+ );
806
+ }
807
+ }
808
+
809
+ function sandboxExecProbeExitCode(result: unknown): number | null {
810
+ if (typeof result === "string") {
811
+ const match = result.match(/Process exited with code (-?\d+)/);
812
+ return match ? Number(match[1]) : null;
813
+ }
814
+ if (!result || typeof result !== "object") return null;
815
+ const candidate = result as {
816
+ exitCode?: unknown;
817
+ exit_code?: unknown;
818
+ code?: unknown;
819
+ status?: unknown;
820
+ };
821
+ for (const value of [candidate.exitCode, candidate.exit_code, candidate.code, candidate.status]) {
822
+ if (typeof value === "number") return value;
823
+ }
824
+ return null;
825
+ }
826
+
827
+ function sandboxExecProbeStillRunning(result: unknown): boolean {
828
+ if (typeof result === "string") return /Process running with session ID \d+/u.test(result);
829
+ if (!result || typeof result !== "object") return false;
830
+ const candidate = result as { sessionId?: unknown; session_id?: unknown };
831
+ return typeof candidate.sessionId === "number" || typeof candidate.session_id === "number";
832
+ }
833
+
834
+ /** A provider handle is not workspace readiness. Modal can return a handle
835
+ * before its command router accepts exec, so every create path must pass this
836
+ * bounded probe before atomically publishing the lease warm/ready. */
837
+ export async function verifySandboxExecReadiness(
838
+ established: EstablishedSandboxSession,
839
+ timeoutMs = 15_000,
840
+ ): Promise<void> {
841
+ if (established.backendId !== "modal") return;
842
+ const session = established.session as {
843
+ exec?: (args: {
844
+ cmd: string;
845
+ yieldTimeMs?: number;
846
+ maxOutputTokens?: number;
847
+ }) => Promise<unknown>;
848
+ execCommand?: (args: {
849
+ cmd: string;
850
+ yieldTimeMs?: number;
851
+ maxOutputTokens?: number;
852
+ }) => Promise<unknown>;
853
+ };
854
+ const run = session.exec ?? session.execCommand;
855
+ if (!run) {
856
+ throw new SandboxExecReadinessError(established.backendId, "exec_probe_unavailable", timeoutMs);
857
+ }
858
+ let timer: ReturnType<typeof setTimeout> | undefined;
859
+ try {
860
+ const result = await Promise.race([
861
+ run.call(session, {
862
+ cmd: "true",
863
+ yieldTimeMs: 1_000,
864
+ maxOutputTokens: 1_000,
865
+ }),
866
+ new Promise<never>((_, reject) => {
867
+ timer = setTimeout(
868
+ () =>
869
+ reject(
870
+ new SandboxExecReadinessError(established.backendId, "exec_probe_timeout", timeoutMs),
871
+ ),
872
+ timeoutMs,
873
+ );
874
+ if (timer && "unref" in timer && typeof timer.unref === "function") timer.unref();
875
+ }),
876
+ ]);
877
+ const exitCode = sandboxExecProbeExitCode(result);
878
+ if (sandboxExecProbeStillRunning(result) || exitCode !== 0) {
879
+ throw new SandboxExecReadinessError(
880
+ established.backendId,
881
+ "exec_probe_failed",
882
+ timeoutMs,
883
+ exitCode,
884
+ );
885
+ }
886
+ } finally {
887
+ if (timer) clearTimeout(timer);
888
+ }
889
+ }
890
+
769
891
  export type SandboxCreatedCallback = (established: EstablishedSandboxSession) => Promise<void>;
770
892
 
771
893
  // The structural slice we need from a provider SandboxClient to resume by id and
@@ -792,64 +914,6 @@ type ResumeCapableClient = {
792
914
  * an unrecognized error is treated as "not NotFound" (propagate), because a
793
915
  * false-positive recreate is the dangerous direction (double-spawn).
794
916
  */
795
- export function isProviderSandboxNotFoundError(backendId: string, error: unknown): boolean {
796
- // selfhosted: agent-offline is NEVER a provider NotFound (the user's machine is
797
- // not recreatable — a false NotFound would cold-create a RIVAL box). The
798
- // selfhosted discriminator ALWAYS returns false; short-circuit so no goneMarker
799
- // string match below can ever flip a selfhosted agent-offline error to true.
800
- if (backendId === "selfhosted") {
801
- return isSelfhostedProviderNotFoundError(error);
802
- }
803
- if (!error) {
804
- return false;
805
- }
806
- const status =
807
- (error as { status?: unknown; statusCode?: unknown }).status ??
808
- (error as { statusCode?: unknown }).statusCode;
809
- if (status === 404) {
810
- return true;
811
- }
812
- const name =
813
- typeof (error as { name?: unknown }).name === "string" ? (error as { name: string }).name : "";
814
- const code =
815
- typeof (error as { code?: unknown }).code === "string" ? (error as { code: string }).code : "";
816
- const message =
817
- error instanceof Error
818
- ? error.message
819
- : typeof error === "string"
820
- ? error
821
- : String((error as { message?: unknown })?.message ?? "");
822
- const haystack = `${name} ${code} ${message}`.toLowerCase();
823
- // Provider-agnostic "gone" markers (Modal: "sandbox … not found" / terminated;
824
- // e2b/daytona/runloop: "not found" / "no longer running" / "terminated" /
825
- // "does not exist"). Kept broad-but-conservative: it matches box-gone phrasing
826
- // and never matches generic 5xx/transport errors.
827
- const goneMarkers = [
828
- "not found",
829
- "no longer running",
830
- "no longer exists",
831
- "does not exist",
832
- "doesn't exist",
833
- "has been terminated",
834
- "was terminated",
835
- "is terminated",
836
- "sandbox terminated",
837
- "notfound",
838
- "sandbox_not_found",
839
- "box no longer running",
840
- ];
841
- // A "running"/"already exists" resume-conflict is explicitly NOT NotFound — the
842
- // box is alive; recreating would double-spawn.
843
- if (
844
- haystack.includes("already running") ||
845
- haystack.includes("still running") ||
846
- haystack.includes("already exists")
847
- ) {
848
- return false;
849
- }
850
- return goneMarkers.some((marker) => haystack.includes(marker));
851
- }
852
-
853
917
  function readInstanceId(session: unknown): string {
854
918
  const state = (session as { state?: Record<string, unknown> }).state ?? {};
855
919
  const candidate =
@@ -910,6 +974,10 @@ export async function establishSandboxSessionFromEnvelope(
910
974
  backendOverride?: SandboxBackend;
911
975
  environment?: Record<string, string>;
912
976
  onSandboxCreated?: SandboxCreatedCallback;
977
+ /** Called after archive hydration but immediately before the exact workspace
978
+ * fingerprint probe. Lease-aware callers persist `verifying` here so a box
979
+ * is never observable as ready while verification is in flight. */
980
+ onWorkspaceRestoreVerifying?: (descriptor: WorkspaceArchiveDescriptor) => Promise<void>;
913
981
  metrics?: RuntimeMetricsHooks;
914
982
  },
915
983
  ): Promise<EstablishedSandboxSession> {
@@ -958,19 +1026,35 @@ export async function establishSandboxSessionFromEnvelope(
958
1026
  // - COLD lease re-warm (confirmDrainCold preserved a MINIMAL archive-only
959
1027
  // envelope `{ sessionState: { workspaceArchive } }` — NO sandboxId, so the
960
1028
  // warm-reattach branch must NOT try resume()-by-id; it cold-creates+hydrates).
961
- const workspaceArchives = readWorkspaceArchivePairFromEnvelopeSessionState(envelopeSessionState);
962
- const workspaceArchive = workspaceArchives.current;
1029
+ const archiveState =
1030
+ envelopeSessionState && typeof envelopeSessionState === "object"
1031
+ ? (envelopeSessionState as {
1032
+ workspaceArchive?: unknown;
1033
+ workspaceArchiveMeta?: unknown;
1034
+ })
1035
+ : undefined;
1036
+ // Exactly one selected revision is restored. A present archive without its
1037
+ // revision/hash/tree descriptor is not recoverable truth and fails before any
1038
+ // provider create. `workspaceArchivePrev` is retained for explicit future
1039
+ // operator selection, never silently substituted in this attempt.
1040
+ const workspaceArchiveBase64 = archiveState?.workspaceArchive;
1041
+ const workspaceArchiveMetadata = archiveState?.workspaceArchiveMeta;
963
1042
 
964
1043
  // create() a FRESH box, THEN replay the persisted /workspace snapshot via
965
1044
  // session.hydrateWorkspace(archive) when one rode the envelope. hydrateWorkspace
966
1045
  // decodes the snapshot-ref and swaps the box for one booted from the snapshot
967
- // image (restoreSnapshotFilesystem); no archive -> a clean empty box. This is the
968
- // SOLE archive-replay seam, shared by the NotFound warm-reattach path AND the
969
- // cold-restore branch (b) below.
970
- const coldRestore = async (
971
- resumeFallbackState?: unknown,
972
- skipWorkspaceHydrate = false,
973
- ): Promise<EstablishedSandboxSession> => {
1046
+ // image (restoreSnapshotFilesystem). No archive is valid only for a genuinely
1047
+ // new workspace; recovery callers select and verify an exact archive before
1048
+ // entering this seam. This is the SOLE archive-replay path, shared by the
1049
+ // NotFound warm-reattach path and the cold-restore branch (b) below.
1050
+ const coldRestore = async (resumeFallbackState?: unknown): Promise<EstablishedSandboxSession> => {
1051
+ // Parse/verify lazily: a warm resume-by-id does not consume its retained
1052
+ // archive, so a legacy live box remains resumable. Creation/restoration is
1053
+ // the boundary that requires complete durable metadata.
1054
+ const workspaceArchive: VerifiedWorkspaceArchive | null = readVerifiedWorkspaceArchive(
1055
+ workspaceArchiveBase64,
1056
+ workspaceArchiveMetadata,
1057
+ );
974
1058
  const createStarted = Date.now();
975
1059
  let restored: Awaited<ReturnType<NonNullable<typeof client.create>>>;
976
1060
  try {
@@ -996,73 +1080,79 @@ export async function establishSandboxSessionFromEnvelope(
996
1080
  throw createCallbackError;
997
1081
  }
998
1082
  }
999
- // Whether an archive was ACTUALLY applied to /workspace — drives `origin`
1000
- // (and the worker's sandbox.box.created `hydrated` field). A clean-box
1001
- // fallback, a backend without hydrateWorkspace, or an all-archives-failed
1002
- // path leaves this false so we never report `hydrated: "archive"` for an
1003
- // empty workspace.
1004
1083
  let hydrationApplied = false;
1005
- if (!skipWorkspaceHydrate && (workspaceArchive || workspaceArchives.previous)) {
1084
+ if (workspaceArchive) {
1006
1085
  const hydrate = (restored as { hydrateWorkspace?: (data: Uint8Array) => Promise<void> })
1007
1086
  .hydrateWorkspace;
1008
- if (typeof hydrate === "function") {
1009
- let hydrated = false;
1010
- for (const candidate of [
1011
- { archive: workspaceArchive, label: "current" },
1012
- { archive: workspaceArchives.previous, label: "previous" },
1013
- ]) {
1014
- if (!candidate.archive) {
1015
- continue;
1016
- }
1087
+ if (typeof hydrate !== "function") {
1088
+ await terminateCreatedSandbox(client, restored, restoredState);
1089
+ throw new WorkspaceArchiveIntegrityError(
1090
+ "archive_hydration_failed",
1091
+ `sandbox backend ${client.backendId} cannot hydrate selected archive revision ${workspaceArchive.descriptor.revision}`,
1092
+ );
1093
+ }
1094
+ try {
1095
+ // hydrateWorkspace may internally replace the underlying box.
1096
+ await hydrate.call(restored, workspaceArchive.bytes);
1097
+ } catch (error) {
1098
+ await terminateCreatedSandbox(client, restored, (restored as { state?: unknown }).state);
1099
+ if (error instanceof WorkspaceArchiveIntegrityError) throw error;
1100
+ throw new WorkspaceArchiveIntegrityError(
1101
+ "archive_hydration_failed",
1102
+ `failed to hydrate selected workspace archive revision ${workspaceArchive.descriptor.revision}`,
1103
+ { retryable: true },
1104
+ );
1105
+ }
1106
+ // hydrateWorkspace may replace the provider box (Modal's native snapshot
1107
+ // restore does this). Attribute the newly-active identity immediately,
1108
+ // before restore-state marking, fingerprint verification, or any caller
1109
+ // can publish the box warm. The callback is the durable lease/tagging
1110
+ // boundary; if it cannot persist the replacement, the caller fails closed
1111
+ // and this exact replacement is terminated below.
1112
+ const hydratedState = (restored as { state?: unknown }).state;
1113
+ const hydratedInstanceId = readInstanceId(restored);
1114
+ if (hydratedInstanceId && hydratedInstanceId !== established.instanceId) {
1115
+ established = {
1116
+ client,
1117
+ session: restored,
1118
+ sessionState: hydratedState ?? resumeFallbackState,
1119
+ instanceId: hydratedInstanceId,
1120
+ backendId: client.backendId,
1121
+ };
1122
+ if (opts.onSandboxCreated) {
1017
1123
  try {
1018
- // hydrateWorkspace may internally REPLACE the underlying box
1019
- // (restoreSnapshotFilesystem creates a replacement sandbox and terminates
1020
- // the placeholder), so the instanceId must be re-read AFTER.
1021
- await hydrate.call(restored, candidate.archive);
1022
- console.info(
1023
- `[sandbox] cold-restore hydrated workspace from ${candidate.label} archive`,
1024
- );
1025
- hydrated = true;
1026
- break;
1027
- } catch (hydrateError) {
1028
- console.warn(
1029
- `[sandbox] cold-restore failed to hydrate workspace from ${candidate.label} archive${
1030
- candidate.label === "current" && workspaceArchives.previous
1031
- ? "; trying previous archive"
1032
- : ""
1033
- }`,
1034
- hydrateError,
1035
- );
1124
+ await opts.onSandboxCreated(established);
1125
+ } catch (createCallbackError) {
1126
+ await terminateCreatedSandbox(client, restored, hydratedState);
1127
+ throw createCallbackError;
1036
1128
  }
1037
1129
  }
1038
- if (!hydrated) {
1039
- // If both retained archives are unusable, drop the placeholder and
1040
- // fall through to the same clean-box behavior as an envelope with no
1041
- // archive. Restore failure is fail-open; it must not strand a turn.
1042
- await terminateCreatedSandbox(client, restored, restoredState);
1043
- return await coldRestore(resumeFallbackState, true);
1044
- }
1045
- hydrationApplied = true;
1046
- const hydratedState = (restored as { state?: unknown }).state;
1047
- const hydratedInstanceId = readInstanceId(restored);
1048
- if (hydratedInstanceId && hydratedInstanceId !== established.instanceId) {
1049
- established = {
1050
- client,
1051
- session: restored,
1052
- sessionState: hydratedState ?? resumeFallbackState,
1053
- instanceId: hydratedInstanceId,
1054
- backendId: client.backendId,
1055
- };
1056
- if (opts.onSandboxCreated) {
1057
- try {
1058
- await opts.onSandboxCreated(established);
1059
- } catch (createCallbackError) {
1060
- await terminateCreatedSandbox(client, restored, hydratedState);
1061
- throw createCallbackError;
1062
- }
1063
- }
1130
+ }
1131
+ if (opts.onWorkspaceRestoreVerifying) {
1132
+ try {
1133
+ await opts.onWorkspaceRestoreVerifying(workspaceArchive.descriptor);
1134
+ } catch (error) {
1135
+ await terminateCreatedSandbox(client, restored, (restored as { state?: unknown }).state);
1136
+ throw error;
1064
1137
  }
1065
1138
  }
1139
+ try {
1140
+ // Verification happens on the hydrated replacement before any caller
1141
+ // may publish it warm or route an operation to it.
1142
+ await verifyRestoredWorkspace(restored, workspaceArchive.descriptor);
1143
+ } catch (error) {
1144
+ await terminateCreatedSandbox(client, restored, (restored as { state?: unknown }).state);
1145
+ if (error instanceof WorkspaceArchiveIntegrityError) throw error;
1146
+ throw new WorkspaceArchiveIntegrityError(
1147
+ "workspace_fingerprint_unavailable",
1148
+ `failed to verify selected workspace archive revision ${workspaceArchive.descriptor.revision}`,
1149
+ { retryable: true },
1150
+ );
1151
+ }
1152
+ hydrationApplied = true;
1153
+ console.info(
1154
+ `[sandbox] cold-restore verified workspace archive revision ${workspaceArchive.descriptor.revision}`,
1155
+ );
1066
1156
  }
1067
1157
  restoredState = (restored as { state?: unknown }).state;
1068
1158
  return {
@@ -1072,6 +1162,7 @@ export async function establishSandboxSessionFromEnvelope(
1072
1162
  instanceId: readInstanceId(restored),
1073
1163
  backendId: client.backendId,
1074
1164
  origin: hydrationApplied ? ("restored" as const) : ("created" as const),
1165
+ ...(workspaceArchive ? { restoredArchive: workspaceArchive.descriptor } : {}),
1075
1166
  };
1076
1167
  };
1077
1168
 
@@ -1246,3 +1337,127 @@ export async function serializeEstablishedSandboxEnvelope(
1246
1337
  return null;
1247
1338
  }
1248
1339
  }
1340
+
1341
+ const PROVIDER_IDENTITY_FIELDS = [
1342
+ "sandboxId",
1343
+ "instanceId",
1344
+ "id",
1345
+ "hostId",
1346
+ "containerId",
1347
+ "workspaceRootPath",
1348
+ "agentId",
1349
+ ] as const;
1350
+
1351
+ /**
1352
+ * Whether a replacement envelope contains a provider-resumable identity. An
1353
+ * archive-only envelope is intentionally valid recovery metadata, but it is
1354
+ * never proof that the newly-created provider box can be resumed or attached.
1355
+ */
1356
+ export function hasPersistableSandboxProviderIdentity(
1357
+ envelope: Record<string, unknown> | null | undefined,
1358
+ ): envelope is Record<string, unknown> {
1359
+ const sessionState =
1360
+ envelope?.sessionState && typeof envelope.sessionState === "object"
1361
+ ? (envelope.sessionState as Record<string, unknown>)
1362
+ : null;
1363
+ const providerState =
1364
+ sessionState?.providerState && typeof sessionState.providerState === "object"
1365
+ ? (sessionState.providerState as Record<string, unknown>)
1366
+ : null;
1367
+ return Boolean(
1368
+ providerState &&
1369
+ PROVIDER_IDENTITY_FIELDS.some(
1370
+ (field) => typeof providerState[field] === "string" && providerState[field].length > 0,
1371
+ ),
1372
+ );
1373
+ }
1374
+
1375
+ /** A replacement provider may not be published warm without a resumable
1376
+ * identity. Archive-only state is retained only when the caller rolls back to
1377
+ * cold and needs the verified workspace bytes for the next attempt. */
1378
+ export class SandboxReplacementProviderStateError extends Error {
1379
+ readonly code = "replacement_provider_state_unpersistable" as const;
1380
+
1381
+ constructor(public readonly backend: string) {
1382
+ super(
1383
+ `sandbox backend "${backend}" replacement provider state could not be serialized; refusing to publish an attachable lease`,
1384
+ );
1385
+ this.name = "SandboxReplacementProviderStateError";
1386
+ }
1387
+ }
1388
+
1389
+ export function requirePersistableReplacementSandboxEnvelope(
1390
+ envelope: Record<string, unknown> | null,
1391
+ backend: string,
1392
+ ): Record<string, unknown> {
1393
+ if (!hasPersistableSandboxProviderIdentity(envelope)) {
1394
+ throw new SandboxReplacementProviderStateError(backend);
1395
+ }
1396
+ return envelope;
1397
+ }
1398
+
1399
+ const DURABLE_WORKSPACE_ARCHIVE_FIELDS = [
1400
+ "workspaceArchive",
1401
+ "workspaceArchiveMeta",
1402
+ "workspaceArchivePrev",
1403
+ "workspaceArchivePrevMeta",
1404
+ "workspaceArchiveAt",
1405
+ ] as const;
1406
+
1407
+ function durableWorkspaceArchiveFields(
1408
+ envelope: Record<string, unknown> | null,
1409
+ ): Record<string, unknown> | null {
1410
+ const sessionState =
1411
+ envelope?.sessionState && typeof envelope.sessionState === "object"
1412
+ ? (envelope.sessionState as Record<string, unknown>)
1413
+ : null;
1414
+ if (
1415
+ !sessionState ||
1416
+ typeof sessionState.workspaceArchive !== "string" ||
1417
+ sessionState.workspaceArchive.length === 0
1418
+ ) {
1419
+ return null;
1420
+ }
1421
+ const fields: Record<string, unknown> = {};
1422
+ for (const key of DURABLE_WORKSPACE_ARCHIVE_FIELDS) {
1423
+ if (sessionState[key] !== undefined && sessionState[key] !== null) {
1424
+ fields[key] = sessionState[key];
1425
+ }
1426
+ }
1427
+ return fields;
1428
+ }
1429
+
1430
+ /**
1431
+ * Build the only resume envelope that may be published for a newly-created
1432
+ * replacement sandbox. Historical state contributes durable archive pointers
1433
+ * only; it can never substitute for serialization of the replacement's provider
1434
+ * identity. If replacement serialization is unavailable or fails, publication
1435
+ * keeps an archive-only envelope (when one exists) or null. The new instance id
1436
+ * remains separately fenced on the lease, while later attach/resume fails closed
1437
+ * rather than targeting the dead provider that initiated rematerialization.
1438
+ */
1439
+ export async function serializeReplacementSandboxEnvelope(
1440
+ established: EstablishedSandboxSession,
1441
+ archiveSource: Record<string, unknown> | null,
1442
+ ): Promise<Record<string, unknown> | null> {
1443
+ const serialized = await serializeEstablishedSandboxEnvelope(established);
1444
+ const archiveFields = durableWorkspaceArchiveFields(archiveSource);
1445
+ if (!serialized && !archiveFields) {
1446
+ return null;
1447
+ }
1448
+ const serializedSessionState =
1449
+ serialized?.sessionState && typeof serialized.sessionState === "object"
1450
+ ? (serialized.sessionState as Record<string, unknown>)
1451
+ : {};
1452
+ return {
1453
+ ...(serialized ?? {}),
1454
+ // Never inherit a historical backend marker when the replacement could not
1455
+ // serialize. The separately-persisted resume_backend_id and this envelope
1456
+ // must both describe the replacement.
1457
+ backendId: established.backendId,
1458
+ sessionState: {
1459
+ ...serializedSessionState,
1460
+ ...(archiveFields ?? {}),
1461
+ },
1462
+ };
1463
+ }