@opengeni/runtime 0.6.0 → 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.
- package/dist/{chunk-BHA2SU3H.js → chunk-RYDCVTMW.js} +1959 -208
- package/dist/chunk-RYDCVTMW.js.map +1 -0
- package/dist/{index-otZkoLYq.d.ts → index-DIiiwQY2.d.ts} +693 -55
- package/dist/index.d.ts +293 -121
- package/dist/index.js +1819 -786
- package/dist/index.js.map +1 -1
- package/dist/sandbox/index.d.ts +1 -1
- package/dist/sandbox/index.js +79 -3
- package/package.json +21 -20
- package/src/codex-tool-search.ts +185 -31
- package/src/context-compaction.ts +78 -243
- package/src/history-sanitizer.ts +172 -20
- package/src/index.ts +2573 -897
- package/src/metrics.ts +28 -2
- package/src/sandbox/capabilities.ts +12 -3
- package/src/sandbox/channel-a.ts +420 -72
- package/src/sandbox/display-stack.ts +18 -4
- package/src/sandbox/errors.ts +13 -0
- package/src/sandbox/index.ts +398 -112
- package/src/sandbox/op-correlation.ts +70 -0
- package/src/sandbox/providers/index.ts +9 -3
- package/src/sandbox/providers/modal.ts +99 -24
- package/src/sandbox/providers/selfhosted.ts +1 -4
- package/src/sandbox/recording.ts +146 -90
- package/src/sandbox/routing/backend-resolver.ts +144 -10
- package/src/sandbox/routing/routing-session.ts +129 -25
- package/src/sandbox/select.ts +60 -8
- package/src/sandbox/selfhosted/capabilities.ts +59 -25
- package/src/sandbox/selfhosted/control-rpc.ts +160 -28
- package/src/sandbox/selfhosted/fault-rendering.ts +226 -0
- package/src/sandbox/selfhosted/op-observer.ts +54 -0
- package/src/sandbox/selfhosted/op-stream.ts +801 -0
- package/src/sandbox/selfhosted/op-testing.ts +468 -0
- package/src/sandbox/selfhosted/op-transport.ts +151 -0
- package/src/sandbox/selfhosted/retry-policy.ts +177 -0
- package/src/sandbox/selfhosted/session.ts +498 -55
- package/src/sandbox/selfhosted/testing.ts +60 -19
- package/src/sandbox/stream-port.ts +17 -5
- package/src/sandbox/stream-token.ts +8 -2
- package/src/sandbox/terminal-server.ts +18 -4
- package/src/sandbox-computer.ts +332 -70
- package/src/usage-telemetry.ts +73 -0
- package/dist/chunk-BHA2SU3H.js.map +0 -1
- package/src/image-history.ts +0 -149
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// src/sandbox/index.ts
|
|
2
2
|
import { collectSandboxEnvironment, parseExposedPorts } from "@opengeni/config";
|
|
3
|
-
import {
|
|
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 =
|
|
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
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
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) {
|
|
@@ -288,7 +300,7 @@ async function ensureModalRegistryImage(settings, loadModal = defaultModalLoader
|
|
|
288
300
|
pending = (async () => {
|
|
289
301
|
const modal = await loadModal();
|
|
290
302
|
const client = new modal.ModalClient(modalClientOptions(settings));
|
|
291
|
-
const secret = await
|
|
303
|
+
const secret = await client.secrets.fromName(
|
|
292
304
|
settings.modalImageRegistrySecret,
|
|
293
305
|
settings.modalEnvironment ? { environment: settings.modalEnvironment } : void 0
|
|
294
306
|
);
|
|
@@ -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(
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
628
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
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
|
-
/**
|
|
762
|
-
*
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
775
|
-
|
|
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
|
-
|
|
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:
|
|
1839
|
+
timeoutMs: executionTimeoutMs
|
|
804
1840
|
};
|
|
805
|
-
|
|
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({
|
|
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({
|
|
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: {
|
|
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({
|
|
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(
|
|
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: {
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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)
|
|
1183
|
-
|
|
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)
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
2711
|
+
return {
|
|
2712
|
+
available: false,
|
|
2713
|
+
modes: [],
|
|
2714
|
+
codecs: [],
|
|
2715
|
+
reason: osReason
|
|
2716
|
+
};
|
|
1497
2717
|
}
|
|
1498
2718
|
if (!cap.available) {
|
|
1499
|
-
return {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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(
|
|
1584
|
-
${
|
|
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({
|
|
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(
|
|
1674
|
-
${
|
|
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({
|
|
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(
|
|
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
|
|
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(
|
|
3146
|
+
throw new RecordingUnavailableError(
|
|
3147
|
+
"session cannot run commands (no exec/execCommand) \u2014 recording finalize unavailable"
|
|
3148
|
+
);
|
|
1899
3149
|
}
|
|
1900
|
-
const sizeOut = (await run(
|
|
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:
|
|
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(
|
|
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: {
|
|
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({
|
|
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({
|
|
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({
|
|
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(
|
|
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({
|
|
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)
|
|
3452
|
+
if (!ok2)
|
|
3453
|
+
throw new ChannelAValidationError(
|
|
3454
|
+
`failed to write ${path}: ${stderr || `exit ${exitCode}`}`
|
|
3455
|
+
);
|
|
2166
3456
|
} else {
|
|
2167
|
-
throw new ChannelAValidationError(
|
|
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(
|
|
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(
|
|
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(
|
|
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({
|
|
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 {
|
|
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({
|
|
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({
|
|
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: {
|
|
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({
|
|
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({
|
|
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
|
-
/**
|
|
2356
|
-
|
|
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
|
|
2359
|
-
|
|
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.
|
|
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)
|
|
2380
|
-
|
|
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.
|
|
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({
|
|
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({
|
|
2431
|
-
|
|
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 = {
|
|
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 === ".."))
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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 = {
|
|
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: {
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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, {
|
|
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(
|
|
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: {
|
|
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(
|
|
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
|
|
3020
|
-
//
|
|
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
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
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
|
|
3229
|
-
*
|
|
3230
|
-
*
|
|
3231
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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(
|
|
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 ? {
|
|
3359
|
-
|
|
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 ? {
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
...client.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
3556
|
-
const
|
|
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
|
-
|
|
5248
|
+
let hydrationApplied = false;
|
|
5249
|
+
if (!skipWorkspaceHydrate && (workspaceArchive || workspaceArchives.previous)) {
|
|
3583
5250
|
const hydrate = restored.hydrateWorkspace;
|
|
3584
5251
|
if (typeof hydrate === "function") {
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
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
|
-
|
|
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(
|
|
5317
|
+
resumedState = await deserializeSandboxSessionStateEnvelope(
|
|
5318
|
+
client,
|
|
5319
|
+
envelopeSessionState
|
|
5320
|
+
);
|
|
3629
5321
|
} catch (error) {
|
|
3630
|
-
throw new SandboxConfigError(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
5531
|
+
//# sourceMappingURL=chunk-RYDCVTMW.js.map
|