@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,35 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `RemoteK8sExecutionEnv` — the Kubernetes pod-sandbox implementation of core's `RemoteExecutionEnv` seam
|
|
3
|
-
* (design/61). A PEER adapter to E2B/SSH/ADB (NOT chained). Use case: self-hosted sandboxes on our k3s with
|
|
4
|
-
* **Kata Containers** (`runtimeClassName: kata-qemu`) — a real VM boundary (guest kernel ≠ host, proven in the
|
|
5
|
-
* 2026-06-10 PoC) at ~2s startup, par with managed E2B.
|
|
6
|
-
*
|
|
7
|
-
* 🔴 Capabilities: `{ isolation: true, suspendable: false }` — Kata gives VM isolation, but
|
|
8
|
-
* there is NO managed memory snapshot (E2B-style pause/resume). `suspendVM`/`resumeVM`/`postResumeInit` →
|
|
9
|
-
* typed `"unsupported"` so durable suspend degrades honestly (checkpoint + cold rebuild, in-VM process state
|
|
10
|
-
* like a language server is lost). True memory snapshots = a separate CRIU/Kata-snapshot project, not implied.
|
|
11
|
-
*
|
|
12
|
-
* Transport: the k8s REST API + the exec WebSocket (channel-multiplexed: 1-byte channel prefix per frame —
|
|
13
|
-
* 1=stdout 2=stderr 3=status JSON) spoken directly over `ws` — deliberately NO `@kubernetes/client-node`
|
|
14
|
-
* dependency (its exec path is the only thing we'd use; our liveness/timeout discipline plugs straight into a
|
|
15
|
-
* raw socket, and the fault-injection seam stays trivial). Unlike `adb exec-out`, the status channel carries a
|
|
16
|
-
* REAL exit code.
|
|
17
|
-
*
|
|
18
|
-
* 🔴 Subprotocol = `v5.channel.k8s.io` first, `v4` fallback (night-drill 2026-06-11): the apiserver's **v4**
|
|
19
|
-
* WebSocket exec writer STALLS after the first ~6KB of a fast output burst (verified: the TCP socket stays
|
|
20
|
-
* healthy/flowing, the apiserver just stops sending; v5 streams the same 1MB+ burst to completion). v5 (k8s
|
|
21
|
-
* 1.29+) is what modern kubectl negotiates; the frame format is identical (it only adds a close-channel we
|
|
22
|
-
* ignore), so offering both lets a v5 apiserver fix the stall while a v4-only one still works for small output.
|
|
23
|
-
*
|
|
24
|
-
* Timeouts follow the E2B class-fix taxonomy: control-plane RPCs get a short wall-clock
|
|
25
|
-
* (`rpcTimeoutMs`); commands get a zero-progress LIVENESS bound (`livenessMs`) plus the caller's explicit
|
|
26
|
-
* wall-clock; pod readiness gets its own generous bound (image pull can be slow). ⚠️ killing the exec socket
|
|
27
|
-
* does NOT kill the remote process — acceptable here because the pod is DISPOSABLE (destroyed with the task;
|
|
28
|
-
* `activeDeadlineSeconds` is the leak guard), unlike the SSH real-machine posture.
|
|
29
|
-
*
|
|
30
|
-
* Auth: a bearer token + CA, either explicit (`K8S_API_URL`/`K8S_TOKEN`/`K8S_CA_CERT_B64`) or in-cluster
|
|
31
|
-
* (service-account files + `KUBERNETES_SERVICE_HOST`) — control-plane config, never agent-reachable.
|
|
32
|
-
*/
|
|
33
1
|
import path from "node:path";
|
|
34
2
|
import { readFileSync } from "node:fs";
|
|
35
3
|
import https from "node:https";
|
|
@@ -44,25 +12,13 @@ import { createPosixShellFs } from "./posix-shell-fs.js";
|
|
|
44
12
|
import { randomUUID } from "node:crypto";
|
|
45
13
|
import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
|
|
46
14
|
const PROVIDER = "k8s";
|
|
47
|
-
/**
|
|
48
|
-
* design/103 background-shell (TOB, path ① — pod-side file buffer re-read by byte cursor). Bounds for kata/k8s.
|
|
49
|
-
* A background job cannot outlive its pod (`activeDeadlineSeconds`), so `maxBgTimeoutSec` is additionally clamped
|
|
50
|
-
* to the pod lifetime at construction. `maxConcurrent` mirrors the TOC default (8) — anti fork-bomb.
|
|
51
|
-
*/
|
|
52
15
|
const K8S_BG_DIR_ROOT = "/tmp/.sema-bg";
|
|
53
16
|
const K8S_BG_MAX_CONCURRENT = numEnvOr("K8S_BG_MAX_CONCURRENT", 8, 1);
|
|
54
17
|
const K8S_BG_DEFAULT_TIMEOUT_SEC = numEnvOr("K8S_BG_DEFAULT_TIMEOUT_SEC", 300, 1);
|
|
55
18
|
const K8S_BG_MAX_TIMEOUT_SEC = numEnvOr("K8S_BG_MAX_TIMEOUT_SEC", 1800, K8S_BG_DEFAULT_TIMEOUT_SEC);
|
|
56
|
-
/** Max bytes returned per stream per poll (bounds the control-plane transfer; the model only displays ~20K anyway). */
|
|
57
19
|
const K8S_BG_READ_CAP = numEnvOr("K8S_BG_READ_CAP", 1024 * 1024, 64 * 1024);
|
|
58
|
-
/** Per-stream pod-side file cap (FIFO+`head` — a runaway `yes` is SIGPIPE'd at this bound; the pod has no ephemeral-storage limit). */
|
|
59
20
|
const K8S_BG_FILE_CAP = numEnvOr("K8S_BG_FILE_CAP", 64 * 1024 * 1024, K8S_BG_READ_CAP);
|
|
60
|
-
/** Per-stream control-plane drain cap for an ADOPTED (mid-flight detach) shell — its output rides the still-open
|
|
61
|
-
* exec WebSocket into worker memory, not pod files, so bound the un-polled queue (drop-oldest → `truncated`). */
|
|
62
21
|
const K8S_BG_MEM_CAP = numEnvOr("K8S_BG_MEM_CAP", 8 * 1024 * 1024, 64 * 1024);
|
|
63
|
-
// design/158 A13: the pure background-shell pod-side script generation/parsing group (buildBg*Script ×5 +
|
|
64
|
-
// buildDetachCapableExec + parseBgPollOutput + ParsedBgPoll) moved VERBATIM to ./k8s-bg-scripts.js —
|
|
65
|
-
// value-imported below (the class body still calls them) and re-exported so existing importers keep working.
|
|
66
22
|
import { buildBgRunnerScript, buildBgLauncherScript, buildBgPollScript, buildBgKillScript, buildBgDisposeScript, buildDetachCapableExec, parseBgPollOutput, } from "./k8s-bg-scripts.js";
|
|
67
23
|
export { buildBgRunnerScript, buildBgLauncherScript, buildBgPollScript, buildBgKillScript, buildBgDisposeScript, buildDetachCapableExec, parseBgPollOutput, } from "./k8s-bg-scripts.js";
|
|
68
24
|
const ok = (value) => ({ ok: true, value });
|
|
@@ -71,29 +27,12 @@ const unsupported = (op) => ({
|
|
|
71
27
|
error: new RemoteExecutionError("unsupported", `${op} is not supported on a Kata pod sandbox (no managed memory snapshot — capabilities.suspendable=false; durable suspend degrades to checkpoint + cold rebuild)`),
|
|
72
28
|
});
|
|
73
29
|
const SA_DIR = "/var/run/secrets/kubernetes.io/serviceaccount";
|
|
74
|
-
/** Exec WebSocket subprotocols, preference order (see header — v4 burst-stall). */
|
|
75
30
|
const EXEC_SUBPROTOCOLS = ["v5.channel.k8s.io", "v4.channel.k8s.io"];
|
|
76
|
-
/**
|
|
77
|
-
* argv-embedded base64 write chunk (SOURCE bytes). 🔴 The bound is Linux `MAX_ARG_STRLEN` = 128KiB (131072),
|
|
78
|
-
* the limit on a SINGLE argv string — and the whole `printf '%s' '<base64>' | base64 -d >> '<path>'` command
|
|
79
|
-
* is passed as one `sh -c <cmd>` argument. base64 inflates 4/3, so a 96KiB source chunk made a 131072-byte
|
|
80
|
-
* base64 = exactly the limit ⇒ silent truncation (night-drill: writes >95KiB failed "command failed"). 64KiB
|
|
81
|
-
* source → 87KiB base64 + wrapper ≈ 87KiB command, a safe ~44KiB under the limit.
|
|
82
|
-
*/
|
|
83
31
|
const WRITE_CHUNK_BYTES = 64 * 1024;
|
|
84
|
-
// design/158 A13: the pod-spec patch group (PodSpecPatch + applyPodSpecPatch + its hardening/volume guards)
|
|
85
|
-
// moved VERBATIM to ./k8s-exec-protocol.js — value-imported below (the class body calls applyPodSpecPatch)
|
|
86
|
-
// and re-exported so existing importers keep working.
|
|
87
32
|
import { applyPodSpecPatch } from "./k8s-exec-protocol.js";
|
|
88
33
|
export { applyPodSpecPatch } from "./k8s-exec-protocol.js";
|
|
89
|
-
// design/158 A13: execSocketTlsOptions + exitCodeFromStatus moved VERBATIM to ./k8s-exec-protocol.js
|
|
90
|
-
// (errorMessageOf, sandwiched between them in the original layout, stays HERE — it is a small ws-error
|
|
91
|
-
// helper, not part of the pod-spec-patch/protocol group). Value-imported below (the class body calls both)
|
|
92
|
-
// and re-exported so existing importers keep working.
|
|
93
34
|
import { execSocketTlsOptions, exitCodeFromStatus } from "./k8s-exec-protocol.js";
|
|
94
35
|
export { execSocketTlsOptions } from "./k8s-exec-protocol.js";
|
|
95
|
-
/** `Error`-or-event → message. Bun's ws shim emits browser-style ErrorEvents (NOT instanceof Error) whose
|
|
96
|
-
* String() is "[object ErrorEvent]" — that masked the real TLS failure for a whole drill. */
|
|
97
36
|
export function errorMessageOf(e) {
|
|
98
37
|
if (e instanceof Error)
|
|
99
38
|
return e.message;
|
|
@@ -105,15 +44,10 @@ export class RemoteK8sExecutionEnv {
|
|
|
105
44
|
capabilities;
|
|
106
45
|
cwd;
|
|
107
46
|
podName;
|
|
108
|
-
/** Cluster-internal pod address, captured when the pod reaches Running — the LSP `getHost` surface.
|
|
109
|
-
* Lifecycle mirrors podName exactly (set on connect, cleared on suspend/destroy/dead-reconnect). */
|
|
110
47
|
podIP;
|
|
111
48
|
destroyed = false;
|
|
112
|
-
/** Set after a successful resumeVM so workspaceHandle() carries the snapshot it was restored from. */
|
|
113
49
|
handleSnapshotId;
|
|
114
|
-
/** In-flight exec count — `suspendVM` refuses while a command runs (council #4 suspend×in-flight). */
|
|
115
50
|
inFlightExecs = 0;
|
|
116
|
-
/** Memoized in-flight connect (lazy-connect; prevents concurrent double pod-create — the E2B race lesson). */
|
|
117
51
|
connectPromise;
|
|
118
52
|
cfg;
|
|
119
53
|
deps;
|
|
@@ -138,31 +72,20 @@ export class RemoteK8sExecutionEnv {
|
|
|
138
72
|
...(config.s3Snapshot ? { s3Snapshot: config.s3Snapshot } : {}),
|
|
139
73
|
...(config.sessionId ? { sessionId: config.sessionId } : {}),
|
|
140
74
|
...(config.podSpecPatch ? { podSpecPatch: config.podSpecPatch } : {}),
|
|
141
|
-
...(config.podEnv ? { podEnv: config.podEnv } : {}),
|
|
75
|
+
...(config.podEnv ? { podEnv: config.podEnv } : {}),
|
|
142
76
|
};
|
|
143
77
|
this.cwd = this.cfg.mountPath;
|
|
144
78
|
this.deps = deps ?? {};
|
|
145
|
-
// Workspace snapshots make this env suspendable (files durable across suspend/resume); without an S3
|
|
146
|
-
// store it stays the honest checkpoint + cold-rebuild degrade.
|
|
147
79
|
this.capabilities = { isolation: true, suspendable: !!this.cfg.s3Snapshot };
|
|
148
|
-
// Clamp BOTH the ceiling AND the default to the pod lifetime — a short-lived pod must not advertise a default
|
|
149
|
-
// bigger than its max (the manager would clamp the actual spawn, making the advertised default a lie).
|
|
150
80
|
const bgMax = Math.max(1, Math.min(K8S_BG_MAX_TIMEOUT_SEC, Math.floor(this.cfg.timeoutMs / 1000)));
|
|
151
81
|
this.backgroundCapabilities = {
|
|
152
82
|
supported: true,
|
|
153
83
|
maxConcurrent: K8S_BG_MAX_CONCURRENT,
|
|
154
84
|
defaultBgTimeoutSec: Math.min(K8S_BG_DEFAULT_TIMEOUT_SEC, bgMax),
|
|
155
85
|
maxBgTimeoutSec: bgMax,
|
|
156
|
-
// core 1.207 design/116 detach (1.82): a running foreground exec is ADOPTED as a background shell
|
|
157
|
-
// (mid-flight ctrl+b) — the exec WebSocket keeps streaming into memory-mode drain buffers; kill works
|
|
158
|
-
// through the pgid the detach-capable exec prelude self-reported (buildDetachCapableExec).
|
|
159
86
|
supportsDetach: true,
|
|
160
87
|
};
|
|
161
88
|
}
|
|
162
|
-
/** 166-T2 — ExecutionEnv 寿命声明:pod `activeDeadlineSeconds = ceil(timeoutMs/1000)` 是平台真硬死线
|
|
163
|
-
* (k8s 到点杀 pod,无 keepalive 续期机制)⇒ 恒声明,core 在死线−60s 处 suspend(基建合格)或响亮
|
|
164
|
-
* 终局(env.lifetime_expired)。`lifetimeStartedAt` 有意不声明:core 只在 prepare 期读一次,lazy-connect
|
|
165
|
-
* 下 pod 尚未创建 ⇒ core 回落铸造时刻为锚——比 pod 真启动早,提早 suspend 是保守方向。 */
|
|
166
89
|
get lifetimeMs() {
|
|
167
90
|
return this.cfg.timeoutMs;
|
|
168
91
|
}
|
|
@@ -172,8 +95,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
172
95
|
workspaceHandle() {
|
|
173
96
|
return { sandboxId: this.podName ?? "(unconnected)", provider: PROVIDER, mountPath: this.cwd, ...(this.handleSnapshotId ? { snapshotId: this.handleSnapshotId } : {}) };
|
|
174
97
|
}
|
|
175
|
-
// ─────────────────────────────── auth / transport ───────────────────────────────
|
|
176
|
-
/** Resolved lazily so an in-cluster pod picks up its service account without explicit config. */
|
|
177
98
|
creds() {
|
|
178
99
|
const apiUrl = this.cfg.apiUrl ??
|
|
179
100
|
(process.env.KUBERNETES_SERVICE_HOST ? `https://${process.env.KUBERNETES_SERVICE_HOST}:${process.env.KUBERNETES_SERVICE_PORT ?? "443"}` : undefined);
|
|
@@ -199,7 +120,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
199
120
|
}
|
|
200
121
|
return { apiUrl: apiUrl.replace(/\/+$/, ""), token, ca };
|
|
201
122
|
}
|
|
202
|
-
/** Control-plane REST call (pod create/get/delete), bounded by rpcTimeoutMs. Injectable for tests. */
|
|
203
123
|
async request(method, apiPath, body, opts) {
|
|
204
124
|
if (this.deps.request)
|
|
205
125
|
return this.deps.request(method, apiPath, body, opts);
|
|
@@ -254,22 +174,15 @@ export class RemoteK8sExecutionEnv {
|
|
|
254
174
|
...(url.startsWith("wss:") ? execSocketTlsOptions(ca, this.cfg.insecureTls) : {}),
|
|
255
175
|
});
|
|
256
176
|
}
|
|
257
|
-
// ─────────────────────────────── lifecycle ───────────────────────────────
|
|
258
177
|
async connect(config) {
|
|
259
178
|
const policy = {
|
|
260
|
-
retryableCodes: ["connect_failed", "timeout", "transport_lost"],
|
|
179
|
+
retryableCodes: ["connect_failed", "timeout", "transport_lost"],
|
|
261
180
|
maxAttempts: this.cfg.retry.maxAttempts,
|
|
262
181
|
backoffMs: (failed) => this.cfg.retry.backoffMs * failed,
|
|
263
182
|
};
|
|
264
183
|
const once = this.deps.connectOnce ?? ((s) => this.connectOnce(s));
|
|
265
184
|
return withRetry(() => once(config?.abortSignal), policy, { signal: config?.abortSignal });
|
|
266
185
|
}
|
|
267
|
-
/**
|
|
268
|
-
* Lazy-connect on first use — 🔴 core NEVER calls `connect()` (verified: the Runner only calls `resumeVM` on
|
|
269
|
-
* the resume path), so an adapter that demands an explicit connect can never work through the production
|
|
270
|
-
* hand-tool path (lazy-connect belongs in the adapter). Memoized so concurrent first ops share ONE
|
|
271
|
-
* pod create; a failed connect clears the memo (next op retries); `destroyed` is terminal.
|
|
272
|
-
*/
|
|
273
186
|
async ensureConnected() {
|
|
274
187
|
if (this.podName)
|
|
275
188
|
return ok(undefined);
|
|
@@ -278,7 +191,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
278
191
|
this.connectPromise ??= this.connect();
|
|
279
192
|
const r = await this.connectPromise;
|
|
280
193
|
if (!r.ok) {
|
|
281
|
-
this.connectPromise = undefined;
|
|
194
|
+
this.connectPromise = undefined;
|
|
282
195
|
return { ok: false, error: new ExecutionError("shell_unavailable", `k8s sandbox connect failed: ${r.error.message}`, r.error) };
|
|
283
196
|
}
|
|
284
197
|
return ok(undefined);
|
|
@@ -299,24 +212,16 @@ export class RemoteK8sExecutionEnv {
|
|
|
299
212
|
spec: {
|
|
300
213
|
...(this.cfg.runtimeClass ? { runtimeClassName: this.cfg.runtimeClass } : {}),
|
|
301
214
|
restartPolicy: "Never",
|
|
302
|
-
// Defense-in-depth (council DESIGN#1): the agent runs INSIDE this pod; never mount a k8s SA token into
|
|
303
|
-
// it. The adapter holds the only credential it needs (control-plane side) — the workload gets none, so
|
|
304
|
-
// an accidental cluster-wide default-SA binding can't hand the agent the API.
|
|
305
215
|
automountServiceAccountToken: false,
|
|
306
|
-
// Leak guard (the E2B-lifetime analogue): the pod self-terminates even if destroy() never runs.
|
|
307
216
|
activeDeadlineSeconds: Math.ceil(this.cfg.timeoutMs / 1000),
|
|
308
217
|
terminationGracePeriodSeconds: 1,
|
|
309
218
|
containers: [
|
|
310
219
|
{
|
|
311
220
|
name: "sandbox",
|
|
312
221
|
image: this.cfg.image,
|
|
313
|
-
// RFC B5: when a pkg-source profile is injected, run the image's materializer ONCE at pod start
|
|
314
|
-
// (root, before any exec — core's exec lanes are non-login `sh -c`, so the profile.d hook alone
|
|
315
|
-
// wouldn't fire there). Script-absent images no-op; podEnv unset ⇒ command byte-identical to today.
|
|
316
222
|
command: this.cfg.podEnv?.SEMA_PKG_SOURCE
|
|
317
223
|
? ["sh", "-c", "[ -f /opt/sema/pkg-source.sh ] && sh /opt/sema/pkg-source.sh || true; sleep infinity"]
|
|
318
224
|
: ["sh", "-c", "sleep infinity"],
|
|
319
|
-
// RFC B5: operator-trusted region env (pkg-source selection). Absent = no env key at all (byte-identical).
|
|
320
225
|
...(this.cfg.podEnv && Object.keys(this.cfg.podEnv).length > 0
|
|
321
226
|
? { env: Object.entries(this.cfg.podEnv).map(([name, value]) => ({ name, value: String(value) })) }
|
|
322
227
|
: {}),
|
|
@@ -325,9 +230,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
325
230
|
],
|
|
326
231
|
},
|
|
327
232
|
};
|
|
328
|
-
// 🔴 ADDITIVE deployment patch: add RO oracle mounts / hardened securityContext for the L8
|
|
329
|
-
// immutable-mount probe + the graderEnvFactory. Applied AFTER the hard invariants above so it can never weaken
|
|
330
|
-
// them (applyPodSpecPatch only touches volumes/mounts/securityContext/labels). No-op when unset (byte-identical).
|
|
331
233
|
if (this.cfg.podSpecPatch)
|
|
332
234
|
applyPodSpecPatch(pod, this.cfg.podSpecPatch);
|
|
333
235
|
let created;
|
|
@@ -346,7 +248,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
346
248
|
const name = created.json?.metadata?.name;
|
|
347
249
|
if (!name)
|
|
348
250
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "pod create returned no name") };
|
|
349
|
-
// Poll until Running (schedule + image pull + Kata VM boot), bounded by readyTimeoutMs.
|
|
350
251
|
const deadline = Date.now() + this.cfg.readyTimeoutMs;
|
|
351
252
|
let podIP;
|
|
352
253
|
for (;;) {
|
|
@@ -383,16 +284,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
383
284
|
}
|
|
384
285
|
await new Promise((r) => setTimeout(r, 1000));
|
|
385
286
|
}
|
|
386
|
-
// Destroy-race guard (the E2B lazy-connect BLOCKER lesson): if destroy() ran while we were creating the
|
|
387
|
-
// pod, kill the fresh pod and bail — never resurrect a destroyed env (destroyed is terminal).
|
|
388
287
|
if (this.destroyed) {
|
|
389
288
|
await this.deletePod(name).catch(() => { });
|
|
390
289
|
return { ok: false, error: new RemoteExecutionError("connect_failed", "env destroyed during connect") };
|
|
391
290
|
}
|
|
392
291
|
this.podName = name;
|
|
393
292
|
this.podIP = podIP;
|
|
394
|
-
// Workspace root (the image may not ship it) — via runExec DIRECTLY: this.exec() would re-enter
|
|
395
|
-
// ensureConnected and deadlock on the still-pending connect memo. Failure = unusable sandbox.
|
|
396
293
|
const mk = await this.runExec(`mkdir -p ${shellQuote(this.cfg.mountPath)}`);
|
|
397
294
|
if (!mk.ok || mk.value.exitCode !== 0) {
|
|
398
295
|
await this.deletePod(name).catch(() => { });
|
|
@@ -402,14 +299,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
402
299
|
}
|
|
403
300
|
return ok(this.workspaceHandle());
|
|
404
301
|
}
|
|
405
|
-
/**
|
|
406
|
-
* Build a validated, presigned URL for a snapshot object — `<prefix>/<sessionId>/<snapshotId>.tar.gz`.
|
|
407
|
-
* 🔴 sessionId comes from the client (`body.sessionId`, security.ts) and is NOT charset-validated upstream, so
|
|
408
|
-
* each path segment is allowlisted here (`^[A-Za-z0-9][A-Za-z0-9._-]*$`) before it enters the object key: a
|
|
409
|
-
* segment of `..` / `../x` / one containing `/` would otherwise let one tenant's snapshot key normalize into
|
|
410
|
-
* another tenant's prefix (cross-tenant read on resume / clobber on suspend). Returns a typed error instead of
|
|
411
|
-
* signing a traversing key. Folds the old snapshotKey()+s3Presign() pair so the two call sites can't diverge.
|
|
412
|
-
*/
|
|
413
302
|
presignSnapshot(method, snapshotId) {
|
|
414
303
|
const s3 = this.cfg.s3Snapshot;
|
|
415
304
|
const prefix = s3.keyPrefix ?? "kata";
|
|
@@ -429,48 +318,29 @@ export class RemoteK8sExecutionEnv {
|
|
|
429
318
|
accessKey: s3.accessKey,
|
|
430
319
|
secretKey: s3.secretKey,
|
|
431
320
|
...(s3.region ? { region: s3.region } : {}),
|
|
432
|
-
// Clamp the presigned-URL TTL to a 1h ceiling — a short-lived single-object credential must not become a
|
|
433
|
-
// long-lived bearer token via an over-large WORKSPACE_SNAPSHOT_TTL_SEC.
|
|
434
321
|
expiresSec: Math.min(s3.presignTtlSec ?? 600, 3600),
|
|
435
322
|
}),
|
|
436
323
|
};
|
|
437
324
|
}
|
|
438
|
-
/** Redact a presigned URL (and any lingering signature) from text before it enters an error/log — the URL is a
|
|
439
|
-
* short-lived bearer credential and must never reach a durable store (secret boundary). */
|
|
440
325
|
redactUrl(text, url) {
|
|
441
326
|
return text.split(url).join("<presigned-url>").replace(/X-Amz-Signature=[0-9a-f]+/gi, "X-Amz-Signature=<redacted>");
|
|
442
327
|
}
|
|
443
|
-
/**
|
|
444
|
-
* Workspace-durable suspend: tar the workspace, upload to S3 via a short-lived adapter-presigned
|
|
445
|
-
* URL the pod curls (credentials never enter the sandbox), then delete the pod. The returned SnapshotId is
|
|
446
|
-
* opaque to core; we use a fresh id and persist the bytes under {@link snapshotKey}. NOT supported without an
|
|
447
|
-
* S3 store (returns the honest "unsupported" so durable suspend degrades to checkpoint + cold rebuild).
|
|
448
|
-
*
|
|
449
|
-
* 🔴 council #4 (suspend × in-flight exec): refuse while a command runs — tarring a mutating tree would
|
|
450
|
-
* snapshot a torn workspace. Core also won't suspend mid-tool, but the adapter enforces it independently.
|
|
451
|
-
*/
|
|
452
328
|
async suspendVM(_options) {
|
|
453
329
|
if (!this.cfg.s3Snapshot)
|
|
454
330
|
return unsupported("suspendVM");
|
|
455
331
|
if (this.inFlightExecs > 0)
|
|
456
332
|
return { ok: false, error: new RemoteExecutionError("command_in_flight", "cannot suspend while a command is in flight (would snapshot a torn workspace)") };
|
|
457
333
|
const snapshotId = `kata-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
458
|
-
// A never-connected env (a plan-only task that suspended without touching the hand) has no workspace to
|
|
459
|
-
// capture — return a snapshotId with no object; resume tolerates the 404 as an empty workspace.
|
|
460
334
|
if (!this.podName)
|
|
461
335
|
return ok(snapshotId);
|
|
462
336
|
const presigned = this.presignSnapshot("PUT", snapshotId);
|
|
463
337
|
if (!presigned.ok)
|
|
464
338
|
return presigned;
|
|
465
339
|
const url = presigned.value;
|
|
466
|
-
// Materialize the archive to a pod-local temp file, THEN upload — a streamed `tar | curl -T -` sends
|
|
467
|
-
// Transfer-Encoding: chunked with no Content-Length, which MinIO rejects with HTTP 411 on a presigned PUT
|
|
468
|
-
// (live-verified). `curl -T <file>` sets Content-Length from the file size. Bytes still go pod→MinIO (never
|
|
469
|
-
// the worker); the temp file lives in the disposable pod and is removed regardless of curl's outcome.
|
|
470
340
|
const tmp = "/tmp/ws-snap.tgz";
|
|
471
341
|
const cmd = `tar czf ${tmp} -C ${shellQuote(this.cfg.mountPath)} . && curl -fsS -X PUT -T ${tmp} ${shellQuote(url)}; ` +
|
|
472
342
|
`rc=$?; rm -f ${tmp}; exit $rc`;
|
|
473
|
-
const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
|
|
343
|
+
const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
|
|
474
344
|
if (!r.ok)
|
|
475
345
|
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace snapshot upload failed: ${this.redactUrl(r.error.message, url)}`, r.error) };
|
|
476
346
|
if (r.value.exitCode !== 0)
|
|
@@ -478,24 +348,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
478
348
|
const name = this.podName;
|
|
479
349
|
this.podName = undefined;
|
|
480
350
|
this.podIP = undefined;
|
|
481
|
-
await this.deletePod(name).catch(() => { });
|
|
351
|
+
await this.deletePod(name).catch(() => { });
|
|
482
352
|
return ok(snapshotId);
|
|
483
353
|
}
|
|
484
|
-
/**
|
|
485
|
-
* Resume a workspace snapshot onto a FRESH pod (resume = executionEnvFactory rebuild + resumeVM):
|
|
486
|
-
* provision a pod, then download + untar the snapshot into the workspace. A missing object (empty/plan-only
|
|
487
|
-
* snapshot) resumes as a clean workspace.
|
|
488
|
-
*
|
|
489
|
-
* [1467]①(priorHandle 根保真)——k8s 走**诚实臂**而非绑定臂:pod 的 workspace volume 挂在**当前**
|
|
490
|
-
* 配置根(cfg.mountPath),往 priorHandle.mountPath 绑=往容器 rootfs untar 大工作区,ephemeral-storage
|
|
491
|
-
* 触顶即 pod 驱逐(恢复中途死,比「根变了 fail-closed reopen」更糟)。所以 untar 落当前根、handle 回报
|
|
492
|
-
* 真生效根(core 契约明许:「做不到就诚实返回实际 mountPath,勿回显未兑现值」);跨 K8S_MOUNT_PATH
|
|
493
|
-
* 变更窗的分歧由 core 侧 fail-closed reopen 兜底,审批不烧错根。
|
|
494
|
-
*/
|
|
495
354
|
async resumeVM(snapshotId, options) {
|
|
496
355
|
if (!this.cfg.s3Snapshot)
|
|
497
356
|
return unsupported("resumeVM");
|
|
498
|
-
// Validate + presign BEFORE provisioning a pod — a traversing/garbage snapshotId should fail fast, not leak a pod.
|
|
499
357
|
const presigned = this.presignSnapshot("GET", snapshotId);
|
|
500
358
|
if (!presigned.ok)
|
|
501
359
|
return presigned;
|
|
@@ -503,14 +361,11 @@ export class RemoteK8sExecutionEnv {
|
|
|
503
361
|
if (!c.ok)
|
|
504
362
|
return c;
|
|
505
363
|
const url = presigned.value;
|
|
506
|
-
// Capture the HTTP status (curl -s -w) and branch on it: 404 → empty/plan-only snapshot, leave the fresh
|
|
507
|
-
// empty workspace; non-200 → real error (exit 22); 200 → untar. (No `-f`: we need the status code, not a
|
|
508
|
-
// curl error exit, to distinguish the tolerated 404 from a genuine failure.)
|
|
509
364
|
const cmd = `code=$(curl -s -o /tmp/ws.tgz -w '%{http_code}' ${shellQuote(url)}); ` +
|
|
510
365
|
`if [ "$code" = "404" ]; then echo EMPTY_SNAPSHOT; exit 0; fi; ` +
|
|
511
366
|
`if [ "$code" != "200" ]; then echo "snapshot GET HTTP $code" >&2; exit 22; fi; ` +
|
|
512
367
|
`tar xzf /tmp/ws.tgz -C ${shellQuote(this.cfg.mountPath)} && rm -f /tmp/ws.tgz`;
|
|
513
|
-
const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
|
|
368
|
+
const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
|
|
514
369
|
if (!r.ok)
|
|
515
370
|
return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, r.error) };
|
|
516
371
|
if (r.value.exitCode !== 0)
|
|
@@ -518,19 +373,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
518
373
|
this.handleSnapshotId = snapshotId;
|
|
519
374
|
return ok(this.workspaceHandle());
|
|
520
375
|
}
|
|
521
|
-
/**
|
|
522
|
-
* Honest post-resume consistency (design/48 §5 council #8): the workspace FILES are restored byte-for-byte
|
|
523
|
-
* from the snapshot, so read-before-edit hashing already lines up — there's nothing to re-fetch. In-VM
|
|
524
|
-
* process state (a language server) is gone by design; core/the LSP manager re-establishes it lazily. So
|
|
525
|
-
* this is a successful no-op rather than the previous `unsupported` (which forced a cold rebuild).
|
|
526
|
-
*/
|
|
527
376
|
async postResumeInit() {
|
|
528
377
|
if (!this.cfg.s3Snapshot)
|
|
529
378
|
return unsupported("postResumeInit");
|
|
530
379
|
return ok(undefined);
|
|
531
380
|
}
|
|
532
381
|
async reconnect(_sessionToken) {
|
|
533
|
-
// The pod either still runs (reuse) or is gone (fresh connect).
|
|
534
382
|
if (this.podName) {
|
|
535
383
|
const alive = await this.request("GET", `/api/v1/namespaces/${this.cfg.namespace}/pods/${this.podName}`).catch(() => undefined);
|
|
536
384
|
const phase = alive?.json?.status?.phase;
|
|
@@ -544,10 +392,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
544
392
|
async deletePod(name) {
|
|
545
393
|
await this.request("DELETE", `/api/v1/namespaces/${this.cfg.namespace}/pods/${name}?gracePeriodSeconds=0`, undefined, { timeoutMs: this.cfg.rpcTimeoutMs });
|
|
546
394
|
}
|
|
547
|
-
/** Delete the sandbox pod — the workspace is DISPOSABLE (vs SSH/ADB disconnect-only). Idempotent, never throws. */
|
|
548
395
|
async destroy() {
|
|
549
|
-
// design/103 §3.7 backstop: kill/clear any background jobs while the pod is still alive (core's run-loop finally
|
|
550
|
-
// usually disposed already → this iterates an empty registry). After this the pod delete reaps anything left.
|
|
551
396
|
await this.disposeBackgroundShells().catch(() => { });
|
|
552
397
|
this.destroyed = true;
|
|
553
398
|
const name = this.podName;
|
|
@@ -559,7 +404,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
559
404
|
async cleanup() {
|
|
560
405
|
return this.destroy();
|
|
561
406
|
}
|
|
562
|
-
// ─────────────────────────────── exec ───────────────────────────────
|
|
563
407
|
execUrl(command) {
|
|
564
408
|
const { apiUrl, token } = this.creds();
|
|
565
409
|
const base = apiUrl.replace(/^http/, "ws");
|
|
@@ -571,18 +415,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
571
415
|
q.append("command", part);
|
|
572
416
|
return { url: `${base}/api/v1/namespaces/${this.cfg.namespace}/pods/${this.podName}/exec?${q.toString()}`, headers: { authorization: `Bearer ${token}` } };
|
|
573
417
|
}
|
|
574
|
-
|
|
575
|
-
* Run a command over the exec WebSocket. Frames: byte0=channel (1 stdout / 2 stderr / 3 status JSON with the
|
|
576
|
-
* REAL exit code). Bounds: caller wall-clock (explicit `timeout`) + zero-progress liveness (`livenessMs`) —
|
|
577
|
-
* a socket killed by either does NOT kill the remote process (disposable-pod posture, see header).
|
|
578
|
-
*/
|
|
579
|
-
runExec(command,
|
|
580
|
-
// 🔴 binary caveat (D2): runExec is SHARED by the model-facing TEXT exec / execStream (boundOutput:true —
|
|
581
|
-
// a `yes`/`cat big` must not OOM the durable worker) AND by readBinaryFile, which reads the file as base64 over
|
|
582
|
-
// execData→runExec and decodes r.value.stdout. Bounding that base64 stream would SILENTLY CORRUPT binary/large
|
|
583
|
-
// reads. So boundOutput DEFAULTS OFF: every data path (execData, snapshot tar up/down, mkdir) keeps EVERY byte
|
|
584
|
-
// byte-exact; ONLY exec()/execStream() opt in. stderr is always bounded (never the file payload).
|
|
585
|
-
opts) {
|
|
418
|
+
runExec(command, opts) {
|
|
586
419
|
let sock;
|
|
587
420
|
let headers;
|
|
588
421
|
let url;
|
|
@@ -594,27 +427,19 @@ export class RemoteK8sExecutionEnv {
|
|
|
594
427
|
return Promise.resolve({ ok: false, error: new ExecutionError("shell_unavailable", e instanceof Error ? e.message : String(e), e instanceof Error ? e : undefined) });
|
|
595
428
|
}
|
|
596
429
|
return new Promise((resolvePromise) => {
|
|
597
|
-
// boundOutput (see signature): TEXT exec/execStream bound stdout to a rolling-TAIL (last 8MB + dropped marker,
|
|
598
|
-
// O(maxBytes)); data paths (boundOutput off) keep full bytes so readBinaryFile's base64 decodes losslessly.
|
|
599
|
-
// stderr is ALWAYS bounded — it never carries the base64 file payload.
|
|
600
430
|
const boundOutput = !!opts?.boundOutput;
|
|
601
|
-
const outBufs = [];
|
|
602
|
-
const outTail = boundOutput ? new RollingTailBuffer() : undefined;
|
|
431
|
+
const outBufs = [];
|
|
432
|
+
const outTail = boundOutput ? new RollingTailBuffer() : undefined;
|
|
603
433
|
const errBuf = new RollingTailBuffer();
|
|
604
|
-
// Per-channel UTF-8 decoders for the STREAMING callbacks only (council BUG#1): a multibyte codepoint can
|
|
605
|
-
// straddle two WS frames → a per-frame `payload.toString("utf8")` emits U+FFFD. StringDecoder buffers the
|
|
606
|
-
// incomplete tail across writes. (The buffered path decodes once via Buffer.concat below — already safe.)
|
|
607
434
|
const outDecoder = opts?.onStdout ? new StringDecoder("utf8") : undefined;
|
|
608
435
|
const errDecoder = opts?.onStderr ? new StringDecoder("utf8") : undefined;
|
|
609
436
|
let statusFrame;
|
|
610
437
|
let settled = false;
|
|
611
438
|
let lastProgress = Date.now();
|
|
612
|
-
// design/116 detach: once set, the socket's frames belong to the ADOPTED shell — message/error/close route
|
|
613
|
-
// here instead of the (already-settled) foreground buffers, and the socket is NOT closed on settle.
|
|
614
439
|
let spliceSink;
|
|
615
|
-
let sinkClosed = false;
|
|
440
|
+
let sinkClosed = false;
|
|
616
441
|
let removeDetachListener;
|
|
617
|
-
const detachTee = opts?.detach?.tee;
|
|
442
|
+
const detachTee = opts?.detach?.tee;
|
|
618
443
|
const finish = (r) => {
|
|
619
444
|
if (settled)
|
|
620
445
|
return;
|
|
@@ -627,19 +452,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
627
452
|
(sock.terminate ?? sock.close).call(sock);
|
|
628
453
|
}
|
|
629
454
|
catch {
|
|
630
|
-
/* already gone */
|
|
631
455
|
}
|
|
632
456
|
resolvePromise(r);
|
|
633
457
|
};
|
|
634
458
|
const wallMs = opts?.timeoutMs;
|
|
635
459
|
const wallTimer = wallMs ? setTimeout(() => finish({ ok: false, error: new ExecutionError("timeout", `command exceeded ${wallMs}ms`) }), wallMs) : undefined;
|
|
636
|
-
// zero-progress liveness (E2B class-fix semantics): on firing, re-check actual idleness and re-arm for
|
|
637
|
-
// EXACTLY the remaining window (the E2B waitWithLiveness/council-DESIGN-1 discipline — a full-window
|
|
638
|
-
// re-arm would let a hang sit for up to 2× livenessMs before detection).
|
|
639
460
|
let livenessTimer;
|
|
640
|
-
// Per-call liveness override (0 = disabled): bulk data transfers (tar→S3 upload, S3→untar restore) are
|
|
641
|
-
// legitimately SILENT for their whole duration, so the zero-output liveness would false-positive a "hang";
|
|
642
|
-
// they rely on the wall-clock `timeoutMs` (dataTimeoutMs) instead. Defaults to the configured command bound.
|
|
643
461
|
const livenessMs = opts?.livenessMs ?? this.cfg.livenessMs;
|
|
644
462
|
const armLiveness = () => {
|
|
645
463
|
if (livenessMs <= 0)
|
|
@@ -666,10 +484,8 @@ export class RemoteK8sExecutionEnv {
|
|
|
666
484
|
return;
|
|
667
485
|
lastProgress = Date.now();
|
|
668
486
|
const channel = buf[0];
|
|
669
|
-
const payload = buf.subarray(1);
|
|
487
|
+
const payload = buf.subarray(1);
|
|
670
488
|
if (spliceSink) {
|
|
671
|
-
// Adopted: output frames feed the background shell's drain buffers; the status frame still lands in
|
|
672
|
-
// `statusFrame` so the close handler can classify the adopted shell's terminal.
|
|
673
489
|
if (channel === 1 || channel === 2)
|
|
674
490
|
spliceSink.data(channel, payload);
|
|
675
491
|
else if (channel === 3)
|
|
@@ -678,11 +494,11 @@ export class RemoteK8sExecutionEnv {
|
|
|
678
494
|
}
|
|
679
495
|
if (channel === 1) {
|
|
680
496
|
if (boundOutput)
|
|
681
|
-
outTail.push(payload);
|
|
497
|
+
outTail.push(payload);
|
|
682
498
|
else
|
|
683
|
-
outBufs.push(payload);
|
|
499
|
+
outBufs.push(payload);
|
|
684
500
|
if (detachTee)
|
|
685
|
-
feedMemStream(detachTee.out, payload, K8S_BG_MEM_CAP);
|
|
501
|
+
feedMemStream(detachTee.out, payload, K8S_BG_MEM_CAP);
|
|
686
502
|
if (outDecoder)
|
|
687
503
|
opts.onStdout(outDecoder.write(payload));
|
|
688
504
|
}
|
|
@@ -700,8 +516,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
700
516
|
sock.on("error", (...args) => {
|
|
701
517
|
const e = args[0];
|
|
702
518
|
if (spliceSink) {
|
|
703
|
-
// Adopted: a socket error mid-background = the output feed is lost; outcome unknown → failed. The
|
|
704
|
-
// process itself may still run in the pod (disposable-pod posture — teardown reaps it).
|
|
705
519
|
if (!sinkClosed) {
|
|
706
520
|
sinkClosed = true;
|
|
707
521
|
spliceSink.closed({ failed: true });
|
|
@@ -715,16 +529,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
715
529
|
if (sinkClosed)
|
|
716
530
|
return;
|
|
717
531
|
sinkClosed = true;
|
|
718
|
-
// Terminal classification mirrors the launch-mode poll: a status frame with an exit code ⇒ exited
|
|
719
|
-
// (non-zero exit is a NORMAL result); no status ⇒ the transport died mid-background ⇒ failed.
|
|
720
532
|
if (statusFrame === undefined)
|
|
721
533
|
return spliceSink.closed({ failed: true });
|
|
722
534
|
const parsed = exitCodeFromStatus(statusFrame);
|
|
723
535
|
return spliceSink.closed("error" in parsed ? { failed: true } : { failed: false, exitCode: parsed.exitCode });
|
|
724
536
|
}
|
|
725
537
|
if (statusFrame === undefined) {
|
|
726
|
-
// closed without a status frame = the transport died mid-command, NOT a command result ([R78]#1).
|
|
727
|
-
// Typed "transport_lost" since core 1.89 (outcome UNKNOWN — retry only per the op's idempotency).
|
|
728
538
|
finish({ ok: false, error: new ExecutionError("transport_lost", "exec socket closed without status — k8s transport lost mid-command") });
|
|
729
539
|
return;
|
|
730
540
|
}
|
|
@@ -740,32 +550,21 @@ export class RemoteK8sExecutionEnv {
|
|
|
740
550
|
finish(ok({ stdout: markTruncated(out.text, out.droppedBytes), stderr, exitCode: parsed.exitCode }));
|
|
741
551
|
}
|
|
742
552
|
else {
|
|
743
|
-
// byte-exact: readBinaryFile decodes this stdout as base64; never truncate.
|
|
744
553
|
finish(ok({ stdout: Buffer.concat(outBufs).toString("utf8"), stderr, exitCode: parsed.exitCode }));
|
|
745
554
|
}
|
|
746
555
|
});
|
|
747
|
-
// design/116 detach — wired AFTER every handler above exists (the host adapter's create-then-abort TDZ
|
|
748
|
-
// lesson): core's ToolDetachHub can hand an ALREADY-aborted signal (detach requested before the exec
|
|
749
|
-
// started), in which case onDetach runs synchronously right here.
|
|
750
556
|
const detach = opts?.detach;
|
|
751
557
|
if (detach) {
|
|
752
558
|
const onDetach = () => {
|
|
753
559
|
if (settled || spliceSink)
|
|
754
560
|
return;
|
|
755
|
-
const out = outTail ? outTail.result() : { text: "", droppedBytes: 0 };
|
|
561
|
+
const out = outTail ? outTail.result() : { text: "", droppedBytes: 0 };
|
|
756
562
|
const err = errBuf.result();
|
|
757
563
|
const shellId = detach.adopt({ sock, reroute: (sink) => { spliceSink = sink; } });
|
|
758
564
|
if (shellId === undefined) {
|
|
759
|
-
// Refused (limit / builder failure) → the foreground continues untouched. 🔴 review #5: `reroute` is a
|
|
760
|
-
// builder SIDE EFFECT — if the builder ran it and then threw, adoptSync's catch returns undefined but
|
|
761
|
-
// spliceSink is already set, which would silently reroute every subsequent fg frame into an unregistered
|
|
762
|
-
// buffer AND make this exec never settle (close would notify a sink nobody owns). Un-splice defensively;
|
|
763
|
-
// same-tick, so no frame can have landed in between.
|
|
764
565
|
spliceSink = undefined;
|
|
765
566
|
return;
|
|
766
567
|
}
|
|
767
|
-
// Settle EARLY *without* closing the socket — the adopted shell owns it now. finish() is deliberately
|
|
768
|
-
// NOT used (it terminates the socket); this is its detach twin.
|
|
769
568
|
settled = true;
|
|
770
569
|
clearTimeout(wallTimer);
|
|
771
570
|
clearTimeout(livenessTimer);
|
|
@@ -786,29 +585,21 @@ export class RemoteK8sExecutionEnv {
|
|
|
786
585
|
const c = await this.ensureConnected();
|
|
787
586
|
if (!c.ok)
|
|
788
587
|
return c;
|
|
789
|
-
// design/116 detach (core 1.207, 1.82): the Bash tool threads a `detachSignal` for every exec once
|
|
790
|
-
// `supportsDetach` is advertised. `ExecOpts` doesn't type it yet (core-side seam) — read it structurally.
|
|
791
588
|
const detachSignal = options?.detachSignal;
|
|
792
|
-
this.inFlightExecs++;
|
|
589
|
+
this.inFlightExecs++;
|
|
793
590
|
try {
|
|
794
591
|
const composed = this.withCwdEnv(command, options?.cwd, options?.env);
|
|
795
592
|
if (!detachSignal) {
|
|
796
593
|
return await this.runExec(composed, {
|
|
797
594
|
signal: options?.abortSignal,
|
|
798
595
|
timeoutMs: options?.timeout != null ? options.timeout * 1000 : undefined,
|
|
799
|
-
boundOutput: true,
|
|
596
|
+
boundOutput: true,
|
|
800
597
|
onStdout: options?.onStdout,
|
|
801
598
|
onStderr: options?.onStderr,
|
|
802
599
|
});
|
|
803
600
|
}
|
|
804
|
-
// Detach-capable exec: the pgid prelude (buildDetachCapableExec) makes the running command adoptable —
|
|
805
|
-
// kill/dispose on the adopted shell reuse the SAME pod-side scripts as a spawned one. On detach, adoptSync
|
|
806
|
-
// splices the still-open exec socket into memory-mode drain buffers (SYNCHRONOUS — no await between the
|
|
807
|
-
// tee handover and the reroute, the host adapter's double-review contract).
|
|
808
601
|
const adoptId = randomUUID();
|
|
809
602
|
const dir = `${K8S_BG_DIR_ROOT}/exec-${adoptId}`;
|
|
810
|
-
// tee-from-start (Fable-5 review #3): runExec feeds every fg frame into this pair through its per-stream
|
|
811
|
-
// StringDecoder; adoption takes the pair over as-is, so ONE decoder spans the detach instant (no U+FFFD seam).
|
|
812
603
|
const tee = { out: seedMemStream(""), err: seedMemStream("") };
|
|
813
604
|
return await this.runExec(buildDetachCapableExec(dir, composed), {
|
|
814
605
|
signal: options?.abortSignal,
|
|
@@ -820,11 +611,8 @@ export class RemoteK8sExecutionEnv {
|
|
|
820
611
|
signal: detachSignal,
|
|
821
612
|
tee,
|
|
822
613
|
adopt: (io) => {
|
|
823
|
-
// 🔴 builder contract (review #5): everything inside is NON-THROWING (object construction + the reroute
|
|
824
|
-
// assignment). adoptSync's catch does NOT undo side effects — future logic that can throw must go before
|
|
825
|
-
// `io.reroute` (the runExec caller also un-splices defensively on an undefined return).
|
|
826
614
|
const adopted = this.bgManager.adoptSync((ctx) => {
|
|
827
|
-
const mem = { out: tee.out, err: tee.err };
|
|
615
|
+
const mem = { out: tee.out, err: tee.err };
|
|
828
616
|
io.reroute({
|
|
829
617
|
data: (channel, payload) => feedMemStream(channel === 1 ? mem.out : mem.err, payload, K8S_BG_MEM_CAP),
|
|
830
618
|
closed: (terminal) => ctx.onTerminal(terminal.failed, terminal.exitCode),
|
|
@@ -840,28 +628,13 @@ export class RemoteK8sExecutionEnv {
|
|
|
840
628
|
this.inFlightExecs--;
|
|
841
629
|
}
|
|
842
630
|
}
|
|
843
|
-
/**
|
|
844
|
-
* LSP surface (lsp/manager.ts `isLspCapable` duck-type, design/64 §13.1): cluster-internal address for a
|
|
845
|
-
* port on the sandbox pod. The E2B counterpart returns a public proxy host; a pod has none — the WORKER
|
|
846
|
-
* must be able to reach pod IPs (in-cluster deployment, or on-node), and the transport is plain `ws://`
|
|
847
|
-
* (cluster network, no TLS — the per-session bridge token still gates access like on E2B).
|
|
848
|
-
*/
|
|
849
631
|
async getHost(port) {
|
|
850
632
|
const c = await this.ensureConnected();
|
|
851
633
|
if (!c.ok || !this.podIP)
|
|
852
|
-
return undefined;
|
|
634
|
+
return undefined;
|
|
853
635
|
return `${this.podIP}:${port}`;
|
|
854
636
|
}
|
|
855
|
-
/**
|
|
856
|
-
* Start a long-lived helper (the LSP bridge) detached from this exec session. Unlike E2B — which reaps a
|
|
857
|
-
* foreground command's process group on completion, forcing a native background API — k8s exec just closes
|
|
858
|
-
* the session: a `nohup`'d child with detached stdio survives, reparented to the container's PID 1 (the
|
|
859
|
-
* gate#2 background-gradle runs proved this through the production path). Output goes to /tmp for postmortem.
|
|
860
|
-
*/
|
|
861
637
|
async startBackground(command) {
|
|
862
|
-
// `command` is a COMPOUND shell line (lspStartCommand: `cd … && ENV=… node bridge.cjs`) — nohup takes a
|
|
863
|
-
// single executable, so wrap the whole line in `sh -c`. Caught live: `nohup cd …` silently started nothing
|
|
864
|
-
// and the bridge connect timed out (gate#2 k8s-LSP attempt 1).
|
|
865
638
|
const wrapped = command.replace(/[\\"$`]/g, (c) => "\\" + c);
|
|
866
639
|
const r = await this.exec(`(nohup sh -c "${wrapped}" >>/tmp/lsp-bridge.log 2>&1 &) ; echo started`, { timeout: 30 });
|
|
867
640
|
if (!r.ok)
|
|
@@ -873,7 +646,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
873
646
|
const c = await this.ensureConnected();
|
|
874
647
|
if (!c.ok)
|
|
875
648
|
throw new RemoteExecutionError("connect_failed", c.error.message, c.error);
|
|
876
|
-
this.inFlightExecs++;
|
|
649
|
+
this.inFlightExecs++;
|
|
877
650
|
const queue = [];
|
|
878
651
|
let finished = false;
|
|
879
652
|
let failure;
|
|
@@ -886,21 +659,12 @@ export class RemoteK8sExecutionEnv {
|
|
|
886
659
|
};
|
|
887
660
|
let lastChunk = Date.now();
|
|
888
661
|
const idleMs = options?.readTimeoutMs ?? this.cfg.livenessMs;
|
|
889
|
-
// Total-output cap (D2): parity with the host/local-docker execStream — when the caller sets
|
|
890
|
-
// maxOutputBytes, count yielded bytes and abort+throw past the cap (the finally's ac.abort() then closes
|
|
891
|
-
// the exec socket). Previously k8s execStream IGNORED maxOutputBytes (it was the only adapter that did).
|
|
892
662
|
const cap = options?.maxOutputBytes;
|
|
893
663
|
let bytes = 0;
|
|
894
|
-
// Internal abort composed with the caller's: EVERY generator exit (normal end, idle-throw, consumer
|
|
895
|
-
// early-break via .return()) must settle runExec IMMEDIATELY — without this, the finally's `await done`
|
|
896
|
-
// would sit until runExec's own liveness fired (~minutes) with the exec socket held open (self-review A).
|
|
897
664
|
const ac = new AbortController();
|
|
898
665
|
const signal = options?.signal ? AbortSignal.any([options.signal, ac.signal]) : ac.signal;
|
|
899
666
|
const done = this.runExec(this.withCwdEnv(command, options?.cwd, options?.env), {
|
|
900
667
|
signal,
|
|
901
|
-
// The consumer reads from the onStdout/onStderr callbacks (full chunks); runExec's retained buffer is
|
|
902
|
-
// discarded here (only exitCode is used). boundOutput:true caps that otherwise-unbounded retained buffer
|
|
903
|
-
// with zero change to what the stream consumer sees (D2).
|
|
904
668
|
boundOutput: true,
|
|
905
669
|
onStdout: (d) => {
|
|
906
670
|
lastChunk = Date.now();
|
|
@@ -916,12 +680,9 @@ export class RemoteK8sExecutionEnv {
|
|
|
916
680
|
if (r.ok)
|
|
917
681
|
exitCode = r.value.exitCode;
|
|
918
682
|
else {
|
|
919
|
-
// Map the buffered-exec ExecutionErrorCode → a RemoteExecutionErrorCode. `spawn_error`/`shell_unavailable`
|
|
920
|
-
// mean the environment is fundamentally broken → "unknown" (non-retryable), NOT "connect_failed" (which
|
|
921
|
-
// the retry whitelist would re-attempt against a broken shell — council BUG#2).
|
|
922
683
|
const code = r.error.code === "aborted"
|
|
923
684
|
? "aborted"
|
|
924
|
-
: r.error.code === "transport_lost"
|
|
685
|
+
: r.error.code === "transport_lost"
|
|
925
686
|
? "transport_lost"
|
|
926
687
|
: r.error.code === "timeout"
|
|
927
688
|
? "timeout"
|
|
@@ -949,10 +710,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
949
710
|
if (finished)
|
|
950
711
|
break;
|
|
951
712
|
const waitMs = Math.max(1, idleMs - (Date.now() - lastChunk));
|
|
952
|
-
// A-002.11: the wait races the drain timer against `wake` (a chunk / completion). When `wake` wins —
|
|
953
|
-
// the common case on a chatty command — the timer stays armed for up to 30s unless it is cleared, so a
|
|
954
|
-
// long-running command leaks one per drain cycle. Same `finally` cleanup form the E2B twin already uses
|
|
955
|
-
// for its idle timer (remote-env-e2b.ts execStream). Clearing an already-fired timer is a no-op.
|
|
956
713
|
let waitTimer;
|
|
957
714
|
try {
|
|
958
715
|
await new Promise((r) => {
|
|
@@ -973,23 +730,11 @@ export class RemoteK8sExecutionEnv {
|
|
|
973
730
|
yield { type: "exit", exitCode };
|
|
974
731
|
}
|
|
975
732
|
finally {
|
|
976
|
-
ac.abort();
|
|
733
|
+
ac.abort();
|
|
977
734
|
await done.catch(() => { });
|
|
978
735
|
this.inFlightExecs--;
|
|
979
736
|
}
|
|
980
737
|
}
|
|
981
|
-
// ─────────────────────────── design/103 background shell (TOB, path ①) ───────────────────────────
|
|
982
|
-
//
|
|
983
|
-
// kata/k8s takes path ① (design/103 §5.2): the pod filesystem buffers output, re-read by byte cursor each poll
|
|
984
|
-
// — unlike E2B there is no provider job handle, and closing the exec socket does NOT kill the remote process
|
|
985
|
-
// (disposable-pod posture, see file header), so termination is detected by an exit-sentinel file and kill goes
|
|
986
|
-
// through the process GROUP (`setsid` + self-reported pgid). Background ops run as short ephemeral execs and
|
|
987
|
-
// deliberately do NOT touch `inFlightExecs` (they are control-plane management, not a foreground command to
|
|
988
|
-
// snapshot; the detached job is killed before `suspendVM` by the core dispose saga, design/103 §3.7).
|
|
989
|
-
/**
|
|
990
|
-
* A background job cannot outlive its pod (`activeDeadlineSeconds` ≈ `timeoutMs`), so the BG ceiling is clamped to
|
|
991
|
-
* it (assigned in the constructor — `this.cfg` is set there, so a field initializer would read it pre-init).
|
|
992
|
-
*/
|
|
993
738
|
backgroundCapabilities;
|
|
994
739
|
_bgManager;
|
|
995
740
|
get bgManager() {
|
|
@@ -1002,11 +747,9 @@ export class RemoteK8sExecutionEnv {
|
|
|
1002
747
|
async launch(command, options, ctx) {
|
|
1003
748
|
const conn = await self.ensureConnected();
|
|
1004
749
|
if (!conn.ok)
|
|
1005
|
-
throw new Error(`pod connect failed: ${conn.error.message}`);
|
|
750
|
+
throw new Error(`pod connect failed: ${conn.error.message}`);
|
|
1006
751
|
const jobId = randomUUID();
|
|
1007
752
|
const dir = `${K8S_BG_DIR_ROOT}/${jobId}`;
|
|
1008
|
-
// 🔴 secret-scrub red line (design/103 §3.1): cwd + env injected via the SAME `withCwdEnv` path as exec —
|
|
1009
|
-
// the host process.env is never injected; only the scrubbed task env core passes reaches the child.
|
|
1010
753
|
const inner = self.withCwdEnv(command, options?.cwd, options?.env);
|
|
1011
754
|
const cmdB64 = Buffer.from(inner, "utf8").toString("base64");
|
|
1012
755
|
const runnerB64 = Buffer.from(buildBgRunnerScript(dir), "utf8").toString("base64");
|
|
@@ -1016,12 +759,10 @@ export class RemoteK8sExecutionEnv {
|
|
|
1016
759
|
if (r.value.exitCode !== 0 || !r.value.stdout.includes("SEMABG_LAUNCHED")) {
|
|
1017
760
|
throw new Error(`background launch exited ${r.value.exitCode}: ${r.value.stderr.slice(0, 200)}`);
|
|
1018
761
|
}
|
|
1019
|
-
void ctx;
|
|
762
|
+
void ctx;
|
|
1020
763
|
return { jobId, dir, stdoutCursor: 0, stderrCursor: 0 };
|
|
1021
764
|
},
|
|
1022
765
|
async read(state) {
|
|
1023
|
-
// ADOPT (memory) mode: the still-open exec socket feeds the drain buffers; termination is PUSH-model
|
|
1024
|
-
// (socket close → ctx.onTerminal), so `read` never reports terminal here — mirror of the E2B/host adopt.
|
|
1025
766
|
if (state.adopt) {
|
|
1026
767
|
const o = drainMemStream(state.adopt.mem.out);
|
|
1027
768
|
const e = drainMemStream(state.adopt.mem.err);
|
|
@@ -1039,20 +780,15 @@ export class RemoteK8sExecutionEnv {
|
|
|
1039
780
|
if (!p)
|
|
1040
781
|
throw new Error("background poll: unparseable response");
|
|
1041
782
|
if (p.gone) {
|
|
1042
|
-
// dir vanished while still owned (dispose clears the registry FIRST, so this isn't us) → abnormal end.
|
|
1043
783
|
return { stdout: "", stderr: "", bytesFromStart: state.stdoutCursor + state.stderrCursor, bytesDroppedBeforeCursor: 0, terminal: { failed: true } };
|
|
1044
784
|
}
|
|
1045
785
|
const droppedO = Math.max(0, p.outTotal - state.stdoutCursor - p.stdoutBytes);
|
|
1046
786
|
const droppedE = Math.max(0, p.errTotal - state.stderrCursor - p.stderrBytes);
|
|
1047
787
|
state.stdoutCursor = p.outTotal;
|
|
1048
788
|
state.stderrCursor = p.errTotal;
|
|
1049
|
-
// Terminal: exit sentinel present → exited (exit code known); else process group gone without a sentinel →
|
|
1050
|
-
// failed (abnormal — a clean exit always writes the sentinel as the runner's last act before the group dies).
|
|
1051
789
|
let terminal = p.exitCode !== undefined ? { failed: false, exitCode: p.exitCode } : !p.alive ? { failed: true } : undefined;
|
|
1052
|
-
// 🔴 disk guard: direct redirect has no in-pod cap, so reap a runaway whose output exceeds K8S_BG_FILE_CAP
|
|
1053
|
-
// (the kata VM isolates the blast to this disposable pod; the BG-timeout + activeDeadlineSeconds also bound it).
|
|
1054
790
|
if (!terminal && (p.outTotal > K8S_BG_FILE_CAP || p.errTotal > K8S_BG_FILE_CAP)) {
|
|
1055
|
-
void bgExec(buildBgKillScript(state.dir), true).catch(() => { });
|
|
791
|
+
void bgExec(buildBgKillScript(state.dir), true).catch(() => { });
|
|
1056
792
|
terminal = { failed: true };
|
|
1057
793
|
}
|
|
1058
794
|
return {
|
|
@@ -1064,23 +800,21 @@ export class RemoteK8sExecutionEnv {
|
|
|
1064
800
|
};
|
|
1065
801
|
},
|
|
1066
802
|
async kill(state) {
|
|
1067
|
-
await bgExec(buildBgKillScript(state.dir), true).catch(() => { });
|
|
803
|
+
await bgExec(buildBgKillScript(state.dir), true).catch(() => { });
|
|
1068
804
|
},
|
|
1069
805
|
async disposeOne(state) {
|
|
1070
|
-
// Adopted shells hold the (kept-open) exec socket — release it on EVERY dispose path, even when the pod
|
|
1071
|
-
// is already being torn down (the socket is a worker-side resource, not a pod-side one).
|
|
1072
806
|
const closeAdopted = () => {
|
|
1073
807
|
if (!state.adopt)
|
|
1074
808
|
return;
|
|
1075
809
|
try {
|
|
1076
810
|
(state.adopt.sock.terminate ?? state.adopt.sock.close).call(state.adopt.sock);
|
|
1077
811
|
}
|
|
1078
|
-
catch {
|
|
812
|
+
catch { }
|
|
1079
813
|
};
|
|
1080
814
|
if (self.destroyed) {
|
|
1081
815
|
closeAdopted();
|
|
1082
816
|
return;
|
|
1083
|
-
}
|
|
817
|
+
}
|
|
1084
818
|
await bgExec(buildBgDisposeScript(state.dir), true).catch(() => { });
|
|
1085
819
|
closeAdopted();
|
|
1086
820
|
},
|
|
@@ -1096,11 +830,8 @@ export class RemoteK8sExecutionEnv {
|
|
|
1096
830
|
return this.bgManager.kill(shellId);
|
|
1097
831
|
}
|
|
1098
832
|
disposeBackgroundShells(opts) {
|
|
1099
|
-
// 签名统一带 opts(core 尾调 {except: keepAlive})。隔离 env 的留活是名义的——沙箱拆机进程必然随之
|
|
1100
|
-
// 消亡(destroy 整体带走),core 的 envDying settle 会先给准确 killed 回执;透传保证签名不静默丢参。
|
|
1101
833
|
return this._bgManager ? this._bgManager.dispose(opts) : Promise.resolve();
|
|
1102
834
|
}
|
|
1103
|
-
// ─────────────────────────────── FileSystem (over exec; real exit codes) ───────────────────────────────
|
|
1104
835
|
async absolutePath(p) {
|
|
1105
836
|
return ok(this.resolve(p));
|
|
1106
837
|
}
|
|
@@ -1109,11 +840,9 @@ export class RemoteK8sExecutionEnv {
|
|
|
1109
840
|
}
|
|
1110
841
|
async readBinaryFile(p, abortSignal) {
|
|
1111
842
|
const abs = this.resolve(p);
|
|
1112
|
-
// base64 keeps the WS text-safe; the REAL exit code separates "empty file" from "read failed".
|
|
1113
843
|
const r = await this.execData(`base64 < ${shellQuote(abs)}`, abortSignal);
|
|
1114
844
|
if (!r.ok)
|
|
1115
845
|
return { ok: false, error: fileErrorFromExec(r.error, abs) };
|
|
1116
|
-
// Non-zero base64 read → central classifier, not_found fallback (truncated-stderr tolerant — council BUG#3).
|
|
1117
846
|
if (r.value.exitCode !== 0)
|
|
1118
847
|
return { ok: false, error: classifyFsStderr(r.value.stderr, abs, "not_found") };
|
|
1119
848
|
return ok(new Uint8Array(Buffer.from(r.value.stdout.replace(/\s+/g, ""), "base64")));
|
|
@@ -1133,11 +862,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
1133
862
|
lines = lines.slice(0, options.maxLines);
|
|
1134
863
|
return ok(lines);
|
|
1135
864
|
}
|
|
1136
|
-
/** 父目录自建(与 host/local-docker/adb/ssh 同口径)。抽成共用是因为 `appendFile` 曾**绕过**它:
|
|
1137
|
-
* 🔴 2026-07-25 实测的跨腿分叉 —— `appendFile` 直接调 `writeChunked`,整条路上没有 `mkdir -p`,于是
|
|
1138
|
-
* 「直接 append 建一个新日志路径」在 k8s 腿上返 `not_found`,而其余 5 腿都成功(host/local-docker/adb 实测,
|
|
1139
|
-
* ssh/e2b 由方法体判据)。core 的后台输出镜像正是"每拍 append 一次"的形,这条分叉会让它在 k8s 腿上从第一拍
|
|
1140
|
-
* 就失败,而 `mirrorFailed` 只会表现成一句「output file is INCOMPLETE」。 */
|
|
1141
865
|
async ensureParentDir(abs, abortSignal) {
|
|
1142
866
|
const dir = path.posix.dirname(abs);
|
|
1143
867
|
if (!dir || dir === "." || dir === "/")
|
|
@@ -1154,21 +878,15 @@ export class RemoteK8sExecutionEnv {
|
|
|
1154
878
|
const bad = await this.ensureParentDir(abs, abortSignal);
|
|
1155
879
|
if (bad)
|
|
1156
880
|
return bad;
|
|
1157
|
-
return this.writeChunked(abs, content, true, abortSignal);
|
|
881
|
+
return this.writeChunked(abs, content, true, abortSignal);
|
|
1158
882
|
}
|
|
1159
883
|
async appendFile(p, content, abortSignal) {
|
|
1160
884
|
const abs = this.resolve(p);
|
|
1161
|
-
const bad = await this.ensureParentDir(abs, abortSignal);
|
|
885
|
+
const bad = await this.ensureParentDir(abs, abortSignal);
|
|
1162
886
|
if (bad)
|
|
1163
887
|
return bad;
|
|
1164
|
-
return this.writeChunked(abs, content, false, abortSignal);
|
|
888
|
+
return this.writeChunked(abs, content, false, abortSignal);
|
|
1165
889
|
}
|
|
1166
|
-
/**
|
|
1167
|
-
* Write `content` to `abs` via argv-embedded base64, chunked at WRITE_CHUNK_BYTES (no stdin on the exec
|
|
1168
|
-
* channel we use; v4 has no per-channel close). `truncateFirst` → the FIRST chunk uses `>` (overwrite),
|
|
1169
|
-
* the rest `>>` (append); `false` → all chunks `>>`. Empty content still runs exactly one command so an
|
|
1170
|
-
* empty file is created (write) / left intact (append). Shared by writeFile/appendFile (council DESIGN#2).
|
|
1171
|
-
*/
|
|
1172
890
|
async writeChunked(abs, content, truncateFirst, abortSignal) {
|
|
1173
891
|
const bytes = typeof content === "string" ? Buffer.from(content, "utf8") : Buffer.from(content);
|
|
1174
892
|
for (let off = 0, first = true; off < bytes.length || first; off += WRITE_CHUNK_BYTES, first = false) {
|
|
@@ -1182,9 +900,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
1182
900
|
}
|
|
1183
901
|
return ok(undefined);
|
|
1184
902
|
}
|
|
1185
|
-
// ── design/158 B6:POSIX shell-out 文件面全部委托共享基座(posix-shell-fs.ts)─────────────────────
|
|
1186
|
-
// 六裁决(`--`/classifyFsStderr/ls -1A/mv 后缀形/temp 位置参数化/`[ -e ]||[ -L ]` exists)与
|
|
1187
|
-
// readLink・lstat 契约见基座头注;本腿此前的 fileErrorFrom 分类形与 exists 形即基座正形的出处。
|
|
1188
903
|
posixFsInst;
|
|
1189
904
|
get posixFs() {
|
|
1190
905
|
return (this.posixFsInst ??= createPosixShellFs({
|
|
@@ -1220,8 +935,6 @@ export class RemoteK8sExecutionEnv {
|
|
|
1220
935
|
async createTempFile(options) {
|
|
1221
936
|
return this.posixFs.createTempFile(options);
|
|
1222
937
|
}
|
|
1223
|
-
// ─────────────────────────────── helpers ───────────────────────────────
|
|
1224
|
-
/** exec with the data-transfer bound (large reads/writes legitimately run longer than a control op). */
|
|
1225
938
|
async execData(command, abortSignal) {
|
|
1226
939
|
const c = await this.ensureConnected();
|
|
1227
940
|
if (!c.ok)
|
|
@@ -1240,14 +953,7 @@ export class RemoteK8sExecutionEnv {
|
|
|
1240
953
|
return parts.join(" ");
|
|
1241
954
|
}
|
|
1242
955
|
}
|
|
1243
|
-
/**
|
|
1244
|
-
* `ExecutionEnvFactory` for the k8s pod-sandbox backend — one fresh (unconnected) env per task; the pod is
|
|
1245
|
-
* created lazily on connect and deleted by `destroy()` (Runner-owned lifetime; `activeDeadlineSeconds` is the
|
|
1246
|
-
* leak guard if destroy never runs).
|
|
1247
|
-
*/
|
|
1248
956
|
export function k8sExecutionEnvFactory(config) {
|
|
1249
|
-
// Thread the task's sessionId so workspace-snapshot keys are namespaced per session
|
|
1250
|
-
// (workspaces/<prefix>/<sessionId>/<snapshotId>.tar.gz).
|
|
1251
957
|
return (ctx) => new RemoteK8sExecutionEnv({ ...config, sessionId: ctx.sessionId });
|
|
1252
958
|
}
|
|
1253
959
|
//# sourceMappingURL=remote-env-k8s.js.map
|