@opengeni/runtime 0.2.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 (65) hide show
  1. package/dist/chunk-2PO56VAL.js +3478 -0
  2. package/dist/chunk-2PO56VAL.js.map +1 -0
  3. package/dist/index.d.ts +912 -0
  4. package/dist/index.js +3663 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/sandbox/index.d.ts +1738 -0
  7. package/dist/sandbox/index.js +187 -0
  8. package/dist/sandbox/index.js.map +1 -0
  9. package/package.json +49 -0
  10. package/src/bundled_hashicorp_terraform_skills/LICENSE +373 -0
  11. package/src/bundled_hashicorp_terraform_skills/README.md +18 -0
  12. package/src/bundled_hashicorp_terraform_skills/UPSTREAM_GIT_SHA +1 -0
  13. package/src/bundled_hashicorp_terraform_skills/azure-verified-modules/SKILL.md +613 -0
  14. package/src/bundled_hashicorp_terraform_skills/checkov/SKILL.md +43 -0
  15. package/src/bundled_hashicorp_terraform_skills/refactor-module/SKILL.md +538 -0
  16. package/src/bundled_hashicorp_terraform_skills/social-media-marketing/SKILL.md +35 -0
  17. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/SKILL.md +372 -0
  18. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/references/MANUAL-IMPORT.md +113 -0
  19. package/src/bundled_hashicorp_terraform_skills/terraform-search-import/scripts/list_resources.sh +38 -0
  20. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/SKILL.md +480 -0
  21. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/api-monitoring.md +543 -0
  22. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/component-blocks.md +476 -0
  23. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/deployment-blocks.md +391 -0
  24. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/examples.md +1529 -0
  25. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/linked-stacks.md +187 -0
  26. package/src/bundled_hashicorp_terraform_skills/terraform-stacks/references/troubleshooting.md +671 -0
  27. package/src/bundled_hashicorp_terraform_skills/terraform-style-guide/SKILL.md +353 -0
  28. package/src/bundled_hashicorp_terraform_skills/terraform-test/SKILL.md +451 -0
  29. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/CI_CD.md +80 -0
  30. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/EXAMPLES.md +314 -0
  31. package/src/bundled_hashicorp_terraform_skills/terraform-test/references/MOCK_PROVIDERS.md +171 -0
  32. package/src/codex-tool-search.ts +267 -0
  33. package/src/context-compaction.ts +538 -0
  34. package/src/history-sanitizer.ts +719 -0
  35. package/src/index.ts +3299 -0
  36. package/src/sandbox/capabilities.ts +69 -0
  37. package/src/sandbox/channel-a.ts +1031 -0
  38. package/src/sandbox/display-stack.ts +231 -0
  39. package/src/sandbox/errors.ts +34 -0
  40. package/src/sandbox/index.ts +832 -0
  41. package/src/sandbox/providers/blaxel.ts +35 -0
  42. package/src/sandbox/providers/cloudflare.ts +24 -0
  43. package/src/sandbox/providers/daytona.ts +34 -0
  44. package/src/sandbox/providers/docker.ts +17 -0
  45. package/src/sandbox/providers/e2b.ts +36 -0
  46. package/src/sandbox/providers/index.ts +107 -0
  47. package/src/sandbox/providers/local.ts +13 -0
  48. package/src/sandbox/providers/modal.ts +55 -0
  49. package/src/sandbox/providers/none.ts +13 -0
  50. package/src/sandbox/providers/runloop.ts +32 -0
  51. package/src/sandbox/providers/selfhosted.ts +96 -0
  52. package/src/sandbox/providers/types.ts +38 -0
  53. package/src/sandbox/providers/vercel.ts +29 -0
  54. package/src/sandbox/recording.ts +286 -0
  55. package/src/sandbox/routing/backend-resolver.ts +189 -0
  56. package/src/sandbox/routing/routing-session.ts +455 -0
  57. package/src/sandbox/select.ts +371 -0
  58. package/src/sandbox/selfhosted/capabilities.ts +255 -0
  59. package/src/sandbox/selfhosted/control-rpc.ts +351 -0
  60. package/src/sandbox/selfhosted/session.ts +930 -0
  61. package/src/sandbox/selfhosted/testing.ts +230 -0
  62. package/src/sandbox/stream-port.ts +185 -0
  63. package/src/sandbox/stream-token.ts +90 -0
  64. package/src/sandbox/terminal-server.ts +203 -0
  65. package/src/sandbox-computer.ts +835 -0
@@ -0,0 +1,286 @@
1
+ // @opengeni/runtime/sandbox — the recording loop (P4.3).
2
+ //
3
+ // ffmpeg x11grab on :0 → mp4/webm artifact on the box → read bytes → PUT to
4
+ // object storage → recording.available. The "agent films itself proving the fix"
5
+ // loop: ffmpeg reads exactly the :0 framebuffer the agent's computer-use draws to
6
+ // and the human watches over Channel B (zero projection).
7
+ //
8
+ // These are PLAIN functions over a live, externally-owned session handle — NO
9
+ // Temporal, NO worker RPC, NO actor. They live in the agent-loop-free leaf so the
10
+ // SAME process that already holds the resumed-by-id box (the agent turn's own
11
+ // activity for an on-turn recording, or the API in-process for an off-turn/manual
12
+ // finalize) reads the bytes and PUTs them straight to storage. The bytes go
13
+ // box → process memory → storage PUT and are NEVER serialized as a Temporal
14
+ // activity result — the 256 MB-vs-payload-limit concern dissolves (F10).
15
+ //
16
+ // ── Adversarial-review fixes folded in (module 05 §Adversarial) ──────────────
17
+ // F1 exec is OPTIONAL on Modal (only execCommand) — every command dual-paths.
18
+ // F3 exec/execCommand YIELDS — the SIGINT-and-wait loop is bounded well under
19
+ // the yield window; a direct `base64` exec does the byte transfer.
20
+ // F8 the byte read does NOT assume any over-limit behavior — we cap the ffmpeg
21
+ // file by size on the box first (stat) and fail `max-bytes-exceeded` rather
22
+ // than silently uploading a truncated video.
23
+ // FR the byte transfer is a DIRECT exec (`base64 <abs-path>`), NOT readFile:
24
+ // the recording lives at an absolute /tmp path (never the user's workspace
25
+ // /git tree), and readFile rejects paths outside the manifest workspace
26
+ // root ("escapes the workspace root"). The base64 exec passes
27
+ // maxOutputTokens:null so a large recording is never truncated.
28
+ // F9 the box file is deleted ONLY after the storage PUT confirms — never
29
+ // before (so a failed upload leaves the bytes recoverable for a retry).
30
+ // F12 ffmpeg/x11vnc backgrounding does not block the yield (nohup … & echo $!).
31
+ // F14 duration is computed from wall-clock (stop − start), not assumed.
32
+
33
+ import { DESKTOP_STREAM_PORT } from "@opengeni/contracts";
34
+
35
+ export { DESKTOP_STREAM_PORT };
36
+
37
+ export type RecordingCodec = "h264-mp4" | "vp9-webm";
38
+ export type RecordingContentType = "video/mp4" | "video/webm";
39
+
40
+ const DEFAULT_MAX_SECONDS = 600; // 10 min hard ceiling (the -t bound)
41
+ const DEFAULT_FRAMERATE = 15;
42
+ const DEFAULT_MAX_BYTES = 268_435_456; // 256 MB
43
+ const DEFAULT_DIMENSIONS: [number, number] = [1280, 800];
44
+ // The SIGINT-and-wait loop is bounded well under the command yield window (F3).
45
+ const STOP_YIELD_MS = 20_000;
46
+ const EXEC_YIELD_MS = 15_000;
47
+
48
+ export function contentTypeForCodec(codec: RecordingCodec): RecordingContentType {
49
+ return codec === "vp9-webm" ? "video/webm" : "video/mp4";
50
+ }
51
+ export function extForCodec(codec: RecordingCodec): string {
52
+ return codec === "vp9-webm" ? "webm" : "mp4";
53
+ }
54
+
55
+ /** No exec/execCommand on the session — the box cannot run ffmpeg. */
56
+ export class RecordingUnavailableError extends Error {
57
+ constructor(message: string) { super(message); this.name = "RecordingUnavailableError"; }
58
+ }
59
+ /** ffmpeg failed, the file is missing, or the byte read failed. */
60
+ export class RecordingError extends Error {
61
+ constructor(message: string, readonly reason: "ffmpeg-error" | "box-death" | "max-bytes-exceeded" | "display-unavailable") {
62
+ super(message);
63
+ this.name = "RecordingError";
64
+ }
65
+ }
66
+
67
+ // The structural slice of a provider session the recording loop drives. exec and
68
+ // execCommand are optional (Modal has only execCommand — F1); readFile present on
69
+ // every desktop-capable provider.
70
+ type ExecResultLike = { output?: string; stdout?: string; stderr?: string; exitCode?: number | null; sessionId?: number };
71
+ type RecordingSession = {
72
+ exec?: (args: { cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }) => Promise<ExecResultLike>;
73
+ execCommand?: (args: { cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number }) => Promise<string>;
74
+ readFile?: (args: { path: string; runAs?: string; maxBytes?: number }) => Promise<string | Uint8Array>;
75
+ };
76
+
77
+ function shq(s: string): string {
78
+ return `'${s.replace(/'/g, `'\\''`)}'`;
79
+ }
80
+
81
+ function resultOutput(result: ExecResultLike | string): string {
82
+ if (typeof result === "string") return result;
83
+ return [result.output, result.stderr, result.stdout].filter((v): v is string => typeof v === "string" && v.length > 0).join("\n");
84
+ }
85
+
86
+ // Default per-command output cap (tokens). The byte-read path overrides this to
87
+ // `null` (no truncation) so a base64-encoded recording is never clipped.
88
+ const DEFAULT_MAX_OUTPUT_TOKENS = 4_000;
89
+
90
+ async function run(
91
+ session: RecordingSession,
92
+ cmd: string,
93
+ runAs?: string,
94
+ yieldTimeMs = EXEC_YIELD_MS,
95
+ maxOutputTokens: number | null = DEFAULT_MAX_OUTPUT_TOKENS,
96
+ ): Promise<string> {
97
+ // `maxOutputTokens: null` disables the provider's output truncation entirely
98
+ // (SDK truncateOutput returns the raw text when the cap is nullish).
99
+ const args = { cmd, ...(runAs ? { runAs } : {}), yieldTimeMs, maxOutputTokens } as {
100
+ cmd: string; runAs?: string; yieldTimeMs?: number; maxOutputTokens?: number;
101
+ };
102
+ if (typeof session.exec === "function") {
103
+ return resultOutput(await session.exec(args));
104
+ }
105
+ if (typeof session.execCommand === "function") {
106
+ return resultOutput(await session.execCommand(args));
107
+ }
108
+ throw new RecordingUnavailableError("session cannot run commands (no exec/execCommand) — recording unavailable");
109
+ }
110
+
111
+ // Extract the command body from a provider exec banner. Modal's execCommand
112
+ // returns "<chunk banner>\nProcess exited…\nOutput:\n<body>"; the body is what
113
+ // follows the last "Output:\n" marker. Plain exec results (no banner) pass
114
+ // through unchanged.
115
+ function stripExecBanner(raw: string): string {
116
+ const marker = raw.lastIndexOf("\nOutput:\n");
117
+ if (marker >= 0) return raw.slice(marker + "\nOutput:\n".length);
118
+ if (raw.startsWith("Output:\n")) return raw.slice("Output:\n".length);
119
+ return raw;
120
+ }
121
+
122
+ export type StartRecordingInput = {
123
+ recordingId: string;
124
+ codec?: RecordingCodec;
125
+ framerate?: number;
126
+ maxSeconds?: number;
127
+ dimensions?: [number, number];
128
+ display?: string; // ":0"
129
+ runAs?: string;
130
+ tmpDir?: string; // "/tmp"
131
+ };
132
+
133
+ export type RecordingProcess = {
134
+ recordingId: string;
135
+ codec: RecordingCodec;
136
+ boxPath: string;
137
+ pidFile: string;
138
+ dimensions: [number, number];
139
+ framerate: number;
140
+ /** epoch-ms when ffmpeg was launched (for duration computation, F14). */
141
+ startedAt: number;
142
+ display: string;
143
+ runAs?: string;
144
+ };
145
+
146
+ /**
147
+ * Launch ffmpeg x11grab on :0 → an mp4/webm file on the box. Backgrounded with
148
+ * `nohup … & echo $!` so the launch returns immediately (F12 — the exec does not
149
+ * block on the recording). A hard `-t <maxSeconds>` ceiling bounds a runaway file
150
+ * across a multi-day turn. Returns the handle the caller carries to stop+finalize.
151
+ */
152
+ export async function startRecording(session: unknown, input: StartRecordingInput): Promise<RecordingProcess> {
153
+ const s = session as RecordingSession;
154
+ const codec = input.codec ?? "h264-mp4";
155
+ const dimensions = input.dimensions ?? DEFAULT_DIMENSIONS;
156
+ const framerate = input.framerate ?? DEFAULT_FRAMERATE;
157
+ const maxSeconds = input.maxSeconds ?? DEFAULT_MAX_SECONDS;
158
+ const display = input.display ?? ":0";
159
+ const tmp = input.tmpDir ?? "/tmp";
160
+ const ext = extForCodec(codec);
161
+ const boxPath = `${tmp}/og-rec-${input.recordingId}.${ext}`;
162
+ const pidFile = `${tmp}/og-rec-${input.recordingId}.pid`;
163
+ const logFile = `${tmp}/og-rec-${input.recordingId}.log`;
164
+ const [w, h] = dimensions;
165
+ const enc = codec === "vp9-webm"
166
+ ? `-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1`
167
+ : `-c:v libx264 -preset veryfast -pix_fmt yuv420p -movflags +faststart`;
168
+ const ffmpeg =
169
+ `nohup ffmpeg -hide_banner -loglevel error -f x11grab -draw_mouse 1 -framerate ${framerate} ` +
170
+ `-video_size ${w}x${h} -i ${display}.0 -t ${maxSeconds} ${enc} ${boxPath} ` +
171
+ `</dev/null >${logFile} 2>&1 & echo $! > ${pidFile}`;
172
+ await run(s, `bash -lc ${shq(ffmpeg)}`, input.runAs);
173
+ return {
174
+ recordingId: input.recordingId,
175
+ codec,
176
+ boxPath,
177
+ pidFile,
178
+ dimensions,
179
+ framerate,
180
+ startedAt: Date.now(),
181
+ display,
182
+ ...(input.runAs ? { runAs: input.runAs } : {}),
183
+ };
184
+ }
185
+
186
+ /**
187
+ * SIGINT ffmpeg (so it writes a clean moov atom / webm trailer) and wait for the
188
+ * pid to exit. Bounded well under the yield window (F3). Idempotent: a missing
189
+ * pid file is a no-op.
190
+ */
191
+ export async function stopRecording(session: unknown, proc: RecordingProcess): Promise<void> {
192
+ const s = session as RecordingSession;
193
+ const wait = `kill -INT "$(cat ${proc.pidFile})" 2>/dev/null; for i in $(seq 1 80); do kill -0 "$(cat ${proc.pidFile})" 2>/dev/null || break; sleep 0.1; done`;
194
+ await run(s, `bash -lc ${shq(wait)}`, proc.runAs, STOP_YIELD_MS).catch(() => undefined);
195
+ }
196
+
197
+ export type FinalizeRecordingResult = {
198
+ bytes: Uint8Array;
199
+ contentType: RecordingContentType;
200
+ sizeBytes: number;
201
+ durationSeconds: number;
202
+ };
203
+
204
+ /**
205
+ * Read the finalized recording bytes off the box.
206
+ *
207
+ * TRANSPORT: the bytes are read via a DIRECT exec (`base64 <path>` over stdout),
208
+ * NOT via session.readFile(). The recording artifact lives at an absolute /tmp
209
+ * path on purpose — recordings must never be written inside the user's workspace
210
+ * /git tree — but session.readFile() resolves every path against the manifest
211
+ * workspace root and rejects anything outside it ("Sandbox path … escapes the
212
+ * workspace root"), which fataled finalize. Raw exec runs unrestricted shell, so
213
+ * `base64` reads the /tmp file directly; we decode the base64 back to bytes here.
214
+ * The byte-read exec passes `maxOutputTokens: null` so the provider never
215
+ * truncates a large recording's base64.
216
+ *
217
+ * F8: we DO NOT assume any over-limit behavior. First `stat` the file size on the
218
+ * box; if it exceeds maxBytes, fail `max-bytes-exceeded` (never upload a truncated
219
+ * video). Otherwise read the raw bytes.
220
+ *
221
+ * F9: this does NOT delete the box file. The caller deletes it (deleteRecordingArtifacts)
222
+ * ONLY after the storage PUT + `available` commit — so a failed upload leaves the
223
+ * bytes recoverable on the box for a retry.
224
+ *
225
+ * F14: duration is wall-clock (now − startedAt), a close approximation of the
226
+ * SIGINT-flushed video length.
227
+ */
228
+ export async function readRecordingBytes(session: unknown, proc: RecordingProcess, maxBytes = DEFAULT_MAX_BYTES): Promise<FinalizeRecordingResult> {
229
+ const s = session as RecordingSession;
230
+ if (typeof s.exec !== "function" && typeof s.execCommand !== "function") {
231
+ throw new RecordingUnavailableError("session cannot run commands (no exec/execCommand) — recording finalize unavailable");
232
+ }
233
+ // F8: size-gate on the box before reading into memory.
234
+ const sizeOut = (await run(s, `bash -lc ${shq(`stat -c %s ${proc.boxPath} 2>/dev/null || echo MISSING`)}`, proc.runAs)).trim();
235
+ const sizeLine = sizeOut.split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? "MISSING";
236
+ if (sizeLine === "MISSING" || sizeLine === "") {
237
+ throw new RecordingError(`recording file missing on box: ${proc.boxPath}`, "box-death");
238
+ }
239
+ const size = Number(sizeLine);
240
+ if (!Number.isFinite(size) || size <= 0) {
241
+ throw new RecordingError(`recording file empty on box: ${proc.boxPath}`, "ffmpeg-error");
242
+ }
243
+ if (size > maxBytes) {
244
+ throw new RecordingError(`recording ${size}B exceeds max ${maxBytes}B`, "max-bytes-exceeded");
245
+ }
246
+ // Read the bytes via a DIRECT exec (base64, no output truncation), so the
247
+ // absolute /tmp path is NOT run through the workspace-root-scoped readFile guard.
248
+ const STOP_YIELD = 60_000; // a large recording's base64 read may take longer than the default exec yield.
249
+ const encoded = stripExecBanner(
250
+ await run(s, `bash -lc ${shq(`base64 ${proc.boxPath}`)}`, proc.runAs, STOP_YIELD, null),
251
+ );
252
+ const base64 = encoded.replace(/\s+/g, "");
253
+ if (base64.length === 0) {
254
+ throw new RecordingError(`recording read returned 0 bytes: ${proc.boxPath}`, "ffmpeg-error");
255
+ }
256
+ let bytes: Uint8Array;
257
+ try {
258
+ bytes = Uint8Array.from(Buffer.from(base64, "base64"));
259
+ } catch (error) {
260
+ throw new RecordingError(`recording base64 decode failed: ${error instanceof Error ? error.message : String(error)}`, "ffmpeg-error");
261
+ }
262
+ if (bytes.length === 0) {
263
+ throw new RecordingError(`recording read returned 0 bytes: ${proc.boxPath}`, "ffmpeg-error");
264
+ }
265
+ return {
266
+ bytes,
267
+ contentType: contentTypeForCodec(proc.codec),
268
+ sizeBytes: bytes.length,
269
+ durationSeconds: Math.max(0, Math.round((Date.now() - proc.startedAt) / 1000)),
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Delete the box artifacts. F9: call this ONLY after the storage PUT confirmed
275
+ * and the `available` row committed — never before. Best-effort; never throws.
276
+ */
277
+ export async function deleteRecordingArtifacts(session: unknown, proc: RecordingProcess): Promise<void> {
278
+ const s = session as RecordingSession;
279
+ const logFile = proc.boxPath.replace(/\.(mp4|webm)$/, ".log");
280
+ await run(s, `rm -f ${proc.boxPath} ${proc.pidFile} ${logFile}`, proc.runAs).catch(() => undefined);
281
+ }
282
+
283
+ /** The storage object key for a recording artifact (parallels the file-asset layout). */
284
+ export function recordingStorageKey(workspaceId: string, sessionId: string, recordingId: string, codec: RecordingCodec): string {
285
+ return `recordings/${workspaceId}/${sessionId}/${recordingId}.${extForCodec(codec)}`;
286
+ }
@@ -0,0 +1,189 @@
1
+ // `makeActiveBackendResolver` — builds the `resolveActiveBackend` closure the
2
+ // `RoutingSandboxSession` calls to turn an active pointer into a live backend
3
+ // session (M7). It is the heterogeneous-dispatch core: a pointer's target is
4
+ // EITHER the session's own group sandbox (the default, `activeSandboxId === null`)
5
+ // OR a first-class named sandbox the session swapped to — a sibling Modal box or
6
+ // a selfhosted machine.
7
+ //
8
+ // This lives in the agent-loop-free leaf and depends ONLY on injected closures +
9
+ // the selfhosted session builder, so the API/worker wire it to the real DB
10
+ // (`getSandbox`/`getEnrollment`/`readActiveSandbox`) + the live NATS ControlRpc
11
+ // without coupling the leaf to `@opengeni/db`.
12
+ //
13
+ // The DEFAULT target (the group box) is supplied as an already-established
14
+ // session (the turn box `resumeBoxForTurn` produced, or the Channel-A established
15
+ // handle) — the proxy does NOT re-establish it (the lease owns its lifecycle). A
16
+ // NON-DEFAULT selfhosted target builds a `SelfhostedSession` bound to the target's
17
+ // enrollment agentId, fenced under the swap's active_epoch. A non-default MODAL
18
+ // target is established via the injected `establishModalTarget` resolver (the
19
+ // API/worker pass a resume-by-id closure for the sibling box's lease).
20
+
21
+ import { buildSelfhostedBackendSession, type SelfhostedRelayConfig } from "../selfhosted/session";
22
+ import type { ControlRpc } from "../selfhosted/control-rpc";
23
+ import type {
24
+ ActivePointer,
25
+ RoutableBackendSession,
26
+ ResolvedActiveBackend,
27
+ } from "./routing-session";
28
+
29
+ /** The structural slice of a first-class sandbox the resolver reads (mirror of
30
+ * `@opengeni/db`'s `SandboxRecord`; structural so the leaf does not import DB). */
31
+ export interface RoutableSandbox {
32
+ id: string;
33
+ kind: "modal" | "selfhosted" | string;
34
+ name: string;
35
+ /** For a selfhosted sandbox this is its enrollment id (== the agent id the
36
+ * control-plane subject `agent.<ws>.<id>.rpc` addresses). Null for modal. */
37
+ enrollmentId: string | null;
38
+ }
39
+
40
+ export interface ActiveBackendResolverDeps {
41
+ /** The workspace the session belongs to (the control-plane subject scope). */
42
+ workspaceId: string;
43
+ /** The session's own group sandbox session — the DEFAULT target
44
+ * (`activeSandboxId === null`). Already established (lease-owned); the proxy
45
+ * never re-establishes it. */
46
+ defaultBackend: RoutableBackendSession;
47
+ /** A label for the default backend (its backend id: "modal"/"selfhosted"/…). */
48
+ defaultKind: string;
49
+ /** Look up a first-class sandbox by id (the swap target). Returns null when the
50
+ * id is unknown or not in this workspace (the caller 409s the swap). */
51
+ getSandbox(sandboxId: string): Promise<RoutableSandbox | null>;
52
+ /** Build a live `ControlRpc` for the selfhosted control plane (the request-
53
+ * scoped NATS connection). Returns a ControlRpc whose offline/timeout maps to
54
+ * agent_offline/agent_reconnecting (never a NotFound). */
55
+ controlRpcFactory(): ControlRpc;
56
+ /** The relay-URL shape config for selfhosted stream endpoints. */
57
+ relay: SelfhostedRelayConfig;
58
+ /** Establish (resume-by-id) a NON-DEFAULT modal target's box session for a swap.
59
+ * Supplied by the API/worker (a closure over the sibling sandbox's lease). When
60
+ * absent, a modal swap target surfaces as unsupported (the caller validated
61
+ * liveness, so this is the "modal swap not wired in this context" guard). */
62
+ establishModalTarget?: (sandbox: RoutableSandbox) => Promise<RoutableBackendSession>;
63
+ /** Override the selfhosted control-op timeout (tests). */
64
+ selfhostedTimeoutMs?: number;
65
+ /**
66
+ * The run's declared sandbox environment — the SAME `Record<string,string>` the
67
+ * worker turn threads into the agent's TARGET manifest (and into the group box at
68
+ * create). Threaded into a selfhosted swap target's session so its
69
+ * `state.manifest.environment` EQUALS the turn's, making the SDK's per-turn
70
+ * provided-session manifest-env delta empty (validateNoEnvironmentDelta).
71
+ * WITHOUT this a pin-to-vm turn throws "Live sandbox sessions cannot change
72
+ * manifest environment variables". Omitted → `{}` (the test/negotiation path).
73
+ */
74
+ environment?: Record<string, string>;
75
+ /**
76
+ * A pre-established selfhosted session to PIN for the STEADY-STATE machine
77
+ * pointer (the worker turn's machine-primary path, Stage D). When the pointer
78
+ * targets THIS sandbox at THIS epoch, the resolver returns this SAME instance
79
+ * instead of building a fresh `SelfhostedSession`. This is the instance-identity
80
+ * pin: the SDK reads/writes `state.manifest` at turn START via the proxy's `state`
81
+ * getter (which reads the default/last-resolved backend's state) and then reads it
82
+ * per op via this resolver — those MUST land on ONE SelfhostedSession/manifest, or
83
+ * a turn-start manifest write is invisible to the per-op reads (two-instance
84
+ * divergence). A swap AWAY (a different sandbox id, or the same id at a moved epoch)
85
+ * falls through to a fresh build under the new epoch. Omitted for the API/live-swap
86
+ * path (which always builds fresh — it has no pre-established turn session).
87
+ */
88
+ pinnedSelfhosted?: { sandboxId: string; epoch: number; session: RoutableBackendSession };
89
+ }
90
+
91
+ /** Thrown when a swap target cannot be resolved (unknown sandbox, or a modal
92
+ * target with no establisher in this context). The caller maps it to a 409. */
93
+ export class ActiveBackendUnresolvableError extends Error {
94
+ readonly name = "ActiveBackendUnresolvableError";
95
+ constructor(message: string) {
96
+ super(message);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Build the `resolveActiveBackend(pointer)` closure for a `RoutingSandboxSession`.
102
+ * The returned closure is re-invoked by the proxy whenever the active_epoch moves
103
+ * (the per-epoch cache miss), so it must be cheap-and-correct for the steady-state
104
+ * (default pointer → the already-established group box) and build a fresh backend
105
+ * for a swap target.
106
+ *
107
+ * - `activeSandboxId === null` → the default group backend (no re-establish).
108
+ * - a selfhosted target → a `SelfhostedSession` bound to the enrollment agentId,
109
+ * fenced under `pointer.activeEpoch` (echoed on every ControlRequest so the
110
+ * agent can reject a stale op with ERROR_CODE_FENCED — the swap-race fence).
111
+ * - a modal target → `establishModalTarget` (the resume-by-id closure), else
112
+ * unresolvable.
113
+ */
114
+ export function makeActiveBackendResolver(
115
+ deps: ActiveBackendResolverDeps,
116
+ ): (pointer: ActivePointer) => Promise<ResolvedActiveBackend> {
117
+ return async (pointer: ActivePointer): Promise<ResolvedActiveBackend> => {
118
+ // The DEFAULT target: the session's own group sandbox (backward-compat). The
119
+ // proxy routes to the already-established box; the lease owns its lifecycle.
120
+ if (pointer.activeSandboxId === null) {
121
+ return { session: deps.defaultBackend, sandboxId: null, kind: deps.defaultKind };
122
+ }
123
+
124
+ // INSTANCE PIN (Stage D machine-primary): the steady-state machine pointer
125
+ // returns the pre-established turn session BY REFERENCE — never a fresh build —
126
+ // so the turn-start manifest write + the per-op reads land on ONE
127
+ // SelfhostedSession/manifest. Matched on BOTH the sandbox id AND the epoch: a
128
+ // swap away (different id) or a swap-back (same id, higher epoch) falls through
129
+ // to a fresh build fenced under the CURRENT epoch (the stale pinned instance is
130
+ // fenced at the old epoch and must not be reused).
131
+ if (
132
+ deps.pinnedSelfhosted
133
+ && pointer.activeSandboxId === deps.pinnedSelfhosted.sandboxId
134
+ && pointer.activeEpoch === deps.pinnedSelfhosted.epoch
135
+ ) {
136
+ return { session: deps.pinnedSelfhosted.session, sandboxId: pointer.activeSandboxId, kind: "selfhosted" };
137
+ }
138
+
139
+ const sandbox = await deps.getSandbox(pointer.activeSandboxId);
140
+ if (!sandbox) {
141
+ throw new ActiveBackendUnresolvableError(
142
+ `active sandbox ${pointer.activeSandboxId} not found in workspace ${deps.workspaceId}`,
143
+ );
144
+ }
145
+
146
+ if (sandbox.kind === "selfhosted") {
147
+ if (!sandbox.enrollmentId) {
148
+ throw new ActiveBackendUnresolvableError(
149
+ `selfhosted sandbox ${sandbox.id} has no enrollment (agent id) to address`,
150
+ );
151
+ }
152
+ // Build a request-scoped selfhosted client bound to the target's workspace +
153
+ // enrollment agentId, fenced under the swap's active_epoch. The agent echoes
154
+ // the epoch and rejects a stale op with ERROR_CODE_FENCED → the proxy
155
+ // re-resolves + retries against the new active sandbox. The SAME factory the
156
+ // worker turn's machine-primary establish branch uses (one build shape).
157
+ const { session } = await buildSelfhostedBackendSession({
158
+ workspaceId: deps.workspaceId,
159
+ relay: deps.relay,
160
+ controlRpcFactory: deps.controlRpcFactory,
161
+ agentId: sandbox.enrollmentId,
162
+ epoch: pointer.activeEpoch,
163
+ ...(deps.selfhostedTimeoutMs !== undefined ? { timeoutMs: deps.selfhostedTimeoutMs } : {}),
164
+ // The turn's declared environment → the session's manifest.environment, so
165
+ // the SDK's per-turn manifest-env delta is empty (no "cannot change manifest
166
+ // environment variables" throw on a pin-to-vm turn).
167
+ ...(deps.environment !== undefined ? { environment: deps.environment } : {}),
168
+ // The session's working directory (per-session pointer) → the path/cwd base
169
+ // for this selfhosted backend. Absent/empty ⇒ the default workspace_root.
170
+ ...(pointer.workingDir ? { workingDir: pointer.workingDir } : {}),
171
+ });
172
+ return { session: session as RoutableBackendSession, sandboxId: sandbox.id, kind: "selfhosted" };
173
+ }
174
+
175
+ if (sandbox.kind === "modal") {
176
+ if (!deps.establishModalTarget) {
177
+ throw new ActiveBackendUnresolvableError(
178
+ `modal swap target ${sandbox.id} cannot be established in this context (no establisher wired)`,
179
+ );
180
+ }
181
+ const session = await deps.establishModalTarget(sandbox);
182
+ return { session, sandboxId: sandbox.id, kind: "modal" };
183
+ }
184
+
185
+ throw new ActiveBackendUnresolvableError(
186
+ `unsupported swap target kind "${sandbox.kind}" for sandbox ${sandbox.id}`,
187
+ );
188
+ };
189
+ }