@opengeni/runtime 0.6.1 → 0.7.1

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 (45) hide show
  1. package/dist/{chunk-DYBF7ZEB.js → chunk-RYDCVTMW.js} +1958 -207
  2. package/dist/chunk-RYDCVTMW.js.map +1 -0
  3. package/dist/{index-BTo_bZvN.d.ts → index-DIiiwQY2.d.ts} +689 -52
  4. package/dist/index.d.ts +296 -123
  5. package/dist/index.js +1907 -809
  6. package/dist/index.js.map +1 -1
  7. package/dist/sandbox/index.d.ts +1 -1
  8. package/dist/sandbox/index.js +79 -3
  9. package/package.json +21 -20
  10. package/src/codex-tool-search.ts +185 -31
  11. package/src/context-compaction.ts +78 -243
  12. package/src/history-sanitizer.ts +172 -20
  13. package/src/index.ts +2687 -920
  14. package/src/metrics.ts +28 -2
  15. package/src/sandbox/capabilities.ts +12 -3
  16. package/src/sandbox/channel-a.ts +420 -72
  17. package/src/sandbox/display-stack.ts +18 -4
  18. package/src/sandbox/errors.ts +13 -0
  19. package/src/sandbox/index.ts +398 -112
  20. package/src/sandbox/op-correlation.ts +70 -0
  21. package/src/sandbox/providers/index.ts +9 -3
  22. package/src/sandbox/providers/modal.ts +84 -14
  23. package/src/sandbox/providers/selfhosted.ts +1 -4
  24. package/src/sandbox/recording.ts +146 -90
  25. package/src/sandbox/routing/backend-resolver.ts +144 -10
  26. package/src/sandbox/routing/routing-session.ts +129 -25
  27. package/src/sandbox/select.ts +60 -8
  28. package/src/sandbox/selfhosted/capabilities.ts +59 -25
  29. package/src/sandbox/selfhosted/control-rpc.ts +160 -28
  30. package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
  31. package/src/sandbox/selfhosted/op-observer.ts +54 -0
  32. package/src/sandbox/selfhosted/op-stream.ts +801 -0
  33. package/src/sandbox/selfhosted/op-testing.ts +468 -0
  34. package/src/sandbox/selfhosted/op-transport.ts +151 -0
  35. package/src/sandbox/selfhosted/retry-policy.ts +177 -0
  36. package/src/sandbox/selfhosted/session.ts +498 -55
  37. package/src/sandbox/selfhosted/testing.ts +60 -19
  38. package/src/sandbox/stream-port.ts +17 -5
  39. package/src/sandbox/stream-token.ts +8 -2
  40. package/src/sandbox/terminal-server.ts +18 -4
  41. package/src/sandbox-computer.ts +332 -70
  42. package/src/usage-telemetry.ts +73 -0
  43. package/dist/chunk-DYBF7ZEB.js.map +0 -1
  44. package/src/image-history.ts +0 -149
  45. package/src/screenshot-omitted-card.ts +0 -15
@@ -0,0 +1,70 @@
1
+ // Durable op-identity correlation (op-stream ruling B1): the DURABLE op id a
2
+ // sandbox transport op carries is `{sdk_tool_call_id}:{ordinal}` — minted ABOVE
3
+ // the transport, at the semantic layer. The tool call id lives in the model's
4
+ // function_call (persisted run history), so a re-dispatched turn re-executes the
5
+ // SAME function_call with the SAME call id, and the transport's idempotent
6
+ // OpStart turns an at-least-once re-execution into an ATTACH instead of a
7
+ // re-run. The ordinal is the deterministic position of the physical sub-op
8
+ // inside one tool invocation (a tool that performs read→write→rm mints :0, :1,
9
+ // :2), so a re-executed invocation re-mints identical ids.
10
+ //
11
+ // The correlation rides an AsyncLocalStorage bound around the SDK tool's
12
+ // `execute` (see `withExecOpCorrelation` in the runtime barrel): the SDK's tool
13
+ // machinery passes `details.toolCall` (with `callId`) into every function-tool
14
+ // invocation, and the shell capability's `configureTools` hook lets us wrap
15
+ // exec_command without touching the SDK. Per-async-chain storage keeps PARALLEL
16
+ // tool calls correctly separated.
17
+ //
18
+ // This module is deliberately dependency-free (node:async_hooks only) so the
19
+ // agent-loop-free sandbox leaf may import it: the leaf READS the context; only
20
+ // the runtime barrel (which owns the SDK imports) BINDS it.
21
+
22
+ import { AsyncLocalStorage } from "node:async_hooks";
23
+
24
+ interface ToolCallCorrelation {
25
+ /** The sanitized sdk tool call id (a legal NATS subject-token fragment). */
26
+ callId: string;
27
+ /** The next sub-op ordinal within this tool invocation (mutable). */
28
+ ordinal: number;
29
+ }
30
+
31
+ const storage = new AsyncLocalStorage<ToolCallCorrelation>();
32
+
33
+ /**
34
+ * Sanitize a tool call id into a legal NATS subject TOKEN fragment. The op id
35
+ * is interpolated into the per-op frame subject, whose tokens must not contain
36
+ * whitespace/control characters or the subject-structure characters (`.`,
37
+ * `*`, `>`); the runner refuses illegal ids loudly. The mapping is INJECTIVE
38
+ * (each disallowed char becomes `_<hex>_`), so two distinct call ids can never
39
+ * collide into one op id — a collision would merge two different execs through
40
+ * the idempotent-OpStart dedup and return the wrong result.
41
+ */
42
+ export function sanitizeOpIdToken(raw: string): string {
43
+ return raw.replace(/[^A-Za-z0-9_-]/g, (c) => `_${c.charCodeAt(0).toString(16)}_`);
44
+ }
45
+
46
+ /**
47
+ * Bind a tool-call correlation context around `fn` (the tool's execute). Every
48
+ * durable op id minted inside — however deep in the transport — is
49
+ * `{callId}:{ordinal}` with ordinals starting at 0 per invocation.
50
+ */
51
+ export function runWithToolCallCorrelation<T>(callId: string, fn: () => T): T {
52
+ return storage.run({ callId: sanitizeOpIdToken(callId), ordinal: 0 }, fn);
53
+ }
54
+
55
+ /**
56
+ * Mint the next durable op id for the current tool invocation, or null when no
57
+ * correlation context is bound (a non-tool caller, e.g. Channel-A structural
58
+ * exec). Callers fall back to a random unique id — safe (never collides, never
59
+ * wrongly dedups), merely not stable across a turn re-dispatch, which degrades
60
+ * that one op to today's at-least-once semantics.
61
+ */
62
+ export function nextDurableOpId(): string | null {
63
+ const context = storage.getStore();
64
+ if (!context) {
65
+ return null;
66
+ }
67
+ const ordinal = context.ordinal;
68
+ context.ordinal += 1;
69
+ return `${context.callId}:${ordinal}`;
70
+ }
@@ -66,16 +66,22 @@ export function assertProviderRegistryInvariants(): void {
66
66
  for (const backend of SandboxBackend.options) {
67
67
  const registration = PROVIDER_REGISTRY[backend];
68
68
  if (registration.backend !== backend) {
69
- throw new Error(`PROVIDER_REGISTRY["${backend}"].backend mismatch (got "${registration.backend}")`);
69
+ throw new Error(
70
+ `PROVIDER_REGISTRY["${backend}"].backend mismatch (got "${registration.backend}")`,
71
+ );
70
72
  }
71
73
  if (registration.descriptor.backend !== backend) {
72
- throw new Error(`PROVIDER_REGISTRY["${backend}"].descriptor.backend mismatch (got "${registration.descriptor.backend}")`);
74
+ throw new Error(
75
+ `PROVIDER_REGISTRY["${backend}"].descriptor.backend mismatch (got "${registration.descriptor.backend}")`,
76
+ );
73
77
  }
74
78
  if (backend === "none") {
75
79
  // "none" has no SDK client (build returns undefined); the descriptor
76
80
  // backendId "none" is self-consistent.
77
81
  if (registration.descriptor.backendId !== "none") {
78
- throw new Error(`"none" descriptor.backendId must be "none" (got "${registration.descriptor.backendId}")`);
82
+ throw new Error(
83
+ `"none" descriptor.backendId must be "none" (got "${registration.descriptor.backendId}")`,
84
+ );
79
85
  }
80
86
  continue;
81
87
  }
@@ -6,7 +6,14 @@ import { SandboxConfigError } from "../errors";
6
6
  import type { ProviderRegistration } from "./types";
7
7
 
8
8
  const MODAL_ORPHAN_SWEEP_LIMIT = 50;
9
- const MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS = 30 * 60_000;
9
+ // A provider box is invisible to the lease until Modal create + manifest
10
+ // materialization returns and the creation callback records its instance id.
11
+ // Production baseline (2026-07-15, all 8 turn workers): 155/155 completed creates
12
+ // finished under 10s (154 under 2.5s). Two minutes is a 12x observed-max buffer,
13
+ // while avoiding the former 30-minute retention of boxes abandoned by a rolling
14
+ // worker restart. The live-instance guard below remains authoritative: once a box
15
+ // is recorded by any lease, age and missing/stale tags can never terminate it.
16
+ const MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS = 2 * 60_000;
10
17
 
11
18
  export type ModalSandboxAttribution = {
12
19
  leaseId: string;
@@ -31,7 +38,9 @@ export type ModalOrphanSweepResult = {
31
38
  skipped: number;
32
39
  };
33
40
 
34
- export function modalSandboxAttributionEnvironment(input: ModalSandboxAttribution): Record<string, string> {
41
+ export function modalSandboxAttributionEnvironment(
42
+ input: ModalSandboxAttribution,
43
+ ): Record<string, string> {
35
44
  return {
36
45
  OPENGENI_SANDBOX_LEASE_ID: input.leaseId,
37
46
  OPENGENI_SANDBOX_GROUP_ID: input.sandboxGroupId,
@@ -39,7 +48,9 @@ export function modalSandboxAttributionEnvironment(input: ModalSandboxAttributio
39
48
  };
40
49
  }
41
50
 
42
- export function modalSandboxAttributionTags(input: ModalSandboxAttribution): Record<string, string> {
51
+ export function modalSandboxAttributionTags(
52
+ input: ModalSandboxAttribution,
53
+ ): Record<string, string> {
43
54
  return {
44
55
  opengeni: "true",
45
56
  opengeni_lease_id: input.leaseId,
@@ -70,10 +81,11 @@ export const modalProvider: ProviderRegistration = {
70
81
  sandboxCreateTimeoutS: Math.ceil(settings.sandboxWarmingTimeoutMs / 1000),
71
82
  exposedPorts,
72
83
  env: environment,
73
- // The Modal JS SDK's sandbox default command already sleeps until timeout
74
- // or explicit termination. Do not let the Agents extension stamp a separate
75
- // hardcoded sleep command; OPENGENI_MODAL_TIMEOUT_SECONDS owns lifetime.
76
- useSleepCmd: false,
84
+ // A registry image's own CMD is not a sandbox keepalive contract (for
85
+ // example, python:3.12-slim can exit immediately). Keep the provider's
86
+ // control process alive so exec/resume remains available; Modal's hard
87
+ // timeout and explicit OpenGeni teardown still own the box lifetime.
88
+ useSleepCmd: true,
77
89
  };
78
90
  // gap-fill (module 03 §4.1): these SDK options were previously unmapped.
79
91
  // ALWAYS pin idleTimeoutMs (sandbox-file-persistence): an UNSET idle timeout
@@ -206,7 +218,9 @@ export function resolveModalImageSelector(settings: Settings): ModalImageSelecto
206
218
  }
207
219
  const registryImage = cachedModalRegistryImage(settings);
208
220
  return registryImage
209
- ? ModalImageSelector.fromImage(registryImage as Parameters<typeof ModalImageSelector.fromImage>[0])
221
+ ? ModalImageSelector.fromImage(
222
+ registryImage as Parameters<typeof ModalImageSelector.fromImage>[0],
223
+ )
210
224
  : ModalImageSelector.fromTag(settings.modalImageRef);
211
225
  }
212
226
 
@@ -216,7 +230,9 @@ export function __resetModalRegistryImageCacheForTest(): void {
216
230
  inFlightRegistryImages.clear();
217
231
  }
218
232
 
219
- function modalClientOptions(settings: Settings): ConstructorParameters<ModalModule["ModalClient"]>[0] {
233
+ function modalClientOptions(
234
+ settings: Settings,
235
+ ): ConstructorParameters<ModalModule["ModalClient"]>[0] {
220
236
  return {
221
237
  ...(settings.modalTokenId ? { tokenId: settings.modalTokenId } : {}),
222
238
  ...(settings.modalTokenSecret ? { tokenSecret: settings.modalTokenSecret } : {}),
@@ -248,7 +264,10 @@ export async function tagModalSandbox(
248
264
  }
249
265
  }
250
266
 
251
- export async function terminateModalSandboxById(settings: Settings, sandboxId: string): Promise<boolean> {
267
+ export async function terminateModalSandboxById(
268
+ settings: Settings,
269
+ sandboxId: string,
270
+ ): Promise<boolean> {
252
271
  if (!sandboxId) {
253
272
  return true;
254
273
  }
@@ -291,14 +310,22 @@ function tagsFromInfo(info: ModalSandboxInfo): Record<string, string> {
291
310
  }
292
311
 
293
312
  function sandboxCreatedAtMs(info: ModalSandboxInfo): number | null {
294
- if (typeof info.createdAt !== "number" || !Number.isFinite(info.createdAt) || info.createdAt <= 0) {
313
+ if (
314
+ typeof info.createdAt !== "number" ||
315
+ !Number.isFinite(info.createdAt) ||
316
+ info.createdAt <= 0
317
+ ) {
295
318
  return null;
296
319
  }
297
320
  // Modal protobuf timestamps in this SDK are seconds as doubles.
298
- return info.createdAt < 10_000_000_000 ? Math.floor(info.createdAt * 1000) : Math.floor(info.createdAt);
321
+ return info.createdAt < 10_000_000_000
322
+ ? Math.floor(info.createdAt * 1000)
323
+ : Math.floor(info.createdAt);
299
324
  }
300
325
 
301
- function attributionKey(input: Pick<ModalSandboxAttribution, "leaseId" | "workspaceId" | "sandboxGroupId">): string {
326
+ function attributionKey(
327
+ input: Pick<ModalSandboxAttribution, "leaseId" | "workspaceId" | "sandboxGroupId">,
328
+ ): string {
302
329
  return `${input.workspaceId}:${input.sandboxGroupId}:${input.leaseId}`;
303
330
  }
304
331
 
@@ -316,6 +343,18 @@ export async function sweepModalOrphanSandboxes(
316
343
  const maxTerminations = options.maxTerminations ?? MODAL_ORPHAN_SWEEP_LIMIT;
317
344
  const unattributedGraceMs = options.unattributedGraceMs ?? MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS;
318
345
  const liveByAttribution = new Map(liveLeases.map((lease) => [attributionKey(lease), lease]));
346
+ // LIVE-INSTANCE GUARD: a box that any live lease's envelope points at is NEVER
347
+ // an orphan, whatever its tags say. Tags are best-effort attribution (setTags
348
+ // is a separate call after create and can fail or lag); the lease envelope is
349
+ // the source of truth the turn path actually resumes by. Judging by tags alone
350
+ // terminated a LIVE box mid-turn at exactly creation+30min (staging session
351
+ // e644e8a8, 2026-07-06) — the box's unpushed work was unrecoverable because
352
+ // nothing outside the reaper drain persists /workspace.
353
+ const liveByInstanceId = new Map(
354
+ liveLeases
355
+ .filter((lease) => lease.instanceId)
356
+ .map((lease) => [lease.instanceId as string, lease]),
357
+ );
319
358
  const ownedClient = options.client ? null : await createModalClient(settings);
320
359
  const modal = (options.client ?? ownedClient)! as ModalCpListClient;
321
360
  try {
@@ -350,9 +389,40 @@ export async function sweepModalOrphanSandboxes(
350
389
  const leaseId = tags.opengeni_lease_id;
351
390
  const workspaceId = tags.opengeni_workspace_id;
352
391
  const sandboxGroupId = tags.opengeni_sandbox_group_id;
392
+ const liveByInstance = info.id ? liveByInstanceId.get(info.id) : undefined;
393
+ if (liveByInstance) {
394
+ // Live-instance guard (see above): a live lease resumes this exact box
395
+ // by id — hard-skip it, and HEAL its attribution tags when they are
396
+ // missing/stale so it stops looking sweep-eligible. Best-effort: a
397
+ // failed re-tag must never fail the sweep (the guard, not the tags,
398
+ // is what protects the box now).
399
+ if (
400
+ leaseId !== liveByInstance.leaseId ||
401
+ workspaceId !== liveByInstance.workspaceId ||
402
+ sandboxGroupId !== liveByInstance.sandboxGroupId
403
+ ) {
404
+ try {
405
+ const sandbox = await modal.sandboxes.fromId(info.id);
406
+ await sandbox.setTags(
407
+ modalSandboxAttributionTags({
408
+ leaseId: liveByInstance.leaseId,
409
+ workspaceId: liveByInstance.workspaceId,
410
+ sandboxGroupId: liveByInstance.sandboxGroupId,
411
+ }),
412
+ );
413
+ } catch {
414
+ // Tag healing is opportunistic; the instance guard already
415
+ // protects this box on every future sweep pass.
416
+ }
417
+ }
418
+ skipped += 1;
419
+ continue;
420
+ }
353
421
  let reason: ModalOrphanSweepTermination["reason"] | null = null;
354
422
  if (leaseId && workspaceId && sandboxGroupId) {
355
- const live = liveByAttribution.get(attributionKey({ leaseId, workspaceId, sandboxGroupId }));
423
+ const live = liveByAttribution.get(
424
+ attributionKey({ leaseId, workspaceId, sandboxGroupId }),
425
+ );
356
426
  if (!live || (live.instanceId && live.instanceId !== info.id)) {
357
427
  reason = "stale_attribution";
358
428
  }
@@ -20,10 +20,7 @@ import {
20
20
  type ControlRpc,
21
21
  type NatsRequestConnection,
22
22
  } from "../selfhosted/control-rpc";
23
- import {
24
- SelfhostedSandboxClient,
25
- type SelfhostedRelayConfig,
26
- } from "../selfhosted/session";
23
+ import { SelfhostedSandboxClient, type SelfhostedRelayConfig } from "../selfhosted/session";
27
24
  import type { ProviderRegistration } from "./types";
28
25
 
29
26
  /**
@@ -1,6 +1,6 @@
1
1
  // @opengeni/runtime/sandbox — the recording loop (P4.3).
2
2
  //
3
- // ffmpeg x11grab on :0 → mp4/webm artifact on the box → read bytes → PUT to
3
+ // ffmpeg x11grab on :0 → mp4/webm artifact on the box → direct scoped PUT to
4
4
  // object storage → recording.available. The "agent films itself proving the fix"
5
5
  // loop: ffmpeg reads exactly the :0 framebuffer the agent's computer-use draws to
6
6
  // and the human watches over Channel B (zero projection).
@@ -9,22 +9,16 @@
9
9
  // Temporal, NO worker RPC, NO actor. They live in the agent-loop-free leaf so the
10
10
  // SAME process that already holds the resumed-by-id box (the agent turn's own
11
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).
12
+ // finalize) authorizes a direct box-to-storage upload. Recording bytes never
13
+ // enter worker memory or a Temporal payload (F10).
15
14
  //
16
15
  // ── Adversarial-review fixes folded in (module 05 §Adversarial) ──────────────
17
16
  // 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.
17
+ // F3 exec/execCommand YIELDS — the stop and direct-upload commands are bounded.
18
+ // F8 size is gated on the box first (stat); over-limit artifacts fail instead
19
+ // of being uploaded partially.
20
+ // FR curl reads the absolute /tmp artifact in the box and sends it directly to
21
+ // the short-lived scoped URL. No workspace-root read API is involved.
28
22
  // F9 the box file is deleted ONLY after the storage PUT confirms — never
29
23
  // before (so a failed upload leaves the bytes recoverable for a retry).
30
24
  // F12 ffmpeg/x11vnc backgrounding does not block the yield (nohup … & echo $!).
@@ -54,24 +48,44 @@ export function extForCodec(codec: RecordingCodec): string {
54
48
 
55
49
  /** No exec/execCommand on the session — the box cannot run ffmpeg. */
56
50
  export class RecordingUnavailableError extends Error {
57
- constructor(message: string) { super(message); this.name = "RecordingUnavailableError"; }
51
+ constructor(message: string) {
52
+ super(message);
53
+ this.name = "RecordingUnavailableError";
54
+ }
58
55
  }
59
- /** ffmpeg failed, the file is missing, or the byte read failed. */
56
+ /** ffmpeg failed, the file is missing, or the artifact is over its size limit. */
60
57
  export class RecordingError extends Error {
61
- constructor(message: string, readonly reason: "ffmpeg-error" | "box-death" | "max-bytes-exceeded" | "display-unavailable") {
58
+ constructor(
59
+ message: string,
60
+ readonly reason: "ffmpeg-error" | "box-death" | "max-bytes-exceeded" | "display-unavailable",
61
+ ) {
62
62
  super(message);
63
63
  this.name = "RecordingError";
64
64
  }
65
65
  }
66
66
 
67
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 };
68
+ // execCommand are optional (Modal has only execCommand — F1).
69
+ type ExecResultLike = {
70
+ output?: string;
71
+ stdout?: string;
72
+ stderr?: string;
73
+ exitCode?: number | null;
74
+ sessionId?: number;
75
+ };
71
76
  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>;
77
+ exec?: (args: {
78
+ cmd: string;
79
+ runAs?: string;
80
+ yieldTimeMs?: number;
81
+ maxOutputTokens?: number;
82
+ }) => Promise<ExecResultLike>;
83
+ execCommand?: (args: {
84
+ cmd: string;
85
+ runAs?: string;
86
+ yieldTimeMs?: number;
87
+ maxOutputTokens?: number;
88
+ }) => Promise<string>;
75
89
  };
76
90
 
77
91
  function shq(s: string): string {
@@ -80,11 +94,12 @@ function shq(s: string): string {
80
94
 
81
95
  function resultOutput(result: ExecResultLike | string): string {
82
96
  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");
97
+ return [result.output, result.stderr, result.stdout]
98
+ .filter((v): v is string => typeof v === "string" && v.length > 0)
99
+ .join("\n");
84
100
  }
85
101
 
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.
102
+ // Default per-command output cap (tokens). Recording transfer does not use stdout.
88
103
  const DEFAULT_MAX_OUTPUT_TOKENS = 4_000;
89
104
 
90
105
  async function run(
@@ -92,20 +107,23 @@ async function run(
92
107
  cmd: string,
93
108
  runAs?: string,
94
109
  yieldTimeMs = EXEC_YIELD_MS,
95
- maxOutputTokens: number | null = DEFAULT_MAX_OUTPUT_TOKENS,
110
+ maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS,
96
111
  ): 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
- };
112
+ const args: {
113
+ cmd: string;
114
+ runAs?: string;
115
+ yieldTimeMs?: number;
116
+ maxOutputTokens?: number;
117
+ } = { cmd, ...(runAs ? { runAs } : {}), yieldTimeMs, maxOutputTokens };
102
118
  if (typeof session.exec === "function") {
103
119
  return resultOutput(await session.exec(args));
104
120
  }
105
121
  if (typeof session.execCommand === "function") {
106
122
  return resultOutput(await session.execCommand(args));
107
123
  }
108
- throw new RecordingUnavailableError("session cannot run commands (no exec/execCommand) — recording unavailable");
124
+ throw new RecordingUnavailableError(
125
+ "session cannot run commands (no exec/execCommand) — recording unavailable",
126
+ );
109
127
  }
110
128
 
111
129
  // Extract the command body from a provider exec banner. Modal's execCommand
@@ -149,7 +167,10 @@ export type RecordingProcess = {
149
167
  * block on the recording). A hard `-t <maxSeconds>` ceiling bounds a runaway file
150
168
  * across a multi-day turn. Returns the handle the caller carries to stop+finalize.
151
169
  */
152
- export async function startRecording(session: unknown, input: StartRecordingInput): Promise<RecordingProcess> {
170
+ export async function startRecording(
171
+ session: unknown,
172
+ input: StartRecordingInput,
173
+ ): Promise<RecordingProcess> {
153
174
  const s = session as RecordingSession;
154
175
  const codec = input.codec ?? "h264-mp4";
155
176
  const dimensions = input.dimensions ?? DEFAULT_DIMENSIONS;
@@ -162,9 +183,10 @@ export async function startRecording(session: unknown, input: StartRecordingInpu
162
183
  const pidFile = `${tmp}/og-rec-${input.recordingId}.pid`;
163
184
  const logFile = `${tmp}/og-rec-${input.recordingId}.log`;
164
185
  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`;
186
+ const enc =
187
+ codec === "vp9-webm"
188
+ ? `-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1`
189
+ : `-c:v libx264 -preset veryfast -pix_fmt yuv420p -movflags +faststart`;
168
190
  const ffmpeg =
169
191
  `nohup ffmpeg -hide_banner -loglevel error -f x11grab -draw_mouse 1 -framerate ${framerate} ` +
170
192
  `-video_size ${w}x${h} -i ${display}.0 -t ${maxSeconds} ${enc} ${boxPath} ` +
@@ -194,45 +216,41 @@ export async function stopRecording(session: unknown, proc: RecordingProcess): P
194
216
  await run(s, `bash -lc ${shq(wait)}`, proc.runAs, STOP_YIELD_MS).catch(() => undefined);
195
217
  }
196
218
 
197
- export type FinalizeRecordingResult = {
198
- bytes: Uint8Array;
219
+ export type RecordingArtifactMetadata = {
199
220
  contentType: RecordingContentType;
200
221
  sizeBytes: number;
201
222
  durationSeconds: number;
202
223
  };
203
224
 
204
225
  /**
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.
226
+ * Inspect and size-gate the finalized recording while it is still on the box.
227
+ * No recording bytes cross into the worker process.
227
228
  */
228
- export async function readRecordingBytes(session: unknown, proc: RecordingProcess, maxBytes = DEFAULT_MAX_BYTES): Promise<FinalizeRecordingResult> {
229
+ export async function inspectRecordingArtifact(
230
+ session: unknown,
231
+ proc: RecordingProcess,
232
+ maxBytes = DEFAULT_MAX_BYTES,
233
+ ): Promise<RecordingArtifactMetadata> {
229
234
  const s = session as RecordingSession;
230
235
  if (typeof s.exec !== "function" && typeof s.execCommand !== "function") {
231
- throw new RecordingUnavailableError("session cannot run commands (no exec/execCommand) — recording finalize unavailable");
236
+ throw new RecordingUnavailableError(
237
+ "session cannot run commands (no exec/execCommand) — recording finalize unavailable",
238
+ );
232
239
  }
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";
240
+ // F8: size-gate on the box before authorizing the upload.
241
+ const sizeOut = (
242
+ await run(
243
+ s,
244
+ `bash -lc ${shq(`stat -c %s ${proc.boxPath} 2>/dev/null || echo MISSING`)}`,
245
+ proc.runAs,
246
+ )
247
+ ).trim();
248
+ const sizeLine =
249
+ sizeOut
250
+ .split("\n")
251
+ .map((l) => l.trim())
252
+ .filter(Boolean)
253
+ .pop() ?? "MISSING";
236
254
  if (sizeLine === "MISSING" || sizeLine === "") {
237
255
  throw new RecordingError(`recording file missing on box: ${proc.boxPath}`, "box-death");
238
256
  }
@@ -243,44 +261,82 @@ export async function readRecordingBytes(session: unknown, proc: RecordingProces
243
261
  if (size > maxBytes) {
244
262
  throw new RecordingError(`recording ${size}B exceeds max ${maxBytes}B`, "max-bytes-exceeded");
245
263
  }
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
264
  return {
266
- bytes,
267
265
  contentType: contentTypeForCodec(proc.codec),
268
- sizeBytes: bytes.length,
266
+ sizeBytes: size,
269
267
  durationSeconds: Math.max(0, Math.round((Date.now() - proc.startedAt) / 1000)),
270
268
  };
271
269
  }
272
270
 
271
+ const RECORDING_UPLOAD_OK = "OPENGENI_RECORDING_UPLOAD_OK";
272
+ const RECORDING_UPLOAD_MAX_SECONDS = 300;
273
+
274
+ /**
275
+ * Upload the finalized artifact directly from the sandbox to a scoped object-
276
+ * storage URL. The worker carries only the short URL/headers and metadata; it
277
+ * never materializes a base64 string or Uint8Array for a recording that may be
278
+ * hundreds of megabytes. curl's success marker makes an exec transport that
279
+ * returns non-zero command output instead of throwing still fail closed.
280
+ */
281
+ export async function uploadRecordingArtifact(
282
+ session: unknown,
283
+ proc: RecordingProcess,
284
+ input: {
285
+ url: string;
286
+ requiredHeaders: Record<string, string>;
287
+ maxBytes?: number;
288
+ maxSeconds?: number;
289
+ },
290
+ ): Promise<RecordingArtifactMetadata> {
291
+ const maxSeconds = input.maxSeconds ?? RECORDING_UPLOAD_MAX_SECONDS;
292
+ if (!Number.isSafeInteger(maxSeconds) || maxSeconds <= 0 || maxSeconds > 300) {
293
+ throw new Error("recording upload maxSeconds must be an integer between 1 and 300");
294
+ }
295
+ const metadata = await inspectRecordingArtifact(session, proc, input.maxBytes);
296
+ const headers = Object.entries(input.requiredHeaders)
297
+ .sort(([left], [right]) => left.localeCompare(right))
298
+ .map(([name, value]) => `--header ${shq(`${name}: ${value}`)}`)
299
+ .join(" ");
300
+ const upload = [
301
+ "set -euo pipefail",
302
+ `curl --fail --silent --show-error --connect-timeout 20 --max-time ${maxSeconds} --request PUT ${headers} --upload-file ${shq(proc.boxPath)} ${shq(input.url)}`,
303
+ `printf '${RECORDING_UPLOAD_OK}\\n'`,
304
+ ].join("; ");
305
+ const output = stripExecBanner(
306
+ await run(
307
+ session as RecordingSession,
308
+ `bash -lc ${shq(upload)}`,
309
+ proc.runAs,
310
+ (maxSeconds + 10) * 1_000,
311
+ ),
312
+ );
313
+ if (!output.includes(RECORDING_UPLOAD_OK)) {
314
+ throw new Error("recording upload did not confirm success");
315
+ }
316
+ return metadata;
317
+ }
318
+
273
319
  /**
274
320
  * Delete the box artifacts. F9: call this ONLY after the storage PUT confirmed
275
321
  * and the `available` row committed — never before. Best-effort; never throws.
276
322
  */
277
- export async function deleteRecordingArtifacts(session: unknown, proc: RecordingProcess): Promise<void> {
323
+ export async function deleteRecordingArtifacts(
324
+ session: unknown,
325
+ proc: RecordingProcess,
326
+ ): Promise<void> {
278
327
  const s = session as RecordingSession;
279
328
  const logFile = proc.boxPath.replace(/\.(mp4|webm)$/, ".log");
280
- await run(s, `rm -f ${proc.boxPath} ${proc.pidFile} ${logFile}`, proc.runAs).catch(() => undefined);
329
+ await run(s, `rm -f ${proc.boxPath} ${proc.pidFile} ${logFile}`, proc.runAs).catch(
330
+ () => undefined,
331
+ );
281
332
  }
282
333
 
283
334
  /** 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 {
335
+ export function recordingStorageKey(
336
+ workspaceId: string,
337
+ sessionId: string,
338
+ recordingId: string,
339
+ codec: RecordingCodec,
340
+ ): string {
285
341
  return `recordings/${workspaceId}/${sessionId}/${recordingId}.${extForCodec(codec)}`;
286
342
  }