@opengeni/runtime 0.6.1 → 0.7.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 (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 +293 -123
  5. package/dist/index.js +1819 -790
  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 +2573 -898
  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
@@ -1,6 +1,9 @@
1
1
  // src/sandbox/index.ts
2
2
  import { collectSandboxEnvironment, parseExposedPorts } from "@opengeni/config";
3
- import { DESKTOP_STREAM_PORT as DESKTOP_STREAM_PORT7, TERMINAL_STREAM_PORT as TERMINAL_STREAM_PORT2 } from "@opengeni/contracts";
3
+ import {
4
+ DESKTOP_STREAM_PORT as DESKTOP_STREAM_PORT7,
5
+ TERMINAL_STREAM_PORT as TERMINAL_STREAM_PORT2
6
+ } from "@opengeni/contracts";
4
7
 
5
8
  // src/sandbox/providers/index.ts
6
9
  import { SandboxBackend as SandboxBackend2 } from "@opengeni/contracts";
@@ -52,6 +55,14 @@ var SandboxConfigError = class extends Error {
52
55
  this.backend = backend;
53
56
  }
54
57
  };
58
+ var SandboxResumeStateUnavailableError = class extends Error {
59
+ backend;
60
+ constructor(backend) {
61
+ super(`Sandbox lease for backend "${backend}" has no resumable provider identity`);
62
+ this.name = "SandboxResumeStateUnavailableError";
63
+ this.backend = backend;
64
+ }
65
+ };
55
66
  var SandboxProviderUnavailableError = class extends Error {
56
67
  backend;
57
68
  constructor(backend) {
@@ -203,7 +214,7 @@ var localProvider = {
203
214
  import { ModalImageSelector, ModalSandboxClient } from "@openai/agents-extensions/sandbox/modal";
204
215
  import { effectiveModalIdleTimeoutSeconds } from "@opengeni/config";
205
216
  var MODAL_ORPHAN_SWEEP_LIMIT = 50;
206
- var MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS = 30 * 6e4;
217
+ var MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS = 2 * 6e4;
207
218
  function modalSandboxAttributionEnvironment(input) {
208
219
  return {
209
220
  OPENGENI_SANDBOX_LEASE_ID: input.leaseId,
@@ -240,10 +251,11 @@ var modalProvider = {
240
251
  sandboxCreateTimeoutS: Math.ceil(settings.sandboxWarmingTimeoutMs / 1e3),
241
252
  exposedPorts,
242
253
  env: environment,
243
- // The Modal JS SDK's sandbox default command already sleeps until timeout
244
- // or explicit termination. Do not let the Agents extension stamp a separate
245
- // hardcoded sleep command; OPENGENI_MODAL_TIMEOUT_SECONDS owns lifetime.
246
- useSleepCmd: false
254
+ // A registry image's own CMD is not a sandbox keepalive contract (for
255
+ // example, python:3.12-slim can exit immediately). Keep the provider's
256
+ // control process alive so exec/resume remains available; Modal's hard
257
+ // timeout and explicit OpenGeni teardown still own the box lifetime.
258
+ useSleepCmd: true
247
259
  };
248
260
  options.idleTimeoutMs = effectiveModalIdleTimeoutSeconds(settings) * 1e3;
249
261
  if (settings.modalWorkspacePersistence) {
@@ -312,7 +324,9 @@ function resolveModalImageSelector(settings) {
312
324
  return void 0;
313
325
  }
314
326
  const registryImage = cachedModalRegistryImage(settings);
315
- return registryImage ? ModalImageSelector.fromImage(registryImage) : ModalImageSelector.fromTag(settings.modalImageRef);
327
+ return registryImage ? ModalImageSelector.fromImage(
328
+ registryImage
329
+ ) : ModalImageSelector.fromTag(settings.modalImageRef);
316
330
  }
317
331
  function modalClientOptions(settings) {
318
332
  return {
@@ -375,6 +389,9 @@ async function sweepModalOrphanSandboxes(settings, liveLeases, options = {}) {
375
389
  const maxTerminations = options.maxTerminations ?? MODAL_ORPHAN_SWEEP_LIMIT;
376
390
  const unattributedGraceMs = options.unattributedGraceMs ?? MODAL_UNATTRIBUTED_ORPHAN_GRACE_MS;
377
391
  const liveByAttribution = new Map(liveLeases.map((lease) => [attributionKey(lease), lease]));
392
+ const liveByInstanceId = new Map(
393
+ liveLeases.filter((lease) => lease.instanceId).map((lease) => [lease.instanceId, lease])
394
+ );
378
395
  const ownedClient = options.client ? null : await createModalClient(settings);
379
396
  const modal = options.client ?? ownedClient;
380
397
  try {
@@ -408,9 +425,29 @@ async function sweepModalOrphanSandboxes(settings, liveLeases, options = {}) {
408
425
  const leaseId = tags.opengeni_lease_id;
409
426
  const workspaceId = tags.opengeni_workspace_id;
410
427
  const sandboxGroupId = tags.opengeni_sandbox_group_id;
428
+ const liveByInstance = info.id ? liveByInstanceId.get(info.id) : void 0;
429
+ if (liveByInstance) {
430
+ if (leaseId !== liveByInstance.leaseId || workspaceId !== liveByInstance.workspaceId || sandboxGroupId !== liveByInstance.sandboxGroupId) {
431
+ try {
432
+ const sandbox = await modal.sandboxes.fromId(info.id);
433
+ await sandbox.setTags(
434
+ modalSandboxAttributionTags({
435
+ leaseId: liveByInstance.leaseId,
436
+ workspaceId: liveByInstance.workspaceId,
437
+ sandboxGroupId: liveByInstance.sandboxGroupId
438
+ })
439
+ );
440
+ } catch {
441
+ }
442
+ }
443
+ skipped += 1;
444
+ continue;
445
+ }
411
446
  let reason = null;
412
447
  if (leaseId && workspaceId && sandboxGroupId) {
413
- const live = liveByAttribution.get(attributionKey({ leaseId, workspaceId, sandboxGroupId }));
448
+ const live = liveByAttribution.get(
449
+ attributionKey({ leaseId, workspaceId, sandboxGroupId })
450
+ );
414
451
  if (!live || live.instanceId && live.instanceId !== info.id) {
415
452
  reason = "stale_attribution";
416
453
  }
@@ -488,11 +525,7 @@ var runloopProvider = {
488
525
  };
489
526
 
490
527
  // src/sandbox/selfhosted/control-rpc.ts
491
- import {
492
- ControlRequest,
493
- ControlResponse,
494
- ErrorCode
495
- } from "@opengeni/agent-proto";
528
+ import { ControlRequest, ControlResponse, ErrorCode } from "@opengeni/agent-proto";
496
529
  function subjectFor(workspaceId, agentId) {
497
530
  return `agent.${workspaceId}.${agentId}.rpc`;
498
531
  }
@@ -505,6 +538,15 @@ var SelfhostedControlError = class extends Error {
505
538
  draining;
506
539
  agentOffline;
507
540
  osNotFound;
541
+ /** The reply exceeded the transport's max payload (a distinguishing flag so the
542
+ * fault renderer can name the size wall + recovery instead of a generic OS error). */
543
+ payloadTooLarge;
544
+ /** The transport synthesized this fault PRE-SEND — it KNOWS the request never
545
+ * reached the machine (no connection, or no responder on the subject), so the op
546
+ * provably did not execute. Only such a fault is safe to re-issue for ANY op kind
547
+ * (a post-send timeout is ambiguous — at-least-once — and must not be re-sent
548
+ * unless the op is read-only). Never set for an agent-returned error. */
549
+ neverSent;
508
550
  detail;
509
551
  constructor(input) {
510
552
  super(input.message);
@@ -515,6 +557,8 @@ var SelfhostedControlError = class extends Error {
515
557
  this.draining = input.draining ?? false;
516
558
  this.agentOffline = input.agentOffline ?? false;
517
559
  this.osNotFound = input.osNotFound ?? false;
560
+ this.payloadTooLarge = input.payloadTooLarge ?? false;
561
+ this.neverSent = input.neverSent ?? false;
518
562
  this.detail = input.detail ?? {};
519
563
  }
520
564
  };
@@ -529,6 +573,7 @@ function agentErrorToControlError(err) {
529
573
  reason: "agent_offline",
530
574
  retryable: false,
531
575
  agentOffline: true,
576
+ neverSent: detail[NEVER_SENT_DETAIL_KEY] === "1",
532
577
  detail
533
578
  });
534
579
  case ErrorCode.ERROR_CODE_TIMEOUT:
@@ -549,13 +594,22 @@ function agentErrorToControlError(err) {
549
594
  });
550
595
  case ErrorCode.ERROR_CODE_DRAINING:
551
596
  return new SelfhostedControlError({
552
- message: message || "the agent is draining and cannot accept new work",
597
+ message: drainingMessage(0),
553
598
  code: err.code,
554
599
  reason: null,
555
600
  retryable: true,
556
601
  draining: true,
557
602
  detail
558
603
  });
604
+ case ErrorCode.ERROR_CODE_PAYLOAD_TOO_LARGE:
605
+ return new SelfhostedControlError({
606
+ message: payloadTooLargeMessage(detail),
607
+ code: err.code,
608
+ reason: null,
609
+ retryable: false,
610
+ payloadTooLarge: true,
611
+ detail
612
+ });
559
613
  case ErrorCode.ERROR_CODE_FENCED:
560
614
  return new SelfhostedControlError({
561
615
  message: message || "a stale op was fenced by the epoch guard; re-resolve and retry",
@@ -584,12 +638,37 @@ function agentErrorToControlError(err) {
584
638
  });
585
639
  }
586
640
  }
587
- function offlineAgentError(message = "no agent responded (offline)") {
641
+ function payloadTooLargeMessage(detail) {
642
+ const encoded = detail.encoded_bytes;
643
+ const max = detail.max_payload;
644
+ const sizes = encoded && max ? `The result was ${encoded} bytes, over the machine link's ${max}-byte per-message limit. ` : "The result was larger than the machine link can deliver in a single message. ";
645
+ return `${sizes}Redirect the command's output to a file (for example \`<command> > /tmp/out.log 2>&1\`) and then read that file back in ranges or chunks instead of returning it all at once.`;
646
+ }
647
+ function drainingMessage(retries) {
648
+ const retried = retries > 0 ? ` It was retried ${retries} time${retries === 1 ? "" : "s"} first.` : "";
649
+ return "The machine is at its concurrent-work capacity and could not accept this command." + retried + " Try again shortly, or reduce the number of commands you run in parallel.";
650
+ }
651
+ function drainingExhaustedError(base, retries) {
652
+ return new SelfhostedControlError({
653
+ message: drainingMessage(retries),
654
+ code: base.code,
655
+ reason: base.reason,
656
+ retryable: base.retryable,
657
+ draining: base.draining,
658
+ // Carry the retry count in `detail` so the fault renderer can state it.
659
+ detail: { ...base.detail, retries: String(retries) }
660
+ });
661
+ }
662
+ function execDeadlineHint(seconds) {
663
+ return `The command was terminated at the ${seconds}-second execution limit. Run long jobs in the background (for example \`nohup <command> > /tmp/job.log 2>&1 &\`, or start them in a terminal session) and poll the output file instead of blocking on a single command.`;
664
+ }
665
+ var NEVER_SENT_DETAIL_KEY = "_never_sent";
666
+ function offlineAgentError(message = "no agent responded (offline)", neverSent = false) {
588
667
  return {
589
668
  code: ErrorCode.ERROR_CODE_AGENT_OFFLINE,
590
669
  message,
591
670
  retryable: false,
592
- detail: {}
671
+ detail: neverSent ? { [NEVER_SENT_DETAIL_KEY]: "1" } : {}
593
672
  };
594
673
  }
595
674
  function timeoutAgentError(message = "the agent did not respond in time") {
@@ -620,24 +699,28 @@ function isRequestTimeoutError(err) {
620
699
  var NatsControlRpc = class {
621
700
  connect;
622
701
  connection;
702
+ connecting;
623
703
  constructor(connect) {
624
704
  this.connect = connect;
625
705
  }
626
706
  async resolveConnection() {
627
- if (this.connection === void 0) {
628
- try {
629
- this.connection = await this.connect();
630
- } catch {
631
- this.connection = null;
632
- return null;
633
- }
707
+ if (this.connection) {
708
+ return this.connection;
634
709
  }
635
- return this.connection ?? null;
710
+ this.connecting ??= this.connect().then((connection) => {
711
+ if (connection) {
712
+ this.connection = connection;
713
+ }
714
+ return connection;
715
+ }).catch(() => null).finally(() => {
716
+ this.connecting = void 0;
717
+ });
718
+ return this.connecting;
636
719
  }
637
720
  async request(subject, req, opts) {
638
721
  const conn = await this.resolveConnection();
639
722
  if (!conn) {
640
- return offlineControlResponse(req.requestId);
723
+ return offlineControlResponse(req.requestId, true);
641
724
  }
642
725
  const payload = ControlRequest.encode(req).finish();
643
726
  try {
@@ -645,7 +728,7 @@ var NatsControlRpc = class {
645
728
  return ControlResponse.decode(reply.data);
646
729
  } catch (err) {
647
730
  if (isNoRespondersError(err)) {
648
- return offlineControlResponse(req.requestId);
731
+ return offlineControlResponse(req.requestId, true);
649
732
  }
650
733
  if (isRequestTimeoutError(err)) {
651
734
  return timeoutControlResponse(req.requestId);
@@ -655,8 +738,8 @@ var NatsControlRpc = class {
655
738
  }
656
739
  }
657
740
  };
658
- function offlineControlResponse(requestId) {
659
- return { requestId, error: offlineAgentError(), result: void 0 };
741
+ function offlineControlResponse(requestId, neverSent = false) {
742
+ return { requestId, error: offlineAgentError(void 0, neverSent), result: void 0 };
660
743
  }
661
744
  function timeoutControlResponse(requestId) {
662
745
  return { requestId, error: timeoutAgentError(), result: void 0 };
@@ -664,11 +747,841 @@ function timeoutControlResponse(requestId) {
664
747
 
665
748
  // src/sandbox/selfhosted/session.ts
666
749
  import {
750
+ ErrorCode as ErrorCode4,
667
751
  FsEntryKind,
668
752
  StreamKind
669
753
  } from "@opengeni/agent-proto";
670
754
  import { DESKTOP_STREAM_PORT as DESKTOP_STREAM_PORT2 } from "@opengeni/contracts";
671
755
  import { Manifest } from "@openai/agents/sandbox";
756
+
757
+ // src/sandbox/selfhosted/retry-policy.ts
758
+ var SELFHOSTED_IDEMPOTENT_READONLY_OPS = /* @__PURE__ */ new Set([
759
+ "ping",
760
+ "metrics",
761
+ "fsStat",
762
+ "fsList",
763
+ "fsRead"
764
+ ]);
765
+ var SELFHOSTED_DRAINING_MAX_RETRIES = 3;
766
+ var SELFHOSTED_EXEC_DRAINING_MAX_RETRIES = 10;
767
+ var SELFHOSTED_TIMEOUT_MAX_RETRIES = 1;
768
+ var SELFHOSTED_NEVER_SENT_MAX_RETRIES = 3;
769
+ var SELFHOSTED_RETRY_BACKOFF_BASE_MS = 500;
770
+ var SELFHOSTED_RETRY_BACKOFF_FACTOR = 2;
771
+ var SELFHOSTED_RETRY_BACKOFF_CAP_MS = 8e3;
772
+ function selfhostedRetryBackoffMs(attempt, jitter) {
773
+ const uncapped = SELFHOSTED_RETRY_BACKOFF_BASE_MS * SELFHOSTED_RETRY_BACKOFF_FACTOR ** attempt;
774
+ const ceiling = Math.min(SELFHOSTED_RETRY_BACKOFF_CAP_MS, uncapped);
775
+ const clampedJitter = Math.min(0.999999, Math.max(0, jitter));
776
+ return Math.floor(clampedJitter * ceiling);
777
+ }
778
+ function decideSelfhostedRetry(input) {
779
+ const { opKind, error, drainingRetries, timeoutRetries, neverSentRetries = 0, jitter } = input;
780
+ if (error.neverSent) {
781
+ if (neverSentRetries >= SELFHOSTED_NEVER_SENT_MAX_RETRIES) {
782
+ return { action: "fail" };
783
+ }
784
+ return { action: "retry", delayMs: selfhostedRetryBackoffMs(neverSentRetries, jitter) };
785
+ }
786
+ if (error.draining) {
787
+ const maxDrainingRetries = opKind === "exec" ? SELFHOSTED_EXEC_DRAINING_MAX_RETRIES : SELFHOSTED_DRAINING_MAX_RETRIES;
788
+ if (drainingRetries >= maxDrainingRetries) {
789
+ return { action: "fail" };
790
+ }
791
+ return { action: "retry", delayMs: selfhostedRetryBackoffMs(drainingRetries, jitter) };
792
+ }
793
+ if (error.reason === "agent_reconnecting") {
794
+ if (!SELFHOSTED_IDEMPOTENT_READONLY_OPS.has(opKind)) {
795
+ return { action: "fail" };
796
+ }
797
+ if (timeoutRetries >= SELFHOSTED_TIMEOUT_MAX_RETRIES) {
798
+ return { action: "fail" };
799
+ }
800
+ return { action: "retry", delayMs: selfhostedRetryBackoffMs(timeoutRetries, jitter) };
801
+ }
802
+ return { action: "fail" };
803
+ }
804
+ var defaultSelfhostedRetryClock = {
805
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
806
+ jitter: () => Math.random()
807
+ };
808
+
809
+ // src/sandbox/selfhosted/fault-rendering.ts
810
+ import { errorCodeToJSON, ErrorCode as ErrorCode2 } from "@opengeni/agent-proto";
811
+ function selfhostedFaultClass(error) {
812
+ if (error.agentOffline || error.code === ErrorCode2.ERROR_CODE_AGENT_OFFLINE) return "offline";
813
+ if (error.draining) return "draining";
814
+ if (error.payloadTooLarge || error.code === ErrorCode2.ERROR_CODE_PAYLOAD_TOO_LARGE)
815
+ return "payload_too_large";
816
+ if (error.reason === "agent_reconnecting") return "reconnecting";
817
+ if (error.reason === "consent_required") return "consent";
818
+ if (error.osNotFound || error.code === ErrorCode2.ERROR_CODE_NOT_FOUND) return "not_found";
819
+ if (error.fenced || error.code === ErrorCode2.ERROR_CODE_FENCED) return "fenced";
820
+ switch (error.code) {
821
+ case ErrorCode2.ERROR_CODE_OS:
822
+ return "os";
823
+ case ErrorCode2.ERROR_CODE_STREAM:
824
+ return "stream";
825
+ case ErrorCode2.ERROR_CODE_PROTOCOL:
826
+ return "protocol";
827
+ default:
828
+ return "unspecified";
829
+ }
830
+ }
831
+ var SELFHOSTED_INFRASTRUCTURE_FAULT_CLASSES = /* @__PURE__ */ new Set([
832
+ "offline",
833
+ "draining",
834
+ "payload_too_large",
835
+ "reconnecting",
836
+ "os",
837
+ "stream",
838
+ "protocol"
839
+ ]);
840
+ var FAULT_FIELD_WHAT_HAPPENED = "What happened:";
841
+ var FAULT_FIELD_WHICH_LAYER = "Which layer:";
842
+ var FAULT_FIELD_WHAT_PRESERVED = "What was preserved:";
843
+ var FAULT_FIELD_WHAT_TO_TRY = "What to try:";
844
+ function assemble(error, f) {
845
+ const code = errorCodeToJSON(error.code);
846
+ return [
847
+ `[connected machine] ${f.headline}`,
848
+ `${FAULT_FIELD_WHAT_HAPPENED} ${f.happened} (control code ${code}).`,
849
+ `${FAULT_FIELD_WHICH_LAYER} ${f.layer}`,
850
+ `${FAULT_FIELD_WHAT_PRESERVED} ${f.preserved}`,
851
+ `${FAULT_FIELD_WHAT_TO_TRY} ${f.tryNext}`
852
+ ].join("\n");
853
+ }
854
+ function renderSelfhostedFault(error) {
855
+ const detail = error.detail;
856
+ if (error.payloadTooLarge || error.code === ErrorCode2.ERROR_CODE_PAYLOAD_TOO_LARGE) {
857
+ if (detail.failure_code === "OP_OVERFLOW") {
858
+ const retained = detail.retained_bytes ? `after retaining ${detail.retained_bytes} bytes of output` : "when its output exceeded what the machine can retain for delivery";
859
+ return assemble(error, {
860
+ headline: "the command produced more output than the machine link can deliver",
861
+ happened: `the machine stopped the command ${retained} \u2014 its output-retention ceiling was reached`,
862
+ layer: "the machine link's output-retention ceiling \u2014 the command was terminated at the ceiling, not by its own logic",
863
+ preserved: "no output was returned, and the command did NOT run to completion \u2014 any side effects up to the stop point may have applied.",
864
+ tryNext: "re-run with the output bounded: redirect to a file (`> /tmp/out.log 2>&1`) then read it back in ranges/chunks, or slice it with `head -c` / `tail -c` / `head` / `tail`."
865
+ });
866
+ }
867
+ const sizes = detail.encoded_bytes && detail.max_payload ? `its ${detail.encoded_bytes}-byte reply exceeded the machine link's ${detail.max_payload}-byte per-message limit` : "its reply was larger than the machine link can deliver in one message";
868
+ return assemble(error, {
869
+ headline: "the command's output was too large to return",
870
+ happened: `the command ran on the machine, but ${sizes}`,
871
+ layer: "the machine link's per-message size limit \u2014 the command itself executed",
872
+ preserved: "nothing was returned; the oversized reply was dropped whole.",
873
+ tryNext: "re-run with the output bounded: redirect to a file (`> /tmp/out.log 2>&1`) then read it back in ranges/chunks, or slice it with `head -c` / `tail -c` / `head` / `tail`."
874
+ });
875
+ }
876
+ if (error.draining) {
877
+ const retried = detail.retries ? ` after ${detail.retries} retries` : "";
878
+ return assemble(error, {
879
+ headline: "the machine is at its concurrent-work capacity",
880
+ happened: `the machine rejected this command at its admission gate${retried} because its work pool is full`,
881
+ layer: "the machine's host-work admission \u2014 the machine is online, just saturated",
882
+ preserved: "nothing ran; the command was rejected before it started.",
883
+ tryNext: "try again shortly, or reduce the number of commands you run in parallel."
884
+ });
885
+ }
886
+ if (error.reason === "consent_required") {
887
+ return assemble(error, {
888
+ headline: "the operation needs consent that has not been granted",
889
+ happened: "the machine rejected the operation because the required consent is not granted",
890
+ layer: "the machine's consent gate \u2014 not your command",
891
+ preserved: "nothing ran; the operation was rejected.",
892
+ tryNext: "the consent must be granted on the machine itself; retrying will keep failing until it is."
893
+ });
894
+ }
895
+ if (error.agentOffline || error.code === ErrorCode2.ERROR_CODE_AGENT_OFFLINE) {
896
+ const preserved = error.neverSent ? "nothing ran; the command never reached the machine." : "unknown \u2014 the link dropped and the command may or may not have run.";
897
+ const tryNext = error.neverSent ? "the machine appears offline; check that it is powered on and connected. Retrying will not help until it reconnects." : "check the machine, and check whether the command already took effect before re-running it.";
898
+ return assemble(error, {
899
+ headline: "the machine is unreachable",
900
+ happened: "the enrolled machine did not respond; it appears offline",
901
+ layer: "the machine link \u2014 the machine itself, not your command",
902
+ preserved,
903
+ tryNext
904
+ });
905
+ }
906
+ if (error.reason === "agent_reconnecting") {
907
+ return assemble(error, {
908
+ headline: "the machine link blipped",
909
+ happened: "the machine did not respond in time; the link may be reconnecting",
910
+ layer: "the machine link (transport) \u2014 not your command",
911
+ preserved: "unknown \u2014 the command may or may not have run (its reply was lost).",
912
+ tryNext: "wait a moment and try again; if this was a state-changing command, check whether it already took effect before re-running it."
913
+ });
914
+ }
915
+ if (error.osNotFound || error.code === ErrorCode2.ERROR_CODE_NOT_FOUND) {
916
+ return assemble(error, {
917
+ headline: "the path or reference was not found on the machine",
918
+ happened: `the machine reported: ${error.message}`,
919
+ layer: "the command / filesystem on the machine \u2014 not the machine link",
920
+ preserved: "not applicable; the operation did not apply.",
921
+ tryNext: "check the path or reference exists on the machine, then re-run."
922
+ });
923
+ }
924
+ if (error.fenced || error.code === ErrorCode2.ERROR_CODE_FENCED) {
925
+ return assemble(error, {
926
+ headline: "the active machine session changed",
927
+ happened: "the machine session was swapped underneath the command",
928
+ layer: "the session-routing layer \u2014 not your command",
929
+ preserved: "nothing ran on this session.",
930
+ tryNext: "re-run the command; it will target the machine's current session."
931
+ });
932
+ }
933
+ return assemble(error, {
934
+ headline: "the command failed on the machine",
935
+ happened: `the machine reported: ${error.message}`,
936
+ layer: "the command / operating system on the machine",
937
+ preserved: "not applicable; the command did not complete successfully.",
938
+ tryNext: "read the message above and adjust the command; a blind retry is unlikely to help."
939
+ });
940
+ }
941
+
942
+ // src/sandbox/op-correlation.ts
943
+ import { AsyncLocalStorage } from "async_hooks";
944
+ var storage = new AsyncLocalStorage();
945
+ function sanitizeOpIdToken(raw) {
946
+ return raw.replace(/[^A-Za-z0-9_-]/g, (c) => `_${c.charCodeAt(0).toString(16)}_`);
947
+ }
948
+ function runWithToolCallCorrelation(callId, fn) {
949
+ return storage.run({ callId: sanitizeOpIdToken(callId), ordinal: 0 }, fn);
950
+ }
951
+ function nextDurableOpId() {
952
+ const context = storage.getStore();
953
+ if (!context) {
954
+ return null;
955
+ }
956
+ const ordinal = context.ordinal;
957
+ context.ordinal += 1;
958
+ return `${context.callId}:${ordinal}`;
959
+ }
960
+
961
+ // src/sandbox/selfhosted/op-stream.ts
962
+ import { blake3 } from "@noble/hashes/blake3";
963
+ import { bytesToHex } from "@noble/hashes/utils";
964
+ import {
965
+ ErrorCode as ErrorCode3,
966
+ OpAck,
967
+ OpChannel,
968
+ OpFrame,
969
+ OpLostReason,
970
+ OpState
971
+ } from "@opengeni/agent-proto";
972
+
973
+ // src/sandbox/selfhosted/op-transport.ts
974
+ function opFrameSubject(workspaceId, agentId, opId) {
975
+ return `agent.${workspaceId}.${agentId}.op.${opId}`;
976
+ }
977
+ function opAckSubject(workspaceId, agentId) {
978
+ return `agent.${workspaceId}.${agentId}.ack`;
979
+ }
980
+ var OpStreamUnavailableError = class extends Error {
981
+ name = "OpStreamUnavailableError";
982
+ };
983
+ var NatsOpStreamTransport = class {
984
+ connect;
985
+ connection;
986
+ connecting;
987
+ constructor(connect) {
988
+ this.connect = connect;
989
+ }
990
+ async resolveConnection() {
991
+ if (this.connection) {
992
+ return this.connection;
993
+ }
994
+ this.connecting ??= this.connect().then((connection) => {
995
+ if (connection) {
996
+ this.connection = connection;
997
+ }
998
+ return connection;
999
+ }).catch(() => null).finally(() => {
1000
+ this.connecting = void 0;
1001
+ });
1002
+ return this.connecting;
1003
+ }
1004
+ async subscribe(subject, onMessage) {
1005
+ const conn = await this.resolveConnection();
1006
+ if (!conn) {
1007
+ throw new OpStreamUnavailableError("op-stream transport has no live connection");
1008
+ }
1009
+ const subscription = conn.subscribe(subject);
1010
+ void (async () => {
1011
+ try {
1012
+ for await (const message of subscription) {
1013
+ onMessage(message.data);
1014
+ }
1015
+ } catch {
1016
+ }
1017
+ })();
1018
+ let unsubscribed = false;
1019
+ return {
1020
+ unsubscribe: () => {
1021
+ if (unsubscribed) {
1022
+ return;
1023
+ }
1024
+ unsubscribed = true;
1025
+ try {
1026
+ subscription.unsubscribe();
1027
+ } catch {
1028
+ }
1029
+ }
1030
+ };
1031
+ }
1032
+ async publish(subject, payload) {
1033
+ const conn = await this.resolveConnection();
1034
+ if (!conn) {
1035
+ throw new OpStreamUnavailableError("op-stream transport has no live connection");
1036
+ }
1037
+ conn.publish(subject, payload);
1038
+ }
1039
+ };
1040
+
1041
+ // src/sandbox/selfhosted/op-stream.ts
1042
+ var OP_STREAM_DEFAULT_WINDOW_BYTES = 4 * 1024 * 1024;
1043
+ var OP_STREAM_ACK_INTERVAL_MS = 5e3;
1044
+ var OP_STREAM_SILENCE_TIMEOUT_MS = 3e4;
1045
+ var OP_STREAM_RECONNECT_HOLD_MS = 12e4;
1046
+ var OP_STREAM_MAX_STASHED_FRAMES = 4096;
1047
+ var OP_START_DRAINING_MAX_RETRIES = 10;
1048
+ var OP_START_BLIP_MAX_RETRIES = 3;
1049
+ var OP_CHANNEL_NAMES = {
1050
+ [OpChannel.OP_CHANNEL_STDOUT]: "stdout",
1051
+ [OpChannel.OP_CHANNEL_STDERR]: "stderr"
1052
+ };
1053
+ var OpStreamExecClient = class {
1054
+ deps;
1055
+ settled = [];
1056
+ inFlight = /* @__PURE__ */ new Set();
1057
+ constructor(deps) {
1058
+ this.deps = deps;
1059
+ }
1060
+ generation() {
1061
+ return this.deps.journal?.attachGeneration() ?? "1";
1062
+ }
1063
+ /**
1064
+ * Run one exec over the op stream. `opId` is the durable id (B1) — a re-issue
1065
+ * with the same id attaches instead of re-running. `deadlineMs` is the exec
1066
+ * process budget (relative); the runner's enforcement is authoritative.
1067
+ * `wallMs` is the client-side give-up wall (deadline + reply grace).
1068
+ */
1069
+ async exec(opId, exec, deadlineMs, wallMs) {
1070
+ if (this.inFlight.has(opId)) {
1071
+ throw new SelfhostedControlError({
1072
+ message: `op-stream exec: duplicate concurrent op id ${opId}`,
1073
+ code: ErrorCode3.ERROR_CODE_PROTOCOL,
1074
+ reason: null,
1075
+ retryable: false
1076
+ });
1077
+ }
1078
+ this.inFlight.add(opId);
1079
+ try {
1080
+ const consumer = new OpConsumer(this.deps, opId, this.generation());
1081
+ try {
1082
+ const outcome = await consumer.run(exec, deadlineMs, wallMs);
1083
+ this.settled.push({ opId, exitSeq: outcome.exitSeq, generation: consumer.generation });
1084
+ return outcome.outcome;
1085
+ } finally {
1086
+ consumer.teardown();
1087
+ }
1088
+ } finally {
1089
+ this.inFlight.delete(opId);
1090
+ }
1091
+ }
1092
+ /**
1093
+ * The turn-end durability hook (the hard gate's ordering): for every op whose
1094
+ * result the turn has durably consumed — (1) already true when this runs —
1095
+ * (2) persist the settled frontier to the journal, then (3) publish the wire
1096
+ * final ack (`acked_seq = exit_seq, final = true`), which licenses the runner
1097
+ * to GC the op. A kill between (2) and (3) is safe: the re-dispatched turn
1098
+ * does not re-execute a durably-recorded call, and the runner's retention TTL
1099
+ * reaps the never-final-acked op — no loss, bounded residue. Publish failures
1100
+ * are swallowed for the same reason.
1101
+ */
1102
+ async finalizeSettledOps() {
1103
+ const ackSubject = opAckSubject(this.deps.workspaceId, this.deps.agentId);
1104
+ while (this.settled.length > 0) {
1105
+ const op = this.settled.shift();
1106
+ await this.deps.journal?.persistSettled(op.opId, op.exitSeq);
1107
+ const ack = OpAck.encode({
1108
+ opId: op.opId,
1109
+ ackedSeq: op.exitSeq,
1110
+ creditBytes: "0",
1111
+ final: true,
1112
+ attachGeneration: op.generation
1113
+ }).finish();
1114
+ try {
1115
+ await this.deps.transport.publish(ackSubject, ack);
1116
+ } catch {
1117
+ }
1118
+ }
1119
+ }
1120
+ };
1121
+ var OpConsumer = class {
1122
+ deps;
1123
+ opId;
1124
+ generation;
1125
+ windowBytes;
1126
+ /** Highest contiguously APPLIED seq (frames start at 1; 0 = none yet). */
1127
+ lastApplied = 0n;
1128
+ stash = /* @__PURE__ */ new Map();
1129
+ chunks = {
1130
+ stdout: [],
1131
+ stderr: []
1132
+ };
1133
+ receivedPayloadBytes = 0n;
1134
+ creditAtLastAck = 0n;
1135
+ exit;
1136
+ exitSeq;
1137
+ heals = 0;
1138
+ lastFrameAt;
1139
+ silenceSince;
1140
+ attachInFlight = false;
1141
+ subscription;
1142
+ ackTimer;
1143
+ settleResolve;
1144
+ settleReject;
1145
+ torn = false;
1146
+ constructor(deps, opId, generation) {
1147
+ this.deps = deps;
1148
+ this.opId = opId;
1149
+ this.generation = generation;
1150
+ this.windowBytes = deps.windowBytes ?? OP_STREAM_DEFAULT_WINDOW_BYTES;
1151
+ this.lastFrameAt = Date.now();
1152
+ }
1153
+ get ackIntervalMs() {
1154
+ return this.deps.ackIntervalMs ?? OP_STREAM_ACK_INTERVAL_MS;
1155
+ }
1156
+ get silenceTimeoutMs() {
1157
+ return this.deps.silenceTimeoutMs ?? OP_STREAM_SILENCE_TIMEOUT_MS;
1158
+ }
1159
+ get reconnectHoldMs() {
1160
+ return this.deps.reconnectHoldMs ?? OP_STREAM_RECONNECT_HOLD_MS;
1161
+ }
1162
+ teardown() {
1163
+ this.torn = true;
1164
+ if (this.ackTimer) {
1165
+ clearInterval(this.ackTimer);
1166
+ this.ackTimer = void 0;
1167
+ }
1168
+ this.subscription?.unsubscribe();
1169
+ this.subscription = void 0;
1170
+ }
1171
+ async run(exec, deadlineMs, wallMs) {
1172
+ const settled = new Promise((resolve, reject) => {
1173
+ this.settleResolve = resolve;
1174
+ this.settleReject = reject;
1175
+ });
1176
+ this.subscription = await this.deps.transport.subscribe(
1177
+ opFrameSubject(this.deps.workspaceId, this.deps.agentId, this.opId),
1178
+ (payload) => this.onFramePayload(payload)
1179
+ );
1180
+ const startRetries = await this.startOp(exec, deadlineMs);
1181
+ await this.attach(this.windowBytes);
1182
+ this.ackTimer = setInterval(() => {
1183
+ void this.sendAck();
1184
+ }, this.ackIntervalMs);
1185
+ const liveness = this.watchLiveness(wallMs);
1186
+ try {
1187
+ await Promise.race([settled, liveness.wall]);
1188
+ } finally {
1189
+ liveness.stop();
1190
+ }
1191
+ const exit = this.exit;
1192
+ const exitSeq = this.exitSeq;
1193
+ this.verifyByteExact(exit);
1194
+ if (exit.failureCode) {
1195
+ throw runnerFailureToControlError(exit);
1196
+ }
1197
+ const stdout = concatChunks(this.chunks.stdout);
1198
+ const stderr = concatChunks(this.chunks.stderr);
1199
+ return {
1200
+ outcome: {
1201
+ response: {
1202
+ exitCode: exit.exitCode,
1203
+ stdout,
1204
+ stderr,
1205
+ timedOut: exit.timedOut,
1206
+ durationMs: exit.durationMs
1207
+ },
1208
+ heals: this.heals,
1209
+ startRetries,
1210
+ replyBytes: stdout.byteLength + stderr.byteLength
1211
+ },
1212
+ exitSeq: exitSeq.toString()
1213
+ };
1214
+ }
1215
+ /** OpStart with the patient DRAINING budget and a small blip budget. OpStart
1216
+ * is IDEMPOTENT BY OP ID (the request id), so a timed-out/never-sent start
1217
+ * is safe to re-issue even though exec mutates — the runner either never saw
1218
+ * it or answers the SAME op. The stable request id is what makes that true. */
1219
+ async startOp(exec, deadlineMs) {
1220
+ let drainingRetries = 0;
1221
+ let blipRetries = 0;
1222
+ for (; ; ) {
1223
+ try {
1224
+ const result = await this.controlOp(
1225
+ {
1226
+ $case: "opStart",
1227
+ opStart: {
1228
+ op: { $case: "exec", exec },
1229
+ windowBytes: String(this.windowBytes),
1230
+ // Absolute epoch deadline (the runner's clock decides the actual
1231
+ // budget; host clock skew shifts it — a known wire caveat).
1232
+ deadlineMs: deadlineMs > 0 ? String(Date.now() + deadlineMs) : "0",
1233
+ originId: this.deps.rpcSubject
1234
+ }
1235
+ },
1236
+ this.opId
1237
+ );
1238
+ if (result.$case !== "opStart") {
1239
+ throw protocolError(`op-stream start: unexpected result ${result.$case}`);
1240
+ }
1241
+ const status = result.opStart.status;
1242
+ if (!result.opStart.accepted) {
1243
+ throw this.refusedStart(status);
1244
+ }
1245
+ if (status?.state === OpState.OP_STATE_LOST) {
1246
+ throw lostToControlError(status);
1247
+ }
1248
+ return drainingRetries + blipRetries;
1249
+ } catch (error) {
1250
+ if (!(error instanceof SelfhostedControlError)) {
1251
+ throw error;
1252
+ }
1253
+ if (error.code === ErrorCode3.ERROR_CODE_PROTOCOL || error.code === ErrorCode3.ERROR_CODE_UNSUPPORTED) {
1254
+ throw new OpStreamUnavailableError(
1255
+ `the runner refused OpStart (${error.message}); falling back to the legacy exec`
1256
+ );
1257
+ }
1258
+ const blip = error.reason === "agent_reconnecting" || error.neverSent;
1259
+ if (error.draining && drainingRetries < OP_START_DRAINING_MAX_RETRIES) {
1260
+ await this.deps.retryClock.sleep(
1261
+ selfhostedRetryBackoffMs(drainingRetries, this.deps.retryClock.jitter())
1262
+ );
1263
+ drainingRetries += 1;
1264
+ continue;
1265
+ }
1266
+ if (blip && blipRetries < OP_START_BLIP_MAX_RETRIES) {
1267
+ await this.deps.retryClock.sleep(
1268
+ selfhostedRetryBackoffMs(blipRetries, this.deps.retryClock.jitter())
1269
+ );
1270
+ blipRetries += 1;
1271
+ continue;
1272
+ }
1273
+ throw error;
1274
+ }
1275
+ }
1276
+ }
1277
+ refusedStart(status) {
1278
+ if (status?.state === OpState.OP_STATE_LOST) {
1279
+ return lostToControlError(status);
1280
+ }
1281
+ return protocolError("op-stream start: the runner refused the op without a typed reason");
1282
+ }
1283
+ /** One control op on the rpc subject; throws the mapped
1284
+ * `SelfhostedControlError` on any AgentError. `requestId` is overridable
1285
+ * because OpStart's REQUEST ID IS THE OP ID (its idempotency key) and must
1286
+ * be STABLE across retries — every other op uses a fresh UUID per attempt. */
1287
+ async controlOp(op, requestId = crypto.randomUUID()) {
1288
+ const res = await this.deps.controlRpc.request(
1289
+ this.deps.rpcSubject,
1290
+ { requestId, epoch: this.deps.epoch, op },
1291
+ { timeoutMs: this.deps.controlTimeoutMs }
1292
+ );
1293
+ if (res.error || !res.result) {
1294
+ throw agentErrorToControlError(
1295
+ res.error ?? {
1296
+ code: ErrorCode3.ERROR_CODE_PROTOCOL,
1297
+ message: "agent returned an empty control response",
1298
+ retryable: false,
1299
+ detail: {}
1300
+ }
1301
+ );
1302
+ }
1303
+ return res.result;
1304
+ }
1305
+ /** OpAttach from the contiguous frontier. `windowBytes` sizes the fresh
1306
+ * replay window; 0 = reuse the OpStart grant (the gap-heal path). */
1307
+ async attach(windowBytes) {
1308
+ const result = await this.controlOp({
1309
+ $case: "opAttach",
1310
+ opAttach: {
1311
+ opId: this.opId,
1312
+ fromSeq: this.lastApplied.toString(),
1313
+ attachGeneration: this.generation,
1314
+ windowBytes: String(windowBytes)
1315
+ }
1316
+ });
1317
+ if (result.$case !== "opStatus") {
1318
+ throw protocolError(`op-stream attach: unexpected result ${result.$case}`);
1319
+ }
1320
+ if (result.opStatus.state === OpState.OP_STATE_LOST) {
1321
+ throw lostToControlError(result.opStatus);
1322
+ }
1323
+ return result.opStatus;
1324
+ }
1325
+ /** Gap/silence heal: one re-attach in flight at a time; each success counts
1326
+ * as a heal (the observer's `healed` breadcrumb). Failures are left to the
1327
+ * liveness watcher — a dead link fails typed there, not here. */
1328
+ requestAttachHeal() {
1329
+ if (this.attachInFlight || this.torn) {
1330
+ return;
1331
+ }
1332
+ this.attachInFlight = true;
1333
+ void this.attach(0).then(() => {
1334
+ this.heals += 1;
1335
+ }).catch((error) => {
1336
+ if (error instanceof SelfhostedControlError && !error.retryable && !error.agentOffline) {
1337
+ this.settleReject?.(error);
1338
+ }
1339
+ }).finally(() => {
1340
+ this.attachInFlight = false;
1341
+ });
1342
+ }
1343
+ onFramePayload(payload) {
1344
+ let frame;
1345
+ try {
1346
+ frame = OpFrame.decode(payload);
1347
+ } catch {
1348
+ return;
1349
+ }
1350
+ if (frame.opId !== this.opId) {
1351
+ return;
1352
+ }
1353
+ this.lastFrameAt = Date.now();
1354
+ this.silenceSince = void 0;
1355
+ const seq = BigInt(frame.seq);
1356
+ if (seq <= this.lastApplied) {
1357
+ return;
1358
+ }
1359
+ if (seq !== this.lastApplied + 1n) {
1360
+ if (this.stash.size < OP_STREAM_MAX_STASHED_FRAMES) {
1361
+ this.stash.set(seq, frame);
1362
+ }
1363
+ this.requestAttachHeal();
1364
+ return;
1365
+ }
1366
+ this.applyFrame(frame);
1367
+ for (; ; ) {
1368
+ const next = this.stash.get(this.lastApplied + 1n);
1369
+ if (!next) {
1370
+ break;
1371
+ }
1372
+ this.stash.delete(this.lastApplied + 1n);
1373
+ this.applyFrame(next);
1374
+ }
1375
+ this.maybeSettle();
1376
+ this.maybeGrantCredit();
1377
+ }
1378
+ applyFrame(frame) {
1379
+ this.lastApplied = BigInt(frame.seq);
1380
+ const body = frame.body;
1381
+ if (!body) {
1382
+ return;
1383
+ }
1384
+ switch (body.$case) {
1385
+ case "data": {
1386
+ const channel = OP_CHANNEL_NAMES[body.data.channel];
1387
+ if (channel) {
1388
+ this.chunks[channel].push(body.data.bytes);
1389
+ }
1390
+ this.receivedPayloadBytes += BigInt(body.data.bytes.byteLength);
1391
+ break;
1392
+ }
1393
+ case "progress":
1394
+ void this.sendAck();
1395
+ break;
1396
+ case "exit":
1397
+ this.exit = body.exit;
1398
+ this.exitSeq = BigInt(frame.seq);
1399
+ break;
1400
+ }
1401
+ }
1402
+ maybeSettle() {
1403
+ if (this.exitSeq !== void 0 && this.lastApplied >= this.exitSeq) {
1404
+ this.settleResolve?.();
1405
+ }
1406
+ }
1407
+ /** Prompt credit replenishment: the periodic ack alone would stall a fast
1408
+ * child for up to the ack interval each window; grant as soon as half the
1409
+ * window has been consumed since the last grant. */
1410
+ maybeGrantCredit() {
1411
+ if (this.receivedPayloadBytes - this.creditAtLastAck >= BigInt(this.windowBytes) / 2n) {
1412
+ void this.sendAck();
1413
+ }
1414
+ }
1415
+ /** The mid-op cumulative ack: CREDIT-ONLY (Δ2 — acked_seq stays 0 so the
1416
+ * runner retains every frame for a possible successor consumer; the credit
1417
+ * is an absolute replacement sized to keep ~one window of live headroom). */
1418
+ async sendAck() {
1419
+ if (this.torn) {
1420
+ return;
1421
+ }
1422
+ this.creditAtLastAck = this.receivedPayloadBytes;
1423
+ const ack = OpAck.encode({
1424
+ opId: this.opId,
1425
+ ackedSeq: "0",
1426
+ creditBytes: (this.receivedPayloadBytes + BigInt(this.windowBytes)).toString(),
1427
+ final: false,
1428
+ attachGeneration: this.generation
1429
+ }).finish();
1430
+ try {
1431
+ await this.deps.transport.publish(
1432
+ opAckSubject(this.deps.workspaceId, this.deps.agentId),
1433
+ ack
1434
+ );
1435
+ } catch {
1436
+ }
1437
+ }
1438
+ /**
1439
+ * The liveness watcher: probes a silent op with OpQuery, re-attaches when the
1440
+ * runner is ahead of us (frames lost in flight), holds a quiet-but-alive op
1441
+ * open through the reconnect window, and enforces the client-side wall.
1442
+ * Resolves only by rejection (typed failure) or by `stop()` at settle.
1443
+ */
1444
+ watchLiveness(wallMs) {
1445
+ let stopped = false;
1446
+ let timer;
1447
+ const wall = new Promise((_, reject) => {
1448
+ const startedAt = Date.now();
1449
+ const tick = async () => {
1450
+ if (stopped) {
1451
+ return;
1452
+ }
1453
+ try {
1454
+ if (Date.now() - startedAt >= wallMs) {
1455
+ reject(agentErrorToControlError(timeoutAgentError()));
1456
+ return;
1457
+ }
1458
+ if (Date.now() - this.lastFrameAt >= this.silenceTimeoutMs) {
1459
+ await this.probeSilence(reject);
1460
+ }
1461
+ } catch (error) {
1462
+ reject(error);
1463
+ return;
1464
+ }
1465
+ timer = setTimeout(tick, Math.min(this.silenceTimeoutMs / 3, 2500));
1466
+ };
1467
+ timer = setTimeout(tick, Math.min(this.silenceTimeoutMs / 3, 2500));
1468
+ });
1469
+ return {
1470
+ wall,
1471
+ stop: () => {
1472
+ stopped = true;
1473
+ if (timer) {
1474
+ clearTimeout(timer);
1475
+ }
1476
+ }
1477
+ };
1478
+ }
1479
+ /** One silence probe: OpQuery the op; a definitive answer acts, an offline /
1480
+ * blip answer holds through the reconnect window, then fails typed. */
1481
+ async probeSilence(reject) {
1482
+ this.silenceSince ??= Date.now();
1483
+ try {
1484
+ const result = await this.controlOp({ $case: "opQuery", opQuery: { opId: this.opId } });
1485
+ if (result.$case !== "opStatus") {
1486
+ throw protocolError(`op-stream query: unexpected result ${result.$case}`);
1487
+ }
1488
+ const status = result.opStatus;
1489
+ if (status.state === OpState.OP_STATE_LOST) {
1490
+ reject(lostToControlError(status));
1491
+ return;
1492
+ }
1493
+ if (BigInt(status.nextSeq) > this.lastApplied + 1n) {
1494
+ this.requestAttachHeal();
1495
+ }
1496
+ } catch (error) {
1497
+ if (!(error instanceof SelfhostedControlError)) {
1498
+ throw error;
1499
+ }
1500
+ const transient = error.agentOffline || error.reason === "agent_reconnecting" || error.draining;
1501
+ if (!transient) {
1502
+ throw error;
1503
+ }
1504
+ const heldFor = Date.now() - (this.silenceSince ?? Date.now());
1505
+ if (heldFor >= this.reconnectHoldMs) {
1506
+ reject(error);
1507
+ }
1508
+ }
1509
+ }
1510
+ /** Byte-exactness proof: totals AND blake3 digests of the reassembled
1511
+ * channels must match the runner's Exit record exactly. */
1512
+ verifyByteExact(exit) {
1513
+ for (const channel of ["stdout", "stderr"]) {
1514
+ const bytes = concatChunks(this.chunks[channel]);
1515
+ const declaredTotal = exit.totals[channel];
1516
+ if (declaredTotal !== void 0 && BigInt(declaredTotal) !== BigInt(bytes.byteLength)) {
1517
+ throw protocolError(
1518
+ `op-stream reassembly: ${channel} total mismatch (got ${bytes.byteLength}, runner declared ${declaredTotal})`
1519
+ );
1520
+ }
1521
+ const declaredDigest = exit.digests[channel];
1522
+ if (declaredDigest) {
1523
+ const digest = bytesToHex(blake3(bytes));
1524
+ if (digest !== declaredDigest) {
1525
+ throw protocolError(
1526
+ `op-stream reassembly: ${channel} digest mismatch (got ${digest}, runner declared ${declaredDigest})`
1527
+ );
1528
+ }
1529
+ }
1530
+ }
1531
+ }
1532
+ };
1533
+ function concatChunks(chunks) {
1534
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
1535
+ const out = new Uint8Array(total);
1536
+ let offset = 0;
1537
+ for (const chunk of chunks) {
1538
+ out.set(chunk, offset);
1539
+ offset += chunk.byteLength;
1540
+ }
1541
+ return out;
1542
+ }
1543
+ function protocolError(message) {
1544
+ return new SelfhostedControlError({
1545
+ message,
1546
+ code: ErrorCode3.ERROR_CODE_PROTOCOL,
1547
+ reason: null,
1548
+ retryable: false
1549
+ });
1550
+ }
1551
+ function lostToControlError(status) {
1552
+ const reason = status.lostReason === OpLostReason.OP_LOST_REASON_EVICTED ? "its retained result was evicted before collection" : status.lostReason === OpLostReason.OP_LOST_REASON_AGENT_RESTARTED ? "the machine agent restarted and the op did not survive" : "the runner reported it lost";
1553
+ return new SelfhostedControlError({
1554
+ message: `The command's result is no longer available on the machine (${reason}). Check whether its effects already took place before re-running it.`,
1555
+ code: ErrorCode3.ERROR_CODE_STREAM,
1556
+ reason: null,
1557
+ retryable: false,
1558
+ detail: { lost_reason: String(status.lostReason) }
1559
+ });
1560
+ }
1561
+ function runnerFailureToControlError(exit) {
1562
+ if (exit.failureCode === "OP_OVERFLOW") {
1563
+ return new SelfhostedControlError({
1564
+ message: "The command produced more output than the machine link can retain for delivery. Redirect the command's output to a file (for example `<command> > /tmp/out.log 2>&1`) and then read that file back in ranges or chunks instead of returning it all at once.",
1565
+ code: ErrorCode3.ERROR_CODE_PAYLOAD_TOO_LARGE,
1566
+ reason: null,
1567
+ retryable: false,
1568
+ payloadTooLarge: true,
1569
+ // failure_code lets the fault renderer distinguish the retention-ceiling
1570
+ // overflow (command TERMINATED at the ceiling) from the legacy reply-size
1571
+ // wall (command ran to completion) — the four fields must tell the truth.
1572
+ detail: { ...exit.failureDetail, failure_code: exit.failureCode }
1573
+ });
1574
+ }
1575
+ return new SelfhostedControlError({
1576
+ message: `The command failed on the machine's streaming transport (${exit.failureCode}). The machine kept running it, but its output could not be captured intact.`,
1577
+ code: ErrorCode3.ERROR_CODE_STREAM,
1578
+ reason: null,
1579
+ retryable: false,
1580
+ detail: { ...exit.failureDetail, failure_code: exit.failureCode }
1581
+ });
1582
+ }
1583
+
1584
+ // src/sandbox/selfhosted/session.ts
672
1585
  var decoder = new TextDecoder();
673
1586
  var encoder = new TextEncoder();
674
1587
  var SELFHOSTED_VIRTUAL_ROOT = "/workspace";
@@ -687,7 +1600,12 @@ function setSelfhostedApplyDiff(fn) {
687
1600
  injectedApplyDiff = fn;
688
1601
  }
689
1602
  var SELFHOSTED_DEFAULT_TIMEOUT_MS = 3e4;
690
- var SELFHOSTED_EXEC_ENV_ALLOWLIST = ["OPENGENI_TOOLSPACE_URL", "OPENGENI_TOOLSPACE_TOKEN_FILE"];
1603
+ var SELFHOSTED_EXEC_REPLY_GRACE_MS = 5e3;
1604
+ var SELFHOSTED_MAX_EXEC_TIMEOUT_MS = 2147483647 - SELFHOSTED_EXEC_REPLY_GRACE_MS;
1605
+ var SELFHOSTED_EXEC_ENV_ALLOWLIST = [
1606
+ "OPENGENI_TOOLSPACE_URL",
1607
+ "OPENGENI_TOOLSPACE_TOKEN_FILE"
1608
+ ];
691
1609
  function selfhostedExecEnv(environment) {
692
1610
  const env = {};
693
1611
  for (const key of SELFHOSTED_EXEC_ENV_ALLOWLIST) {
@@ -707,7 +1625,14 @@ var SelfhostedSession = class {
707
1625
  relay;
708
1626
  epoch;
709
1627
  timeoutMs;
1628
+ /** The exec process deadline (falls back to `timeoutMs` when unset). */
1629
+ execTimeoutMs;
1630
+ retryClock;
1631
+ onOp;
710
1632
  subject;
1633
+ /** The op-stream exec client — constructed iff `deps.opStream` was injected
1634
+ * (= the streaming transport is enabled for this session). */
1635
+ opStreamClient;
711
1636
  /** The session working directory — the path/cwd base every op is rooted under
712
1637
  * (see `toMachinePath`). "" by default ⇒ today's workspace_root behavior. */
713
1638
  workingDir;
@@ -746,44 +1671,155 @@ var SelfhostedSession = class {
746
1671
  this.relay = deps.relay;
747
1672
  this.epoch = deps.epoch ?? 0;
748
1673
  this.timeoutMs = deps.timeoutMs ?? SELFHOSTED_DEFAULT_TIMEOUT_MS;
1674
+ this.execTimeoutMs = deps.execTimeoutMs;
1675
+ this.retryClock = deps.retryClock ?? defaultSelfhostedRetryClock;
1676
+ this.onOp = deps.onOp;
749
1677
  this.subject = subjectFor(deps.workspaceId, deps.agentId);
750
1678
  this.workingDir = deps.workingDir ?? "";
1679
+ this.opStreamClient = deps.opStream ? new OpStreamExecClient({
1680
+ workspaceId: deps.workspaceId,
1681
+ agentId: deps.agentId,
1682
+ epoch: this.epoch,
1683
+ controlRpc: deps.controlRpc,
1684
+ rpcSubject: this.subject,
1685
+ transport: deps.opStream.transport,
1686
+ controlTimeoutMs: this.timeoutMs,
1687
+ retryClock: this.retryClock,
1688
+ ...deps.opStream.journal !== void 0 ? { journal: deps.opStream.journal } : {},
1689
+ ...deps.opStream.windowBytes !== void 0 ? { windowBytes: deps.opStream.windowBytes } : {},
1690
+ ...deps.opStream.ackIntervalMs !== void 0 ? { ackIntervalMs: deps.opStream.ackIntervalMs } : {},
1691
+ ...deps.opStream.silenceTimeoutMs !== void 0 ? { silenceTimeoutMs: deps.opStream.silenceTimeoutMs } : {},
1692
+ ...deps.opStream.reconnectHoldMs !== void 0 ? { reconnectHoldMs: deps.opStream.reconnectHoldMs } : {}
1693
+ }) : void 0;
751
1694
  this.state = {
752
1695
  agentId: deps.agentId,
753
1696
  instanceId: deps.agentId,
754
- manifest: new Manifest({ root: "/workspace", entries: {}, environment: deps.environment ?? {} }),
1697
+ manifest: new Manifest({
1698
+ root: "/workspace",
1699
+ entries: {},
1700
+ environment: deps.environment ?? {}
1701
+ }),
755
1702
  // The SDK `SandboxSessionState.environment` — the run's threaded env (or `{}`).
756
1703
  // The group client's end-of-turn serialize reads `state.environment` directly
757
1704
  // (Object.entries), so it must be a defined object, not absent.
758
1705
  environment: deps.environment ?? {}
759
1706
  };
760
1707
  }
761
- /** Issue a control op, decoding the agent's reply or throwing the mapped
762
- * `SelfhostedControlError` on an AgentError (incl. a synthesized offline /
763
- * timeout error from the transport). */
764
- async call(op) {
765
- const req = {
766
- requestId: crypto.randomUUID(),
767
- epoch: this.epoch,
768
- op
769
- };
770
- const res = await this.controlRpc.request(this.subject, req, { timeoutMs: this.timeoutMs });
771
- if (res.error) {
772
- throw agentErrorToControlError(res.error);
1708
+ /** Invoke the injected per-op observer, guarded: a telemetry tap must NEVER break
1709
+ * an op (a throwing sink is swallowed). No-op when no observer is wired. */
1710
+ emitOp(observation) {
1711
+ if (!this.onOp) {
1712
+ return;
1713
+ }
1714
+ try {
1715
+ this.onOp(observation);
1716
+ } catch {
773
1717
  }
774
- if (!res.result) {
775
- throw agentErrorToControlError({
1718
+ }
1719
+ /**
1720
+ * Issue a control op, decoding the agent's reply or throwing the mapped
1721
+ * `SelfhostedControlError` on an AgentError (incl. a synthesized offline /
1722
+ * timeout error from the transport).
1723
+ *
1724
+ * Two failure modes are retried in-place (the decision is the pure
1725
+ * `decideSelfhostedRetry` policy):
1726
+ * - DRAINING — a pre-admission host-work backpressure rejection; the op never
1727
+ * started, so it is safe to re-issue for ANY op kind (bounded).
1728
+ * - a TIMEOUT / `agent_reconnecting` blip — re-issued ONCE, and ONLY for a
1729
+ * read-only idempotent-safe op. A timed-out MUTATION is NEVER re-issued: it
1730
+ * may already have executed on the machine (at-least-once), which would
1731
+ * duplicate the write/command. FENCED is intentionally NOT retried here — the
1732
+ * routing proxy retries it against a re-resolved backend under a fresh epoch.
1733
+ * Each attempt carries a FRESH `requestId` (a retry is a distinct request).
1734
+ */
1735
+ async call(op, timeoutMs = this.timeoutMs) {
1736
+ const opKind = op.$case;
1737
+ const startedAt = Date.now();
1738
+ let drainingRetries = 0;
1739
+ let timeoutRetries = 0;
1740
+ let neverSentRetries = 0;
1741
+ for (; ; ) {
1742
+ const req = {
1743
+ requestId: crypto.randomUUID(),
1744
+ epoch: this.epoch,
1745
+ op
1746
+ };
1747
+ const res = await this.controlRpc.request(this.subject, req, { timeoutMs });
1748
+ if (!res.error && res.result) {
1749
+ const retries = drainingRetries + timeoutRetries + neverSentRetries;
1750
+ this.emitOp({
1751
+ op: opKind,
1752
+ outcome: "ok",
1753
+ healed: retries > 0,
1754
+ retries,
1755
+ durationMs: Date.now() - startedAt,
1756
+ machineId: this.agentId,
1757
+ // A healed op's class is whichever retry budget it recovered from.
1758
+ ...retries > 0 ? {
1759
+ faultClass: neverSentRetries > 0 ? "offline" : drainingRetries > 0 ? "draining" : "reconnecting"
1760
+ } : {}
1761
+ });
1762
+ return res.result;
1763
+ }
1764
+ const error = res.error ? agentErrorToControlError(res.error) : agentErrorToControlError({
776
1765
  code: 7,
777
1766
  // ERROR_CODE_PROTOCOL — an empty result is a protocol violation
778
1767
  message: "agent returned an empty control response",
779
1768
  retryable: false,
780
1769
  detail: {}
781
1770
  });
1771
+ const decision = decideSelfhostedRetry({
1772
+ opKind,
1773
+ error,
1774
+ drainingRetries,
1775
+ timeoutRetries,
1776
+ neverSentRetries,
1777
+ jitter: this.retryClock.jitter()
1778
+ });
1779
+ if (decision.action === "fail") {
1780
+ const retries = drainingRetries + timeoutRetries + neverSentRetries;
1781
+ const encoded = Number(error.detail.encoded_bytes);
1782
+ this.emitOp({
1783
+ op: opKind,
1784
+ outcome: "failed",
1785
+ healed: false,
1786
+ retries,
1787
+ durationMs: Date.now() - startedAt,
1788
+ code: error.code,
1789
+ reason: error.reason,
1790
+ neverSent: error.neverSent,
1791
+ machineId: this.agentId,
1792
+ faultClass: selfhostedFaultClass(error),
1793
+ // Known only on a PAYLOAD_TOO_LARGE fault (the agent's encoded_bytes detail).
1794
+ ...Number.isFinite(encoded) ? { replyBytes: encoded } : {}
1795
+ });
1796
+ if (error.draining && drainingRetries > 0) {
1797
+ throw drainingExhaustedError(error, drainingRetries);
1798
+ }
1799
+ throw error;
1800
+ }
1801
+ await this.retryClock.sleep(decision.delayMs);
1802
+ if (error.neverSent) {
1803
+ neverSentRetries += 1;
1804
+ } else if (error.draining) {
1805
+ drainingRetries += 1;
1806
+ } else {
1807
+ timeoutRetries += 1;
1808
+ }
782
1809
  }
783
- return res.result;
1810
+ }
1811
+ /** The clamped exec process deadline (ms): the exec-specific budget when threaded,
1812
+ * else the control timeout. Shared by `exec()` (the wire deadline) and
1813
+ * `execCommand()` (the timed-out hint's "N-second limit"), so the two never drift. */
1814
+ execDeadlineMs() {
1815
+ return Math.max(
1816
+ 1,
1817
+ Math.min(Math.trunc(this.execTimeoutMs ?? this.timeoutMs), SELFHOSTED_MAX_EXEC_TIMEOUT_MS)
1818
+ );
784
1819
  }
785
1820
  /** Channel-A `exec`: run a command on the machine and return its output. */
786
1821
  async exec(args) {
1822
+ const executionTimeoutMs = this.execDeadlineMs();
787
1823
  const execReq = {
788
1824
  // The agent does NOT shell-interpret unless `shell` — Channel-A passes a
789
1825
  // single shell command string, so run it through the platform shell.
@@ -800,13 +1836,98 @@ var SelfhostedSession = class {
800
1836
  // seed hook can find the tool surface. See SELFHOSTED_EXEC_ENV_ALLOWLIST.
801
1837
  env: selfhostedExecEnv(this.state.environment),
802
1838
  stdin: new Uint8Array(0),
803
- timeoutMs: 0
1839
+ timeoutMs: executionTimeoutMs
804
1840
  };
805
- const result = await this.call({ $case: "exec", exec: execReq });
1841
+ if (this.opStreamClient) {
1842
+ try {
1843
+ return await this.execViaOpStream(this.opStreamClient, execReq, executionTimeoutMs);
1844
+ } catch (error) {
1845
+ if (!(error instanceof OpStreamUnavailableError)) {
1846
+ throw error;
1847
+ }
1848
+ }
1849
+ }
1850
+ return this.execLegacy(execReq, executionTimeoutMs);
1851
+ }
1852
+ /** The legacy monolithic exec request/reply — the permanent fallback wire
1853
+ * form (and the only form for runners without `op_stream`). */
1854
+ async execLegacy(execReq, executionTimeoutMs) {
1855
+ const result = await this.call(
1856
+ { $case: "exec", exec: execReq },
1857
+ executionTimeoutMs + SELFHOSTED_EXEC_REPLY_GRACE_MS
1858
+ );
806
1859
  if (result.$case !== "exec") {
807
1860
  throw new Error(`selfhosted exec: unexpected result ${result.$case}`);
808
1861
  }
809
- return execResultToChannelA(result.exec);
1862
+ return execResultToChannelA(result.exec, executionTimeoutMs);
1863
+ }
1864
+ /**
1865
+ * The op-stream exec path: the SAME `ExecRequest`, streamed (see
1866
+ * op-stream.ts). The durable op id comes from the tool-call correlation
1867
+ * context (`{callId}:{ordinal}` — B1) when this exec runs inside an SDK tool
1868
+ * invocation; a non-tool caller falls back to a random unique id (never
1869
+ * collides, merely not stable across a turn re-dispatch). Emits the SAME
1870
+ * per-op observation the legacy path does, with `replyBytes` filled from the
1871
+ * reassembled stream (the field the framed transport was designed to own).
1872
+ */
1873
+ async execViaOpStream(client, execReq, executionTimeoutMs) {
1874
+ const startedAt = Date.now();
1875
+ const opId = nextDurableOpId() ?? `anon_${crypto.randomUUID()}`;
1876
+ try {
1877
+ const outcome = await client.exec(
1878
+ opId,
1879
+ execReq,
1880
+ executionTimeoutMs,
1881
+ executionTimeoutMs + SELFHOSTED_EXEC_REPLY_GRACE_MS
1882
+ );
1883
+ const retries = outcome.heals + outcome.startRetries;
1884
+ this.emitOp({
1885
+ op: "exec",
1886
+ outcome: "ok",
1887
+ healed: retries > 0,
1888
+ retries,
1889
+ durationMs: Date.now() - startedAt,
1890
+ machineId: this.agentId,
1891
+ replyBytes: outcome.replyBytes,
1892
+ // A healed op's class: attach heals are link blips (reconnecting);
1893
+ // start retries without heals were admission backpressure (draining).
1894
+ ...retries > 0 ? { faultClass: outcome.heals > 0 ? "reconnecting" : "draining" } : {}
1895
+ });
1896
+ return execResultToChannelA(outcome.response, executionTimeoutMs);
1897
+ } catch (error) {
1898
+ if (error instanceof OpStreamUnavailableError) {
1899
+ throw error;
1900
+ }
1901
+ const controlError = error instanceof SelfhostedControlError ? error : new SelfhostedControlError({
1902
+ message: error instanceof Error ? error.message : String(error),
1903
+ code: ErrorCode4.ERROR_CODE_PROTOCOL,
1904
+ reason: null,
1905
+ retryable: false
1906
+ });
1907
+ this.emitOp({
1908
+ op: "exec",
1909
+ outcome: "failed",
1910
+ healed: false,
1911
+ retries: 0,
1912
+ durationMs: Date.now() - startedAt,
1913
+ code: controlError.code,
1914
+ reason: controlError.reason,
1915
+ neverSent: controlError.neverSent,
1916
+ machineId: this.agentId,
1917
+ faultClass: selfhostedFaultClass(controlError)
1918
+ });
1919
+ throw controlError;
1920
+ }
1921
+ }
1922
+ /**
1923
+ * The turn-end op-stream durability hook: persists each settled op's frontier
1924
+ * to the journal, then final-acks it on the wire (licensing the runner to GC
1925
+ * its retained frames). The WORKER calls this after the turn's results are
1926
+ * durably recorded — never mid-turn (the durable-before-wire-ack ordering).
1927
+ * A no-op for sessions without the op-stream transport.
1928
+ */
1929
+ async finalizeOpStreamOps() {
1930
+ await this.opStreamClient?.finalizeSettledOps();
810
1931
  }
811
1932
  // ── The agent-turn provided-session contract (over the SAME NATS primitives) ──
812
1933
  // These are what the @openai/agents shell/filesystem/skills capabilities call on
@@ -814,9 +1935,23 @@ var SelfhostedSession = class {
814
1935
  // exec/fs ops above; the machine owns its filesystem (materialization is a no-op).
815
1936
  /** SDK shell capability `execCommand`: run a command and return its stdout (the
816
1937
  * `exec_command` tool). Selfhosted exec is non-interactive (no PTY) — `tty` is
817
- * ignored; `supportsPty()` is false so the SDK never offers a stdin session. */
1938
+ * ignored; `supportsPty()` is false so the SDK never offers a stdin session.
1939
+ *
1940
+ * On a DEADLINE timeout the SDK path is stdout-only (the model sees `result.output`
1941
+ * and nothing else), so the deadline hint that `exec()` puts on stderr would be
1942
+ * invisible — a >deadline command would return `{"output":""}` and the model would
1943
+ * conclude "no output" with no idea it was killed. So when the result timed out we
1944
+ * surface the hint HERE, on stdout: the hint alone when stdout is empty, or appended
1945
+ * after a newline when there is partial output (a timed-out result is already not
1946
+ * cleanly parseable — silence is worse than an explanatory suffix). The structured
1947
+ * `exec()` result is left untouched for the Channel-A parsers. */
818
1948
  async execCommand(args) {
819
1949
  const result = await this.exec({ cmd: args.cmd, workdir: args.workdir, runAs: args.runAs });
1950
+ if (result.timedOut) {
1951
+ const hint = execDeadlineHint(Math.round(this.execDeadlineMs() / 1e3));
1952
+ return result.output ? `${result.output}
1953
+ ${hint}` : hint;
1954
+ }
820
1955
  return result.output;
821
1956
  }
822
1957
  /** SDK shell capability never calls this (gated on `supportsPty()` which is
@@ -829,7 +1964,10 @@ var SelfhostedSession = class {
829
1964
  * and wrap them in the tool-output image shape (magic-byte sniff + path fallback,
830
1965
  * mirroring the SDK's `imageOutputFromBytes`). */
831
1966
  async viewImage(args) {
832
- const bytes = await this.readFile({ path: args.path, ...args.runAs ? { runAs: args.runAs } : {} });
1967
+ const bytes = await this.readFile({
1968
+ path: args.path,
1969
+ ...args.runAs ? { runAs: args.runAs } : {}
1970
+ });
833
1971
  const mediaType = sniffImageMediaType(bytes, args.path);
834
1972
  if (!mediaType) {
835
1973
  throw new Error(`selfhosted view_image: unsupported image format for ${args.path}`);
@@ -877,7 +2015,10 @@ var SelfhostedSession = class {
877
2015
  await this.writeFile({ path, content, createParents: true });
878
2016
  };
879
2017
  const deletePath = async (path) => {
880
- await this.exec({ cmd: `rm -rf -- ${shellQuote(toMachinePath(path, ""))}`, ...runAs ? { runAs } : {} });
2018
+ await this.exec({
2019
+ cmd: `rm -rf -- ${shellQuote(toMachinePath(path, ""))}`,
2020
+ ...runAs ? { runAs } : {}
2021
+ });
881
2022
  };
882
2023
  return {
883
2024
  async createFile(operation) {
@@ -940,7 +2081,10 @@ var SelfhostedSession = class {
940
2081
  async listFiles(args) {
941
2082
  const result = await this.call({
942
2083
  $case: "fsList",
943
- fsList: { path: toMachinePath(args.path, this.workingDir), recursive: args.recursive ?? false }
2084
+ fsList: {
2085
+ path: toMachinePath(args.path, this.workingDir),
2086
+ recursive: args.recursive ?? false
2087
+ }
944
2088
  });
945
2089
  if (result.$case !== "fsList") {
946
2090
  throw new Error(`selfhosted listFiles: unexpected result ${result.$case}`);
@@ -949,7 +2093,10 @@ var SelfhostedSession = class {
949
2093
  }
950
2094
  /** Stat a path on the machine. */
951
2095
  async statFile(args) {
952
- const result = await this.call({ $case: "fsStat", fsStat: { path: toMachinePath(args.path, this.workingDir) } });
2096
+ const result = await this.call({
2097
+ $case: "fsStat",
2098
+ fsStat: { path: toMachinePath(args.path, this.workingDir) }
2099
+ });
953
2100
  if (result.$case !== "fsStat") {
954
2101
  throw new Error(`selfhosted statFile: unexpected result ${result.$case}`);
955
2102
  }
@@ -1031,7 +2178,9 @@ var SelfhostedSession = class {
1031
2178
  desktopEnsure: { width: 0, height: 0 }
1032
2179
  });
1033
2180
  if (result.$case !== "desktopEnsure") {
1034
- throw new Error(`selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`);
2181
+ throw new Error(
2182
+ `selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`
2183
+ );
1035
2184
  }
1036
2185
  channel = result.desktopEnsure.channel;
1037
2186
  } else {
@@ -1040,10 +2189,19 @@ var SelfhostedSession = class {
1040
2189
  // Open the terminal in the session workingDir (default "" ⇒ the agent's
1041
2190
  // workspace_root, byte-identical to before). A relative workingDir resolves
1042
2191
  // under workspace_root; an absolute one is used as-is by the agent.
1043
- ptyOpen: { command: [], cwd: this.workingDir, env: {}, cols: 0, rows: 0, term: "xterm-256color" }
2192
+ ptyOpen: {
2193
+ command: [],
2194
+ cwd: this.workingDir,
2195
+ env: {},
2196
+ cols: 0,
2197
+ rows: 0,
2198
+ term: "xterm-256color"
2199
+ }
1044
2200
  });
1045
2201
  if (result.$case !== "ptyOpen") {
1046
- throw new Error(`selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`);
2202
+ throw new Error(
2203
+ `selfhosted resolveExposedPort(${port}): unexpected result ${result.$case}`
2204
+ );
1047
2205
  }
1048
2206
  channel = result.ptyOpen.channel;
1049
2207
  }
@@ -1074,8 +2232,11 @@ var SelfhostedSandboxClient = class {
1074
2232
  defaultAgentId;
1075
2233
  epoch;
1076
2234
  timeoutMs;
2235
+ execTimeoutMs;
1077
2236
  environment;
1078
2237
  workingDir;
2238
+ onOp;
2239
+ opStream;
1079
2240
  controlRpcMemo;
1080
2241
  constructor(opts) {
1081
2242
  this.workspaceId = opts.workspaceId;
@@ -1084,8 +2245,11 @@ var SelfhostedSandboxClient = class {
1084
2245
  this.defaultAgentId = opts.agentId;
1085
2246
  this.epoch = opts.epoch;
1086
2247
  this.timeoutMs = opts.timeoutMs;
2248
+ this.execTimeoutMs = opts.execTimeoutMs;
1087
2249
  this.environment = opts.environment;
1088
2250
  this.workingDir = opts.workingDir;
2251
+ this.onOp = opts.onOp;
2252
+ this.opStream = opts.opStream;
1089
2253
  }
1090
2254
  controlRpc() {
1091
2255
  if (!this.controlRpcMemo) {
@@ -1101,8 +2265,11 @@ var SelfhostedSandboxClient = class {
1101
2265
  relay: this.relay,
1102
2266
  ...this.epoch !== void 0 ? { epoch: this.epoch } : {},
1103
2267
  ...this.timeoutMs !== void 0 ? { timeoutMs: this.timeoutMs } : {},
2268
+ ...this.execTimeoutMs !== void 0 ? { execTimeoutMs: this.execTimeoutMs } : {},
1104
2269
  ...this.environment !== void 0 ? { environment: this.environment } : {},
1105
- ...this.workingDir !== void 0 ? { workingDir: this.workingDir } : {}
2270
+ ...this.workingDir !== void 0 ? { workingDir: this.workingDir } : {},
2271
+ ...this.onOp !== void 0 ? { onOp: this.onOp } : {},
2272
+ ...this.opStream !== void 0 ? { opStream: this.opStream } : {}
1106
2273
  });
1107
2274
  }
1108
2275
  /** Bind a session to the live agent subject. There is no box to provision. */
@@ -1132,7 +2299,9 @@ var SelfhostedSandboxClient = class {
1132
2299
  }
1133
2300
  requireAgentId() {
1134
2301
  if (!this.defaultAgentId) {
1135
- throw new Error("selfhosted sandbox client: no agentId bound (create()/resume() need a session state carrying agentId)");
2302
+ throw new Error(
2303
+ "selfhosted sandbox client: no agentId bound (create()/resume() need a session state carrying agentId)"
2304
+ );
1136
2305
  }
1137
2306
  return this.defaultAgentId;
1138
2307
  }
@@ -1145,8 +2314,11 @@ async function buildSelfhostedBackendSession(deps) {
1145
2314
  agentId: deps.agentId,
1146
2315
  epoch: deps.epoch,
1147
2316
  ...deps.timeoutMs !== void 0 ? { timeoutMs: deps.timeoutMs } : {},
2317
+ ...deps.execTimeoutMs !== void 0 ? { execTimeoutMs: deps.execTimeoutMs } : {},
2318
+ ...deps.onOp !== void 0 ? { onOp: deps.onOp } : {},
1148
2319
  ...deps.environment !== void 0 ? { environment: deps.environment } : {},
1149
- ...deps.workingDir ? { workingDir: deps.workingDir } : {}
2320
+ ...deps.workingDir ? { workingDir: deps.workingDir } : {},
2321
+ ...deps.opStream !== void 0 ? { opStream: deps.opStream } : {}
1150
2322
  });
1151
2323
  const session = await client.resume({ agentId: deps.agentId });
1152
2324
  return { client, session };
@@ -1160,14 +2332,20 @@ function readAgentId(state) {
1160
2332
  }
1161
2333
  return void 0;
1162
2334
  }
1163
- function execResultToChannelA(res) {
2335
+ function execResultToChannelA(res, execDeadlineMs) {
1164
2336
  const stdout = decoder.decode(res.stdout);
1165
- const stderr = decoder.decode(res.stderr);
2337
+ let stderr = decoder.decode(res.stderr);
2338
+ if (res.timedOut) {
2339
+ const hint = execDeadlineHint(Math.round(execDeadlineMs / 1e3));
2340
+ stderr = stderr ? `${stderr}
2341
+ ${hint}` : hint;
2342
+ }
1166
2343
  return {
1167
2344
  output: stdout,
1168
2345
  stdout,
1169
2346
  stderr,
1170
- exitCode: res.exitCode
2347
+ exitCode: res.exitCode,
2348
+ timedOut: res.timedOut
1171
2349
  };
1172
2350
  }
1173
2351
  function channelKey(workspaceId, agentId, port) {
@@ -1177,12 +2355,16 @@ function shellQuote(value) {
1177
2355
  return `'${value.replace(/'/g, `'\\''`)}'`;
1178
2356
  }
1179
2357
  function sniffImageMediaType(bytes, path) {
1180
- if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) return "image/png";
2358
+ if (bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71)
2359
+ return "image/png";
1181
2360
  if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
1182
- if (bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 56) return "image/gif";
1183
- if (bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) return "image/webp";
2361
+ if (bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 56)
2362
+ return "image/gif";
2363
+ if (bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80)
2364
+ return "image/webp";
1184
2365
  if (bytes[0] === 66 && bytes[1] === 77) return "image/bmp";
1185
- if (bytes[0] === 73 && bytes[1] === 73 && bytes[2] === 42 && bytes[3] === 0 || bytes[0] === 77 && bytes[1] === 77 && bytes[2] === 0 && bytes[3] === 42) return "image/tiff";
2366
+ if (bytes[0] === 73 && bytes[1] === 73 && bytes[2] === 42 && bytes[3] === 0 || bytes[0] === 77 && bytes[1] === 77 && bytes[2] === 0 && bytes[3] === 42)
2367
+ return "image/tiff";
1186
2368
  if (looksLikeSvg(bytes)) return "image/svg+xml";
1187
2369
  return mediaTypeFromPath(path);
1188
2370
  }
@@ -1329,14 +2511,20 @@ function assertProviderRegistryInvariants() {
1329
2511
  for (const backend of SandboxBackend2.options) {
1330
2512
  const registration = PROVIDER_REGISTRY[backend];
1331
2513
  if (registration.backend !== backend) {
1332
- throw new Error(`PROVIDER_REGISTRY["${backend}"].backend mismatch (got "${registration.backend}")`);
2514
+ throw new Error(
2515
+ `PROVIDER_REGISTRY["${backend}"].backend mismatch (got "${registration.backend}")`
2516
+ );
1333
2517
  }
1334
2518
  if (registration.descriptor.backend !== backend) {
1335
- throw new Error(`PROVIDER_REGISTRY["${backend}"].descriptor.backend mismatch (got "${registration.descriptor.backend}")`);
2519
+ throw new Error(
2520
+ `PROVIDER_REGISTRY["${backend}"].descriptor.backend mismatch (got "${registration.descriptor.backend}")`
2521
+ );
1336
2522
  }
1337
2523
  if (backend === "none") {
1338
2524
  if (registration.descriptor.backendId !== "none") {
1339
- throw new Error(`"none" descriptor.backendId must be "none" (got "${registration.descriptor.backendId}")`);
2525
+ throw new Error(
2526
+ `"none" descriptor.backendId must be "none" (got "${registration.descriptor.backendId}")`
2527
+ );
1340
2528
  }
1341
2529
  continue;
1342
2530
  }
@@ -1394,7 +2582,14 @@ function negotiateCapabilities(ctx) {
1394
2582
  const osReason = osSupported ? null : "os_unsupported";
1395
2583
  const fileSystem = (() => {
1396
2584
  if (osReason) {
1397
- return { available: false, readOnly: true, root: descriptor.workspaceRoot, pathSep: "/", treeMode: "lazy", reason: osReason };
2585
+ return {
2586
+ available: false,
2587
+ readOnly: true,
2588
+ root: descriptor.workspaceRoot,
2589
+ pathSep: "/",
2590
+ treeMode: "lazy",
2591
+ reason: osReason
2592
+ };
1398
2593
  }
1399
2594
  const cap = descriptor.capabilities.FileSystem;
1400
2595
  return {
@@ -1409,10 +2604,26 @@ function negotiateCapabilities(ctx) {
1409
2604
  const terminal = (() => {
1410
2605
  const cap = descriptor.capabilities.Terminal;
1411
2606
  if (osReason) {
1412
- return { transport: null, ptyCapable: false, shell: "/bin/bash", url: null, token: null, expiresAt: null, reason: osReason };
2607
+ return {
2608
+ transport: null,
2609
+ ptyCapable: false,
2610
+ shell: "/bin/bash",
2611
+ url: null,
2612
+ token: null,
2613
+ expiresAt: null,
2614
+ reason: osReason
2615
+ };
1413
2616
  }
1414
2617
  if (!cap.available) {
1415
- return { transport: null, ptyCapable: false, shell: "/bin/bash", url: null, token: null, expiresAt: null, reason: "backend_unsupported" };
2618
+ return {
2619
+ transport: null,
2620
+ ptyCapable: false,
2621
+ shell: "/bin/bash",
2622
+ url: null,
2623
+ token: null,
2624
+ expiresAt: null,
2625
+ reason: "backend_unsupported"
2626
+ };
1416
2627
  }
1417
2628
  const ptyCapable = cap.pty;
1418
2629
  let transport = ptyCapable ? "pty-ws" : "sse-events";
@@ -1440,7 +2651,11 @@ function negotiateCapabilities(ctx) {
1440
2651
  if (osReason) {
1441
2652
  return { available: false, repos: [], reason: osReason };
1442
2653
  }
1443
- return { available: cap.available, repos: [], reason: cap.available ? null : "backend_unsupported" };
2654
+ return {
2655
+ available: cap.available,
2656
+ repos: [],
2657
+ reason: cap.available ? null : "backend_unsupported"
2658
+ };
1444
2659
  })();
1445
2660
  const desktop = (() => {
1446
2661
  const cap = descriptor.capabilities.DesktopStream;
@@ -1493,13 +2708,28 @@ function negotiateCapabilities(ctx) {
1493
2708
  const recording = (() => {
1494
2709
  const cap = descriptor.capabilities.Recording;
1495
2710
  if (osReason) {
1496
- return { available: false, modes: [], codecs: [], reason: osReason };
2711
+ return {
2712
+ available: false,
2713
+ modes: [],
2714
+ codecs: [],
2715
+ reason: osReason
2716
+ };
1497
2717
  }
1498
2718
  if (!cap.available) {
1499
- return { available: false, modes: [], codecs: [], reason: descriptor.tier === "headless" ? "tier_headless" : "backend_unsupported" };
2719
+ return {
2720
+ available: false,
2721
+ modes: [],
2722
+ codecs: [],
2723
+ reason: descriptor.tier === "headless" ? "tier_headless" : "backend_unsupported"
2724
+ };
1500
2725
  }
1501
2726
  if (!ctx.desktopEnabled) {
1502
- return { available: false, modes: [], codecs: [], reason: "disabled_by_policy" };
2727
+ return {
2728
+ available: false,
2729
+ modes: [],
2730
+ codecs: [],
2731
+ reason: "disabled_by_policy"
2732
+ };
1503
2733
  }
1504
2734
  return {
1505
2735
  available: true,
@@ -1515,7 +2745,11 @@ function negotiateCapabilities(ctx) {
1515
2745
  return { available: false, readOnly, reason: osReason };
1516
2746
  }
1517
2747
  if (!desktopCapable) {
1518
- return { available: false, readOnly, reason: descriptor.tier === "headless" ? "tier_headless" : "backend_unsupported" };
2748
+ return {
2749
+ available: false,
2750
+ readOnly,
2751
+ reason: descriptor.tier === "headless" ? "tier_headless" : "backend_unsupported"
2752
+ };
1519
2753
  }
1520
2754
  if (!ctx.desktopEnabled || ctx.computerUseEnabled === false) {
1521
2755
  return { available: false, readOnly, reason: "disabled_by_policy" };
@@ -1546,7 +2780,9 @@ import {
1546
2780
  signStreamToken,
1547
2781
  verifyStreamToken as verifyStreamTokenEnvelope
1548
2782
  } from "@opengeni/contracts";
1549
- import { StreamTokenPayload as StreamTokenPayload2 } from "@opengeni/contracts";
2783
+ import {
2784
+ StreamTokenPayload as StreamTokenPayload2
2785
+ } from "@opengeni/contracts";
1550
2786
  var STREAM_TOKEN_DEFAULT_TTL_SECONDS = 120;
1551
2787
  async function mintStreamToken(secret, input) {
1552
2788
  const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1e3);
@@ -1580,8 +2816,10 @@ var DisplayStackError = class extends Error {
1580
2816
  stage;
1581
2817
  constructor(exitCode, output) {
1582
2818
  const stage = exitCode === 11 ? "xvfb" : exitCode === 12 ? "x11vnc" : exitCode === 13 ? "websockify" : exitCode === 14 ? "paint" : "unknown";
1583
- super(`desktop display stack failed at stage "${stage}" (exit ${exitCode})${output ? `:
1584
- ${output}` : ""}`);
2819
+ super(
2820
+ `desktop display stack failed at stage "${stage}" (exit ${exitCode})${output ? `:
2821
+ ${output}` : ""}`
2822
+ );
1585
2823
  this.name = "DisplayStackError";
1586
2824
  this.exitCode = exitCode;
1587
2825
  this.stage = stage;
@@ -1658,7 +2896,11 @@ async function tearDownDisplayStack(session) {
1658
2896
  return;
1659
2897
  }
1660
2898
  if (typeof s?.execCommand === "function") {
1661
- await s.execCommand({ cmd: "opengeni-desktop-down", yieldTimeMs: 1e4, maxOutputTokens: 4e3 });
2899
+ await s.execCommand({
2900
+ cmd: "opengeni-desktop-down",
2901
+ yieldTimeMs: 1e4,
2902
+ maxOutputTokens: 4e3
2903
+ });
1662
2904
  }
1663
2905
  }
1664
2906
 
@@ -1670,8 +2912,10 @@ var TerminalServerError = class extends Error {
1670
2912
  stage;
1671
2913
  constructor(exitCode, output) {
1672
2914
  const stage = exitCode === 14 ? "ttyd" : "unknown";
1673
- super(`terminal server failed at stage "${stage}" (exit ${exitCode})${output ? `:
1674
- ${output}` : ""}`);
2915
+ super(
2916
+ `terminal server failed at stage "${stage}" (exit ${exitCode})${output ? `:
2917
+ ${output}` : ""}`
2918
+ );
1675
2919
  this.name = "TerminalServerError";
1676
2920
  this.exitCode = exitCode;
1677
2921
  this.stage = stage;
@@ -1734,7 +2978,11 @@ async function tearDownTerminalServer(session) {
1734
2978
  return;
1735
2979
  }
1736
2980
  if (typeof s?.execCommand === "function") {
1737
- await s.execCommand({ cmd: "opengeni-terminal-down", yieldTimeMs: 1e4, maxOutputTokens: 4e3 });
2981
+ await s.execCommand({
2982
+ cmd: "opengeni-terminal-down",
2983
+ yieldTimeMs: 1e4,
2984
+ maxOutputTokens: 4e3
2985
+ });
1738
2986
  }
1739
2987
  }
1740
2988
 
@@ -1746,7 +2994,6 @@ var StreamPortUnavailableError = class extends Error {
1746
2994
  this.cause = cause;
1747
2995
  this.name = "StreamPortUnavailableError";
1748
2996
  }
1749
- cause;
1750
2997
  };
1751
2998
  var DEFAULT_RESOLUTION = [1280, 800];
1752
2999
  function buildStreamUrl(endpoint) {
@@ -1833,7 +3080,6 @@ var RecordingError = class extends Error {
1833
3080
  this.reason = reason;
1834
3081
  this.name = "RecordingError";
1835
3082
  }
1836
- reason;
1837
3083
  };
1838
3084
  function shq(s) {
1839
3085
  return `'${s.replace(/'/g, `'\\''`)}'`;
@@ -1851,7 +3097,9 @@ async function run(session, cmd, runAs, yieldTimeMs = EXEC_YIELD_MS, maxOutputTo
1851
3097
  if (typeof session.execCommand === "function") {
1852
3098
  return resultOutput(await session.execCommand(args));
1853
3099
  }
1854
- throw new RecordingUnavailableError("session cannot run commands (no exec/execCommand) \u2014 recording unavailable");
3100
+ throw new RecordingUnavailableError(
3101
+ "session cannot run commands (no exec/execCommand) \u2014 recording unavailable"
3102
+ );
1855
3103
  }
1856
3104
  function stripExecBanner(raw) {
1857
3105
  const marker = raw.lastIndexOf("\nOutput:\n");
@@ -1892,12 +3140,18 @@ async function stopRecording(session, proc) {
1892
3140
  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`;
1893
3141
  await run(s, `bash -lc ${shq(wait)}`, proc.runAs, STOP_YIELD_MS).catch(() => void 0);
1894
3142
  }
1895
- async function readRecordingBytes(session, proc, maxBytes = DEFAULT_MAX_BYTES) {
3143
+ async function inspectRecordingArtifact(session, proc, maxBytes = DEFAULT_MAX_BYTES) {
1896
3144
  const s = session;
1897
3145
  if (typeof s.exec !== "function" && typeof s.execCommand !== "function") {
1898
- throw new RecordingUnavailableError("session cannot run commands (no exec/execCommand) \u2014 recording finalize unavailable");
3146
+ throw new RecordingUnavailableError(
3147
+ "session cannot run commands (no exec/execCommand) \u2014 recording finalize unavailable"
3148
+ );
1899
3149
  }
1900
- const sizeOut = (await run(s, `bash -lc ${shq(`stat -c %s ${proc.boxPath} 2>/dev/null || echo MISSING`)}`, proc.runAs)).trim();
3150
+ const sizeOut = (await run(
3151
+ s,
3152
+ `bash -lc ${shq(`stat -c %s ${proc.boxPath} 2>/dev/null || echo MISSING`)}`,
3153
+ proc.runAs
3154
+ )).trim();
1901
3155
  const sizeLine = sizeOut.split("\n").map((l) => l.trim()).filter(Boolean).pop() ?? "MISSING";
1902
3156
  if (sizeLine === "MISSING" || sizeLine === "") {
1903
3157
  throw new RecordingError(`recording file missing on box: ${proc.boxPath}`, "box-death");
@@ -1909,34 +3163,45 @@ async function readRecordingBytes(session, proc, maxBytes = DEFAULT_MAX_BYTES) {
1909
3163
  if (size > maxBytes) {
1910
3164
  throw new RecordingError(`recording ${size}B exceeds max ${maxBytes}B`, "max-bytes-exceeded");
1911
3165
  }
1912
- const STOP_YIELD = 6e4;
1913
- const encoded = stripExecBanner(
1914
- await run(s, `bash -lc ${shq(`base64 ${proc.boxPath}`)}`, proc.runAs, STOP_YIELD, null)
1915
- );
1916
- const base64 = encoded.replace(/\s+/g, "");
1917
- if (base64.length === 0) {
1918
- throw new RecordingError(`recording read returned 0 bytes: ${proc.boxPath}`, "ffmpeg-error");
1919
- }
1920
- let bytes;
1921
- try {
1922
- bytes = Uint8Array.from(Buffer.from(base64, "base64"));
1923
- } catch (error) {
1924
- throw new RecordingError(`recording base64 decode failed: ${error instanceof Error ? error.message : String(error)}`, "ffmpeg-error");
1925
- }
1926
- if (bytes.length === 0) {
1927
- throw new RecordingError(`recording read returned 0 bytes: ${proc.boxPath}`, "ffmpeg-error");
1928
- }
1929
3166
  return {
1930
- bytes,
1931
3167
  contentType: contentTypeForCodec(proc.codec),
1932
- sizeBytes: bytes.length,
3168
+ sizeBytes: size,
1933
3169
  durationSeconds: Math.max(0, Math.round((Date.now() - proc.startedAt) / 1e3))
1934
3170
  };
1935
3171
  }
3172
+ var RECORDING_UPLOAD_OK = "OPENGENI_RECORDING_UPLOAD_OK";
3173
+ var RECORDING_UPLOAD_MAX_SECONDS = 300;
3174
+ async function uploadRecordingArtifact(session, proc, input) {
3175
+ const maxSeconds = input.maxSeconds ?? RECORDING_UPLOAD_MAX_SECONDS;
3176
+ if (!Number.isSafeInteger(maxSeconds) || maxSeconds <= 0 || maxSeconds > 300) {
3177
+ throw new Error("recording upload maxSeconds must be an integer between 1 and 300");
3178
+ }
3179
+ const metadata = await inspectRecordingArtifact(session, proc, input.maxBytes);
3180
+ const headers = Object.entries(input.requiredHeaders).sort(([left], [right]) => left.localeCompare(right)).map(([name, value]) => `--header ${shq(`${name}: ${value}`)}`).join(" ");
3181
+ const upload = [
3182
+ "set -euo pipefail",
3183
+ `curl --fail --silent --show-error --connect-timeout 20 --max-time ${maxSeconds} --request PUT ${headers} --upload-file ${shq(proc.boxPath)} ${shq(input.url)}`,
3184
+ `printf '${RECORDING_UPLOAD_OK}\\n'`
3185
+ ].join("; ");
3186
+ const output = stripExecBanner(
3187
+ await run(
3188
+ session,
3189
+ `bash -lc ${shq(upload)}`,
3190
+ proc.runAs,
3191
+ (maxSeconds + 10) * 1e3
3192
+ )
3193
+ );
3194
+ if (!output.includes(RECORDING_UPLOAD_OK)) {
3195
+ throw new Error("recording upload did not confirm success");
3196
+ }
3197
+ return metadata;
3198
+ }
1936
3199
  async function deleteRecordingArtifacts(session, proc) {
1937
3200
  const s = session;
1938
3201
  const logFile = proc.boxPath.replace(/\.(mp4|webm)$/, ".log");
1939
- await run(s, `rm -f ${proc.boxPath} ${proc.pidFile} ${logFile}`, proc.runAs).catch(() => void 0);
3202
+ await run(s, `rm -f ${proc.boxPath} ${proc.pidFile} ${logFile}`, proc.runAs).catch(
3203
+ () => void 0
3204
+ );
1940
3205
  }
1941
3206
  function recordingStorageKey(workspaceId, sessionId, recordingId, codec) {
1942
3207
  return `recordings/${workspaceId}/${sessionId}/${recordingId}.${extForCodec(codec)}`;
@@ -1967,9 +3232,13 @@ var ChannelAUnsupportedError = class extends Error {
1967
3232
  this.name = "ChannelAUnsupportedError";
1968
3233
  }
1969
3234
  };
3235
+ var REPOSITORY_DISCOVERY_LIMIT = 256;
3236
+ var REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL = "__OPENGENI_REPOSITORY_DISCOVERY_TRUNCATED__";
3237
+ var REPOSITORY_DISCOVERY_STATUS_PREFIX = "__OPENGENI_REPOSITORY_DISCOVERY_STATUS__:";
1970
3238
  var NUL = String.fromCharCode(0);
1971
3239
  var US = String.fromCharCode(31);
1972
3240
  var RS = String.fromCharCode(30);
3241
+ var SELFHOSTED_VIRTUAL_ROOT2 = "/workspace";
1973
3242
  var SandboxChannelAService = class {
1974
3243
  session;
1975
3244
  workspaceRoot;
@@ -1991,7 +3260,11 @@ var SandboxChannelAService = class {
1991
3260
  const hasExec = Boolean(s.exec || s.execCommand);
1992
3261
  const hasFs = Boolean(s.readFile && (s.exec || s.execCommand || s.createEditor));
1993
3262
  return {
1994
- FileSystem: { available: hasFs, readOnly: !(s.exec || s.createEditor), root: this.workspaceRoot },
3263
+ FileSystem: {
3264
+ available: hasFs,
3265
+ readOnly: !(s.exec || s.createEditor),
3266
+ root: this.workspaceRoot
3267
+ },
1995
3268
  Terminal: {
1996
3269
  events: hasExec,
1997
3270
  exec: hasExec,
@@ -2037,7 +3310,10 @@ var SandboxChannelAService = class {
2037
3310
  const depthArg = Math.max(1, req.depth);
2038
3311
  const hidden = req.includeHidden ? "" : ` -not -path '*/.*'`;
2039
3312
  const gnuFind = `find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -printf '%y\\t%s\\t%T@\\t%m\\t%p\\0' 2>/dev/null`;
2040
- let { stdout } = await this.run({ cmd: `bash -lc ${shellQuote2(gnuFind)}`, workdir: this.workspaceRoot || void 0 });
3313
+ let { stdout } = await this.run({
3314
+ cmd: `bash -lc ${shellQuote2(gnuFind)}`,
3315
+ workdir: this.workspaceRoot || void 0
3316
+ });
2041
3317
  if (!stdout) {
2042
3318
  const portableFind = [
2043
3319
  `find ${findRoot} -mindepth 1 -maxdepth ${depthArg}${hidden} -print0 2>/dev/null | while IFS= read -r -d '' p; do`,
@@ -2047,7 +3323,10 @@ var SandboxChannelAService = class {
2047
3323
  `printf '%s\\t%s\\t%s\\t%s\\t%s\\0' "$t" "$size" "$mtime" "$mode" "$p";`,
2048
3324
  `done`
2049
3325
  ].join(" ");
2050
- ({ stdout } = await this.run({ cmd: `bash -lc ${shellQuote2(portableFind)}`, workdir: this.workspaceRoot || void 0 }));
3326
+ ({ stdout } = await this.run({
3327
+ cmd: `bash -lc ${shellQuote2(portableFind)}`,
3328
+ workdir: this.workspaceRoot || void 0
3329
+ }));
2051
3330
  }
2052
3331
  const entries = stdout.split(NUL).filter((s) => s.length > 0);
2053
3332
  const rootNode = {
@@ -2103,12 +3382,18 @@ var SandboxChannelAService = class {
2103
3382
  }
2104
3383
  let raw;
2105
3384
  try {
2106
- raw = await this.session.readFile({ path: this.joinRoot(path), maxBytes: req.maxBytes, ...this.runAs ? { runAs: this.runAs } : {} });
3385
+ raw = await this.session.readFile({
3386
+ path: this.joinRoot(path),
3387
+ maxBytes: req.maxBytes,
3388
+ ...this.runAs ? { runAs: this.runAs } : {}
3389
+ });
2107
3390
  } catch (error) {
2108
3391
  if (isWorkspaceEscapeError(error)) {
2109
3392
  return await this.fsReadViaExec(path, req);
2110
3393
  }
2111
- throw new ChannelANotFoundError(`file not found: ${path} (${error instanceof Error ? error.message : String(error)})`);
3394
+ throw new ChannelANotFoundError(
3395
+ `file not found: ${path} (${error instanceof Error ? error.message : String(error)})`
3396
+ );
2112
3397
  }
2113
3398
  const bytes = typeof raw === "string" ? Buffer.from(raw, "utf8") : Buffer.from(raw);
2114
3399
  return this.shapeRead(path, bytes, req);
@@ -2119,7 +3404,9 @@ var SandboxChannelAService = class {
2119
3404
  * node itself is in-workspace). `base64 <path>` follows the symlink. */
2120
3405
  async fsReadViaExec(path, req) {
2121
3406
  const abs = this.joinRoot(path);
2122
- const { stdout, exitCode } = await this.run({ cmd: `base64 ${shellQuote2(abs)} 2>/dev/null | head -c ${Math.ceil(req.maxBytes * 1.4)}` });
3407
+ const { stdout, exitCode } = await this.run({
3408
+ cmd: `base64 ${shellQuote2(abs)} 2>/dev/null | head -c ${Math.ceil(req.maxBytes * 1.4)}`
3409
+ });
2123
3410
  if (exitCode !== null && exitCode !== 0 && stdout === "") {
2124
3411
  throw new ChannelANotFoundError(`file not found: ${path}`);
2125
3412
  }
@@ -2162,13 +3449,21 @@ var SandboxChannelAService = class {
2162
3449
  if (exitCode !== null && exitCode !== 0) {
2163
3450
  if (req.encoding !== "base64" && this.session.createEditor) {
2164
3451
  const ok2 = await this.tryEditorWrite(abs, req.content);
2165
- if (!ok2) throw new ChannelAValidationError(`failed to write ${path}: ${stderr || `exit ${exitCode}`}`);
3452
+ if (!ok2)
3453
+ throw new ChannelAValidationError(
3454
+ `failed to write ${path}: ${stderr || `exit ${exitCode}`}`
3455
+ );
2166
3456
  } else {
2167
- throw new ChannelAValidationError(`failed to write ${path}: ${stderr || `exit ${exitCode}`}`);
3457
+ throw new ChannelAValidationError(
3458
+ `failed to write ${path}: ${stderr || `exit ${exitCode}`}`
3459
+ );
2168
3460
  }
2169
3461
  }
2170
3462
  this.revision++;
2171
- await this.emitFsChanged([{ path, kind: "modified", isDir: false, sizeBytes: bytes.byteLength }], "write");
3463
+ await this.emitFsChanged(
3464
+ [{ path, kind: "modified", isDir: false, sizeBytes: bytes.byteLength }],
3465
+ "write"
3466
+ );
2172
3467
  return { path, sizeBytes: bytes.byteLength, revision: this.revision };
2173
3468
  }
2174
3469
  async tryEditorWrite(absPath, content) {
@@ -2188,7 +3483,9 @@ var SandboxChannelAService = class {
2188
3483
  const flag = req.recursive ? "-rf" : "-f";
2189
3484
  const { exitCode, stderr } = await this.run({ cmd: `rm ${flag} ${shellQuote2(abs)}` });
2190
3485
  if (exitCode !== null && exitCode !== 0) {
2191
- throw new ChannelAValidationError(`failed to delete ${path}: ${stderr || `exit ${exitCode}`}`);
3486
+ throw new ChannelAValidationError(
3487
+ `failed to delete ${path}: ${stderr || `exit ${exitCode}`}`
3488
+ );
2192
3489
  }
2193
3490
  this.revision++;
2194
3491
  await this.emitFsChanged([{ path, kind: "deleted", isDir: false, sizeBytes: null }], "write");
@@ -2214,7 +3511,9 @@ var SandboxChannelAService = class {
2214
3511
  cmd: `mv ${flag}${shellQuote2(abs)} ${shellQuote2(newAbs)}`
2215
3512
  });
2216
3513
  if (exitCode !== null && exitCode !== 0) {
2217
- throw new ChannelAValidationError(`failed to move ${path} -> ${newPath}: ${stderr || `exit ${exitCode}`}`);
3514
+ throw new ChannelAValidationError(
3515
+ `failed to move ${path} -> ${newPath}: ${stderr || `exit ${exitCode}`}`
3516
+ );
2218
3517
  }
2219
3518
  this.revision++;
2220
3519
  await this.emitFsChanged(
@@ -2241,11 +3540,26 @@ var SandboxChannelAService = class {
2241
3540
  // ════════════════════════════ Git (A2, read-only) ═════════════════════════
2242
3541
  async gitStatus(req) {
2243
3542
  const repo = this.repoWorkdir(req.path);
2244
- const inside = await this.run({ cmd: "git rev-parse --is-inside-work-tree 2>/dev/null", workdir: repo });
3543
+ const inside = await this.run({
3544
+ cmd: "git rev-parse --is-inside-work-tree 2>/dev/null",
3545
+ workdir: repo
3546
+ });
2245
3547
  if (inside.stdout.trim() !== "true") {
2246
- return { isRepo: false, head: null, detached: false, upstream: null, ahead: 0, behind: 0, files: [], revision: this.revision };
3548
+ return {
3549
+ isRepo: false,
3550
+ head: null,
3551
+ detached: false,
3552
+ upstream: null,
3553
+ ahead: 0,
3554
+ behind: 0,
3555
+ files: [],
3556
+ revision: this.revision
3557
+ };
2247
3558
  }
2248
- const { stdout } = await this.run({ cmd: "git status --porcelain=v2 --branch -z", workdir: repo });
3559
+ const { stdout } = await this.run({
3560
+ cmd: "git status --porcelain=v2 --branch -z",
3561
+ workdir: repo
3562
+ });
2249
3563
  return { ...parsePorcelainV2(stdout), revision: this.revision };
2250
3564
  }
2251
3565
  async gitDiff(req) {
@@ -2256,7 +3570,10 @@ var SandboxChannelAService = class {
2256
3570
  else if (req.fromRef) range = `${shellQuote2(req.fromRef)}`;
2257
3571
  else if (req.staged) range = "--cached";
2258
3572
  const pathspec = req.pathspec.length ? ` -- ${req.pathspec.map(shellQuote2).join(" ")}` : "";
2259
- const numstat = await this.run({ cmd: `git -c core.quotePath=false diff --no-color -z --numstat ${range}${pathspec}`.trim(), workdir: repo });
3573
+ const numstat = await this.run({
3574
+ cmd: `git -c core.quotePath=false diff --no-color -z --numstat ${range}${pathspec}`.trim(),
3575
+ workdir: repo
3576
+ });
2260
3577
  const stats = parseNumstatZ(numstat.stdout);
2261
3578
  const files = [];
2262
3579
  for (const stat of stats) {
@@ -2343,24 +3660,121 @@ var SandboxChannelAService = class {
2343
3660
  return {
2344
3661
  commit: null,
2345
3662
  files: [],
2346
- blob: { content: encoding === "base64" ? clamped.toString("base64") : clamped.toString("utf8"), encoding, sizeBytes: clamped.byteLength, truncated },
3663
+ blob: {
3664
+ content: encoding === "base64" ? clamped.toString("base64") : clamped.toString("utf8"),
3665
+ encoding,
3666
+ sizeBytes: clamped.byteLength,
3667
+ truncated
3668
+ },
2347
3669
  revision: this.revision
2348
3670
  };
2349
3671
  }
2350
- const log = await this.gitLog({ path: req.path, ref: req.ref, maxCount: 1, skip: 0, pathspec: [] });
3672
+ const log = await this.gitLog({
3673
+ path: req.path,
3674
+ ref: req.ref,
3675
+ maxCount: 1,
3676
+ skip: 0,
3677
+ pathspec: []
3678
+ });
2351
3679
  const commit = log.commits[0] ?? null;
2352
- const diff = await this.gitDiff({ path: req.path, staged: false, fromRef: `${req.ref}^`, toRef: req.ref, pathspec: [], contextLines: 3, maxBytesPerFile: req.maxBytesPerFile });
3680
+ const diff = await this.gitDiff({
3681
+ path: req.path,
3682
+ staged: false,
3683
+ fromRef: `${req.ref}^`,
3684
+ toRef: req.ref,
3685
+ pathspec: [],
3686
+ contextLines: 3,
3687
+ maxBytesPerFile: req.maxBytesPerFile
3688
+ });
2353
3689
  return { commit, files: diff.files, blob: null, revision: this.revision };
2354
3690
  }
2355
- /** Detect repo roots within the workspace (for the Git.repos capability). */
2356
- async detectRepos() {
3691
+ /**
3692
+ * Detect repo roots within every supported workspace layout.
3693
+ *
3694
+ * The platform seeds GitHub repositories at
3695
+ * `repos/<owner>/<repo>`, which puts the `.git` marker at depth four from the
3696
+ * workspace root. Do not reintroduce a fixed maxdepth here. Traversal is
3697
+ * bounded instead by pruning known machine/build residue, a wall-clock
3698
+ * timeout, and a result limit. `.git` may be either a directory (ordinary
3699
+ * clone/submodule) or a file (linked worktree).
3700
+ *
3701
+ * Callers that persist an authoritative snapshot must use this detailed
3702
+ * result and surface an incomplete discovery. The compact `detectRepos()`
3703
+ * wrapper remains for capability negotiation, where the best available list
3704
+ * is preferable to failing the whole capabilities document.
3705
+ */
3706
+ async detectReposDetailed() {
2357
3707
  try {
2358
- const { stdout } = await this.run({ cmd: `find . -maxdepth 3 -name .git -type d 2>/dev/null`, workdir: this.workspaceRoot || void 0 });
2359
- return stdout.split("\n").map((l) => l.trim()).filter(Boolean).map((g) => dirnameAbs(stripDotSlash(g, "")) || "");
3708
+ const discovery = [
3709
+ "find . -xdev",
3710
+ "\\( -name .git \\( -type d -o -type f \\) -print -prune \\)",
3711
+ "-o \\( -type d \\( -name node_modules -o -name .cache -o -name .local",
3712
+ "-o -name dist -o -name build -o -name target -o -name .venv",
3713
+ "-o -name __pycache__ -o -name .next \\) -prune \\)",
3714
+ "2>/dev/null"
3715
+ ].join(" ");
3716
+ const command = [
3717
+ 'results_file=$(mktemp "${TMPDIR:-/tmp}/opengeni-repository-discovery.XXXXXX") || exit 70',
3718
+ 'timeout_file="${results_file}.timed-out"',
3719
+ "discovery_pid=",
3720
+ "watchdog_pid=",
3721
+ 'cleanup() { if [ -n "$discovery_pid" ]; then kill "$discovery_pid" 2>/dev/null || true; fi; if [ -n "$watchdog_pid" ]; then kill "$watchdog_pid" 2>/dev/null || true; fi; rm -f "$results_file" "$timeout_file"; }',
3722
+ "abort() { trap - EXIT; cleanup; exit 143; }",
3723
+ "trap cleanup EXIT",
3724
+ "trap abort HUP INT TERM",
3725
+ `${discovery} > "$results_file" &`,
3726
+ "discovery_pid=$!",
3727
+ '(sleeper_pid=; stop_watchdog() { if [ -n "$sleeper_pid" ]; then kill "$sleeper_pid" 2>/dev/null || true; wait "$sleeper_pid" 2>/dev/null || true; fi; exit 0; }; trap stop_watchdog HUP INT TERM; sleep 15 & sleeper_pid=$!; wait "$sleeper_pid"; sleeper_pid=; if kill -TERM "$discovery_pid" 2>/dev/null; then : > "$timeout_file"; fi) </dev/null >/dev/null 2>&1 &',
3728
+ "watchdog_pid=$!",
3729
+ 'wait "$discovery_pid"',
3730
+ "status=$?",
3731
+ "discovery_pid=",
3732
+ 'kill "$watchdog_pid" 2>/dev/null || true',
3733
+ 'wait "$watchdog_pid" 2>/dev/null || true',
3734
+ "watchdog_pid=",
3735
+ 'if [ "$status" -ne 0 ] && [ -f "$timeout_file" ]; then status=124; fi',
3736
+ `if [ "$status" -eq 0 ]; then awk 'NR <= ${REPOSITORY_DISCOVERY_LIMIT} { print } NR == ${REPOSITORY_DISCOVERY_LIMIT + 1} { print "${REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL}" }' "$results_file"; fi`,
3737
+ `printf '\\n${REPOSITORY_DISCOVERY_STATUS_PREFIX}%s\\n' "$status"`,
3738
+ 'exit "$status"'
3739
+ ].join("\n");
3740
+ const { stdout } = await this.run({
3741
+ cmd: `bash -lc ${shellQuote2(command)}`,
3742
+ workdir: this.workspaceRoot || void 0,
3743
+ yieldTimeMs: 2e4,
3744
+ // At most 256 Unix paths (PATH_MAX each) plus the status trailer. This
3745
+ // prevents a provider's ordinary output cap from dropping the trailer
3746
+ // and making a partial list look complete.
3747
+ maxOutputTokens: 3e5
3748
+ });
3749
+ const statusLine = stdout.split("\n").find((line) => line.startsWith(REPOSITORY_DISCOVERY_STATUS_PREFIX));
3750
+ const embeddedStatus = statusLine ? Number.parseInt(statusLine.slice(REPOSITORY_DISCOVERY_STATUS_PREFIX.length), 10) : null;
3751
+ const status = Number.isInteger(embeddedStatus) ? embeddedStatus : null;
3752
+ if (status === 124) {
3753
+ return { repos: [], complete: false, degradedReason: "command_timed_out" };
3754
+ }
3755
+ if (status !== 0) {
3756
+ return { repos: [], complete: false, degradedReason: "command_failed" };
3757
+ }
3758
+ const lines = stdout.split("\n").map((l) => l.trim()).filter((line) => Boolean(line) && !line.startsWith(REPOSITORY_DISCOVERY_STATUS_PREFIX));
3759
+ const truncated = lines.includes(REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL);
3760
+ const repos = [
3761
+ ...new Set(
3762
+ lines.filter((line) => line !== REPOSITORY_DISCOVERY_TRUNCATED_SENTINEL).map((gitMarker) => dirnameAbs(stripDotSlash(gitMarker, "")) || "")
3763
+ )
3764
+ ].sort();
3765
+ return {
3766
+ repos,
3767
+ complete: !truncated,
3768
+ degradedReason: truncated ? "result_limit_exceeded" : null
3769
+ };
2360
3770
  } catch {
2361
- return [];
3771
+ return { repos: [], complete: false, degradedReason: "command_failed" };
2362
3772
  }
2363
3773
  }
3774
+ /** Detect repo roots within the workspace (for the Git.repos capability). */
3775
+ async detectRepos() {
3776
+ return (await this.detectReposDetailed()).repos;
3777
+ }
2364
3778
  // ════════════════════════ Terminal exec + PTY (A2) ════════════════════════
2365
3779
  /** Run a bounded command, return buffered stdout/stderr + exit code inline. The
2366
3780
  * long-running tail (when the process hasn't exited within timeoutMs) keeps
@@ -2369,15 +3783,23 @@ var SandboxChannelAService = class {
2369
3783
  async terminalExec(req) {
2370
3784
  const r = await this.run({
2371
3785
  cmd: req.command,
2372
- workdir: this.repoWorkdir(req.cwd),
3786
+ workdir: this.terminalWorkdir(req.cwd),
2373
3787
  yieldTimeMs: req.timeoutMs
2374
3788
  });
2375
3789
  const running = r.exitCode === null && typeof r.sessionId === "number";
2376
3790
  if (req.emitStream && (r.stdout || r.stderr)) {
2377
3791
  const events = [];
2378
3792
  const commandId = crypto.randomUUID();
2379
- if (r.stdout) events.push({ type: "sandbox.command.output.delta", payload: { stream: "stdout", chunk: r.stdout, commandId, seq: 0 } });
2380
- if (r.stderr) events.push({ type: "sandbox.command.output.delta", payload: { stream: "stderr", chunk: r.stderr, commandId, seq: 1 } });
3793
+ if (r.stdout)
3794
+ events.push({
3795
+ type: "sandbox.command.output.delta",
3796
+ payload: { stream: "stdout", chunk: r.stdout, commandId, seq: 0 }
3797
+ });
3798
+ if (r.stderr)
3799
+ events.push({
3800
+ type: "sandbox.command.output.delta",
3801
+ payload: { stream: "stderr", chunk: r.stderr, commandId, seq: 1 }
3802
+ });
2381
3803
  await this.emitEvents(events);
2382
3804
  }
2383
3805
  return {
@@ -2398,7 +3820,7 @@ var SandboxChannelAService = class {
2398
3820
  const shell = req.shell ?? "/bin/bash";
2399
3821
  const r = await this.run({
2400
3822
  cmd: shell,
2401
- workdir: this.repoWorkdir(req.cwd),
3823
+ workdir: this.terminalWorkdir(req.cwd),
2402
3824
  tty: true,
2403
3825
  login: true,
2404
3826
  yieldTimeMs: 250
@@ -2417,7 +3839,11 @@ var SandboxChannelAService = class {
2417
3839
  if (!this.session.writeStdin) {
2418
3840
  throw new ChannelAUnsupportedError("interactive terminal unsupported on this backend");
2419
3841
  }
2420
- const out = await this.session.writeStdin({ sessionId: execSessionId, chars: data, yieldTimeMs: 250 });
3842
+ const out = await this.session.writeStdin({
3843
+ sessionId: execSessionId,
3844
+ chars: data,
3845
+ yieldTimeMs: 250
3846
+ });
2421
3847
  if (isExecSessionLostBanner(out, execSessionId)) {
2422
3848
  throw new ChannelAConflictError("pty session lost on the live box; reopen the terminal");
2423
3849
  }
@@ -2427,8 +3853,12 @@ var SandboxChannelAService = class {
2427
3853
  * no resize method; stty in the same tty session updates the geometry. */
2428
3854
  async ptyResize(req, execSessionId) {
2429
3855
  if (!this.session.writeStdin) return;
2430
- await this.session.writeStdin({ sessionId: execSessionId, chars: `stty cols ${req.cols} rows ${req.rows}
2431
- `, yieldTimeMs: 50 });
3856
+ await this.session.writeStdin({
3857
+ sessionId: execSessionId,
3858
+ chars: `stty cols ${req.cols} rows ${req.rows}
3859
+ `,
3860
+ yieldTimeMs: 50
3861
+ });
2432
3862
  }
2433
3863
  /** Close an open PTY: write exit/EOF. The caller marks the row closed + emits
2434
3864
  * terminal.pty.exited. */
@@ -2454,6 +3884,12 @@ var SandboxChannelAService = class {
2454
3884
  const joined = this.joinRoot(safe);
2455
3885
  return joined === "." ? this.workspaceRoot || void 0 : joined;
2456
3886
  }
3887
+ terminalWorkdir(cwd) {
3888
+ if (cwd === SELFHOSTED_VIRTUAL_ROOT2 || cwd.startsWith(`${SELFHOSTED_VIRTUAL_ROOT2}/`)) {
3889
+ return cwd;
3890
+ }
3891
+ return this.repoWorkdir(cwd);
3892
+ }
2457
3893
  async emitEvents(events) {
2458
3894
  if (!this.emit || events.length === 0) return;
2459
3895
  try {
@@ -2462,7 +3898,12 @@ var SandboxChannelAService = class {
2462
3898
  }
2463
3899
  }
2464
3900
  async emitFsChanged(changes, source) {
2465
- const payload = { changes, source, revision: this.revision, leaseEpoch: this.leaseEpoch };
3901
+ const payload = {
3902
+ changes,
3903
+ source,
3904
+ revision: this.revision,
3905
+ leaseEpoch: this.leaseEpoch
3906
+ };
2466
3907
  await this.emitEvents([{ type: "fs.changed", payload }]);
2467
3908
  }
2468
3909
  /** Re-probe git after a mutation and emit git.changed (best-effort, used by the
@@ -2523,7 +3964,8 @@ function assertSafeRelPath(p) {
2523
3964
  const norm = normalizeRelPath(p);
2524
3965
  if (norm === "") throw new ChannelAValidationError("path is required");
2525
3966
  if (p.startsWith("/")) throw new ChannelAValidationError(`absolute paths are not allowed: ${p}`);
2526
- if (norm.split("/").some((seg) => seg === "..")) throw new ChannelAValidationError(`path traversal is not allowed: ${p}`);
3967
+ if (norm.split("/").some((seg) => seg === ".."))
3968
+ throw new ChannelAValidationError(`path traversal is not allowed: ${p}`);
2527
3969
  return norm;
2528
3970
  }
2529
3971
  function shellQuote2(s) {
@@ -2623,11 +4065,29 @@ function parsePorcelainV2(z) {
2623
4065
  } else if (rec.startsWith("u ")) {
2624
4066
  const fields = rec.split(" ");
2625
4067
  const path = fields.slice(10).join(" ");
2626
- files.push({ path, oldPath: null, index: "conflicted", worktree: "conflicted", isConflicted: true });
4068
+ files.push({
4069
+ path,
4070
+ oldPath: null,
4071
+ index: "conflicted",
4072
+ worktree: "conflicted",
4073
+ isConflicted: true
4074
+ });
2627
4075
  } else if (rec.startsWith("? ")) {
2628
- files.push({ path: rec.slice(2), oldPath: null, index: null, worktree: "untracked", isConflicted: false });
4076
+ files.push({
4077
+ path: rec.slice(2),
4078
+ oldPath: null,
4079
+ index: null,
4080
+ worktree: "untracked",
4081
+ isConflicted: false
4082
+ });
2629
4083
  } else if (rec.startsWith("! ")) {
2630
- files.push({ path: rec.slice(2), oldPath: null, index: null, worktree: "ignored", isConflicted: false });
4084
+ files.push({
4085
+ path: rec.slice(2),
4086
+ oldPath: null,
4087
+ index: null,
4088
+ worktree: "ignored",
4089
+ isConflicted: false
4090
+ });
2631
4091
  }
2632
4092
  }
2633
4093
  return { isRepo: true, head, detached, upstream, ahead, behind, files };
@@ -2687,10 +4147,22 @@ function parseNumstatZ(z) {
2687
4147
  if (pathPart === "") {
2688
4148
  const oldPath = fields[i + 1] ?? null;
2689
4149
  const newPath = fields[i + 2] ?? "";
2690
- out.push({ additions: binary ? 0 : Number(addStr), deletions: binary ? 0 : Number(delStr), binary, oldPath, newPath });
4150
+ out.push({
4151
+ additions: binary ? 0 : Number(addStr),
4152
+ deletions: binary ? 0 : Number(delStr),
4153
+ binary,
4154
+ oldPath,
4155
+ newPath
4156
+ });
2691
4157
  i += 3;
2692
4158
  } else {
2693
- out.push({ additions: binary ? 0 : Number(addStr), deletions: binary ? 0 : Number(delStr), binary, oldPath: null, newPath: pathPart });
4159
+ out.push({
4160
+ additions: binary ? 0 : Number(addStr),
4161
+ deletions: binary ? 0 : Number(delStr),
4162
+ binary,
4163
+ oldPath: null,
4164
+ newPath: pathPart
4165
+ });
2694
4166
  i++;
2695
4167
  }
2696
4168
  }
@@ -2714,7 +4186,14 @@ function parseUnifiedPatch(patch) {
2714
4186
  const oldLines = m[2] !== void 0 ? Number(m[2]) : 1;
2715
4187
  const newStart = Number(m[3]);
2716
4188
  const newLines = m[4] !== void 0 ? Number(m[4]) : 1;
2717
- current = { oldStart, oldLines, newStart, newLines, header: (m[5] ?? "").trim(), lines: [] };
4189
+ current = {
4190
+ oldStart,
4191
+ oldLines,
4192
+ newStart,
4193
+ newLines,
4194
+ header: (m[5] ?? "").trim(),
4195
+ lines: []
4196
+ };
2718
4197
  hunks.push(current);
2719
4198
  oldNo = oldStart;
2720
4199
  newNo = newStart;
@@ -2749,6 +4228,9 @@ function selfhostedLiveness(input) {
2749
4228
  }
2750
4229
  const consented = enrollment.exposure === "whole-machine" && enrollment.allowScreenControl;
2751
4230
  const hasDisplay = enrollment.hasDisplay;
4231
+ if (enrollment.wentOfflineAt) {
4232
+ return { state: "offline", consented, hasDisplay };
4233
+ }
2752
4234
  if (input.probeResponded) {
2753
4235
  return { state: "online", consented, hasDisplay };
2754
4236
  }
@@ -2787,7 +4269,14 @@ async function negotiateSelfhostedCapabilities(input) {
2787
4269
  return {
2788
4270
  ...caps,
2789
4271
  FileSystem: { ...caps.FileSystem, available: false, readOnly: true, reason },
2790
- Terminal: { ...caps.Terminal, transport: null, url: null, token: null, expiresAt: null, reason },
4272
+ Terminal: {
4273
+ ...caps.Terminal,
4274
+ transport: null,
4275
+ url: null,
4276
+ token: null,
4277
+ expiresAt: null,
4278
+ reason
4279
+ },
2791
4280
  Git: { ...caps.Git, available: false, reason },
2792
4281
  DesktopStream: {
2793
4282
  ...caps.DesktopStream,
@@ -2841,7 +4330,7 @@ async function negotiateSelfhostedCapabilities(input) {
2841
4330
 
2842
4331
  // src/sandbox/selfhosted/testing.ts
2843
4332
  import {
2844
- ErrorCode as ErrorCode2,
4333
+ ErrorCode as ErrorCode5,
2845
4334
  FsEntryKind as FsEntryKind2
2846
4335
  } from "@opengeni/agent-proto";
2847
4336
  var encoder2 = new TextEncoder();
@@ -2862,7 +4351,10 @@ var MockAgentResponder = class {
2862
4351
  this.hostname = opts.hostname ?? "mock-machine";
2863
4352
  this.execHandler = opts.exec ?? ((req) => defaultEcho(req, this.hostname));
2864
4353
  for (const [path, content] of Object.entries(opts.files ?? {})) {
2865
- this.files.set(normalize(path), typeof content === "string" ? encoder2.encode(content) : content);
4354
+ this.files.set(
4355
+ normalize(path),
4356
+ typeof content === "string" ? encoder2.encode(content) : content
4357
+ );
2866
4358
  }
2867
4359
  }
2868
4360
  /** Flip the responder offline mid-test (a deliberate stop / blip). */
@@ -2877,18 +4369,31 @@ var MockAgentResponder = class {
2877
4369
  async request(subject, req, _opts) {
2878
4370
  this.requests.push({ subject, req });
2879
4371
  if (!this.online) {
2880
- return errorResponse(req.requestId, ErrorCode2.ERROR_CODE_AGENT_OFFLINE, "the enrolled agent is offline", false);
4372
+ return errorResponse(
4373
+ req.requestId,
4374
+ ErrorCode5.ERROR_CODE_AGENT_OFFLINE,
4375
+ "the enrolled agent is offline",
4376
+ false
4377
+ );
2881
4378
  }
2882
4379
  if (this.draining) {
2883
- return errorResponse(req.requestId, ErrorCode2.ERROR_CODE_DRAINING, "the agent is draining", true);
4380
+ return errorResponse(
4381
+ req.requestId,
4382
+ ErrorCode5.ERROR_CODE_DRAINING,
4383
+ "the agent is draining",
4384
+ true
4385
+ );
2884
4386
  }
2885
4387
  const op = req.op;
2886
4388
  if (!op) {
2887
- return errorResponse(req.requestId, ErrorCode2.ERROR_CODE_PROTOCOL, "empty op", false);
4389
+ return errorResponse(req.requestId, ErrorCode5.ERROR_CODE_PROTOCOL, "empty op", false);
2888
4390
  }
2889
4391
  switch (op.$case) {
2890
4392
  case "ping":
2891
- return ok(req.requestId, { $case: "ping", ping: { nonce: op.ping.nonce, agentMonotonicMs: "0" } });
4393
+ return ok(req.requestId, {
4394
+ $case: "ping",
4395
+ ping: { nonce: op.ping.nonce, agentMonotonicMs: "0" }
4396
+ });
2892
4397
  case "exec": {
2893
4398
  const res = await this.execHandler(op.exec);
2894
4399
  return ok(req.requestId, { $case: "exec", exec: res });
@@ -2896,7 +4401,12 @@ var MockAgentResponder = class {
2896
4401
  case "fsRead": {
2897
4402
  const bytes = this.files.get(normalize(op.fsRead.path));
2898
4403
  if (!bytes) {
2899
- return errorResponse(req.requestId, ErrorCode2.ERROR_CODE_NOT_FOUND, `no such file: ${op.fsRead.path}`, false);
4404
+ return errorResponse(
4405
+ req.requestId,
4406
+ ErrorCode5.ERROR_CODE_NOT_FOUND,
4407
+ `no such file: ${op.fsRead.path}`,
4408
+ false
4409
+ );
2900
4410
  }
2901
4411
  const res = { content: bytes, totalSize: String(bytes.length) };
2902
4412
  return ok(req.requestId, { $case: "fsRead", fsRead: res });
@@ -2945,7 +4455,13 @@ var MockAgentResponder = class {
2945
4455
  return ok(req.requestId, {
2946
4456
  $case: "desktopEnsure",
2947
4457
  desktopEnsure: {
2948
- channel: { channelId: "mock-desktop", workspaceId: "", agentId: "", kind: 1, port: 6080 },
4458
+ channel: {
4459
+ channelId: "mock-desktop",
4460
+ workspaceId: "",
4461
+ agentId: "",
4462
+ kind: 1,
4463
+ port: 6080
4464
+ },
2949
4465
  display: { id: ":99", width: 1024, height: 768, virtual: true }
2950
4466
  }
2951
4467
  });
@@ -2960,7 +4476,12 @@ var MockAgentResponder = class {
2960
4476
  });
2961
4477
  }
2962
4478
  default:
2963
- return errorResponse(req.requestId, ErrorCode2.ERROR_CODE_UNSUPPORTED, `mock does not implement ${op.$case}`, false);
4479
+ return errorResponse(
4480
+ req.requestId,
4481
+ ErrorCode5.ERROR_CODE_UNSUPPORTED,
4482
+ `mock does not implement ${op.$case}`,
4483
+ false
4484
+ );
2964
4485
  }
2965
4486
  }
2966
4487
  };
@@ -3016,9 +4537,17 @@ function isFenceError(error) {
3016
4537
  var RoutingSandboxSession = class {
3017
4538
  deps;
3018
4539
  maxFenceRetries;
3019
- // The per-epoch resolved-backend cache. Keyed by activeEpoch: a swap bumps the
3020
- // epoch, invalidating the cache so the NEXT op re-resolves the new backend.
4540
+ // The resolved-backend cache. Keyed by the FULL pointer tuple
4541
+ // `(activeEpoch, activeSandboxId)` — NOT the epoch alone. A swap bumps the epoch,
4542
+ // but a pointer can also change its target id WITHOUT an epoch bump: the
4543
+ // `sessions.active_sandbox_id` FK is `ON DELETE SET NULL`, so a cascade that
4544
+ // deletes the pointed-at sandbox row nulls the id at the SAME epoch. Keying on the
4545
+ // epoch alone would then keep serving the deleted/stale backend for that epoch
4546
+ // (issue #341 §5.2 — a swap-free route to the Shape-3 symptom). Keying on the tuple
4547
+ // makes any target change — epoch-bumped OR not — invalidate the cache so the next
4548
+ // op re-resolves (and, for a null id, re-resolves the session HOME).
3021
4549
  cachedEpoch;
4550
+ cachedSandboxId;
3022
4551
  cached;
3023
4552
  // The last-resolved backend, exposed via the `state` getter (a method-free read
3024
4553
  // of the active backend's `state`). Updated on every resolve.
@@ -3079,12 +4608,18 @@ var RoutingSandboxSession = class {
3079
4608
  */
3080
4609
  async resolve() {
3081
4610
  const pointer = await this.deps.readPointer();
3082
- if (this.cachedEpoch === pointer.activeEpoch && this.cached) {
4611
+ if (this.cachedEpoch === pointer.activeEpoch && this.cachedSandboxId === pointer.activeSandboxId && this.cached) {
3083
4612
  return this.cached;
3084
4613
  }
3085
4614
  const fromEpoch = this.cachedEpoch ?? pointer.activeEpoch;
3086
4615
  const resolved = await this.deps.resolveActiveBackend(pointer);
4616
+ if (resolved.session === this) {
4617
+ throw new Error(
4618
+ "RoutingSandboxSession.resolveActiveBackend returned the proxy itself as the active backend (re-entrancy) \u2014 the resolver must return the underlying box session, not the routing proxy."
4619
+ );
4620
+ }
3087
4621
  this.cachedEpoch = pointer.activeEpoch;
4622
+ this.cachedSandboxId = pointer.activeSandboxId;
3088
4623
  this.cached = resolved;
3089
4624
  this.lastResolved = resolved;
3090
4625
  this.deps.onTransition?.({
@@ -3120,6 +4655,7 @@ var RoutingSandboxSession = class {
3120
4655
  }
3121
4656
  lastError = error;
3122
4657
  this.cachedEpoch = void 0;
4658
+ this.cachedSandboxId = void 0;
3123
4659
  this.cached = void 0;
3124
4660
  this.deps.onTransition?.({
3125
4661
  type: "fenced-retry",
@@ -3133,6 +4669,30 @@ var RoutingSandboxSession = class {
3133
4669
  }
3134
4670
  throw lastError ?? new Error(`routing op "${op}" exhausted fence retries`);
3135
4671
  }
4672
+ /**
4673
+ * The failure-visibility boundary for the `exec_command` SDK capability tool — the
4674
+ * dominant fault surface, and the one whose thrown `SelfhostedControlError` reaches
4675
+ * the model wrapped by the SDK's generic tool-error function as
4676
+ * "…Please try again. Error: …" — actively wrong for a machine that is offline, a
4677
+ * consent that is not granted, or an oversized reply. Since the SDK closure-captures
4678
+ * its `errorFunction` (there is no seam to attach one to its internally-built tools),
4679
+ * we render the fault into the doctrine's four fields HERE and return it as the
4680
+ * tool's string result — legible, in-band, and free of the misleading wrapper.
4681
+ * (`apply_patch` already surfaces `error.message` via the SDK's own catch; the
4682
+ * skills / `view_image` tools consume their session methods internally, so they are
4683
+ * rendered elsewhere or left to their own SDK renderers — not this string boundary.)
4684
+ *
4685
+ * A FENCE error is re-thrown, NEVER rendered: `dispatch` already retries it against
4686
+ * a re-resolved backend, and a fence that escapes retries is a routing condition the
4687
+ * turn handles, not a model-facing fault. Any non-selfhosted error (a Modal fault, a
4688
+ * `RoutingUnsupportedError`) is re-thrown unchanged.
4689
+ */
4690
+ renderSelfhostedFaultOrThrow(error) {
4691
+ if (error instanceof SelfhostedControlError && !error.fenced) {
4692
+ return renderSelfhostedFault(error);
4693
+ }
4694
+ throw error;
4695
+ }
3136
4696
  // ── The forwarded structural surface ──────────────────────────────────────
3137
4697
  // Every method is PRESENT on the proxy (the SDK binds presence once) and
3138
4698
  // dispatches to the active backend at call-time. A missing backend method
@@ -3149,16 +4709,20 @@ var RoutingSandboxSession = class {
3149
4709
  });
3150
4710
  }
3151
4711
  async execCommand(args) {
3152
- return this.dispatch("execCommand", async (s) => {
3153
- if (s.execCommand) {
3154
- return s.execCommand(args);
3155
- }
3156
- if (s.exec) {
3157
- const r = await s.exec(args);
3158
- return r.stdout ?? r.output ?? "";
3159
- }
3160
- throw new RoutingUnsupportedError("execCommand", this.cached?.kind ?? "unknown");
3161
- });
4712
+ try {
4713
+ return await this.dispatch("execCommand", async (s) => {
4714
+ if (s.execCommand) {
4715
+ return s.execCommand(args);
4716
+ }
4717
+ if (s.exec) {
4718
+ const r = await s.exec(args);
4719
+ return r.stdout ?? r.output ?? "";
4720
+ }
4721
+ throw new RoutingUnsupportedError("execCommand", this.cached?.kind ?? "unknown");
4722
+ });
4723
+ } catch (error) {
4724
+ return this.renderSelfhostedFaultOrThrow(error);
4725
+ }
3162
4726
  }
3163
4727
  async writeStdin(args) {
3164
4728
  return this.dispatch("writeStdin", async (s) => {
@@ -3225,12 +4789,34 @@ var RoutingSandboxSession = class {
3225
4789
  const s = (this.lastResolved ?? this.deps.defaultResolved)?.session;
3226
4790
  return Boolean(s?.supportsPty?.());
3227
4791
  }
3228
- /** createEditor is a synchronous factory in the SDK surface; it binds to the
3229
- * last-resolved backend's editor (or the default backend before the first op).
3230
- * Returns undefined when the active backend has no editor (channel-a falls back
3231
- * to its exec-based write path). */
4792
+ /** createEditor is a synchronous factory in the SDK surface. The SDK's filesystem
4793
+ * capability calls it ONCE at tool-BIND time — `FilesystemCapability.tools()`,
4794
+ * every turn, before any tool runs — and throws "Filesystem sandbox sessions must
4795
+ * provide createEditor()" if it returns falsy. When a backend is already resolved
4796
+ * (eager/selfhosted routing) we bind to its editor directly, byte-for-byte as
4797
+ * before. But under LAZY provisioning the backend is not established yet
4798
+ * (defaultResolved is the synthetic unprovisioned session with no editor), so a
4799
+ * direct delegate returns undefined and every lazy turn would die at bind. Return a
4800
+ * LAZY EDITOR PROXY instead: a non-null editor whose async ops resolve the active
4801
+ * backend (establishing the box on first use, via `dispatch`) and delegate to its
4802
+ * real editor — mirroring how this proxy defers exec/readFile. */
3232
4803
  createEditor(runAs) {
3233
- return (this.lastResolved ?? this.deps.defaultResolved)?.session.createEditor?.(runAs);
4804
+ const eager = (this.lastResolved ?? this.deps.defaultResolved)?.session.createEditor?.(runAs);
4805
+ if (eager) {
4806
+ return eager;
4807
+ }
4808
+ const op = (name) => (operation, context) => this.dispatch(`editor.${name}`, async (s) => {
4809
+ const editor = s.createEditor?.(runAs);
4810
+ if (!editor?.[name]) {
4811
+ throw new RoutingUnsupportedError(`editor.${name}`, this.cached?.kind ?? "unknown");
4812
+ }
4813
+ return editor[name](operation, context);
4814
+ });
4815
+ return {
4816
+ createFile: op("createFile"),
4817
+ updateFile: op("updateFile"),
4818
+ deleteFile: op("deleteFile")
4819
+ };
3234
4820
  }
3235
4821
  async resolveExposedPort(port) {
3236
4822
  return this.dispatch("resolveExposedPort", async (s) => {
@@ -3260,27 +4846,61 @@ var RoutingSandboxSession = class {
3260
4846
  // src/sandbox/routing/backend-resolver.ts
3261
4847
  var ActiveBackendUnresolvableError = class extends Error {
3262
4848
  name = "ActiveBackendUnresolvableError";
3263
- constructor(message) {
4849
+ code;
4850
+ constructor(code, message) {
3264
4851
  super(message);
4852
+ this.code = code;
3265
4853
  }
3266
4854
  };
4855
+ function swapTargetEstablishability(target) {
4856
+ if (target.isSessionGroup) {
4857
+ return { ok: true };
4858
+ }
4859
+ if (target.kind === "selfhosted") {
4860
+ return { ok: true };
4861
+ }
4862
+ if (target.kind === "modal") {
4863
+ return {
4864
+ ok: false,
4865
+ code: "unsupported_backend_context",
4866
+ reason: "a Modal sandbox other than this session's own box cannot be established by a turn yet; swap back to the session default or attach a Connected Machine"
4867
+ };
4868
+ }
4869
+ return {
4870
+ ok: false,
4871
+ code: "unsupported_backend_context",
4872
+ reason: `a sandbox of kind "${target.kind}" cannot be established by a turn`
4873
+ };
4874
+ }
3267
4875
  function makeActiveBackendResolver(deps) {
3268
4876
  return async (pointer) => {
3269
4877
  if (pointer.activeSandboxId === null) {
4878
+ if (deps.defaultIsHome === false) {
4879
+ throw new ActiveBackendUnresolvableError(
4880
+ "home_unavailable_this_turn",
4881
+ "the active sandbox was detached to the session default (home), but this turn started pinned to a machine and established no home box; the detach is accepted and takes effect on the next turn \u2014 this turn has no active home box"
4882
+ );
4883
+ }
3270
4884
  return { session: deps.defaultBackend, sandboxId: null, kind: deps.defaultKind };
3271
4885
  }
3272
4886
  if (deps.pinnedSelfhosted && pointer.activeSandboxId === deps.pinnedSelfhosted.sandboxId && pointer.activeEpoch === deps.pinnedSelfhosted.epoch) {
3273
- return { session: deps.pinnedSelfhosted.session, sandboxId: pointer.activeSandboxId, kind: "selfhosted" };
4887
+ return {
4888
+ session: deps.pinnedSelfhosted.session,
4889
+ sandboxId: pointer.activeSandboxId,
4890
+ kind: "selfhosted"
4891
+ };
3274
4892
  }
3275
4893
  const sandbox = await deps.getSandbox(pointer.activeSandboxId);
3276
4894
  if (!sandbox) {
3277
4895
  throw new ActiveBackendUnresolvableError(
4896
+ "stale_pointer",
3278
4897
  `active sandbox ${pointer.activeSandboxId} not found in workspace ${deps.workspaceId}`
3279
4898
  );
3280
4899
  }
3281
4900
  if (sandbox.kind === "selfhosted") {
3282
4901
  if (!sandbox.enrollmentId) {
3283
4902
  throw new ActiveBackendUnresolvableError(
4903
+ "offline_enrollment",
3284
4904
  `selfhosted sandbox ${sandbox.id} has no enrollment (agent id) to address`
3285
4905
  );
3286
4906
  }
@@ -3291,6 +4911,8 @@ function makeActiveBackendResolver(deps) {
3291
4911
  agentId: sandbox.enrollmentId,
3292
4912
  epoch: pointer.activeEpoch,
3293
4913
  ...deps.selfhostedTimeoutMs !== void 0 ? { timeoutMs: deps.selfhostedTimeoutMs } : {},
4914
+ ...deps.selfhostedExecTimeoutMs !== void 0 ? { execTimeoutMs: deps.selfhostedExecTimeoutMs } : {},
4915
+ ...deps.selfhostedOnOp !== void 0 ? { onOp: deps.selfhostedOnOp } : {},
3294
4916
  // The turn's declared environment → the session's manifest.environment, so
3295
4917
  // the SDK's per-turn manifest-env delta is empty (no "cannot change manifest
3296
4918
  // environment variables" throw on a pin-to-vm turn).
@@ -3299,11 +4921,16 @@ function makeActiveBackendResolver(deps) {
3299
4921
  // for this selfhosted backend. Absent/empty ⇒ the default workspace_root.
3300
4922
  ...pointer.workingDir ? { workingDir: pointer.workingDir } : {}
3301
4923
  });
3302
- return { session, sandboxId: sandbox.id, kind: "selfhosted" };
4924
+ return {
4925
+ session,
4926
+ sandboxId: sandbox.id,
4927
+ kind: "selfhosted"
4928
+ };
3303
4929
  }
3304
4930
  if (sandbox.kind === "modal") {
3305
4931
  if (!deps.establishModalTarget) {
3306
4932
  throw new ActiveBackendUnresolvableError(
4933
+ "unsupported_backend_context",
3307
4934
  `modal swap target ${sandbox.id} cannot be established in this context (no establisher wired)`
3308
4935
  );
3309
4936
  }
@@ -3311,6 +4938,7 @@ function makeActiveBackendResolver(deps) {
3311
4938
  return { session, sandboxId: sandbox.id, kind: "modal" };
3312
4939
  }
3313
4940
  throw new ActiveBackendUnresolvableError(
4941
+ "unsupported_backend_context",
3314
4942
  `unsupported swap target kind "${sandbox.kind}" for sandbox ${sandbox.id}`
3315
4943
  );
3316
4944
  };
@@ -3318,7 +4946,11 @@ function makeActiveBackendResolver(deps) {
3318
4946
 
3319
4947
  // src/sandbox/index.ts
3320
4948
  function createSandboxClient(settings, environment = collectSandboxEnvironment(settings)) {
3321
- return createSandboxClientForBackend(settings.sandboxBackend, settings, environment);
4949
+ return createSandboxClientForBackend(
4950
+ settings.sandboxBackend,
4951
+ settings,
4952
+ environment
4953
+ );
3322
4954
  }
3323
4955
  function createSandboxClientForBackend(backend, settings, environment = collectSandboxEnvironment(settings)) {
3324
4956
  const registration = PROVIDER_REGISTRY[backend];
@@ -3355,13 +4987,25 @@ function withDockerNetwork(client, network) {
3355
4987
  return {
3356
4988
  backendId: client.backendId,
3357
4989
  ...client.supportsDefaultOptions !== void 0 ? { supportsDefaultOptions: client.supportsDefaultOptions } : {},
3358
- ...client.create ? { create: async (...args) => await wrapSession(await client.create(...args)) } : {},
3359
- ...client.resume ? { resume: async (state) => await wrapSession(await client.resume(state)) } : {},
4990
+ ...client.create ? {
4991
+ create: async (...args) => await wrapSession(await client.create(...args))
4992
+ } : {},
4993
+ ...client.resume ? {
4994
+ resume: async (state) => await wrapSession(await client.resume(state))
4995
+ } : {},
3360
4996
  ...client.delete ? { delete: async (state) => await client.delete(state) } : {},
3361
- ...client.serializeSessionState ? { serializeSessionState: async (state, options) => await client.serializeSessionState(state, options) } : {},
3362
- ...client.canPersistOwnedSessionState ? { canPersistOwnedSessionState: async (state) => await client.canPersistOwnedSessionState(state) } : {},
3363
- ...client.canReusePreservedOwnedSession ? { canReusePreservedOwnedSession: async (state) => await client.canReusePreservedOwnedSession(state) } : {},
3364
- ...client.deserializeSessionState ? { deserializeSessionState: async (state) => await client.deserializeSessionState(state) } : {}
4997
+ ...client.serializeSessionState ? {
4998
+ serializeSessionState: async (state, options) => await client.serializeSessionState(state, options)
4999
+ } : {},
5000
+ ...client.canPersistOwnedSessionState ? {
5001
+ canPersistOwnedSessionState: async (state) => await client.canPersistOwnedSessionState(state)
5002
+ } : {},
5003
+ ...client.canReusePreservedOwnedSession ? {
5004
+ canReusePreservedOwnedSession: async (state) => await client.canReusePreservedOwnedSession(state)
5005
+ } : {},
5006
+ ...client.deserializeSessionState ? {
5007
+ deserializeSessionState: async (state) => await client.deserializeSessionState(state)
5008
+ } : {}
3365
5009
  };
3366
5010
  }
3367
5011
  async function connectDockerNetwork(network, containerId) {
@@ -3376,7 +5020,9 @@ async function connectDockerNetwork(network, containerId) {
3376
5020
  if (stderr.includes("already exists")) {
3377
5021
  return;
3378
5022
  }
3379
- throw new Error(`Failed to connect Docker sandbox container to network ${network}: ${stderr.trim()}`);
5023
+ throw new Error(
5024
+ `Failed to connect Docker sandbox container to network ${network}: ${stderr.trim()}`
5025
+ );
3380
5026
  }
3381
5027
  function sandboxStateEntryFromRunState(state) {
3382
5028
  const sandboxState = state?._sandbox;
@@ -3417,6 +5063,20 @@ function readWorkspaceArchiveFromEnvelopeSessionState(sessionState) {
3417
5063
  return void 0;
3418
5064
  }
3419
5065
  }
5066
+ function readWorkspaceArchivePairFromEnvelopeSessionState(sessionState) {
5067
+ if (!sessionState || typeof sessionState !== "object") {
5068
+ return {};
5069
+ }
5070
+ const state = sessionState;
5071
+ const current = readWorkspaceArchiveFromEnvelopeSessionState(sessionState);
5072
+ const previous = readWorkspaceArchiveFromEnvelopeSessionState({
5073
+ workspaceArchive: state.workspaceArchivePrev
5074
+ });
5075
+ return {
5076
+ ...current ? { current } : {},
5077
+ ...previous ? { previous } : {}
5078
+ };
5079
+ }
3420
5080
  var MODAL_SNAPSHOT_REF_PREFIXES = [
3421
5081
  "MODAL_SANDBOX_FS_SNAPSHOT_V1\n",
3422
5082
  "MODAL_SANDBOX_DIR_SNAPSHOT_V1\n"
@@ -3472,7 +5132,9 @@ async function deserializeSandboxSessionStateEnvelope(client, envelope) {
3472
5132
  return void 0;
3473
5133
  }
3474
5134
  if (!client.deserializeSessionState) {
3475
- throw new Error("Sandbox client must implement deserializeSessionState() to resume RunState sandbox state");
5135
+ throw new Error(
5136
+ "Sandbox client must implement deserializeSessionState() to resume RunState sandbox state"
5137
+ );
3476
5138
  }
3477
5139
  const state = envelope;
3478
5140
  return await client.deserializeSessionState({
@@ -3521,7 +5183,7 @@ function isProviderSandboxNotFoundError(backendId, error) {
3521
5183
  }
3522
5184
  function readInstanceId(session) {
3523
5185
  const state = session.state ?? {};
3524
- const candidate = state.sandboxId ?? state.instanceId ?? state.id ?? state.hostId ?? state.containerId;
5186
+ const candidate = state.sandboxId ?? state.instanceId ?? state.id ?? state.hostId ?? state.containerId ?? state.workspaceRootPath;
3525
5187
  return typeof candidate === "string" && candidate.length > 0 ? candidate : "";
3526
5188
  }
3527
5189
  async function terminateCreatedSandbox(client, session, sessionState) {
@@ -3545,15 +5207,19 @@ async function establishSandboxSessionFromEnvelope(settings, envelope, opts) {
3545
5207
  const environment = opts.environment ?? collectSandboxEnvironment(settings);
3546
5208
  const client = createSandboxClientForBackend(backend, settings, environment);
3547
5209
  if (!client) {
3548
- throw new SandboxConfigError(backend, `Cannot establish a sandbox session for backend "${backend}" (no client; sandboxBackend=none?)`);
5210
+ throw new SandboxConfigError(
5211
+ backend,
5212
+ `Cannot establish a sandbox session for backend "${backend}" (no client; sandboxBackend=none?)`
5213
+ );
3549
5214
  }
3550
- if (!client.create) {
5215
+ if (opts.recovery === "create-or-restore" && !client.create) {
3551
5216
  throw new SandboxConfigError(backend, `Sandbox backend "${backend}" does not support create()`);
3552
5217
  }
3553
5218
  const createManifest = { environment };
3554
5219
  const envelopeSessionState = envelope && typeof envelope === "object" ? envelope.sessionState : void 0;
3555
- const workspaceArchive = readWorkspaceArchiveFromEnvelopeSessionState(envelopeSessionState);
3556
- const coldRestore = async (resumeFallbackState) => {
5220
+ const workspaceArchives = readWorkspaceArchivePairFromEnvelopeSessionState(envelopeSessionState);
5221
+ const workspaceArchive = workspaceArchives.current;
5222
+ const coldRestore = async (resumeFallbackState, skipWorkspaceHydrate = false) => {
3557
5223
  const createStarted = Date.now();
3558
5224
  let restored;
3559
5225
  try {
@@ -3579,15 +5245,37 @@ async function establishSandboxSessionFromEnvelope(settings, envelope, opts) {
3579
5245
  throw createCallbackError;
3580
5246
  }
3581
5247
  }
3582
- if (workspaceArchive) {
5248
+ let hydrationApplied = false;
5249
+ if (!skipWorkspaceHydrate && (workspaceArchive || workspaceArchives.previous)) {
3583
5250
  const hydrate = restored.hydrateWorkspace;
3584
5251
  if (typeof hydrate === "function") {
3585
- try {
3586
- await hydrate.call(restored, workspaceArchive);
3587
- } catch (hydrateError) {
5252
+ let hydrated = false;
5253
+ for (const candidate of [
5254
+ { archive: workspaceArchive, label: "current" },
5255
+ { archive: workspaceArchives.previous, label: "previous" }
5256
+ ]) {
5257
+ if (!candidate.archive) {
5258
+ continue;
5259
+ }
5260
+ try {
5261
+ await hydrate.call(restored, candidate.archive);
5262
+ console.info(
5263
+ `[sandbox] cold-restore hydrated workspace from ${candidate.label} archive`
5264
+ );
5265
+ hydrated = true;
5266
+ break;
5267
+ } catch (hydrateError) {
5268
+ console.warn(
5269
+ `[sandbox] cold-restore failed to hydrate workspace from ${candidate.label} archive${candidate.label === "current" && workspaceArchives.previous ? "; trying previous archive" : ""}`,
5270
+ hydrateError
5271
+ );
5272
+ }
5273
+ }
5274
+ if (!hydrated) {
3588
5275
  await terminateCreatedSandbox(client, restored, restoredState);
3589
- throw hydrateError;
5276
+ return await coldRestore(resumeFallbackState, true);
3590
5277
  }
5278
+ hydrationApplied = true;
3591
5279
  const hydratedState = restored.state;
3592
5280
  const hydratedInstanceId = readInstanceId(restored);
3593
5281
  if (hydratedInstanceId && hydratedInstanceId !== established.instanceId) {
@@ -3615,32 +5303,57 @@ async function establishSandboxSessionFromEnvelope(settings, envelope, opts) {
3615
5303
  session: restored,
3616
5304
  sessionState: restoredState ?? resumeFallbackState,
3617
5305
  instanceId: readInstanceId(restored),
3618
- backendId: client.backendId
5306
+ backendId: client.backendId,
5307
+ origin: hydrationApplied ? "restored" : "created"
3619
5308
  };
3620
5309
  };
3621
5310
  const envelopeProviderState = envelopeSessionState && typeof envelopeSessionState === "object" ? envelopeSessionState.providerState : void 0;
3622
5311
  const hasResumableInstance = Boolean(
3623
- envelopeProviderState && typeof envelopeProviderState === "object" && (envelopeProviderState.sandboxId || envelopeProviderState.instanceId || envelopeProviderState.id || envelopeProviderState.containerId)
5312
+ envelopeProviderState && typeof envelopeProviderState === "object" && (envelopeProviderState.sandboxId || envelopeProviderState.instanceId || envelopeProviderState.id || envelopeProviderState.containerId || envelopeProviderState.workspaceRootPath)
3624
5313
  );
3625
5314
  if (hasResumableInstance && envelopeSessionState && client.resume && client.deserializeSessionState) {
3626
5315
  let resumedState;
3627
5316
  try {
3628
- resumedState = await deserializeSandboxSessionStateEnvelope(client, envelopeSessionState);
5317
+ resumedState = await deserializeSandboxSessionStateEnvelope(
5318
+ client,
5319
+ envelopeSessionState
5320
+ );
3629
5321
  } catch (error) {
3630
- throw new SandboxConfigError(backend, `Failed to deserialize sandbox resume envelope for backend "${backend}": ${error instanceof Error ? error.message : String(error)}`);
5322
+ throw new SandboxConfigError(
5323
+ backend,
5324
+ `Failed to deserialize sandbox resume envelope for backend "${backend}": ${error instanceof Error ? error.message : String(error)}`
5325
+ );
3631
5326
  }
3632
5327
  if (resumedState !== void 0) {
3633
5328
  try {
3634
5329
  const session = await client.resume(resumedState);
3635
- return { client, session, sessionState: resumedState, instanceId: readInstanceId(session), backendId: client.backendId };
5330
+ return {
5331
+ client,
5332
+ session,
5333
+ sessionState: resumedState,
5334
+ instanceId: readInstanceId(session),
5335
+ backendId: client.backendId,
5336
+ origin: "resumed"
5337
+ };
3636
5338
  } catch (error) {
3637
- if (!isProviderSandboxNotFoundError(client.backendId, error)) {
5339
+ if (!isProviderSandboxNotFoundError(client.backendId, error) || opts.recovery === "resume-only") {
3638
5340
  throw error;
3639
5341
  }
3640
- return await coldRestore(resumedState);
5342
+ const lostInstanceId = [
5343
+ envelopeProviderState?.sandboxId,
5344
+ envelopeProviderState?.instanceId,
5345
+ envelopeProviderState?.id,
5346
+ envelopeProviderState?.containerId,
5347
+ envelopeProviderState?.workspaceRootPath
5348
+ ].find((value) => typeof value === "string" && value.length > 0);
5349
+ const restoredSession = await coldRestore(resumedState);
5350
+ return lostInstanceId ? { ...restoredSession, lostInstanceId } : restoredSession;
3641
5351
  }
3642
5352
  }
3643
5353
  }
5354
+ if (opts.recovery === "resume-only") {
5355
+ throw new SandboxResumeStateUnavailableError(backend);
5356
+ }
3644
5357
  return await coldRestore();
3645
5358
  }
3646
5359
  function recordSandboxCreateMetric(metrics, backend, outcome, startedMs) {
@@ -3683,6 +5396,7 @@ export {
3683
5396
  DESKTOP_STREAM_PORT,
3684
5397
  assertDescriptorRegistryInvariants,
3685
5398
  SandboxConfigError,
5399
+ SandboxResumeStateUnavailableError,
3686
5400
  SandboxProviderUnavailableError,
3687
5401
  modalSandboxAttributionEnvironment,
3688
5402
  modalSandboxAttributionTags,
@@ -3693,11 +5407,45 @@ export {
3693
5407
  subjectFor,
3694
5408
  SelfhostedControlError,
3695
5409
  agentErrorToControlError,
5410
+ payloadTooLargeMessage,
5411
+ drainingMessage,
5412
+ drainingExhaustedError,
5413
+ execDeadlineHint,
5414
+ NEVER_SENT_DETAIL_KEY,
3696
5415
  offlineAgentError,
3697
5416
  timeoutAgentError,
3698
5417
  NatsControlRpc,
3699
5418
  offlineControlResponse,
3700
5419
  timeoutControlResponse,
5420
+ SELFHOSTED_IDEMPOTENT_READONLY_OPS,
5421
+ SELFHOSTED_DRAINING_MAX_RETRIES,
5422
+ SELFHOSTED_EXEC_DRAINING_MAX_RETRIES,
5423
+ SELFHOSTED_TIMEOUT_MAX_RETRIES,
5424
+ SELFHOSTED_NEVER_SENT_MAX_RETRIES,
5425
+ SELFHOSTED_RETRY_BACKOFF_BASE_MS,
5426
+ SELFHOSTED_RETRY_BACKOFF_FACTOR,
5427
+ SELFHOSTED_RETRY_BACKOFF_CAP_MS,
5428
+ selfhostedRetryBackoffMs,
5429
+ decideSelfhostedRetry,
5430
+ defaultSelfhostedRetryClock,
5431
+ selfhostedFaultClass,
5432
+ SELFHOSTED_INFRASTRUCTURE_FAULT_CLASSES,
5433
+ FAULT_FIELD_WHAT_HAPPENED,
5434
+ FAULT_FIELD_WHICH_LAYER,
5435
+ FAULT_FIELD_WHAT_PRESERVED,
5436
+ FAULT_FIELD_WHAT_TO_TRY,
5437
+ renderSelfhostedFault,
5438
+ sanitizeOpIdToken,
5439
+ runWithToolCallCorrelation,
5440
+ nextDurableOpId,
5441
+ opFrameSubject,
5442
+ opAckSubject,
5443
+ OpStreamUnavailableError,
5444
+ NatsOpStreamTransport,
5445
+ OP_STREAM_DEFAULT_WINDOW_BYTES,
5446
+ OP_STREAM_ACK_INTERVAL_MS,
5447
+ OP_STREAM_SILENCE_TIMEOUT_MS,
5448
+ OP_STREAM_RECONNECT_HOLD_MS,
3701
5449
  setSelfhostedApplyDiff,
3702
5450
  SELFHOSTED_DEFAULT_TIMEOUT_MS,
3703
5451
  SELFHOSTED_RELAY_STREAM_PATH,
@@ -3739,13 +5487,15 @@ export {
3739
5487
  RecordingError,
3740
5488
  startRecording,
3741
5489
  stopRecording,
3742
- readRecordingBytes,
5490
+ inspectRecordingArtifact,
5491
+ uploadRecordingArtifact,
3743
5492
  deleteRecordingArtifacts,
3744
5493
  recordingStorageKey,
3745
5494
  ChannelAValidationError,
3746
5495
  ChannelAConflictError,
3747
5496
  ChannelANotFoundError,
3748
5497
  ChannelAUnsupportedError,
5498
+ REPOSITORY_DISCOVERY_LIMIT,
3749
5499
  SandboxChannelAService,
3750
5500
  stripExecBanner2 as stripExecBanner,
3751
5501
  isWorkspaceEscapeError,
@@ -3762,6 +5512,7 @@ export {
3762
5512
  RoutingUnsupportedError,
3763
5513
  RoutingSandboxSession,
3764
5514
  ActiveBackendUnresolvableError,
5515
+ swapTargetEstablishability,
3765
5516
  makeActiveBackendResolver,
3766
5517
  createSandboxClient,
3767
5518
  createSandboxClientForBackend,
@@ -3777,4 +5528,4 @@ export {
3777
5528
  collectSandboxEnvironment2 as collectSandboxEnvironment,
3778
5529
  parseExposedPorts2 as parseExposedPorts
3779
5530
  };
3780
- //# sourceMappingURL=chunk-DYBF7ZEB.js.map
5531
+ //# sourceMappingURL=chunk-RYDCVTMW.js.map