@sema-agent/server 7.43.0 → 7.44.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/USAGE.md +18 -0
- package/dist/adoption/plan.js +31 -192
- package/dist/adoption/quiesce.js +3 -86
- package/dist/adoption/runner.js +3 -140
- package/dist/adoption/sql.js +0 -74
- package/dist/adoption/wire.js +0 -80
- package/dist/approval-ask-machine.js +0 -75
- package/dist/approval-card.js +0 -323
- package/dist/approval-deny-reasons.js +0 -42
- package/dist/approval-hmac.js +0 -35
- package/dist/approval-reconciler.js +8 -166
- package/dist/approval.js +4 -67
- package/dist/audit.js +1 -44
- package/dist/auth-bridge.js +4 -47
- package/dist/auth-keys.js +0 -23
- package/dist/bake-runner/main.js +4 -65
- package/dist/bake-runner/protocol.js +3 -58
- package/dist/bake-runner/runner.js +5 -91
- package/dist/bench/l8/artifact.js +2 -61
- package/dist/bench/l8/escape.js +0 -25
- package/dist/bench/l8/index.js +0 -14
- package/dist/bench/l8/probes.js +0 -60
- package/dist/bench/l8/run-probes.js +2 -45
- package/dist/bench/s1/arms.js +14 -189
- package/dist/bench/s1/live-deps.js +11 -253
- package/dist/bench/s1/oracle.js +1 -36
- package/dist/bench/s1/repair-oracle-adapter.js +0 -30
- package/dist/bench/s1/reviewer.js +0 -34
- package/dist/bench/s1/row.js +0 -62
- package/dist/bench/s1/run-firm.js +4 -70
- package/dist/bench/s1/runner-ctx.js +0 -40
- package/dist/bench/s1/tasks.js +0 -115
- package/dist/boot/adoption.js +0 -20
- package/dist/boot/budget-tracing.js +3 -55
- package/dist/boot/config-center.js +43 -551
- package/dist/boot/coordinators.js +1 -69
- package/dist/boot/crash-last.js +0 -18
- package/dist/boot/deferred-sandbox-path-env.js +3 -119
- package/dist/boot/execution-env.js +5 -171
- package/dist/boot/governance-seams.js +0 -92
- package/dist/boot/leader.js +0 -69
- package/dist/boot/limit-sync.js +0 -2
- package/dist/boot/memory-boundary.js +3 -91
- package/dist/boot/org-memory.js +1 -25
- package/dist/boot/parked-revive-gate.js +0 -150
- package/dist/boot/permission-rules-audit.js +1 -56
- package/dist/boot/reapers.js +15 -273
- package/dist/boot/resolve-spec.js +9 -768
- package/dist/boot/retention-lane.js +4 -87
- package/dist/boot/runner-deps.js +3 -297
- package/dist/boot/runtime-caps.js +0 -40
- package/dist/boot/session-faces.js +2 -133
- package/dist/boot/shutdown.js +9 -83
- package/dist/boot/side-query-lane.js +2 -137
- package/dist/boot/stores.js +15 -215
- package/dist/boot/task-list-lane.js +0 -18
- package/dist/boot/webfetch-summarize-lane.js +0 -47
- package/dist/boot/workflow-orchestration.js +3 -99
- package/dist/boot-reclaim.js +0 -20
- package/dist/bounded-session-map.js +0 -19
- package/dist/brain.js +2 -139
- package/dist/budget.js +9 -244
- package/dist/capabilities/builtin-tools.js +0 -3
- package/dist/capabilities/center-plugins.js +6 -51
- package/dist/capabilities/center-prompts.js +6 -71
- package/dist/capabilities/code-review-council.js +7 -40
- package/dist/capabilities/collab-workflows.js +1 -44
- package/dist/capabilities/hands-lane.js +0 -65
- package/dist/capabilities/memory-notice.js +0 -70
- package/dist/capabilities/prompt.js +0 -14
- package/dist/capabilities/prompts/code-review.js +0 -14
- package/dist/capabilities/prompts/identity.js +0 -6
- package/dist/capabilities/prompts/team.js +0 -4
- package/dist/capabilities/repo-tools.js +0 -34
- package/dist/capabilities/sandbox-file-send.js +3 -92
- package/dist/capabilities/scenarios.d.ts +0 -1
- package/dist/capabilities/scenarios.js +18 -279
- package/dist/capabilities/select-environment-tool.js +0 -32
- package/dist/capabilities/send-user-file-tool.js +2 -66
- package/dist/capabilities/skills.d.ts +1 -2
- package/dist/capabilities/skills.js +3 -21
- package/dist/capabilities/team.d.ts +3 -11
- package/dist/capabilities/team.js +3 -51
- package/dist/capabilities/tool-defer.js +0 -3
- package/dist/config-center/apply-effective.js +15 -330
- package/dist/config-center/apply-ledger.js +5 -38
- package/dist/config-center/facade.js +0 -41
- package/dist/config-center/hot-keys-registry.js +0 -20
- package/dist/config-center/http-client.js +2 -125
- package/dist/config-center/mcp-revocation.js +2 -34
- package/dist/config-center/read-face.js +0 -59
- package/dist/config-center/restart-signal.js +1 -85
- package/dist/config-center/skills-mcp.d.ts +1 -1
- package/dist/config-center/skills-mcp.js +9 -76
- package/dist/config-center/stage-limits.js +8 -35
- package/dist/config-invariants.js +0 -16
- package/dist/config-lkg.js +0 -42
- package/dist/config-provider.js +3 -186
- package/dist/config-types.js +0 -5
- package/dist/config.js +107 -1145
- package/dist/degenerate-instrument.js +3 -67
- package/dist/deployment-governance.js +0 -124
- package/dist/digest-form.js +0 -12
- package/dist/elicitation.js +3 -86
- package/dist/env-facts.js +7 -75
- package/dist/fleet/fleet-bus.js +34 -507
- package/dist/fleet/fleet-reconciler.js +9 -149
- package/dist/fleet/fleet-terminal-window.js +10 -178
- package/dist/fleet/subagent-tail-bus.js +3 -72
- package/dist/fleet-client.js +10 -70
- package/dist/fleet-lease.js +5 -79
- package/dist/git-api-kind.js +0 -3
- package/dist/governance-ask-marks.js +2 -78
- package/dist/hooks/branch-transcript.js +0 -74
- package/dist/hooks/cc-agent-hook-prompt.js +0 -29
- package/dist/hooks/cc-stop-prompt.js +1 -46
- package/dist/hooks/hook-llm.js +1 -53
- package/dist/hooks/hook-runner.js +20 -414
- package/dist/http/active-run-conflict.js +4 -129
- package/dist/http/cursor-fingerprint.d.ts +5 -0
- package/dist/http/cursor-fingerprint.js +5 -0
- package/dist/http/idempotency.js +0 -37
- package/dist/http/principal-gate.js +3 -40
- package/dist/http/route-ctx.js +0 -9
- package/dist/http/routes/a2a-serve.js +7 -319
- package/dist/http/routes/admin-config-refresh.js +0 -4
- package/dist/http/routes/admin-drain.js +0 -10
- package/dist/http/routes/adoption.js +1 -29
- package/dist/http/routes/agents-roster.js +1 -47
- package/dist/http/routes/approvals-assistant.js +32 -395
- package/dist/http/routes/attachments.js +4 -22
- package/dist/http/routes/capabilities.js +3 -420
- package/dist/http/routes/diagnostics.js +1 -81
- package/dist/http/routes/fleet.js +9 -185
- package/dist/http/routes/images.js +14 -239
- package/dist/http/routes/leader.js +0 -13
- package/dist/http/routes/memory-bundle.js +1 -61
- package/dist/http/routes/memory-policy.js +9 -99
- package/dist/http/routes/notify-wake.js +3 -37
- package/dist/http/routes/observability.js +2 -19
- package/dist/http/routes/retention-ops.js +2 -34
- package/dist/http/routes/rules.js +1 -93
- package/dist/http/routes/runs.js +64 -873
- package/dist/http/routes/session-sync.js +19 -258
- package/dist/http/routes/sessions-list.js +17 -43
- package/dist/http/routes/sessions.js +34 -226
- package/dist/http/routes/shared-memory.js +5 -36
- package/dist/http/routes/side-query.js +1 -87
- package/dist/http/routes/tasks.js +52 -722
- package/dist/http/routes/trace-usage.js +32 -195
- package/dist/http/routes/workflows.js +19 -195
- package/dist/http/run-meta.js +0 -6
- package/dist/http/send.js +0 -32
- package/dist/http/server.js +92 -1597
- package/dist/http/sse-lifecycle.js +2 -13
- package/dist/http/sse-log.js +3 -48
- package/dist/http/tar.js +5 -21
- package/dist/http/verify-rounds.js +0 -5
- package/dist/http/wire-gate.js +0 -9
- package/dist/http/workspace-content.js +0 -10
- package/dist/images/bake-validate.js +1 -70
- package/dist/images/manifest.js +1 -6
- package/dist/index.js +0 -21
- package/dist/key-resolver.js +2 -17
- package/dist/leader/diffout.js +1 -20
- package/dist/leader/diffup.js +0 -47
- package/dist/leader/endpoint.js +2 -57
- package/dist/leader/fanout.js +3 -45
- package/dist/leader/grader-env-factory.js +3 -72
- package/dist/leader/leader.js +5 -156
- package/dist/leader/merge.js +8 -115
- package/dist/leader/planner.js +3 -54
- package/dist/leader/repair-oracle.js +1 -60
- package/dist/leader/repair-wire.js +2 -79
- package/dist/leader/wire.js +8 -307
- package/dist/lsp/e2b-bridge.js +4 -64
- package/dist/lsp/e2b-manager.js +6 -94
- package/dist/lsp/lsp-frames.js +0 -12
- package/dist/lsp/manager.js +4 -96
- package/dist/lsp/ws-transport.js +5 -55
- package/dist/lsp-evict.js +1 -15
- package/dist/main.js +57 -765
- package/dist/memory-bundle-engine.js +0 -55
- package/dist/memory-export.js +0 -4
- package/dist/memory-posture.js +1 -15
- package/dist/memory-scope.js +11 -148
- package/dist/memory-sync-client.js +2 -44
- package/dist/memory-sync.js +1 -80
- package/dist/model-select.js +3 -80
- package/dist/observability/cost-quota.js +1 -17
- package/dist/observability/cost-taxonomy.js +0 -34
- package/dist/observability/fail-open.js +7 -86
- package/dist/observability/logger.js +0 -6
- package/dist/observability/metrics.js +0 -94
- package/dist/observability/otel-exporter.js +3 -13
- package/dist/observability/principal-context.js +0 -9
- package/dist/observability/prompt-manifest.js +1 -37
- package/dist/observability/rate-limit.js +0 -4
- package/dist/observability/secret-env-scrub.js +2 -56
- package/dist/observability/tool-trace.js +1 -70
- package/dist/orchestration/hardened-vm-runner.js +4 -118
- package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
- package/dist/orchestration/hardened-vm-worker.js +0 -27
- package/dist/orchestration/subagent-steer.js +1 -45
- package/dist/orchestration/workflow-agent-steer.js +1 -80
- package/dist/orchestration/workflow-completion-inbox.js +32 -285
- package/dist/orchestration/workflow-notify-journal.js +16 -259
- package/dist/org-memory-admission.js +3 -47
- package/dist/parent-watch.js +2 -48
- package/dist/parked-decide.js +1 -109
- package/dist/per-task-image.js +0 -57
- package/dist/plan-cache-probe.js +3 -27
- package/dist/plugins/adoption-log-sql.js +2 -119
- package/dist/plugins/approval-ask-store-memory.js +3 -38
- package/dist/plugins/approval-ask-store-sql.js +6 -188
- package/dist/plugins/approval-exemption-store.js +2 -28
- package/dist/plugins/background-agent-store-sql.js +4 -105
- package/dist/plugins/background-shell-support.js +14 -122
- package/dist/plugins/blob-backend.js +6 -169
- package/dist/plugins/breaker-state-sql.js +8 -46
- package/dist/plugins/caching-session-store.js +4 -106
- package/dist/plugins/checkpoint-store-sql.js +18 -505
- package/dist/plugins/e2b-orphan-reclaim.js +0 -45
- package/dist/plugins/file-outcome-sink.js +0 -9
- package/dist/plugins/file-resume-anchor-store.js +4 -43
- package/dist/plugins/file-run-store.js +26 -364
- package/dist/plugins/file-snapshot-store-sql.js +10 -181
- package/dist/plugins/fork-routing-session-store.js +8 -111
- package/dist/plugins/host-platform.js +2 -91
- package/dist/plugins/image-bake-store-sql.js +7 -250
- package/dist/plugins/image-index-sql.js +4 -123
- package/dist/plugins/k8s-bg-scripts.js +4 -88
- package/dist/plugins/k8s-exec-protocol.js +0 -41
- package/dist/plugins/leader-run-store-sql.js +0 -101
- package/dist/plugins/local-checkpoint-store.js +8 -128
- package/dist/plugins/local-session-store.js +34 -296
- package/dist/plugins/local-task-attachment-store.js +2 -16
- package/dist/plugins/mailbox-store-sql.js +8 -65
- package/dist/plugins/memory-embedder-fingerprint.js +5 -166
- package/dist/plugins/memory-embedder.js +4 -70
- package/dist/plugins/memory-engine-pg.js +6 -170
- package/dist/plugins/memory-engine-tidb.js +7 -155
- package/dist/plugins/memory-engine-vector-util.js +0 -10
- package/dist/plugins/memory-key-guards.js +0 -34
- package/dist/plugins/memory-origin-law.js +0 -187
- package/dist/plugins/memory-resume-anchor-store.js +0 -17
- package/dist/plugins/memory-run-store.js +12 -89
- package/dist/plugins/memory-session-policy-store.js +0 -17
- package/dist/plugins/memory-sync-store-pg.js +4 -49
- package/dist/plugins/memory-sync-store-tidb.js +3 -35
- package/dist/plugins/outcome-ledger-sql.js +3 -97
- package/dist/plugins/permission-rule-store-file.js +5 -133
- package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
- package/dist/plugins/permission-rule-store-sql.js +50 -307
- package/dist/plugins/pg-cost-quota.js +0 -7
- package/dist/plugins/pg-pool.js +0 -92
- package/dist/plugins/pg-rate-limiter.js +2 -13
- package/dist/plugins/pg-safe-json.js +4 -40
- package/dist/plugins/pg-session-storage.js +25 -189
- package/dist/plugins/posix-shell-fs.js +1 -31
- package/dist/plugins/remote-env-adb.js +12 -101
- package/dist/plugins/remote-env-e2b.js +40 -371
- package/dist/plugins/remote-env-file-error.js +0 -37
- package/dist/plugins/remote-env-host.js +63 -483
- package/dist/plugins/remote-env-k8s.js +32 -326
- package/dist/plugins/remote-env-local-docker.js +24 -157
- package/dist/plugins/remote-env-ssh.js +20 -128
- package/dist/plugins/remote-scratchpad.js +2 -32
- package/dist/plugins/remote-shell.js +1 -32
- package/dist/plugins/resume-anchor-store-sql.js +0 -11
- package/dist/plugins/retention-lane-store-sql.js +0 -108
- package/dist/plugins/retention-store-sql.js +4 -383
- package/dist/plugins/roster-store-sql.js +0 -55
- package/dist/plugins/run-store-sql.js +14 -251
- package/dist/plugins/s3-presign.js +2 -49
- package/dist/plugins/scheduler-support.js +3 -80
- package/dist/plugins/send-file-ledger.js +4 -53
- package/dist/plugins/send-user-file.js +4 -94
- package/dist/plugins/session-placement.js +1 -89
- package/dist/plugins/session-policy-store-sql.js +3 -81
- package/dist/plugins/session-store.js +0 -57
- package/dist/plugins/shared-memory-store-sql.js +4 -167
- package/dist/plugins/sql-driver.js +0 -17
- package/dist/plugins/sql-errors.js +0 -7
- package/dist/plugins/sql-escape.js +0 -8
- package/dist/plugins/sql-row-helpers.js +0 -25
- package/dist/plugins/store-backend.js +42 -222
- package/dist/plugins/store-contracts.js +2 -46
- package/dist/plugins/task-attachment-store.js +3 -49
- package/dist/plugins/task-list-store-sql.js +0 -79
- package/dist/plugins/tidb-cost-quota.js +1 -4
- package/dist/plugins/tidb-pool.js +1 -207
- package/dist/plugins/tidb-rate-limiter.js +3 -9
- package/dist/plugins/tidb-session-storage.js +4 -70
- package/dist/plugins/tidb-session-store.js +19 -341
- package/dist/plugins/tool-result-store-sql.js +4 -179
- package/dist/plugins/usage-window-store-sql.js +0 -8
- package/dist/plugins/web-search.js +10 -132
- package/dist/plugins/workflow-journal-store-sql.js +2 -58
- package/dist/plugins/workflow-run-store-sql.js +6 -91
- package/dist/plugins/worktree-isolation.js +6 -126
- package/dist/plugins/write-behind-counter.js +16 -75
- package/dist/principal-jwt.js +5 -60
- package/dist/project-memory.js +15 -146
- package/dist/prompts-domain-validate.js +1 -51
- package/dist/question.js +2 -111
- package/dist/resource-suspend.js +0 -18
- package/dist/router/route-orchestration.js +0 -77
- package/dist/rules-consent.d.ts +44 -5
- package/dist/rules-consent.js +75 -215
- package/dist/run-local.js +14 -381
- package/dist/runs.js +29 -617
- package/dist/runtime-caps-resolver.js +6 -132
- package/dist/runtime-governance.js +1 -232
- package/dist/sandbox-pkg-source.js +0 -37
- package/dist/sealed-key.js +3 -68
- package/dist/security.js +6 -291
- package/dist/session-leaf-bus.js +0 -32
- package/dist/session-sync-content.js +1 -69
- package/dist/session-sync-kernel.js +3 -60
- package/dist/session-sync.js +3 -66
- package/dist/session-titler.js +7 -41
- package/dist/session-watch.js +9 -73
- package/dist/shared-memory-scope-authorizer.js +0 -18
- package/dist/sighup-idle.js +1 -12
- package/dist/spec-fields.js +4 -143
- package/dist/store-live-probe.js +3 -49
- package/dist/task-a2a.js +1 -126
- package/dist/task-cwd.js +1 -103
- package/dist/task-mcp.js +1 -89
- package/dist/task-settings.js +10 -300
- package/dist/task-workflow.js +8 -75
- package/dist/tool-approval.d.ts +28 -1
- package/dist/tool-approval.js +70 -1430
- package/dist/trace/artifacts.js +5 -20
- package/dist/trace/engine-notice-wire.js +6 -138
- package/dist/trace/ledger-sink.js +7 -96
- package/dist/trace/project.js +4 -448
- package/dist/trace/redact.js +9 -77
- package/dist/turn-activity.js +1 -27
- package/dist/usage-analytics.js +4 -34
- package/dist/wall-clock-jump-guard.js +1 -68
- package/package.json +3 -3
- package/dist/capabilities/scenario-alias.d.ts +0 -27
- package/dist/capabilities/scenario-alias.js +0 -61
|
@@ -1,24 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `RemoteContainerExecutionEnv` — the E2B implementation of core's `RemoteExecutionEnv` seam
|
|
3
|
-
* (core src/core/remote-env.ts, design/48 §5). The service control plane owns this backend; core
|
|
4
|
-
* owns the seam shape (exactly like `CheckpointStore` / `SqlExecutor`).
|
|
5
|
-
*
|
|
6
|
-
* **Scope = design/48 §0.5 v1 minimal slice**: single worker, E2B managed, **Tier 2 only** (every
|
|
7
|
-
* op runs in a per-task E2B Firecracker VM — no tiering/classification), and the ~4 live seam methods
|
|
8
|
-
* `connect` / `execStream` / `workspaceHandle` / `destroy` + the base `ExecutionEnv` (FileSystem + Shell).
|
|
9
|
-
*
|
|
10
|
-
* **v1.5 (design/49, core 1.69):** `suspendVM` (E2B `pause()`) / `resumeVM` (`Sandbox.connect(id)` auto-resume)
|
|
11
|
-
* / `postResumeInit` (reachability + at-rest-encryption red line) are IMPLEMENTED — core orchestrates them when
|
|
12
|
-
* a task with this remote env hits a durable-approval `ask` (workspaceHandle → Checkpoint.state). `reconnect`
|
|
13
|
-
* (cross-replica re-attach to a still-running VM) stays deferred to **v2** (v1.5 always snapshots → resumeVM).
|
|
14
|
-
*
|
|
15
|
-
* **Provider seam discipline (self-host endgame):** every E2B SDK call is confined to this one class. A
|
|
16
|
-
* future OSS-self-hosted E2B (clay's "最终自部署" constraint) swaps the backend without touching consumers.
|
|
17
|
-
*
|
|
18
|
-
* **never-throw invariant:** the base `ExecutionEnv` contract requires every FileSystem/Shell method to
|
|
19
|
-
* encode failure in the returned `Result` (never throw). The one exception is `execStream`, whose async
|
|
20
|
-
* iterator MAY throw `RemoteExecutionError` mid-stream before an `exit` chunk (seam contract, OutputChunk).
|
|
21
|
-
*/
|
|
22
1
|
import path from "node:path";
|
|
23
2
|
import { Sandbox, CommandExitError, FileType } from "e2b";
|
|
24
3
|
import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
|
|
@@ -26,91 +5,32 @@ import { fileErrorFromExec } from "./remote-env-file-error.js";
|
|
|
26
5
|
import { numEnvOr, shellQuote } from "./remote-shell.js";
|
|
27
6
|
import { BackgroundShellManager } from "./background-shell-support.js";
|
|
28
7
|
const PROVIDER = "e2b";
|
|
29
|
-
/** E2B default sandbox user home — the workspace root unless overridden. */
|
|
30
8
|
const DEFAULT_MOUNT_PATH = "/home/user";
|
|
31
|
-
/** Default sandbox lifetime if the caller does not set one (E2B bills for this window). */
|
|
32
9
|
const DEFAULT_TIMEOUT_MS = 5 * 60_000;
|
|
33
|
-
/**
|
|
34
|
-
* 🔴 BL-31 — the E2B sandbox lifetime (`Sandbox.create({ timeoutMs })`) is bounded by E2B's per-sandbox MAX
|
|
35
|
-
* (≈1h on the fleet plan). A caller that passes a long deadline as the sandbox lifetime — the static-lane leader
|
|
36
|
-
* forwards `leaderTimeoutMs` (default 24h, the leader's OVERALL deadline) straight through — makes `create()`
|
|
37
|
-
* reject and `connect()` fail (found live: canary leader judare, 2026-06-17). Clamp the requested lifetime to
|
|
38
|
-
* this max (env-tunable `E2B_SANDBOX_MAX_MS`, default 1h) — the sandbox lifetime is decoupled from the leader's
|
|
39
|
-
* overall deadline. A run longer than the clamp needs a sandbox keep-alive/extend (`setTimeout`, follow-up);
|
|
40
|
-
* today an unclamped 24h simply never connects, so a clamp is strictly safer.
|
|
41
|
-
*/
|
|
42
10
|
const E2B_SANDBOX_MAX_MS = numEnvOr("E2B_SANDBOX_MAX_MS", 3_600_000, 60_000);
|
|
43
|
-
/**
|
|
44
|
-
* 🔴 Class-fix for the E2B-hang bug (core timeout/liveness contract). Three SEMANTICALLY DISTINCT
|
|
45
|
-
* timeouts that were previously conflated into the one sandbox-lifetime value:
|
|
46
|
-
* - {@link DEFAULT_RPC_TIMEOUT_MS} — wall-clock bound for a CONTROL-PLANE RPC (create/resume/pause/kill/isRunning/
|
|
47
|
-
* files.*). These are "should-be-fast" ops; a provider hang here used to wait the whole lifetime (~30 min).
|
|
48
|
-
* - {@link DEFAULT_LIVENESS_MS} — idle/liveness bound for a COMMAND (exec/execStream): max time with NO output
|
|
49
|
-
* AND not yet complete = suspected hang. The precise hang-killer: only kills "zero-progress",
|
|
50
|
-
* never a slow-but-streaming `npm ci`. MUST also cover the command-CREATION RPC (hang happens before chunk #1).
|
|
51
|
-
* - per-command wall-clock = the caller's explicit `timeout` (slow-but-progressing cap), which MUST NOT fall back
|
|
52
|
-
* to the sandbox lifetime. Unset → liveness is the safety net, not the lifetime.
|
|
53
|
-
*/
|
|
54
11
|
const DEFAULT_RPC_TIMEOUT_MS = 60_000;
|
|
55
12
|
const DEFAULT_LIVENESS_MS = 120_000;
|
|
56
|
-
/**
|
|
57
|
-
* Wall-clock for DATA-TRANSFER / provisioning RPCs (large `files.read`/`files.write`, `suspendVM`/`resumeVM`
|
|
58
|
-
* snapshot, sandbox `create`/`connect`). 🔴 These legitimately run MINUTES — a short fixed
|
|
59
|
-
* wall-clock would kill the durable-checkpoint path (suspending a large workspace). E2B's file/lifecycle SDK
|
|
60
|
-
* exposes NO progress/heartbeat to drive a true idle-bound, so this is a GENEROUS wall-clock; the moment the SDK
|
|
61
|
-
* surfaces progress, these should move to a real liveness bound. OP-CLASS sized: cheap control RPCs
|
|
62
|
-
* (isRunning/kill/metadata) keep the short {@link DEFAULT_RPC_TIMEOUT_MS}; this only widens the transfer class.
|
|
63
|
-
*/
|
|
64
13
|
const DEFAULT_DATA_TIMEOUT_MS = 5 * 60_000;
|
|
65
|
-
/**
|
|
66
|
-
* design/103 background-shell bounds for E2B (TOB). A background process cannot outlive its sandbox, so the BG
|
|
67
|
-
* timeout ceiling is well under the per-sandbox max ({@link E2B_SANDBOX_MAX_MS}). `maxConcurrent` mirrors the TOC
|
|
68
|
-
* default (8) — anti fork-bomb, not a workload limit.
|
|
69
|
-
*/
|
|
70
14
|
const E2B_BG_MAX_CONCURRENT = numEnvOr("E2B_BG_MAX_CONCURRENT", 8, 1);
|
|
71
15
|
const E2B_BG_DEFAULT_TIMEOUT_SEC = numEnvOr("E2B_BG_DEFAULT_TIMEOUT_SEC", 300, 1);
|
|
72
16
|
const E2B_BG_MAX_TIMEOUT_SEC = numEnvOr("E2B_BG_MAX_TIMEOUT_SEC", 1800, E2B_BG_DEFAULT_TIMEOUT_SEC);
|
|
73
|
-
/** Pad (sec) added to the E2B command `timeoutMs` backstop so the control-plane hard wall flips status to `killed` FIRST. */
|
|
74
17
|
const E2B_BG_PROVIDER_BACKSTOP_PAD_SEC = 30;
|
|
75
18
|
const ok = (value) => ({ ok: true, value });
|
|
76
|
-
/**
|
|
77
|
-
* E2B-backed remote `ExecutionEnv`. **Lazy by default (v1b):** the VM is provisioned on the
|
|
78
|
-
* FIRST FileSystem/Shell/execStream call (auto `connect()`), so a task that never touches the hand pays zero
|
|
79
|
-
* cost. `connect()` may still be called explicitly (idempotent + concurrency-memoized) if the control plane
|
|
80
|
-
* wants to provision eagerly. `destroy()` on task end (the Runner calls it via `hasDestroy`); a never-used env's
|
|
81
|
-
* destroy is a no-op (nothing to kill, no bill). One container per task (design/48 §5 §7 Q7).
|
|
82
|
-
*/
|
|
83
19
|
export class RemoteContainerExecutionEnv {
|
|
84
|
-
/** Current working directory for relative paths (FileSystem contract; mutable, core may reassign). */
|
|
85
20
|
cwd;
|
|
86
21
|
cfg;
|
|
87
22
|
sandboxApi;
|
|
88
23
|
sbx;
|
|
89
|
-
/** 🔴 BL-31 keep-alive — the E2B sandbox lifetime is hard-capped (≈1h, {@link E2B_SANDBOX_MAX_MS}), but a long
|
|
90
|
-
* leader/repair run legitimately exceeds it. This interval RE-extends the sandbox (`setTimeout(timeoutMs)` resets
|
|
91
|
-
* the auto-kill window from now) while the env is held, so the sandbox tracks ACTUAL usage rather than dying
|
|
92
|
-
* mid-run at the cap (otherwise: a silent dead sandbox at hour 1). Cleared on destroy;
|
|
93
|
-
* a no-op while paused/destroyed. `unref`'d so it never holds the process open. */
|
|
94
24
|
keepAlive;
|
|
95
25
|
handle;
|
|
96
|
-
/** Terminal once set by {@link destroy} — never reset to false (no resurrection). Guards connect/ensureConnected
|
|
97
|
-
* and the post-await re-check in BOTH async-provision paths ({@link doConnect} and {@link doResumeVM}) against
|
|
98
|
-
* a destroy that races an in-flight provision/restore. */
|
|
99
26
|
destroyed = false;
|
|
100
|
-
/** In-flight connect/resume, memoized so concurrent lazy first-use OR resume provisions exactly one VM (no double-bill). */
|
|
101
27
|
connecting;
|
|
102
|
-
/** Count of exec/execStream calls in flight — `suspendVM` refuses while > 0 (council #4: never snapshot a running command). */
|
|
103
28
|
activeCommands = 0;
|
|
104
|
-
/** True between a `suspendVM` (VM paused, external connection dead) and the next `resumeVM` reconnect. While
|
|
105
|
-
* paused, `this.sbx` still points at the (now-dead) handle, so `resumeVM`'s idempotency guard must NOT treat
|
|
106
|
-
* it as live — it has to fall through and actually `Sandbox.connect()`. Without this, a suspend→resume on the
|
|
107
|
-
* SAME env instance skips the reconnect and every later fs/exec runs against the dead VM. */
|
|
108
29
|
paused = false;
|
|
109
30
|
constructor(config, deps = {}) {
|
|
110
31
|
this.cfg = {
|
|
111
32
|
...config,
|
|
112
33
|
mountPath: config.mountPath ?? DEFAULT_MOUNT_PATH,
|
|
113
|
-
// BL-31: clamp the sandbox lifetime to E2B's per-sandbox max (a 24h leaderTimeoutMs blows the cap → connect fails).
|
|
114
34
|
timeoutMs: Math.min(config.timeoutMs ?? DEFAULT_TIMEOUT_MS, E2B_SANDBOX_MAX_MS),
|
|
115
35
|
allowInternetAccess: config.allowInternetAccess ?? true,
|
|
116
36
|
rpcTimeoutMs: config.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
|
|
@@ -120,30 +40,17 @@ export class RemoteContainerExecutionEnv {
|
|
|
120
40
|
this.cwd = this.cfg.mountPath;
|
|
121
41
|
this.sandboxApi = deps.sandboxApi ?? Sandbox;
|
|
122
42
|
}
|
|
123
|
-
/** design/61 §9: E2B is a FULL RemoteExecutionEnv — an isolated, snapshot-suspendable microVM. (SSH/ADB
|
|
124
|
-
* adapters declare {isolation:false, suspendable:false} — real machines/devices, not snapshotable.) */
|
|
125
43
|
capabilities = { isolation: true, suspendable: true };
|
|
126
|
-
// ─────────────────────────────── RemoteExecutionEnv: lifecycle ───────────────────────────────
|
|
127
44
|
workspaceHandle() {
|
|
128
45
|
if (!this.handle) {
|
|
129
46
|
throw new RemoteExecutionError("connect_failed", "workspaceHandle() called before connect()");
|
|
130
47
|
}
|
|
131
48
|
return this.handle;
|
|
132
49
|
}
|
|
133
|
-
/**
|
|
134
|
-
* Public host for a port inside the sandbox (E2B reverse proxy → `<port>-<id>.e2b.app`). The LSP sidecar
|
|
135
|
-
* (design/64 §13.1) uses this to reach the in-sandbox language-server WS bridge over `wss://`. Lazily
|
|
136
|
-
* provisions the VM (like exec/fs); `undefined` if it can't connect → the LSP manager degrades.
|
|
137
|
-
*/
|
|
138
50
|
async getHost(port) {
|
|
139
51
|
const conn = await this.ensureConnected();
|
|
140
52
|
return conn.ok ? conn.value.getHost(port) : undefined;
|
|
141
53
|
}
|
|
142
|
-
/**
|
|
143
|
-
* Launch a long-running process as a TRUE E2B background command (resolves once launched, E2B keeps it alive)
|
|
144
|
-
* — for the LSP bridge (design/64 §13.1). NOT the same as `exec` with a shell `&`: E2B reaps a foreground
|
|
145
|
-
* command's process group on completion, which would kill an in-shell `&`/nohup child. Fire-and-forget.
|
|
146
|
-
*/
|
|
147
54
|
async startBackground(command) {
|
|
148
55
|
const conn = await this.ensureConnected();
|
|
149
56
|
if (conn.ok)
|
|
@@ -151,57 +58,46 @@ export class RemoteContainerExecutionEnv {
|
|
|
151
58
|
}
|
|
152
59
|
async connect(config) {
|
|
153
60
|
if (config?.snapshotId) {
|
|
154
|
-
// resume-from-snapshot lands via resumeVM (v1.5); connect() just delegates.
|
|
155
61
|
return this.resumeVM(config.snapshotId, config.abortSignal ? { abortSignal: config.abortSignal } : undefined);
|
|
156
62
|
}
|
|
157
63
|
if (this.destroyed)
|
|
158
64
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env already destroyed") };
|
|
159
|
-
// A paused (suspended) env still HOLDS this.sbx but the external connection is dead — returning it as live
|
|
160
|
-
// would let a late fs/exec hit the dead VM with a confusing low-level error. Fail clearly until resumeVM.
|
|
161
65
|
if (this.paused)
|
|
162
66
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env is suspended (paused) — resumeVM(snapshotId) before use") };
|
|
163
67
|
if (this.sbx)
|
|
164
|
-
return ok(this.workspaceHandle());
|
|
68
|
+
return ok(this.workspaceHandle());
|
|
165
69
|
if (this.connecting)
|
|
166
|
-
return this.connecting;
|
|
70
|
+
return this.connecting;
|
|
167
71
|
this.connecting = this.doConnect(config);
|
|
168
72
|
const r = await this.connecting;
|
|
169
73
|
if (!r.ok)
|
|
170
|
-
this.connecting = undefined;
|
|
74
|
+
this.connecting = undefined;
|
|
171
75
|
return r;
|
|
172
76
|
}
|
|
173
77
|
async doConnect(config) {
|
|
174
78
|
try {
|
|
175
|
-
// ⚠️ This `await` yields — destroy() can run during it. A destroy that lands here sees sbx===undefined
|
|
176
|
-
// and returns without killing; if we then blindly assigned + reset `destroyed`, we'd RESURRECT a
|
|
177
|
-
// destroyed env and orphan a live billable VM (the Runner never calls destroy() twice). So after the
|
|
178
|
-
// await we re-check `destroyed` and kill-then-bail. Note: destroy is terminal — we never reset it to false.
|
|
179
79
|
const sbx = await this.rpc("sandbox.create", () => this.sandboxApi.create({
|
|
180
80
|
...(this.cfg.template ? { template: this.cfg.template } : {}),
|
|
181
81
|
apiKey: this.cfg.apiKey,
|
|
182
82
|
timeoutMs: this.cfg.timeoutMs,
|
|
183
83
|
allowInternetAccess: this.cfg.allowInternetAccess,
|
|
184
84
|
...(this.cfg.metadata ? { metadata: this.cfg.metadata } : {}),
|
|
185
|
-
// Out-of-band secret/config env (never logged) — sandbox-wide so every command + the LSP/bg process
|
|
186
|
-
// sees it without the value ever touching the model prompt or the tool command string.
|
|
187
85
|
...(this.cfg.sandboxEnv && Object.keys(this.cfg.sandboxEnv).length > 0 ? { envs: this.cfg.sandboxEnv } : {}),
|
|
188
86
|
}), config?.abortSignal, this.cfg.dataTimeoutMs);
|
|
189
87
|
if (this.destroyed) {
|
|
190
|
-
await sbx.kill().catch(() => { });
|
|
88
|
+
await sbx.kill().catch(() => { });
|
|
191
89
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env destroyed during connect") };
|
|
192
90
|
}
|
|
193
91
|
this.sbx = sbx;
|
|
194
92
|
this.paused = false;
|
|
195
|
-
this.startKeepAlive();
|
|
93
|
+
this.startKeepAlive();
|
|
196
94
|
this.cfg.logger?.info?.("remote_env_connect", { sandboxId: sbx.sandboxId });
|
|
197
95
|
this.handle = {
|
|
198
96
|
sandboxId: sbx.sandboxId,
|
|
199
97
|
provider: PROVIDER,
|
|
200
98
|
mountPath: this.cfg.mountPath,
|
|
201
|
-
sessionToken: sbx.sandboxId,
|
|
99
|
+
sessionToken: sbx.sandboxId,
|
|
202
100
|
};
|
|
203
|
-
// design/48 §5 gap 7: secrets are injected at connect time. v1 defers secret *resolution* to the
|
|
204
|
-
// control plane (SecretRef carries only name+locator, no value) — recorded, wired in v1b/v1.5.
|
|
205
101
|
if (config?.secrets?.length) {
|
|
206
102
|
this.cfg.logger?.warn?.("remote-env: connect secrets are not injected in v1 (control-plane resolution deferred)", {
|
|
207
103
|
count: config.secrets.length,
|
|
@@ -215,12 +111,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
215
111
|
return { ok: false, error: this.remoteError(e, "connect_failed") };
|
|
216
112
|
}
|
|
217
113
|
}
|
|
218
|
-
/**
|
|
219
|
-
* v1.5 (design/49): snapshot the workspace via E2B `pause()` (stores fs+memory+process; the snapshot id IS
|
|
220
|
-
* the sandbox id — a paused E2B sandbox is resumed by its id). Atomic contract (council): on failure the VM
|
|
221
|
-
* is left UNTOUCHED (still running) so core can fall back to onAsk. Refuses if a command is in flight
|
|
222
|
-
* (council #4) — never silently snapshot a running command. `abortSignal` bounds a hung pause (council BUG#2).
|
|
223
|
-
*/
|
|
224
114
|
async suspendVM(options) {
|
|
225
115
|
const sbx = this.sbx;
|
|
226
116
|
if (!sbx)
|
|
@@ -232,18 +122,12 @@ export class RemoteContainerExecutionEnv {
|
|
|
232
122
|
}
|
|
233
123
|
try {
|
|
234
124
|
await this.rpc("sandbox.pause", (signal) => sbx.pause({ signal }), options?.abortSignal, this.cfg.dataTimeoutMs);
|
|
235
|
-
// Same race as doConnect/doResumeVM: this `await` yielded, so destroy() may have landed. If destroy()
|
|
236
|
-
// killed a still-pausing sandbox its kill can be rejected, leaving a paused-but-unreferenced snapshot
|
|
237
|
-
// (billed storage until E2B's TTL); and we must NOT report a snapshotId for it — core would persist it
|
|
238
|
-
// and every later resume would fail. Best-effort kill (symmetric with doConnect/doResumeVM) + bail.
|
|
239
125
|
if (this.destroyed) {
|
|
240
|
-
// best-effort: if BOTH this and destroy()'s kill fail, a paused snapshot is orphaned (billed storage)
|
|
241
|
-
// until E2B's TTL — log it (destroy() logs its own kill failures too) so that is observable.
|
|
242
126
|
await sbx.kill().catch((e) => this.cfg.logger?.warn?.("remote-env: suspend-recheck kill failed (snapshot may orphan)", { error: errMsg(e) }));
|
|
243
127
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env destroyed during suspend") };
|
|
244
128
|
}
|
|
245
|
-
this.paused = true;
|
|
246
|
-
this.connecting = undefined;
|
|
129
|
+
this.paused = true;
|
|
130
|
+
this.connecting = undefined;
|
|
247
131
|
this.cfg.metrics?.inc("remote_env_suspend_total", { provider: PROVIDER, result: "ok" });
|
|
248
132
|
this.cfg.logger?.info?.("remote_env_suspend", { snapshotId: sbx.sandboxId });
|
|
249
133
|
return ok(sbx.sandboxId);
|
|
@@ -253,48 +137,30 @@ export class RemoteContainerExecutionEnv {
|
|
|
253
137
|
return { ok: false, error: this.remoteError(e, options?.abortSignal?.aborted ? "aborted" : "unknown") };
|
|
254
138
|
}
|
|
255
139
|
}
|
|
256
|
-
/**
|
|
257
|
-
* v1.5: restore from a snapshot via E2B `Sandbox.connect(snapshotId)` (auto-resumes a paused sandbox).
|
|
258
|
-
* Idempotent: a no-op if already on that sandbox. Concurrent resume/connect for the same snapshot share ONE
|
|
259
|
-
* provision (memoized via `connecting`, like {@link connect}) so a racing call never double-creates a billable
|
|
260
|
-
* VM (council #3). `abortSignal` bounds a hung restore.
|
|
261
|
-
*/
|
|
262
140
|
async resumeVM(snapshotId, options) {
|
|
263
141
|
if (this.destroyed)
|
|
264
142
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "env already destroyed") };
|
|
265
143
|
if (this.sbx && !this.paused && this.sbx.sandboxId === snapshotId)
|
|
266
|
-
return ok(this.workspaceHandle());
|
|
144
|
+
return ok(this.workspaceHandle());
|
|
267
145
|
if (this.connecting)
|
|
268
|
-
return this.connecting;
|
|
146
|
+
return this.connecting;
|
|
269
147
|
this.connecting = this.doResumeVM(snapshotId, options);
|
|
270
148
|
const r = await this.connecting;
|
|
271
149
|
if (!r.ok)
|
|
272
|
-
this.connecting = undefined;
|
|
150
|
+
this.connecting = undefined;
|
|
273
151
|
return r;
|
|
274
152
|
}
|
|
275
153
|
async doResumeVM(snapshotId, options) {
|
|
276
154
|
try {
|
|
277
|
-
// Carry the SAME apiKey + lifetime as doConnect. `SandboxConnectOpts.timeoutMs` defaults to 5 minutes;
|
|
278
|
-
// omitting it would reset a resumed VM's lifetime to 5min regardless of cfg.timeoutMs, so a long task
|
|
279
|
-
// resumed after a durable-approval ask would be reaped by E2B mid-run (snapshot already consumed). The
|
|
280
|
-
// apiKey is explicit too (don't depend on the SDK's E2B_API_KEY env fallback once keys move to the
|
|
281
|
-
// control plane). Wrapped in `rpc` so the connect also gets the data-transfer timeout/abort.
|
|
282
155
|
const sbx = await this.rpc("sandbox.connect", (signal) => this.sandboxApi.connect(snapshotId, { apiKey: this.cfg.apiKey, timeoutMs: this.cfg.timeoutMs, signal }), options?.abortSignal, this.cfg.dataTimeoutMs);
|
|
283
|
-
// Same race as doConnect: this `await` yields → destroy() can land during it. Re-check + kill-then-bail
|
|
284
|
-
// so we never assign a live VM to a destroyed env (which would orphan a billable sandbox — destroy() is
|
|
285
|
-
// terminal and won't be called again). (council #1)
|
|
286
156
|
if (this.destroyed) {
|
|
287
157
|
await sbx.kill().catch(() => { });
|
|
288
158
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env destroyed during resume") };
|
|
289
159
|
}
|
|
290
160
|
this.sbx = sbx;
|
|
291
161
|
this.paused = false;
|
|
292
|
-
this.startKeepAlive();
|
|
162
|
+
this.startKeepAlive();
|
|
293
163
|
this.cfg.logger?.info?.("remote_env_resume", { requested: snapshotId, connected: sbx.sandboxId });
|
|
294
|
-
// [1467]①(core 1.360 VmLifecycleOptions.priorHandle):恢复根=checkpoint 的 mountPath——E2B resume
|
|
295
|
-
// 恢复的是整个 VM 文件系统,旧根里的产物原位在;当前配置值只该管新建沙箱。exec/fs/mktemp 三腿同源
|
|
296
|
-
// 消费 this.cwd,一并重基;handle 回报的就是真生效根(诚实契约:绝不回显未兑现值)。缺 priorHandle
|
|
297
|
-
// (旧 core/旧 checkpoint)= 落当前配置值——此窗内改 E2B_MOUNT_PATH 仍需先 drain 挂起任务。
|
|
298
164
|
const restoredRoot = options?.priorHandle?.mountPath ?? this.cfg.mountPath;
|
|
299
165
|
this.cwd = restoredRoot;
|
|
300
166
|
this.handle = { sandboxId: sbx.sandboxId, provider: PROVIDER, mountPath: restoredRoot, snapshotId, sessionToken: sbx.sandboxId };
|
|
@@ -306,17 +172,9 @@ export class RemoteContainerExecutionEnv {
|
|
|
306
172
|
return { ok: false, error: this.remoteError(e, options?.abortSignal?.aborted ? "aborted" : "connect_failed") };
|
|
307
173
|
}
|
|
308
174
|
}
|
|
309
|
-
/** Cross-replica re-attach to a still-running VM = v2 (design/49 council #15: v1.5 always snapshots→resumeVM). */
|
|
310
175
|
async reconnect(_sessionToken) {
|
|
311
176
|
return { ok: false, error: new RemoteExecutionError("unknown", "reconnect is deferred to v2 (v1.5 always snapshots → use resumeVM)") };
|
|
312
177
|
}
|
|
313
|
-
/**
|
|
314
|
-
* v1.5: re-establish consistency after resumeVM. The resumed VM is restored to its paused state (fs+memory+
|
|
315
|
-
* process); this verifies it is reachable. Per-command secrets are re-applied automatically (this adapter
|
|
316
|
-
* injects envs per command, not persistently). The VM-internal agent rebuilds its own external connections
|
|
317
|
-
* (git/API don't survive pause). 🔴 At-rest snapshot encryption is the provider's here (E2B managed) — a
|
|
318
|
-
* self-hosted E2B (v2) MUST guarantee it before any secret injection (remote-env.ts §5 red line).
|
|
319
|
-
*/
|
|
320
178
|
async postResumeInit() {
|
|
321
179
|
const sbx = this.sbx;
|
|
322
180
|
if (!sbx)
|
|
@@ -331,15 +189,12 @@ export class RemoteContainerExecutionEnv {
|
|
|
331
189
|
return { ok: false, error: this.remoteError(e, "post_resume_failed") };
|
|
332
190
|
}
|
|
333
191
|
}
|
|
334
|
-
/** BL-31 — (re)start the periodic sandbox-lifetime extension. `setTimeout(timeoutMs)` resets the auto-kill window
|
|
335
|
-
* from now; we re-arm at ~80% of the (clamped, ≤1h) window so a long-held sandbox never expires mid-run. A no-op
|
|
336
|
-
* when paused/destroyed; `unref`'d so it never keeps the process alive; cleared on destroy. */
|
|
337
192
|
startKeepAlive() {
|
|
338
193
|
this.stopKeepAlive();
|
|
339
194
|
const everyMs = Math.max(30_000, Math.floor(this.cfg.timeoutMs * 0.8));
|
|
340
195
|
this.keepAlive = setInterval(() => {
|
|
341
196
|
if (this.destroyed || this.paused || !this.sbx)
|
|
342
|
-
return;
|
|
197
|
+
return;
|
|
343
198
|
void this.sbx.setTimeout(this.cfg.timeoutMs).catch((e) => this.cfg.logger?.warn?.("remote_env_keepalive_failed", { error: errMsg(e) }));
|
|
344
199
|
}, everyMs);
|
|
345
200
|
this.keepAlive.unref?.();
|
|
@@ -352,47 +207,37 @@ export class RemoteContainerExecutionEnv {
|
|
|
352
207
|
}
|
|
353
208
|
async destroy() {
|
|
354
209
|
if (this.destroyed)
|
|
355
|
-
return;
|
|
210
|
+
return;
|
|
356
211
|
this.destroyed = true;
|
|
357
212
|
this.connecting = undefined;
|
|
358
|
-
this.stopKeepAlive();
|
|
359
|
-
await this.disposeBackgroundShells();
|
|
213
|
+
this.stopKeepAlive();
|
|
214
|
+
await this.disposeBackgroundShells();
|
|
360
215
|
const sbx = this.sbx;
|
|
361
216
|
this.sbx = undefined;
|
|
362
217
|
if (!sbx)
|
|
363
|
-
return;
|
|
218
|
+
return;
|
|
364
219
|
const startedAt = Date.now();
|
|
365
220
|
try {
|
|
366
221
|
await this.rpc("sandbox.kill", () => sbx.kill());
|
|
367
222
|
this.cfg.metrics?.inc("remote_env_destroy_total", { provider: PROVIDER, result: "ok" });
|
|
368
|
-
// Symmetric with remote_env_connect: a clean teardown line carrying the same sandboxId
|
|
369
|
-
// gives the cleanup leg a log-only proof — no need to cross-check the provider's running-sandbox API.
|
|
370
223
|
this.cfg.logger?.info?.("remote_env_destroy", { sandboxId: sbx.sandboxId, ms: Date.now() - startedAt });
|
|
371
224
|
}
|
|
372
225
|
catch (e) {
|
|
373
|
-
// best-effort, must never throw (cleanup contract)
|
|
374
226
|
this.cfg.metrics?.inc("remote_env_destroy_total", { provider: PROVIDER, result: "error" });
|
|
375
227
|
this.cfg.logger?.warn?.("remote_env_destroy_failed", { sandboxId: sbx.sandboxId, ms: Date.now() - startedAt, error: errMsg(e) });
|
|
376
228
|
}
|
|
377
229
|
}
|
|
378
|
-
// FileSystem + Shell both declare cleanup(); both map to destroy() (release all VM resources).
|
|
379
230
|
async cleanup() {
|
|
380
231
|
await this.destroy();
|
|
381
232
|
}
|
|
382
|
-
// ─────────────────────────────── Shell: buffered exec ───────────────────────────────
|
|
383
233
|
async exec(command, options) {
|
|
384
|
-
// Already cancelled → don't lazily provision a billable VM just to immediately abort it.
|
|
385
234
|
if (options?.abortSignal?.aborted)
|
|
386
235
|
return { ok: false, error: new ExecutionError("aborted", "exec aborted before start") };
|
|
387
|
-
const conn = await this.ensureConnected();
|
|
236
|
+
const conn = await this.ensureConnected();
|
|
388
237
|
if (!conn.ok)
|
|
389
238
|
return { ok: false, error: new ExecutionError("shell_unavailable", `remote connect failed: ${conn.error.message}`, conn.error) };
|
|
390
239
|
const sbx = conn.value;
|
|
391
|
-
this.activeCommands++;
|
|
392
|
-
// design/116 detach (core 1.207, 1.82): the Bash tool threads a `detachSignal` when the operator can mid-flight
|
|
393
|
-
// ctrl+b. `ExecOpts` doesn't type it yet (core-side seam) — read it structurally. When present, tee all output
|
|
394
|
-
// into a rolling-tail pair shaped EXACTLY like the bg driver's per-shell state (E2bBgStream) — adoption is then
|
|
395
|
-
// a zero-copy handover of {handle, stdout, stderr} into the SAME state the launch path builds.
|
|
240
|
+
this.activeCommands++;
|
|
396
241
|
const detachSignal = options?.detachSignal;
|
|
397
242
|
const tee = detachSignal
|
|
398
243
|
? {
|
|
@@ -406,23 +251,13 @@ export class RemoteContainerExecutionEnv {
|
|
|
406
251
|
s.tail.push(b);
|
|
407
252
|
s.total += b.length;
|
|
408
253
|
};
|
|
409
|
-
|
|
410
|
-
// `commands.run` only aborts the local request — the VM process keeps running (and billing) until the
|
|
411
|
-
// sandbox times out. The handle still buffers stdout/stderr; wait() returns the full CommandResult.
|
|
412
|
-
let lastProgress = Date.now(); // bumped by stdout/stderr → drives the wait()-phase liveness (waitWithLiveness)
|
|
254
|
+
let lastProgress = Date.now();
|
|
413
255
|
let handle;
|
|
414
256
|
try {
|
|
415
|
-
// 🔴 Bound the command-CREATION RPC with the liveness timer — a provider hang here (before the
|
|
416
|
-
// command even starts) used to wait the whole sandbox lifetime. The wait() phase below is then
|
|
417
|
-
// bounded by waitWithLiveness (idle-bound), so BOTH phases of a hang are covered.
|
|
418
257
|
handle = await this.rpc("commands.run", () => sbx.commands.run(command, {
|
|
419
258
|
background: true,
|
|
420
259
|
cwd: this.resolve(options?.cwd ?? this.cwd),
|
|
421
260
|
...(options?.env ? { envs: options.env } : {}),
|
|
422
|
-
// Detach-capable exec: the provider command timeout is fixed at creation, so it must not undercut a
|
|
423
|
-
// LATER adoption's BG wall — lift it to the BG ceiling (+backstop pad, same discipline as the bg
|
|
424
|
-
// launch) and enforce the FOREGROUND wall with the control-plane `wallMs` in waitWithLiveness below
|
|
425
|
-
// (disarmed on adoption). Without a detachSignal the provider timeout stays the wall, as before.
|
|
426
261
|
timeoutMs: tee
|
|
427
262
|
? Math.max(this.commandTimeoutMs(options?.timeout), (E2B_BG_MAX_TIMEOUT_SEC + E2B_BG_PROVIDER_BACKSTOP_PAD_SEC) * 1000)
|
|
428
263
|
: this.commandTimeoutMs(options?.timeout),
|
|
@@ -448,8 +283,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
448
283
|
this.activeCommands--;
|
|
449
284
|
return { ok: false, error: this.execError(e, options?.abortSignal) };
|
|
450
285
|
}
|
|
451
|
-
// Post-adoption the abort belongs to the (already-settled) foreground tool call — it must NOT kill the
|
|
452
|
-
// adopted background job (core aborts the per-call signal right after the early tool result).
|
|
453
286
|
const onAbort = () => { if (!detached)
|
|
454
287
|
void handle.kill().catch(() => { }); };
|
|
455
288
|
if (options?.abortSignal) {
|
|
@@ -458,10 +291,8 @@ export class RemoteContainerExecutionEnv {
|
|
|
458
291
|
else
|
|
459
292
|
options.abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
460
293
|
}
|
|
461
|
-
// 🔴 Consume `handle.wait()` exactly ONCE (the SDK processes the command's event stream in it): the foreground
|
|
462
|
-
// race and the post-adoption terminal notification both hang off this single promise.
|
|
463
294
|
const waitP = handle.wait();
|
|
464
|
-
let fgResolved = false;
|
|
295
|
+
let fgResolved = false;
|
|
465
296
|
const markResolved = () => { fgResolved = true; };
|
|
466
297
|
waitP.then(markResolved, markResolved);
|
|
467
298
|
let fgSettled = false;
|
|
@@ -470,19 +301,8 @@ export class RemoteContainerExecutionEnv {
|
|
|
470
301
|
const detachedP = detachSignal && tee
|
|
471
302
|
? new Promise((res) => {
|
|
472
303
|
onDetach = () => {
|
|
473
|
-
// 🔴 review MED: E2B's onAbort only fires an async `handle.kill()` (no synchronous settle flag), unlike
|
|
474
|
-
// host/k8s whose abort synchronously settles (finish→settled) and whose onDetach guards on it. Without
|
|
475
|
-
// `abortSignal?.aborted` here, an abort in flight (kill RPC not yet completed, so `fgResolved` unset)
|
|
476
|
-
// followed by a detach would ADOPT a process that is already being killed — the aborted call would return
|
|
477
|
-
// detached success and hand the operator a corpse shellId. Align E2B with the settled-blocks-detach rule.
|
|
478
304
|
if (detached || fgSettled || fgResolved || options?.abortSignal?.aborted)
|
|
479
305
|
return;
|
|
480
|
-
// SYNC adopt (manager contract): the tee buffers already hold every byte from process start (the
|
|
481
|
-
// onStdout/onStderr closures keep feeding them — no listener re-registration exists on an E2B
|
|
482
|
-
// CommandHandle, which is exactly why the tee is created up-front). Terminal rides the shared waitP.
|
|
483
|
-
// 🔴 builder contract (Fable-5 review #5): everything inside is NON-THROWING (.then/.catch on an
|
|
484
|
-
// existing promise + object construction). adoptSync's catch does NOT undo side effects — future
|
|
485
|
-
// logic that can throw must go before the first side effect.
|
|
486
306
|
const adopted = this.bgManager.adoptSync((ctx) => {
|
|
487
307
|
waitP
|
|
488
308
|
.then((r) => ctx.onTerminal(false, r.exitCode))
|
|
@@ -495,13 +315,11 @@ export class RemoteContainerExecutionEnv {
|
|
|
495
315
|
return { handle, stdout: tee.stdout, stderr: tee.stderr };
|
|
496
316
|
});
|
|
497
317
|
if (!adopted)
|
|
498
|
-
return;
|
|
318
|
+
return;
|
|
499
319
|
adoptedShellId = adopted.shellId;
|
|
500
|
-
detached = true;
|
|
320
|
+
detached = true;
|
|
501
321
|
res("detached");
|
|
502
322
|
};
|
|
503
|
-
// core's ToolDetachHub is create-then-abort: a detach that landed BEFORE this exec started arrives as an
|
|
504
|
-
// ALREADY-aborted signal → adopt immediately (everything onDetach touches is initialized above).
|
|
505
323
|
if (detachSignal.aborted)
|
|
506
324
|
onDetach();
|
|
507
325
|
else
|
|
@@ -512,7 +330,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
512
330
|
const fg = this.waitWithLiveness(handle, () => lastProgress, {
|
|
513
331
|
waitP,
|
|
514
332
|
disarmed: () => detached,
|
|
515
|
-
// Foreground wall, control-plane enforced on the detach-capable path (provider timeout was lifted above).
|
|
516
333
|
...(tee ? { wallMs: this.commandTimeoutMs(options?.timeout) } : {}),
|
|
517
334
|
});
|
|
518
335
|
const winner = await (detachedP ? Promise.race([fg, detachedP]) : fg);
|
|
@@ -526,11 +343,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
526
343
|
detached: { shellId: adoptedShellId },
|
|
527
344
|
});
|
|
528
345
|
}
|
|
529
|
-
// 夜测 N2(2026-07-09,MED):an aborted exec's Result shape was NON-DETERMINISTIC — the abort path only
|
|
530
|
-
// fires an async handle.kill(), after which wait() may settle as a plain result OR a CommandExitError
|
|
531
|
-
// (non-zero exit from the kill), both previously returned ok:true; only an rpc-layer failure surfaced
|
|
532
|
-
// "aborted". Normalize: an aborted, non-detached exec ALWAYS returns {ok:false, error:"aborted"} (the
|
|
533
|
-
// remote process IS terminated either way — this only stabilizes the caller's Result contract).
|
|
534
346
|
if (options?.abortSignal?.aborted && !detached) {
|
|
535
347
|
return { ok: false, error: new ExecutionError("aborted", "exec aborted (remote process killed)") };
|
|
536
348
|
}
|
|
@@ -538,9 +350,8 @@ export class RemoteContainerExecutionEnv {
|
|
|
538
350
|
}
|
|
539
351
|
catch (e) {
|
|
540
352
|
if (options?.abortSignal?.aborted && !detached) {
|
|
541
|
-
return { ok: false, error: this.execError(e, options.abortSignal) };
|
|
353
|
+
return { ok: false, error: this.execError(e, options.abortSignal) };
|
|
542
354
|
}
|
|
543
|
-
// E2B throws CommandExitError on non-zero exit, but ExecutionEnv treats exitCode as a normal value.
|
|
544
355
|
if (e instanceof CommandExitError) {
|
|
545
356
|
return ok({ stdout: e.stdout, stderr: e.stderr, exitCode: e.exitCode });
|
|
546
357
|
}
|
|
@@ -554,13 +365,11 @@ export class RemoteContainerExecutionEnv {
|
|
|
554
365
|
options?.abortSignal?.removeEventListener("abort", onAbort);
|
|
555
366
|
}
|
|
556
367
|
}
|
|
557
|
-
// ─────────────────────────────── RemoteExecutionEnv: streaming exec ───────────────────────────────
|
|
558
368
|
async *execStream(command, options) {
|
|
559
|
-
const conn = await this.ensureConnected();
|
|
369
|
+
const conn = await this.ensureConnected();
|
|
560
370
|
if (!conn.ok)
|
|
561
371
|
throw conn.error;
|
|
562
372
|
const sbx = conn.value;
|
|
563
|
-
// Producer/consumer queue: E2B delivers output via onStdout/onStderr callbacks; we pull as an iterator.
|
|
564
373
|
const queue = [];
|
|
565
374
|
let finished = false;
|
|
566
375
|
let failure;
|
|
@@ -575,15 +384,11 @@ export class RemoteContainerExecutionEnv {
|
|
|
575
384
|
const cap = options?.maxOutputBytes;
|
|
576
385
|
const push = (type, data) => {
|
|
577
386
|
if (finished)
|
|
578
|
-
return;
|
|
387
|
+
return;
|
|
579
388
|
bytes += Buffer.byteLength(data, "utf8");
|
|
580
389
|
queue.push({ type, data });
|
|
581
390
|
signalReady();
|
|
582
391
|
};
|
|
583
|
-
// Increment BEFORE awaiting commands.run (mirrors exec): `commands.run` is a round-trip that may start the
|
|
584
|
-
// command on the VM before it resolves, so suspendVM must already see activeCommands > 0 to refuse — else a
|
|
585
|
-
// suspend racing the launch could snapshot a running command (council #4 invariant). Decrement on launch
|
|
586
|
-
// failure here; the normal path decrements in the outer finally.
|
|
587
392
|
this.activeCommands++;
|
|
588
393
|
let handle;
|
|
589
394
|
try {
|
|
@@ -597,14 +402,9 @@ export class RemoteContainerExecutionEnv {
|
|
|
597
402
|
}), options?.signal, this.cfg.livenessMs);
|
|
598
403
|
}
|
|
599
404
|
catch (e) {
|
|
600
|
-
// failed before any exit chunk → throw (streaming analogue of never-throw Result)
|
|
601
405
|
this.activeCommands--;
|
|
602
406
|
throw this.remoteError(e, "connect_failed");
|
|
603
407
|
}
|
|
604
|
-
// Completion: E2B throws CommandExitError on non-zero exit — that is a NORMAL finish, not a stream failure.
|
|
605
|
-
// `finished` is a first-writer-wins latch: whichever of {wait-resolve, wait-reject, abort} fires first sets
|
|
606
|
-
// the terminal state; the rest no-op. Without it, an abort (or a late wait settlement) firing AFTER a clean
|
|
607
|
-
// completion — but before the consumer pulls the final chunk — would clobber a successful exit into a throw.
|
|
608
408
|
handle
|
|
609
409
|
.wait()
|
|
610
410
|
.then((r) => {
|
|
@@ -640,18 +440,13 @@ export class RemoteContainerExecutionEnv {
|
|
|
640
440
|
else
|
|
641
441
|
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
642
442
|
}
|
|
643
|
-
// Drain via a read-index, not Array.shift() — shift() is O(n) per call → O(n²) on large output (a
|
|
644
|
-
// `npm ci`/build streaming millions of lines). Compacts once fully caught up. Throws (after killing the
|
|
645
|
-
// command) on maxOutputBytes. Single helper so the main loop and the post-completion flush stay in lockstep.
|
|
646
443
|
let head = 0;
|
|
647
444
|
const drainQueued = function* () {
|
|
648
445
|
while (head < queue.length) {
|
|
649
446
|
const chunk = queue[head++];
|
|
650
447
|
yield chunk;
|
|
651
|
-
// Soft cap: checked AFTER yield, so the chunk that crosses `cap` is still delivered (bounded by one
|
|
652
|
-
// E2B chunk). It's flood-protection (stop a `find /` drowning the control plane), not a byte-exact limit.
|
|
653
448
|
if (cap != null && bytes > cap) {
|
|
654
|
-
finished = true;
|
|
449
|
+
finished = true;
|
|
655
450
|
void handle.kill().catch(() => { });
|
|
656
451
|
throw new RemoteExecutionError("unknown", `execStream output exceeded maxOutputBytes=${cap}`);
|
|
657
452
|
}
|
|
@@ -664,15 +459,9 @@ export class RemoteContainerExecutionEnv {
|
|
|
664
459
|
yield* drainQueued();
|
|
665
460
|
if (finished)
|
|
666
461
|
break;
|
|
667
|
-
// Idle read-timeout (design/48 §5, E2B #1128): a streaming call has no read timeout, so an
|
|
668
|
-
// unreachable sandbox would hang forever. Bound the wait for the NEXT chunk and kill on expiry.
|
|
669
462
|
const next = new Promise((resolve) => {
|
|
670
463
|
wake = resolve;
|
|
671
464
|
});
|
|
672
|
-
// 🔴 Liveness is DEFAULT-ON: a missing readTimeoutMs MUST NOT mean "wait forever / until the env
|
|
673
|
-
// dies" — fall back to cfg.livenessMs. (The command-CREATION RPC is bounded separately by rpc() above, so
|
|
674
|
-
// the two together cover both phases of a provider hang.) Only kills a ZERO-PROGRESS command — a slow but
|
|
675
|
-
// streaming `npm ci` keeps resetting the timer via push()→signalReady().
|
|
676
465
|
const idleMs = options?.readTimeoutMs ?? this.cfg.livenessMs;
|
|
677
466
|
let timer;
|
|
678
467
|
const idle = new Promise((_, reject) => {
|
|
@@ -682,7 +471,7 @@ export class RemoteContainerExecutionEnv {
|
|
|
682
471
|
await Promise.race([next, idle]);
|
|
683
472
|
}
|
|
684
473
|
catch (e) {
|
|
685
|
-
finished = true;
|
|
474
|
+
finished = true;
|
|
686
475
|
void handle.kill().catch(() => { });
|
|
687
476
|
this.cfg.metrics?.inc("remote_env_exec_stream_read_timeout_total", { provider: PROVIDER });
|
|
688
477
|
throw e instanceof RemoteExecutionError ? e : this.remoteError(e);
|
|
@@ -692,16 +481,12 @@ export class RemoteContainerExecutionEnv {
|
|
|
692
481
|
clearTimeout(timer);
|
|
693
482
|
}
|
|
694
483
|
}
|
|
695
|
-
yield* drainQueued();
|
|
484
|
+
yield* drainQueued();
|
|
696
485
|
if (failure)
|
|
697
486
|
throw failure;
|
|
698
487
|
yield { type: "exit", exitCode };
|
|
699
488
|
}
|
|
700
489
|
finally {
|
|
701
|
-
// If the consumer abandons the iterator early (`break`/upstream cancel without an abort signal), the
|
|
702
|
-
// generator runs only this finally — the remote command would otherwise keep running (and billing) for
|
|
703
|
-
// its whole timeout, AND activeCommands would drop to 0 with a live command, letting suspendVM snapshot a
|
|
704
|
-
// running command. Kill on any non-finished exit so the council #4 invariant holds on every path.
|
|
705
490
|
if (!finished) {
|
|
706
491
|
finished = true;
|
|
707
492
|
void handle.kill().catch(() => { });
|
|
@@ -710,34 +495,11 @@ export class RemoteContainerExecutionEnv {
|
|
|
710
495
|
options?.signal?.removeEventListener("abort", onAbort);
|
|
711
496
|
}
|
|
712
497
|
}
|
|
713
|
-
// ─────────────────────────── design/103 background shell (TOB, path ②) ───────────────────────────
|
|
714
|
-
//
|
|
715
|
-
// E2B takes path ② (design/103 §5.2): a resident control-plane reader. `commands.run({background:true})` returns
|
|
716
|
-
// a `CommandHandle` immediately whose `onStdout`/`onStderr` callbacks keep firing as output arrives; we tee them
|
|
717
|
-
// into a per-shell rolling-tail buffer and serve `pollBackground` from that buffer's cursor. Consequence (design
|
|
718
|
-
// §5.2): the buffer is REPLICA-AFFINE — it lives in this env instance's heap and does NOT survive a reconnect to
|
|
719
|
-
// another replica. That is fine: background shells never cross a durable suspend (the Runner disposes them before
|
|
720
|
-
// `suspendVM`, design/103 §3.7), and a reconnected replica's empty registry yields `not_found` (VM destroy reaps
|
|
721
|
-
// the orphaned in-sandbox job). Background jobs deliberately do NOT touch `activeCommands` — they are long-lived
|
|
722
|
-
// and would otherwise wedge `suspendVM` forever; the dispose-before-suspend ordering (core saga) is what keeps a
|
|
723
|
-
// running background job from being snapshotted.
|
|
724
|
-
//
|
|
725
|
-
// 🔴 KILL SCOPE (honest, live-verified limitation): `handle.kill()` reliably SIGKILLs the MAIN process the model
|
|
726
|
-
// started — which is the model's KillShell intent (the dev server stops, its port frees). It does NOT group-kill
|
|
727
|
-
// the subtree, and E2B offers no SAFE way to: the per-command process group is SHARED with envd (killing it kills
|
|
728
|
-
// the sandbox), a ppid tree-walk doesn't reach E2B's reparented/disowned descendants, and `/proc/<pid>/environ`
|
|
729
|
-
// is ptrace-hardened (an env-marker scan reads nothing). All probed live. So detached grandchildren are reaped by
|
|
730
|
-
// the sandbox VM teardown (`destroy()`/`sbx.kill()`), which ALWAYS runs at task end — the reliable reaper (mirrors
|
|
731
|
-
// TOC's crash-honesty: we don't fake a group-kill the provider can't safely do). Practical impact is bounded:
|
|
732
|
-
// KillShell stops the server; on dispose-before-suspend the main processes die and any survivor is frozen (paused
|
|
733
|
-
// VM = no CPU) then reaped at the eventual destroy. Relayed to core for the design ledger.
|
|
734
498
|
backgroundCapabilities = {
|
|
735
499
|
supported: true,
|
|
736
500
|
maxConcurrent: E2B_BG_MAX_CONCURRENT,
|
|
737
501
|
defaultBgTimeoutSec: E2B_BG_DEFAULT_TIMEOUT_SEC,
|
|
738
502
|
maxBgTimeoutSec: E2B_BG_MAX_TIMEOUT_SEC,
|
|
739
|
-
// core 1.207 design/116 detach (1.82): a running foreground exec can be ADOPTED as a background shell
|
|
740
|
-
// (mid-flight ctrl+b) — the exec's CommandHandle + tee buffers hand over into the launch-shaped state.
|
|
741
503
|
supportsDetach: true,
|
|
742
504
|
};
|
|
743
505
|
_bgManager;
|
|
@@ -748,21 +510,16 @@ export class RemoteContainerExecutionEnv {
|
|
|
748
510
|
const self = this;
|
|
749
511
|
return {
|
|
750
512
|
async launch(command, options, ctx) {
|
|
751
|
-
const conn = await self.ensureConnected();
|
|
513
|
+
const conn = await self.ensureConnected();
|
|
752
514
|
if (!conn.ok)
|
|
753
|
-
throw new Error(`remote connect failed: ${conn.error.message}`);
|
|
515
|
+
throw new Error(`remote connect failed: ${conn.error.message}`);
|
|
754
516
|
const sbx = conn.value;
|
|
755
517
|
const stdout = { tail: new RollingTailBuffer(), total: 0, cursor: 0 };
|
|
756
518
|
const stderr = { tail: new RollingTailBuffer(), total: 0, cursor: 0 };
|
|
757
|
-
// 🔴 secret-scrub red line (design/103 §3.1): env is passed EXACTLY as `exec`/`execStream` do
|
|
758
|
-
// (`{ envs: options.env }`) — the host process.env is never inherited; the sandbox base env is the
|
|
759
|
-
// operator-trusted `sandboxEnv` set at create. A background child therefore sees the SAME scrubbed env.
|
|
760
519
|
const handle = await self.rpc("commands.run.bg", () => sbx.commands.run(command, {
|
|
761
520
|
background: true,
|
|
762
521
|
cwd: self.resolve(options?.cwd ?? self.cwd),
|
|
763
522
|
...(options?.env ? { envs: options.env } : {}),
|
|
764
|
-
// Provider-level backstop ONLY: padded past the control-plane hard wall so the manager's timer flips
|
|
765
|
-
// status to `killed` first; this reaps the in-sandbox job if the control plane dies (design/103 §3.6).
|
|
766
523
|
timeoutMs: (ctx.bgTimeoutSec + E2B_BG_PROVIDER_BACKSTOP_PAD_SEC) * 1000,
|
|
767
524
|
onStdout: (d) => {
|
|
768
525
|
const b = Buffer.from(d, "utf8");
|
|
@@ -775,8 +532,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
775
532
|
stderr.total += b.length;
|
|
776
533
|
},
|
|
777
534
|
}), undefined, self.cfg.livenessMs);
|
|
778
|
-
// Push-model terminal (design/103): wait() resolves on clean exit, rejects with CommandExitError on non-zero
|
|
779
|
-
// (a NORMAL finish → exited), or any other error → failed. `.catch` is mandatory (no unhandled rejection).
|
|
780
535
|
handle
|
|
781
536
|
.wait()
|
|
782
537
|
.then((r) => ctx.onTerminal(false, r.exitCode))
|
|
@@ -790,18 +545,10 @@ export class RemoteContainerExecutionEnv {
|
|
|
790
545
|
},
|
|
791
546
|
async read(state) {
|
|
792
547
|
const slice = (s) => {
|
|
793
|
-
// Cursor over ABSOLUTE byte offsets; the tail retains [droppedBytes, total). Return [cursor, total).
|
|
794
548
|
const { text, droppedBytes } = s.tail.result();
|
|
795
549
|
const droppedBeforeCursor = Math.max(0, droppedBytes - s.cursor);
|
|
796
550
|
const startByte = Math.max(s.cursor, droppedBytes);
|
|
797
551
|
const startInTail = startByte - droppedBytes;
|
|
798
|
-
// 🔴 re-encode delta (Fable-5 review #2): `startInTail` is an ORIGINAL-byte offset, but `text` is a
|
|
799
|
-
// decode→re-encode round trip. The tail's content is always valid UTF-8 (fed from SDK strings) EXCEPT
|
|
800
|
-
// its very head when an evict partially cut a multibyte codepoint — each stranded byte decodes to one
|
|
801
|
-
// U+FFFD (3 bytes re-encoded), so the re-encoded buffer is `delta` bytes longer than the original tail
|
|
802
|
-
// and the difference sits entirely BEFORE any valid cursor position (the cursor always lands on an SDK
|
|
803
|
-
// string boundary, past the stranded head). Shift the slice start by delta; without it the increment
|
|
804
|
-
// re-served up to 2 already-returned bytes per stranded byte (byte-drifted output).
|
|
805
552
|
const reencoded = Buffer.from(text, "utf8");
|
|
806
553
|
const delta = reencoded.length - (s.total - droppedBytes);
|
|
807
554
|
const inc = startInTail <= 0 ? text : reencoded.subarray(startInTail + delta).toString("utf8");
|
|
@@ -818,10 +565,10 @@ export class RemoteContainerExecutionEnv {
|
|
|
818
565
|
};
|
|
819
566
|
},
|
|
820
567
|
async kill(state) {
|
|
821
|
-
await state.handle.kill().catch(() => { });
|
|
568
|
+
await state.handle.kill().catch(() => { });
|
|
822
569
|
},
|
|
823
570
|
async disposeOne(state) {
|
|
824
|
-
await state.handle.kill().catch(() => { });
|
|
571
|
+
await state.handle.kill().catch(() => { });
|
|
825
572
|
},
|
|
826
573
|
};
|
|
827
574
|
}
|
|
@@ -835,11 +582,8 @@ export class RemoteContainerExecutionEnv {
|
|
|
835
582
|
return this.bgManager.kill(shellId);
|
|
836
583
|
}
|
|
837
584
|
disposeBackgroundShells(opts) {
|
|
838
|
-
// 签名统一带 opts(core 尾调 {except: keepAlive})。隔离 env 的留活是名义的——沙箱拆机进程必然随之
|
|
839
|
-
// 消亡(destroy 整体带走),core 的 envDying settle 会先给准确 killed 回执;透传保证签名不静默丢参。
|
|
840
585
|
return this._bgManager ? this._bgManager.dispose(opts) : Promise.resolve();
|
|
841
586
|
}
|
|
842
|
-
// ─────────────────────────────── FileSystem ───────────────────────────────
|
|
843
587
|
async absolutePath(p) {
|
|
844
588
|
return ok(this.resolve(p));
|
|
845
589
|
}
|
|
@@ -864,7 +608,7 @@ export class RemoteContainerExecutionEnv {
|
|
|
864
608
|
return r;
|
|
865
609
|
let lines = r.value.split(/\r?\n/);
|
|
866
610
|
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
867
|
-
lines.pop();
|
|
611
|
+
lines.pop();
|
|
868
612
|
if (options?.maxLines != null)
|
|
869
613
|
lines = lines.slice(0, options.maxLines);
|
|
870
614
|
return ok(lines);
|
|
@@ -886,7 +630,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
886
630
|
if (!sbx.ok)
|
|
887
631
|
return sbx;
|
|
888
632
|
try {
|
|
889
|
-
// E2B write creates parent directories automatically. Uint8Array → its backing ArrayBuffer slice.
|
|
890
633
|
const data = typeof content === "string" ? content : toArrayBuffer(content);
|
|
891
634
|
await this.rpc("files.write", (signal) => sbx.value.files.write(this.resolve(p), data, { signal }), abortSignal, this.cfg.dataTimeoutMs);
|
|
892
635
|
return ok(undefined);
|
|
@@ -896,7 +639,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
896
639
|
}
|
|
897
640
|
}
|
|
898
641
|
async appendFile(p, content, abortSignal) {
|
|
899
|
-
// E2B has no native append; read-modify-write (v1 single-worker → no concurrent-append race).
|
|
900
642
|
const abs = this.resolve(p);
|
|
901
643
|
const existing = await this.readBinaryFile(abs, abortSignal);
|
|
902
644
|
let base;
|
|
@@ -939,9 +681,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
939
681
|
return { ok: false, error: this.fileError(e, this.resolve(p), abortSignal) };
|
|
940
682
|
}
|
|
941
683
|
}
|
|
942
|
-
/** 见其余 shell 腿上同名方法的旁注(2026-07-25 六腿补齐):core 的敏感路径门在 `canonicalPath` 失败时退到它;
|
|
943
|
-
* 缺它 ⇒ 合法的悬空链接让整个工具调用失败。必须返回**原始链接目标**(`readlink` 不带 `-f`)。
|
|
944
|
-
* 本腿的文件面也走 `exec`(与 canonicalPath 同通道),所以实现方式与 shell 腿一致。 */
|
|
945
684
|
async readLink(p, abortSignal) {
|
|
946
685
|
const abs = this.resolve(p);
|
|
947
686
|
const r = await this.exec(`readlink -- ${shellQuote(abs)}`, { abortSignal });
|
|
@@ -957,7 +696,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
957
696
|
if (!r.ok)
|
|
958
697
|
return { ok: false, error: fileErrorFromExec(r.error, abs) };
|
|
959
698
|
if (r.value.exitCode !== 0) {
|
|
960
|
-
// realpath can fail for more than missing paths — classify like fileError() instead of collapsing to not_found.
|
|
961
699
|
const msg = r.value.stderr.trim();
|
|
962
700
|
if (/permission denied|access denied/i.test(msg))
|
|
963
701
|
return { ok: false, error: new FileError("permission_denied", msg, abs) };
|
|
@@ -978,7 +716,7 @@ export class RemoteContainerExecutionEnv {
|
|
|
978
716
|
catch (e) {
|
|
979
717
|
const fe = this.fileError(e, this.resolve(p), abortSignal);
|
|
980
718
|
if (fe.code === "not_found")
|
|
981
|
-
return ok(false);
|
|
719
|
+
return ok(false);
|
|
982
720
|
return { ok: false, error: fe };
|
|
983
721
|
}
|
|
984
722
|
}
|
|
@@ -987,7 +725,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
987
725
|
if (!sbx.ok)
|
|
988
726
|
return sbx;
|
|
989
727
|
try {
|
|
990
|
-
// E2B makeDir creates necessary parent directories; returns false if it already exists (treated as ok).
|
|
991
728
|
await this.rpc("files.makeDir", (signal) => sbx.value.files.makeDir(this.resolve(p), { signal }), options?.abortSignal);
|
|
992
729
|
return ok(undefined);
|
|
993
730
|
}
|
|
@@ -1001,7 +738,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
1001
738
|
return sbx;
|
|
1002
739
|
const abs = this.resolve(p);
|
|
1003
740
|
if (options?.recursive) {
|
|
1004
|
-
// E2B files.remove does not guarantee recursive directory removal → shell rm -rf.
|
|
1005
741
|
const flags = options.force ? "-rf" : "-r";
|
|
1006
742
|
const r = await this.exec(`rm ${flags} -- ${shellQuote(abs)}`, { abortSignal: options.abortSignal });
|
|
1007
743
|
if (!r.ok)
|
|
@@ -1032,17 +768,9 @@ export class RemoteContainerExecutionEnv {
|
|
|
1032
768
|
const r = await this.exec(`mktemp ${suffixArg} ${shellQuote(`${prefix}XXXXXX`)}`, { abortSignal: options?.abortSignal });
|
|
1033
769
|
return this.tempResult(r, "mktemp");
|
|
1034
770
|
}
|
|
1035
|
-
// ─────────────────────────────── internals ───────────────────────────────
|
|
1036
771
|
resolve(p) {
|
|
1037
772
|
return path.posix.isAbsolute(p) ? path.posix.normalize(p) : path.posix.normalize(path.posix.join(this.cwd, p));
|
|
1038
773
|
}
|
|
1039
|
-
/**
|
|
1040
|
-
* 🔴 (hang class-fix) Bound a CONTROL-PLANE provider RPC (create/resume/pause/kill/isRunning/files.*) with a
|
|
1041
|
-
* client-side wall-clock deadline + the caller's AbortSignal. Without this, an E2B RPC that hangs (the original
|
|
1042
|
-
* bug: ESTABLISHED socket, no response) pins the worker for the whole sandbox lifetime. On deadline we abort the
|
|
1043
|
-
* op and throw a typed, retryable {@link RemoteExecutionError} code `"timeout"` (caller retries per idempotency —
|
|
1044
|
-
* the adapter NEVER blind-retries). A caller AbortSignal still aborts independently (op surfaces it / "aborted").
|
|
1045
|
-
*/
|
|
1046
774
|
async rpc(label, op, userSignal, timeoutMs = this.cfg.rpcTimeoutMs) {
|
|
1047
775
|
const deadline = new AbortController();
|
|
1048
776
|
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
@@ -1053,55 +781,24 @@ export class RemoteContainerExecutionEnv {
|
|
|
1053
781
|
reject(new RemoteExecutionError("timeout", `remote ${label} exceeded ${timeoutMs}ms (provider RPC hang)`));
|
|
1054
782
|
}, { once: true }));
|
|
1055
783
|
try {
|
|
1056
|
-
// RACE the op against the deadline: even if the provider SDK ignores `signal` (e.g. create/isRunning/kill),
|
|
1057
|
-
// the worker is freed on timeout (the op may leak in the background until the SDK's own timeout — but it no
|
|
1058
|
-
// longer PINS us for the sandbox lifetime, the original hang bug). Passing `signal` lets SDK methods that DO
|
|
1059
|
-
// honor it (files.*, pause, connect) actually cancel the request. A caller `userSignal` abort propagates via
|
|
1060
|
-
// `signal` → the op rejects with its own abort error (NOT surfaced as "timeout"; deadline never fired).
|
|
1061
784
|
return await Promise.race([op(signal), onDeadline]);
|
|
1062
785
|
}
|
|
1063
786
|
finally {
|
|
1064
787
|
clearTimeout(timer);
|
|
1065
|
-
combined?.dispose();
|
|
788
|
+
combined?.dispose();
|
|
1066
789
|
}
|
|
1067
790
|
}
|
|
1068
|
-
/**
|
|
1069
|
-
* Per-command wall-clock for the E2B SDK (seconds→ms). 🔴 This is a PHYSICAL CAP, NOT the hang
|
|
1070
|
-
* detector. The old code returned `this.cfg.timeoutMs` (the sandbox lifetime) when no `timeout` was given AND
|
|
1071
|
-
* had no liveness check → a hung command waited the WHOLE lifetime (~30 min, the original hang bug). The fix is
|
|
1072
|
-
* NOT to shorten this cap (that would spuriously kill a long-but-progressing `npm ci`) — it's to add a LIVENESS
|
|
1073
|
-
* bound ({@link cfg.livenessMs}) in exec/execStream that catches a *zero-progress* command in ~livenessMs,
|
|
1074
|
-
* independent of this cap. So: explicit caller `timeout` wins; else the lifetime is the cap (a command can't
|
|
1075
|
-
* outlive its VM) but hang detection no longer depends on it.
|
|
1076
|
-
*/
|
|
1077
791
|
commandTimeoutMs(timeoutSec) {
|
|
1078
792
|
return timeoutSec != null ? timeoutSec * 1000 : this.cfg.timeoutMs;
|
|
1079
793
|
}
|
|
1080
|
-
|
|
1081
|
-
* 🔴 Liveness for the buffered exec WAIT phase. `handle.wait()` is a long-poll with no read timeout —
|
|
1082
|
-
* a command that hangs DURING execution (not creation) would otherwise wait the E2B command timeout (= the
|
|
1083
|
-
* lifetime when none is set, the original hang bug). Races wait() against an idle check: if no stdout/stderr for
|
|
1084
|
-
* `livenessMs`, the command is hung → kill + typed retryable "timeout". `lastProgress()` is bumped by the
|
|
1085
|
-
* stdout/stderr callbacks, so a slow-but-PROGRESSING command (long `npm ci`) is never killed. (execStream has
|
|
1086
|
-
* its own chunk-driven idle timer; this is the buffered-exec analogue.)
|
|
1087
|
-
*/
|
|
1088
|
-
async waitWithLiveness(handle, lastProgress,
|
|
1089
|
-
// design/116 detach: `waitP` shares the ONE `handle.wait()` consumption with exec's adoption path; `disarmed`
|
|
1090
|
-
// (⇒ the command was adopted as a background shell) makes the liveness/wall timers dead switches — the fg leg
|
|
1091
|
-
// must NEVER kill a process the bgManager now owns; `wallMs` is the control-plane foreground wall for the
|
|
1092
|
-
// detach-capable path (whose provider timeout was lifted to the BG ceiling).
|
|
1093
|
-
opts) {
|
|
794
|
+
async waitWithLiveness(handle, lastProgress, opts) {
|
|
1094
795
|
let timer;
|
|
1095
796
|
let wallTimer;
|
|
1096
|
-
// council DESIGN-1: a fixed 5s polling setInterval can fire AT the deadline and kill a command that just
|
|
1097
|
-
// finished (race vs handle.wait settling, discarding its exitCode). Instead arm a setTimeout for EXACTLY the
|
|
1098
|
-
// remaining idle window; when it fires, re-check lastProgress() — if output advanced it, re-arm for the new
|
|
1099
|
-
// remainder (so a slow-but-progressing command is never killed), else it is genuinely idle → kill + timeout.
|
|
1100
797
|
const idle = new Promise((_, reject) => {
|
|
1101
798
|
const arm = () => {
|
|
1102
799
|
timer = setTimeout(() => {
|
|
1103
800
|
if (opts?.disarmed?.())
|
|
1104
|
-
return;
|
|
801
|
+
return;
|
|
1105
802
|
const idleFor = Date.now() - lastProgress();
|
|
1106
803
|
if (idleFor >= this.cfg.livenessMs) {
|
|
1107
804
|
void handle.kill().catch(() => { });
|
|
@@ -1109,7 +806,7 @@ export class RemoteContainerExecutionEnv {
|
|
|
1109
806
|
reject(new RemoteExecutionError("timeout", `exec idle > ${this.cfg.livenessMs}ms (provider hang / unreachable sandbox)`));
|
|
1110
807
|
}
|
|
1111
808
|
else {
|
|
1112
|
-
arm();
|
|
809
|
+
arm();
|
|
1113
810
|
}
|
|
1114
811
|
}, Math.max(this.cfg.livenessMs - (Date.now() - lastProgress()), 0));
|
|
1115
812
|
};
|
|
@@ -1124,8 +821,6 @@ export class RemoteContainerExecutionEnv {
|
|
|
1124
821
|
}
|
|
1125
822
|
});
|
|
1126
823
|
try {
|
|
1127
|
-
// handle.wait() settling (resolve OR CommandExitError reject) wins the race over a still-pending timer, so a
|
|
1128
|
-
// command that completes is never killed; finally clears the timer on every exit path.
|
|
1129
824
|
return await Promise.race([opts?.waitP ?? handle.wait(), idle]);
|
|
1130
825
|
}
|
|
1131
826
|
finally {
|
|
@@ -1135,10 +830,7 @@ export class RemoteContainerExecutionEnv {
|
|
|
1135
830
|
clearTimeout(wallTimer);
|
|
1136
831
|
}
|
|
1137
832
|
}
|
|
1138
|
-
/** Lazily provision the VM on first use: a task that never touches the hand pays zero cost.
|
|
1139
|
-
* Concurrent first-calls share one provision via the memoized {@link connect}. */
|
|
1140
833
|
async ensureConnected() {
|
|
1141
|
-
// A paused (suspended) env's this.sbx is a dead handle — fail clearly rather than running fs/exec against it.
|
|
1142
834
|
if (this.paused)
|
|
1143
835
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env is suspended (paused) — resume before fs/exec") };
|
|
1144
836
|
if (this.sbx)
|
|
@@ -1180,16 +872,10 @@ export class RemoteContainerExecutionEnv {
|
|
|
1180
872
|
const name = e?.name;
|
|
1181
873
|
if (abortSignal?.aborted || name === "AbortError")
|
|
1182
874
|
return new FileError("aborted", msg, p);
|
|
1183
|
-
// A remote-fs RPC liveness timeout arrives as RemoteExecutionError("timeout"); surface it on the FileSystem
|
|
1184
|
-
// seam as the symmetric FileError("timeout") (core 1.77.0 added "timeout" to FileErrorCode too — [R71]) so the
|
|
1185
|
-
// retryable-hang signal travels as a typed code, not just a message. + a metric so a file-RPC hang stays
|
|
1186
|
-
// observable. Minor: file RPCs rarely hang and only past dataTimeoutMs (5m).
|
|
1187
875
|
if (e instanceof RemoteExecutionError && e.code === "timeout") {
|
|
1188
876
|
this.cfg.metrics?.inc("remote_env_file_timeout_total", { provider: PROVIDER });
|
|
1189
877
|
return new FileError("timeout", msg, p, e);
|
|
1190
878
|
}
|
|
1191
|
-
// The sandbox itself vanishing (reaped/timed-out) is an INFRASTRUCTURE failure, not a confirmed-absent
|
|
1192
|
-
// file — never collapse it to not_found, or exists()/stat() would report a dead VM as "file is absent".
|
|
1193
879
|
if (name === "SandboxNotFoundError" || /sandbox .*not found|sandbox .*not running|sandbox was not found/i.test(msg)) {
|
|
1194
880
|
return new FileError("unknown", msg, p);
|
|
1195
881
|
}
|
|
@@ -1223,31 +909,15 @@ export class RemoteContainerExecutionEnv {
|
|
|
1223
909
|
return new RemoteExecutionError(code, errMsg(e), e instanceof Error ? e : undefined);
|
|
1224
910
|
}
|
|
1225
911
|
}
|
|
1226
|
-
/**
|
|
1227
|
-
* Build the deployment-level `RunnerDeps.executionEnvFactory` (design/48 v1b). Wiring this onto a
|
|
1228
|
-
* deployment makes it a "code-agent worker": every task's hand runs in a per-task E2B VM. Deployments that do
|
|
1229
|
-
* NOT set this (e.g. OA / review) keep the in-process stub env — routing is **deployment-level** (the factory
|
|
1230
|
-
* ctx carries only sessionId/taskId, never a service scenario; that stays out of the generic core seam).
|
|
1231
|
-
*
|
|
1232
|
-
* The factory returns the env **unconnected** (lazy): the VM is provisioned on first FS/Shell/execStream use,
|
|
1233
|
-
* so a task that never touches the hand (e.g. a leader that only plans) pays zero VM cost. The Runner owns the
|
|
1234
|
-
* lifetime and calls `destroy()` on task end (via `hasDestroy`); a never-used env's destroy is a no-op.
|
|
1235
|
-
*/
|
|
1236
912
|
export function e2bExecutionEnvFactory(config, deps) {
|
|
1237
913
|
return (ctx) => new RemoteContainerExecutionEnv({
|
|
1238
914
|
...config,
|
|
1239
|
-
// #242:semaManaged=归属标(孤儿回收腿 e2b-orphan-reclaim.ts 按它服务端过滤;值恒 "1")。
|
|
1240
915
|
metadata: { ...config.metadata, semaManaged: "1", sessionId: ctx.sessionId, ...(ctx.taskId ? { taskId: ctx.taskId } : {}) },
|
|
1241
916
|
}, deps);
|
|
1242
917
|
}
|
|
1243
|
-
// ─────────────────────────────── module helpers ───────────────────────────────
|
|
1244
918
|
function errMsg(e) {
|
|
1245
919
|
return e instanceof Error ? e.message : String(e);
|
|
1246
920
|
}
|
|
1247
|
-
/** E2B FilesystemRequestOpts `{ signal }` spread helper (omit the key entirely when no signal). */
|
|
1248
|
-
/** Combine signals into one that aborts when ANY input does (used by {@link RemoteContainerExecutionEnv.rpc} to
|
|
1249
|
-
* race the caller's abort with the client-side deadline). Uses the platform `AbortSignal.any` when present
|
|
1250
|
-
* (Node ≥20 / Bun), else a small fallback. */
|
|
1251
921
|
function anySignal(signals) {
|
|
1252
922
|
const ctor = AbortSignal;
|
|
1253
923
|
if (typeof ctor.any === "function")
|
|
@@ -1261,11 +931,10 @@ function anySignal(signals) {
|
|
|
1261
931
|
break;
|
|
1262
932
|
}
|
|
1263
933
|
s.addEventListener("abort", onAbort, { once: true });
|
|
1264
|
-
cleanups.push(() => s.removeEventListener("abort", onAbort));
|
|
934
|
+
cleanups.push(() => s.removeEventListener("abort", onAbort));
|
|
1265
935
|
}
|
|
1266
936
|
return { signal: ctrl.signal, dispose: () => cleanups.forEach((c) => c()) };
|
|
1267
937
|
}
|
|
1268
|
-
/** Copy a Uint8Array's exact bytes into a standalone ArrayBuffer (E2B write accepts ArrayBuffer). */
|
|
1269
938
|
function toArrayBuffer(u) {
|
|
1270
939
|
return u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength);
|
|
1271
940
|
}
|