@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.
Files changed (345) hide show
  1. package/USAGE.md +18 -0
  2. package/dist/adoption/plan.js +31 -192
  3. package/dist/adoption/quiesce.js +3 -86
  4. package/dist/adoption/runner.js +3 -140
  5. package/dist/adoption/sql.js +0 -74
  6. package/dist/adoption/wire.js +0 -80
  7. package/dist/approval-ask-machine.js +0 -75
  8. package/dist/approval-card.js +0 -323
  9. package/dist/approval-deny-reasons.js +0 -42
  10. package/dist/approval-hmac.js +0 -35
  11. package/dist/approval-reconciler.js +8 -166
  12. package/dist/approval.js +4 -67
  13. package/dist/audit.js +1 -44
  14. package/dist/auth-bridge.js +4 -47
  15. package/dist/auth-keys.js +0 -23
  16. package/dist/bake-runner/main.js +4 -65
  17. package/dist/bake-runner/protocol.js +3 -58
  18. package/dist/bake-runner/runner.js +5 -91
  19. package/dist/bench/l8/artifact.js +2 -61
  20. package/dist/bench/l8/escape.js +0 -25
  21. package/dist/bench/l8/index.js +0 -14
  22. package/dist/bench/l8/probes.js +0 -60
  23. package/dist/bench/l8/run-probes.js +2 -45
  24. package/dist/bench/s1/arms.js +14 -189
  25. package/dist/bench/s1/live-deps.js +11 -253
  26. package/dist/bench/s1/oracle.js +1 -36
  27. package/dist/bench/s1/repair-oracle-adapter.js +0 -30
  28. package/dist/bench/s1/reviewer.js +0 -34
  29. package/dist/bench/s1/row.js +0 -62
  30. package/dist/bench/s1/run-firm.js +4 -70
  31. package/dist/bench/s1/runner-ctx.js +0 -40
  32. package/dist/bench/s1/tasks.js +0 -115
  33. package/dist/boot/adoption.js +0 -20
  34. package/dist/boot/budget-tracing.js +3 -55
  35. package/dist/boot/config-center.js +43 -551
  36. package/dist/boot/coordinators.js +1 -69
  37. package/dist/boot/crash-last.js +0 -18
  38. package/dist/boot/deferred-sandbox-path-env.js +3 -119
  39. package/dist/boot/execution-env.js +5 -171
  40. package/dist/boot/governance-seams.js +0 -92
  41. package/dist/boot/leader.js +0 -69
  42. package/dist/boot/limit-sync.js +0 -2
  43. package/dist/boot/memory-boundary.js +3 -91
  44. package/dist/boot/org-memory.js +1 -25
  45. package/dist/boot/parked-revive-gate.js +0 -150
  46. package/dist/boot/permission-rules-audit.js +1 -56
  47. package/dist/boot/reapers.js +15 -273
  48. package/dist/boot/resolve-spec.js +9 -768
  49. package/dist/boot/retention-lane.js +4 -87
  50. package/dist/boot/runner-deps.js +3 -297
  51. package/dist/boot/runtime-caps.js +0 -40
  52. package/dist/boot/session-faces.js +2 -133
  53. package/dist/boot/shutdown.js +9 -83
  54. package/dist/boot/side-query-lane.js +2 -137
  55. package/dist/boot/stores.js +15 -215
  56. package/dist/boot/task-list-lane.js +0 -18
  57. package/dist/boot/webfetch-summarize-lane.js +0 -47
  58. package/dist/boot/workflow-orchestration.js +3 -99
  59. package/dist/boot-reclaim.js +0 -20
  60. package/dist/bounded-session-map.js +0 -19
  61. package/dist/brain.js +2 -139
  62. package/dist/budget.js +9 -244
  63. package/dist/capabilities/builtin-tools.js +0 -3
  64. package/dist/capabilities/center-plugins.js +6 -51
  65. package/dist/capabilities/center-prompts.js +6 -71
  66. package/dist/capabilities/code-review-council.js +7 -40
  67. package/dist/capabilities/collab-workflows.js +1 -44
  68. package/dist/capabilities/hands-lane.js +0 -65
  69. package/dist/capabilities/memory-notice.js +0 -70
  70. package/dist/capabilities/prompt.js +0 -14
  71. package/dist/capabilities/prompts/code-review.js +0 -14
  72. package/dist/capabilities/prompts/identity.js +0 -6
  73. package/dist/capabilities/prompts/team.js +0 -4
  74. package/dist/capabilities/repo-tools.js +0 -34
  75. package/dist/capabilities/sandbox-file-send.js +3 -92
  76. package/dist/capabilities/scenarios.d.ts +0 -1
  77. package/dist/capabilities/scenarios.js +18 -279
  78. package/dist/capabilities/select-environment-tool.js +0 -32
  79. package/dist/capabilities/send-user-file-tool.js +2 -66
  80. package/dist/capabilities/skills.d.ts +1 -2
  81. package/dist/capabilities/skills.js +3 -21
  82. package/dist/capabilities/team.d.ts +3 -11
  83. package/dist/capabilities/team.js +3 -51
  84. package/dist/capabilities/tool-defer.js +0 -3
  85. package/dist/config-center/apply-effective.js +15 -330
  86. package/dist/config-center/apply-ledger.js +5 -38
  87. package/dist/config-center/facade.js +0 -41
  88. package/dist/config-center/hot-keys-registry.js +0 -20
  89. package/dist/config-center/http-client.js +2 -125
  90. package/dist/config-center/mcp-revocation.js +2 -34
  91. package/dist/config-center/read-face.js +0 -59
  92. package/dist/config-center/restart-signal.js +1 -85
  93. package/dist/config-center/skills-mcp.d.ts +1 -1
  94. package/dist/config-center/skills-mcp.js +9 -76
  95. package/dist/config-center/stage-limits.js +8 -35
  96. package/dist/config-invariants.js +0 -16
  97. package/dist/config-lkg.js +0 -42
  98. package/dist/config-provider.js +3 -186
  99. package/dist/config-types.js +0 -5
  100. package/dist/config.js +107 -1145
  101. package/dist/degenerate-instrument.js +3 -67
  102. package/dist/deployment-governance.js +0 -124
  103. package/dist/digest-form.js +0 -12
  104. package/dist/elicitation.js +3 -86
  105. package/dist/env-facts.js +7 -75
  106. package/dist/fleet/fleet-bus.js +34 -507
  107. package/dist/fleet/fleet-reconciler.js +9 -149
  108. package/dist/fleet/fleet-terminal-window.js +10 -178
  109. package/dist/fleet/subagent-tail-bus.js +3 -72
  110. package/dist/fleet-client.js +10 -70
  111. package/dist/fleet-lease.js +5 -79
  112. package/dist/git-api-kind.js +0 -3
  113. package/dist/governance-ask-marks.js +2 -78
  114. package/dist/hooks/branch-transcript.js +0 -74
  115. package/dist/hooks/cc-agent-hook-prompt.js +0 -29
  116. package/dist/hooks/cc-stop-prompt.js +1 -46
  117. package/dist/hooks/hook-llm.js +1 -53
  118. package/dist/hooks/hook-runner.js +20 -414
  119. package/dist/http/active-run-conflict.js +4 -129
  120. package/dist/http/cursor-fingerprint.d.ts +5 -0
  121. package/dist/http/cursor-fingerprint.js +5 -0
  122. package/dist/http/idempotency.js +0 -37
  123. package/dist/http/principal-gate.js +3 -40
  124. package/dist/http/route-ctx.js +0 -9
  125. package/dist/http/routes/a2a-serve.js +7 -319
  126. package/dist/http/routes/admin-config-refresh.js +0 -4
  127. package/dist/http/routes/admin-drain.js +0 -10
  128. package/dist/http/routes/adoption.js +1 -29
  129. package/dist/http/routes/agents-roster.js +1 -47
  130. package/dist/http/routes/approvals-assistant.js +32 -395
  131. package/dist/http/routes/attachments.js +4 -22
  132. package/dist/http/routes/capabilities.js +3 -420
  133. package/dist/http/routes/diagnostics.js +1 -81
  134. package/dist/http/routes/fleet.js +9 -185
  135. package/dist/http/routes/images.js +14 -239
  136. package/dist/http/routes/leader.js +0 -13
  137. package/dist/http/routes/memory-bundle.js +1 -61
  138. package/dist/http/routes/memory-policy.js +9 -99
  139. package/dist/http/routes/notify-wake.js +3 -37
  140. package/dist/http/routes/observability.js +2 -19
  141. package/dist/http/routes/retention-ops.js +2 -34
  142. package/dist/http/routes/rules.js +1 -93
  143. package/dist/http/routes/runs.js +64 -873
  144. package/dist/http/routes/session-sync.js +19 -258
  145. package/dist/http/routes/sessions-list.js +17 -43
  146. package/dist/http/routes/sessions.js +34 -226
  147. package/dist/http/routes/shared-memory.js +5 -36
  148. package/dist/http/routes/side-query.js +1 -87
  149. package/dist/http/routes/tasks.js +52 -722
  150. package/dist/http/routes/trace-usage.js +32 -195
  151. package/dist/http/routes/workflows.js +19 -195
  152. package/dist/http/run-meta.js +0 -6
  153. package/dist/http/send.js +0 -32
  154. package/dist/http/server.js +92 -1597
  155. package/dist/http/sse-lifecycle.js +2 -13
  156. package/dist/http/sse-log.js +3 -48
  157. package/dist/http/tar.js +5 -21
  158. package/dist/http/verify-rounds.js +0 -5
  159. package/dist/http/wire-gate.js +0 -9
  160. package/dist/http/workspace-content.js +0 -10
  161. package/dist/images/bake-validate.js +1 -70
  162. package/dist/images/manifest.js +1 -6
  163. package/dist/index.js +0 -21
  164. package/dist/key-resolver.js +2 -17
  165. package/dist/leader/diffout.js +1 -20
  166. package/dist/leader/diffup.js +0 -47
  167. package/dist/leader/endpoint.js +2 -57
  168. package/dist/leader/fanout.js +3 -45
  169. package/dist/leader/grader-env-factory.js +3 -72
  170. package/dist/leader/leader.js +5 -156
  171. package/dist/leader/merge.js +8 -115
  172. package/dist/leader/planner.js +3 -54
  173. package/dist/leader/repair-oracle.js +1 -60
  174. package/dist/leader/repair-wire.js +2 -79
  175. package/dist/leader/wire.js +8 -307
  176. package/dist/lsp/e2b-bridge.js +4 -64
  177. package/dist/lsp/e2b-manager.js +6 -94
  178. package/dist/lsp/lsp-frames.js +0 -12
  179. package/dist/lsp/manager.js +4 -96
  180. package/dist/lsp/ws-transport.js +5 -55
  181. package/dist/lsp-evict.js +1 -15
  182. package/dist/main.js +57 -765
  183. package/dist/memory-bundle-engine.js +0 -55
  184. package/dist/memory-export.js +0 -4
  185. package/dist/memory-posture.js +1 -15
  186. package/dist/memory-scope.js +11 -148
  187. package/dist/memory-sync-client.js +2 -44
  188. package/dist/memory-sync.js +1 -80
  189. package/dist/model-select.js +3 -80
  190. package/dist/observability/cost-quota.js +1 -17
  191. package/dist/observability/cost-taxonomy.js +0 -34
  192. package/dist/observability/fail-open.js +7 -86
  193. package/dist/observability/logger.js +0 -6
  194. package/dist/observability/metrics.js +0 -94
  195. package/dist/observability/otel-exporter.js +3 -13
  196. package/dist/observability/principal-context.js +0 -9
  197. package/dist/observability/prompt-manifest.js +1 -37
  198. package/dist/observability/rate-limit.js +0 -4
  199. package/dist/observability/secret-env-scrub.js +2 -56
  200. package/dist/observability/tool-trace.js +1 -70
  201. package/dist/orchestration/hardened-vm-runner.js +4 -118
  202. package/dist/orchestration/hardened-vm-worker-runner.js +1 -26
  203. package/dist/orchestration/hardened-vm-worker.js +0 -27
  204. package/dist/orchestration/subagent-steer.js +1 -45
  205. package/dist/orchestration/workflow-agent-steer.js +1 -80
  206. package/dist/orchestration/workflow-completion-inbox.js +32 -285
  207. package/dist/orchestration/workflow-notify-journal.js +16 -259
  208. package/dist/org-memory-admission.js +3 -47
  209. package/dist/parent-watch.js +2 -48
  210. package/dist/parked-decide.js +1 -109
  211. package/dist/per-task-image.js +0 -57
  212. package/dist/plan-cache-probe.js +3 -27
  213. package/dist/plugins/adoption-log-sql.js +2 -119
  214. package/dist/plugins/approval-ask-store-memory.js +3 -38
  215. package/dist/plugins/approval-ask-store-sql.js +6 -188
  216. package/dist/plugins/approval-exemption-store.js +2 -28
  217. package/dist/plugins/background-agent-store-sql.js +4 -105
  218. package/dist/plugins/background-shell-support.js +14 -122
  219. package/dist/plugins/blob-backend.js +6 -169
  220. package/dist/plugins/breaker-state-sql.js +8 -46
  221. package/dist/plugins/caching-session-store.js +4 -106
  222. package/dist/plugins/checkpoint-store-sql.js +18 -505
  223. package/dist/plugins/e2b-orphan-reclaim.js +0 -45
  224. package/dist/plugins/file-outcome-sink.js +0 -9
  225. package/dist/plugins/file-resume-anchor-store.js +4 -43
  226. package/dist/plugins/file-run-store.js +26 -364
  227. package/dist/plugins/file-snapshot-store-sql.js +10 -181
  228. package/dist/plugins/fork-routing-session-store.js +8 -111
  229. package/dist/plugins/host-platform.js +2 -91
  230. package/dist/plugins/image-bake-store-sql.js +7 -250
  231. package/dist/plugins/image-index-sql.js +4 -123
  232. package/dist/plugins/k8s-bg-scripts.js +4 -88
  233. package/dist/plugins/k8s-exec-protocol.js +0 -41
  234. package/dist/plugins/leader-run-store-sql.js +0 -101
  235. package/dist/plugins/local-checkpoint-store.js +8 -128
  236. package/dist/plugins/local-session-store.js +34 -296
  237. package/dist/plugins/local-task-attachment-store.js +2 -16
  238. package/dist/plugins/mailbox-store-sql.js +8 -65
  239. package/dist/plugins/memory-embedder-fingerprint.js +5 -166
  240. package/dist/plugins/memory-embedder.js +4 -70
  241. package/dist/plugins/memory-engine-pg.js +6 -170
  242. package/dist/plugins/memory-engine-tidb.js +7 -155
  243. package/dist/plugins/memory-engine-vector-util.js +0 -10
  244. package/dist/plugins/memory-key-guards.js +0 -34
  245. package/dist/plugins/memory-origin-law.js +0 -187
  246. package/dist/plugins/memory-resume-anchor-store.js +0 -17
  247. package/dist/plugins/memory-run-store.js +12 -89
  248. package/dist/plugins/memory-session-policy-store.js +0 -17
  249. package/dist/plugins/memory-sync-store-pg.js +4 -49
  250. package/dist/plugins/memory-sync-store-tidb.js +3 -35
  251. package/dist/plugins/outcome-ledger-sql.js +3 -97
  252. package/dist/plugins/permission-rule-store-file.js +5 -133
  253. package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
  254. package/dist/plugins/permission-rule-store-sql.js +50 -307
  255. package/dist/plugins/pg-cost-quota.js +0 -7
  256. package/dist/plugins/pg-pool.js +0 -92
  257. package/dist/plugins/pg-rate-limiter.js +2 -13
  258. package/dist/plugins/pg-safe-json.js +4 -40
  259. package/dist/plugins/pg-session-storage.js +25 -189
  260. package/dist/plugins/posix-shell-fs.js +1 -31
  261. package/dist/plugins/remote-env-adb.js +12 -101
  262. package/dist/plugins/remote-env-e2b.js +40 -371
  263. package/dist/plugins/remote-env-file-error.js +0 -37
  264. package/dist/plugins/remote-env-host.js +63 -483
  265. package/dist/plugins/remote-env-k8s.js +32 -326
  266. package/dist/plugins/remote-env-local-docker.js +24 -157
  267. package/dist/plugins/remote-env-ssh.js +20 -128
  268. package/dist/plugins/remote-scratchpad.js +2 -32
  269. package/dist/plugins/remote-shell.js +1 -32
  270. package/dist/plugins/resume-anchor-store-sql.js +0 -11
  271. package/dist/plugins/retention-lane-store-sql.js +0 -108
  272. package/dist/plugins/retention-store-sql.js +4 -383
  273. package/dist/plugins/roster-store-sql.js +0 -55
  274. package/dist/plugins/run-store-sql.js +14 -251
  275. package/dist/plugins/s3-presign.js +2 -49
  276. package/dist/plugins/scheduler-support.js +3 -80
  277. package/dist/plugins/send-file-ledger.js +4 -53
  278. package/dist/plugins/send-user-file.js +4 -94
  279. package/dist/plugins/session-placement.js +1 -89
  280. package/dist/plugins/session-policy-store-sql.js +3 -81
  281. package/dist/plugins/session-store.js +0 -57
  282. package/dist/plugins/shared-memory-store-sql.js +4 -167
  283. package/dist/plugins/sql-driver.js +0 -17
  284. package/dist/plugins/sql-errors.js +0 -7
  285. package/dist/plugins/sql-escape.js +0 -8
  286. package/dist/plugins/sql-row-helpers.js +0 -25
  287. package/dist/plugins/store-backend.js +42 -222
  288. package/dist/plugins/store-contracts.js +2 -46
  289. package/dist/plugins/task-attachment-store.js +3 -49
  290. package/dist/plugins/task-list-store-sql.js +0 -79
  291. package/dist/plugins/tidb-cost-quota.js +1 -4
  292. package/dist/plugins/tidb-pool.js +1 -207
  293. package/dist/plugins/tidb-rate-limiter.js +3 -9
  294. package/dist/plugins/tidb-session-storage.js +4 -70
  295. package/dist/plugins/tidb-session-store.js +19 -341
  296. package/dist/plugins/tool-result-store-sql.js +4 -179
  297. package/dist/plugins/usage-window-store-sql.js +0 -8
  298. package/dist/plugins/web-search.js +10 -132
  299. package/dist/plugins/workflow-journal-store-sql.js +2 -58
  300. package/dist/plugins/workflow-run-store-sql.js +6 -91
  301. package/dist/plugins/worktree-isolation.js +6 -126
  302. package/dist/plugins/write-behind-counter.js +16 -75
  303. package/dist/principal-jwt.js +5 -60
  304. package/dist/project-memory.js +15 -146
  305. package/dist/prompts-domain-validate.js +1 -51
  306. package/dist/question.js +2 -111
  307. package/dist/resource-suspend.js +0 -18
  308. package/dist/router/route-orchestration.js +0 -77
  309. package/dist/rules-consent.d.ts +44 -5
  310. package/dist/rules-consent.js +75 -215
  311. package/dist/run-local.js +14 -381
  312. package/dist/runs.js +29 -617
  313. package/dist/runtime-caps-resolver.js +6 -132
  314. package/dist/runtime-governance.js +1 -232
  315. package/dist/sandbox-pkg-source.js +0 -37
  316. package/dist/sealed-key.js +3 -68
  317. package/dist/security.js +6 -291
  318. package/dist/session-leaf-bus.js +0 -32
  319. package/dist/session-sync-content.js +1 -69
  320. package/dist/session-sync-kernel.js +3 -60
  321. package/dist/session-sync.js +3 -66
  322. package/dist/session-titler.js +7 -41
  323. package/dist/session-watch.js +9 -73
  324. package/dist/shared-memory-scope-authorizer.js +0 -18
  325. package/dist/sighup-idle.js +1 -12
  326. package/dist/spec-fields.js +4 -143
  327. package/dist/store-live-probe.js +3 -49
  328. package/dist/task-a2a.js +1 -126
  329. package/dist/task-cwd.js +1 -103
  330. package/dist/task-mcp.js +1 -89
  331. package/dist/task-settings.js +10 -300
  332. package/dist/task-workflow.js +8 -75
  333. package/dist/tool-approval.d.ts +28 -1
  334. package/dist/tool-approval.js +70 -1430
  335. package/dist/trace/artifacts.js +5 -20
  336. package/dist/trace/engine-notice-wire.js +6 -138
  337. package/dist/trace/ledger-sink.js +7 -96
  338. package/dist/trace/project.js +4 -448
  339. package/dist/trace/redact.js +9 -77
  340. package/dist/turn-activity.js +1 -27
  341. package/dist/usage-analytics.js +4 -34
  342. package/dist/wall-clock-jump-guard.js +1 -68
  343. package/package.json +3 -3
  344. package/dist/capabilities/scenario-alias.d.ts +0 -27
  345. package/dist/capabilities/scenario-alias.js +0 -61
@@ -1,37 +1,3 @@
1
- /**
2
- * `RemoteHostExecutionEnv` — the **TOC `host` exec adapter** (DUAL-MODE-DESIGN §5). Runs
3
- * commands DIRECTLY on THIS machine, in NO container — the TOC ("take-over-this-computer") default, exactly
4
- * like a local coding agent operating on the user's own box. **Isolation = none.**
5
- *
6
- * It is a PEER adapter to the E2B / SSH / k8s ones (not chained): all four implement the SAME core seam
7
- * `RemoteExecutionEnv` (core src/core/remote-env.ts, design/48 §5) so the agent's "hand" plugs into any of
8
- * them via the deployment-level `ExecutionEnvFactory`. The discriminator that selects between them is the
9
- * `remoteExec.provider` superset — this adapter is `provider:"host"` (DUAL-MODE-DESIGN §5).
10
- *
11
- * 🔴 **`host` targets the operator's OWN machine, not a disposable sandbox** (DUAL-MODE-DESIGN §5):
12
- * - `capabilities = { isolation: false, suspendable: false }` — there is NO container boundary and NO
13
- * snapshot. Side effects are PERMANENT and visible to the whole machine. The orchestrator/leader MUST
14
- * tighten autonomy + the design/37 policy gate + HITL on a non-isolated env (design/53 zero-trust) —
15
- * identical posture to the SSH adapter, which also runs on a real (un-isolated) system.
16
- * - **NO network restriction** — this is the user's own machine; the agent has the same network the user
17
- * does (that is the whole point of the TOC mode).
18
- * - `suspendVM`/`resumeVM`/`postResumeInit` → typed `"unsupported"` (a live host process tree isn't
19
- * snapshotable — durable suspend N/A). `reconnect` is a no-op success (the "connection" is the local
20
- * process; nothing to re-establish).
21
- * - `destroy()` / `cleanup()` **`rm -rf` the per-task working directory** (the workspace this adapter
22
- * created), NOT the machine. Anything the agent created OUTSIDE the workspace persists (that is the
23
- * operator's/gate's concern — there is no isolation to undo).
24
- *
25
- * **never-throw invariant:** every base `ExecutionEnv` FileSystem/Shell method encodes failure in its
26
- * returned `Result` (never throws). `execStream`'s iterator MAY throw `RemoteExecutionError` mid-stream
27
- * before an `exit` chunk (the seam contract, OutputChunk). A non-zero command exit is a NORMAL value
28
- * (`{ ok:true, exitCode }`), never an error.
29
- *
30
- * Platforms: macOS + Linux (POSIX shell). Forward-compatible with the `remoteExec` superset: this file also
31
- * hosts the `local-docker` / `remote-docker` providers' factory entry points — both now IMPLEMENTED by the
32
- * container adapter in remote-env-local-docker.ts (the two providers differ only in whether the `-H` daemon
33
- * endpoint is off-box and required), re-exported/wrapped here so the discriminator has one canonical site.
34
- */
35
1
  import { spawn } from "node:child_process";
36
2
  import os from "node:os";
37
3
  import path from "node:path";
@@ -40,40 +6,25 @@ import { randomBytes } from "node:crypto";
40
6
  import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdirSync, rmdirSync, mkdtempSync, existsSync, realpathSync } from "node:fs";
41
7
  import { StringDecoder } from "node:string_decoder";
42
8
  import { armPipeDestroyGrace, numEnvOr } from "./remote-shell.js";
43
- // design/158 B4 (lens4 N8): the two host-lane kill switches were the negative names `HOST_BG_DISABLED` /
44
- // `HOST_EXEC_SPOOL_DISABLED`, parsed inline. Their canonical names are now positive (`HOST_BG_ENABLED` /
45
- // `HOST_EXEC_SPOOL_ENABLED`, default ON) and resolved by the shared config helpers, which keep the retired
46
- // names working for one deprecation window and register both in the boot polarity table.
47
9
  import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
48
10
  import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys } from "./host-platform.js";
49
11
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
50
12
  import { FileError, ExecutionError, RemoteExecutionError, scrubSecretEnv, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
51
13
  import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
52
14
  const PROVIDER = "host";
53
- /** Default per-command wall-clock when the caller passes none (NOT a hang detector — host commands rarely hang).
54
- * ⚠️ 同判据三腿之一(另两条:`remote-env-local-docker.ts` 同值 30min、`remote-env-ssh.ts` **5min**)——
55
- * 分歧成文与候裁说明写在 ssh 那处的 `commandTimeoutMs` 顶注(#176 二轮扫描 finding)。 */
56
15
  const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60_000;
57
- // ─── design/103 host-lane background shell: TOC's run_in_background / BashOutput / KillShell ───
58
- // host driver = design/103 §5.2 path ①: a `detached` child (its OWN process group via `detached:true` → pgid ===
59
- // child.pid) writes stdout/stderr DIRECTLY to two local files; poll re-reads each by byte cursor; kill/dispose reap
60
- // the whole group with `process.kill(-pid)`. The BackgroundShellManager owns every red-line (opaque id / owned-id
61
- // isolation / maxConcurrent / timeout hard wall / dispose); this driver only writes the 4 transport primitives.
62
16
  const HOST_BG_MAX_CONCURRENT = numEnvOr("HOST_BG_MAX_CONCURRENT", 8, 1);
63
17
  const HOST_BG_DEFAULT_TIMEOUT_SEC = numEnvOr("HOST_BG_DEFAULT_TIMEOUT_SEC", 300, 1);
64
18
  const HOST_BG_MAX_TIMEOUT_SEC = numEnvOr("HOST_BG_MAX_TIMEOUT_SEC", 1800, HOST_BG_DEFAULT_TIMEOUT_SEC);
65
- const HOST_BG_READ_CAP = numEnvOr("HOST_BG_READ_CAP", 1024 * 1024, 64 * 1024); // per-poll incremental read ceiling
66
- const HOST_BG_FILE_CAP = numEnvOr("HOST_BG_FILE_CAP", 64 * 1024 * 1024, HOST_BG_READ_CAP); // disk guard: reap a runaway
67
- const HOST_BG_KILL_GRACE_MS = numEnvOr("HOST_BG_KILL_GRACE_MS", 1000, 0); // SIGTERM → grace → SIGKILL (no host isolation)
68
- /** Per-stream memory-mode drain cap: an un-polled adopted child cannot grow the worker heap without bound (the
69
- * host lane has no VM to contain it). Drop-oldest beyond this, counting dropped bytes → surfaced as `truncated`. */
19
+ const HOST_BG_READ_CAP = numEnvOr("HOST_BG_READ_CAP", 1024 * 1024, 64 * 1024);
20
+ const HOST_BG_FILE_CAP = numEnvOr("HOST_BG_FILE_CAP", 64 * 1024 * 1024, HOST_BG_READ_CAP);
21
+ const HOST_BG_KILL_GRACE_MS = numEnvOr("HOST_BG_KILL_GRACE_MS", 1000, 0);
70
22
  const HOST_BG_MEM_CAP = numEnvOr("HOST_BG_MEM_CAP", 8 * 1024 * 1024, 64 * 1024);
71
23
  const ok = (value) => ({ ok: true, value });
72
24
  const unsupported = (op) => ({
73
25
  ok: false,
74
26
  error: new RemoteExecutionError("unsupported", `${op} is not supported on a host target (the operator's own machine — no container boundary, not snapshotable; capabilities.suspendable=false)`),
75
27
  });
76
- // The cap shape an env without a backend advertises — `supported:false` makes `hasScheduler` false (INERT, no tools).
77
28
  const UNSUPPORTED_SCHEDULER_CAPS = {
78
29
  supported: false,
79
30
  maxScheduledPerScope: 0,
@@ -82,11 +33,7 @@ const UNSUPPORTED_SCHEDULER_CAPS = {
82
33
  maxScheduledHorizonSec: 0,
83
34
  };
84
35
  export class RemoteHostExecutionEnv {
85
- /** design/61 §9 / DUAL-MODE-DESIGN §5: a `host` env is the operator's real machine — no isolation, no snapshot. */
86
36
  capabilities = { isolation: false, suspendable: false };
87
- /** R7 自唤醒: structural delegation to the injected scheduler backend. `hasScheduler(env)` checks BOTH the method
88
- * presence (always, since we implement the interface) AND `schedulerCapabilities.supported===true` — which is only
89
- * true when a backend was injected, so a host env without one stays INERT (the four scheduler tools don't mount). */
90
37
  get schedulerCapabilities() {
91
38
  return this.cfg.scheduler?.schedulerCapabilities ?? UNSUPPORTED_SCHEDULER_CAPS;
92
39
  }
@@ -105,45 +52,30 @@ export class RemoteHostExecutionEnv {
105
52
  ? this.cfg.scheduler.list(ctx)
106
53
  : Promise.resolve({ ok: false, error: new SchedulerError("unsupported", "scheduler not enabled on this worker") });
107
54
  }
108
- /** Working directory; relative paths resolve against it (ExecutionEnv contract). Starts at the workspace root. */
109
55
  cwd;
110
56
  workspaceDir;
111
57
  cfg;
112
- /** Workspace created on disk (lazily, on first use). */
113
58
  prepared = false;
114
- /** Terminal once {@link destroy} ran — guards fs/exec from running against a removed workspace. */
115
59
  destroyed = false;
116
- /** [1116] 五轮复审:fg/bg spool 根=tmpdir 私有目录(core bgSpoolDir 同形)。workspace 是命令可写区,
117
- * `git clean -fdx` 类清理会 unlink 活跃 spool(子进程经继承 fd 对 unlinked inode 继续写,而父进程
118
- * 按路径 statSync/read 静默断=输出丢失+轮转帽失效);顺带不再弄脏 user-owned workspace(TOC/CC
119
- * persistent 形)。懒建;destroy 整树清。 */
120
60
  spoolRootDir;
121
- /** 🔴 在飞的 fg spool 目录登记表(2026-07-25)。`destroy()` 原来只看 `bgManager.hasShells()` 就 `rm -rf`
122
- * spool 根 —— 而**前台** exec 的 spool 也在同一个根下,子进程通过继承的 fd 继续往已 unlink 的 inode 写,
123
- * 父进程按**路径** stat/read 于是什么都看不到 ⇒ 命令照跑、副作用照落盘,但 stdout 被静默截断,还返回
124
- * `exitCode 0`。对模型而言那与「成功且无输出」**不可区分**。(与 core RB-73 同形:写已释放的 inode。)
125
- * 判据与 `hasShells()` 并列:**只要还有人在读写这个根下的文件,就不删它**。 */
126
61
  liveFgSpools = new Set();
127
62
  ensureSpoolRoot() {
128
63
  if (!this.spoolRootDir)
129
64
  this.spoolRootDir = mkdtempSync(path.join(os.tmpdir(), "sema-host-spool-"));
130
65
  return this.spoolRootDir;
131
66
  }
132
- /** True when `workspaceDir` was given (user-owned persistent dir) → destroy() must NOT delete it. */
133
67
  persistent;
134
- /** Memoized lazy `mkdir -p` so concurrent first-use prepares the workspace exactly once. */
135
68
  preparing;
136
69
  handle;
137
70
  constructor(config = {}) {
138
71
  if (config.workspaceDir) {
139
- // TOC/CC mode: the user's OWN dir, verbatim — no random subdir, never deleted (see destroy()).
140
72
  this.workspaceDir = path.resolve(config.workspaceDir);
141
73
  this.persistent = true;
142
74
  }
143
75
  else {
144
76
  const base = config.workspaceBase ?? os.tmpdir();
145
77
  const id = config.id ?? randomBytes(6).toString("hex");
146
- const suffix = randomBytes(4).toString("hex"); // always-unique tail so a reused id never collides
78
+ const suffix = randomBytes(4).toString("hex");
147
79
  this.workspaceDir = path.resolve(base, `sema-host-${sanitizeId(id)}-${suffix}`);
148
80
  this.persistent = false;
149
81
  }
@@ -154,13 +86,12 @@ export class RemoteHostExecutionEnv {
154
86
  this.cwd = this.workspaceDir;
155
87
  this.handle = { sandboxId: this.workspaceDir, provider: PROVIDER, mountPath: this.workspaceDir };
156
88
  }
157
- // ─────────────────────────────── lifecycle ───────────────────────────────
158
89
  workspaceHandle() {
159
90
  return this.handle;
160
91
  }
161
92
  async connect(config) {
162
93
  if (config?.snapshotId)
163
- return unsupported("connect(snapshotId)"); // host has no snapshots
94
+ return unsupported("connect(snapshotId)");
164
95
  if (this.destroyed)
165
96
  return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env already destroyed") };
166
97
  if (config?.abortSignal?.aborted)
@@ -172,7 +103,6 @@ export class RemoteHostExecutionEnv {
172
103
  return { ok: false, error: new RemoteExecutionError("connect_failed", `failed to create host workspace ${this.workspaceDir}: ${errMsg(e)}`, asError(e)) };
173
104
  }
174
105
  if (config?.secrets?.length) {
175
- // Secrets are injected per-command via cfg.env (resolved by the control plane), not at connect — record only.
176
106
  }
177
107
  return ok(this.handle);
178
108
  }
@@ -185,72 +115,46 @@ export class RemoteHostExecutionEnv {
185
115
  async postResumeInit() {
186
116
  return unsupported("postResumeInit");
187
117
  }
188
- /** The "connection" is the local process; there is nothing to re-establish. Idempotent success (unless destroyed). */
189
118
  async reconnect(_sessionToken) {
190
119
  if (this.destroyed)
191
120
  return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env already destroyed") };
192
121
  return ok(this.handle);
193
122
  }
194
- /** `rm -rf` the per-task workspace (NOT the machine). Idempotent, best-effort, never throws (cleanup contract).
195
- *
196
- * core 1.258.2 destroy 契约注(remote-env.d.ts):非隔离 env(host lane,capabilities.isolation
197
- * 缺席)的 destroy = 工作区/对象级清理,**不得收割仍在跑的后台进程**——收割全权归 `disposeBackgroundShells`
198
- * (它带 timeout/session keep-alive except 名单)。此前这里的无-except 全灭把
199
- * core 留活过的驻留 watcher 又杀了一遍(run 尾 dispose→destroy 顺序,destroy 是第二刀)。隔离 env(容器/VM)
200
- * 整体拆机自然带走进程,与本契约不冲突(k8s/e2b 各自 destroy 不走这条路径)。 */
201
123
  async destroy() {
202
124
  if (this.destroyed)
203
- return; // idempotent — safe double-call (Runner end + reaper)
125
+ return;
204
126
  this.destroyed = true;
205
- // Await any in-flight prepare so a destroy-vs-first-use race can't leak the workspace dir:
206
- // an attached .then may still flip prepared=true after we null the memo. Then rm unconditionally
207
- // (force:true no-ops a never-created path), eliminating the prepared-flag race entirely.
208
127
  const p = this.preparing;
209
128
  this.preparing = undefined;
210
129
  try {
211
130
  await p?.catch(() => { });
212
131
  }
213
132
  finally {
214
- // spool 根(tmpdir 私有)与 workspace 归属无关——persistent 形也要清(五轮复审迁址后属本 env 自有)。
215
- // 六轮复审:但驻留 shell(core 1.258.2「destroy 不得收割后台进程」契约,keep-alive except 名单)与
216
- // terminal 残余(dispose 前仍可轮询)还在读写这些文件——有任何 shell 在册即跳过整树删,逐 shell 清理
217
- // 归 dispose 面(state.dir 单删);空根目录留给 OS tmp 周期清,量级=一个空目录。
218
- // 判据两条并列(见 liveFgSpools 旁注):后台 shell **或**在飞的前台 exec,任一还在就不删这个根 ——
219
- // 删了等于把它们正在写的文件从脚下抽走,而写者拿的是 fd、察觉不到,只有读者看到"空输出"。
220
133
  if (this.spoolRootDir && !this.bgManager.hasShells() && this.liveFgSpools.size === 0) {
221
134
  try {
222
135
  await fs.rm(this.spoolRootDir, { recursive: true, force: true });
223
136
  }
224
- catch { /* best-effort */ }
137
+ catch { }
225
138
  this.spoolRootDir = undefined;
226
139
  }
227
140
  if (this.persistent)
228
- return; // user-owned persistent dir (TOC/CC) — NEVER rm -rf it
141
+ return;
229
142
  try {
230
143
  await fs.rm(this.workspaceDir, { recursive: true, force: true });
231
144
  }
232
145
  catch {
233
- /* best-effort: never throw on teardown */
234
146
  }
235
147
  }
236
148
  }
237
149
  async cleanup() {
238
150
  return this.destroy();
239
151
  }
240
- // ─────────────────────────────── design/103 background shell (host lane) ───────────────────────────────
241
- // Host lane = single-user TOC (the user's own box) → background shell is ON (run_in_background/BashOutput/KillShell
242
- // auto-mount via core's `hasBackgroundShell`). `HOST_BG_ENABLED=false` turns it INERT (defence for a mis-configured
243
- // multi-tenant host lane). caps tuned via HOST_BG_* env. The manager owns the red-lines; the driver below is local.
244
- // A getter (NOT a field initializer) so it reads `this.cfg` which is set in the constructor — a field initializer
245
- // runs before the constructor body and would see cfg undefined (the kata adapter hit the same ordering trap).
246
152
  get backgroundCapabilities() {
247
153
  return {
248
154
  supported: (this.cfg.backgroundShell ?? true) && hostBackgroundShellEnabled(),
249
155
  maxConcurrent: HOST_BG_MAX_CONCURRENT,
250
156
  defaultBgTimeoutSec: HOST_BG_DEFAULT_TIMEOUT_SEC,
251
157
  maxBgTimeoutSec: HOST_BG_MAX_TIMEOUT_SEC,
252
- // core 1.207 design/116 detach (clay 排, 1.81): a running foreground exec can be ADOPTED as a background
253
- // shell (mid-flight ctrl+b). Core's Bash tool gates the whole detach chain on this flag === true.
254
158
  supportsDetach: (this.cfg.backgroundShell ?? true) && hostBackgroundShellEnabled(),
255
159
  };
256
160
  }
@@ -262,25 +166,21 @@ export class RemoteHostExecutionEnv {
262
166
  const self = this;
263
167
  return {
264
168
  async launch(command, options, ctx) {
265
- const ready = await self.shellReady(); // lazily prepares the workspace dir
169
+ const ready = await self.shellReady();
266
170
  if (!ready.ok)
267
171
  throw new BackgroundShellError("spawn_failed", ready.error.message);
268
172
  const dir = path.join(self.ensureSpoolRoot(), "bg-" + randomBytes(8).toString("hex"));
269
173
  await fs.mkdir(dir, { recursive: true });
270
174
  const outPath = path.join(dir, "out");
271
175
  const errPath = path.join(dir, "err");
272
- // Open the child's stdout/stderr targets; the parent closes its OWN fd copies right after spawn so file
273
- // size/EOF reflect only the child's writes (the child keeps its inherited fds).
274
176
  const outFh = await fs.open(outPath, "w");
275
177
  const errFh = await fs.open(errPath, "w");
276
178
  let child;
277
179
  try {
278
- const sh = hostShell(); // Windows S1/S3: behind shellReady() above → resolved
180
+ const sh = hostShell();
279
181
  child = spawn(sh.shell, [...sh.args, command], {
280
182
  cwd: self.resolve(options?.cwd ?? self.cwd),
281
- env: self.mergeEnv(options?.env), // 🔴 SAME secret-scrub as exec (design/103 §3.1) — host process.env minus secret-pattern keys
282
- // POSIX: detached:true → child = process-group leader (pgid === pid), kill(-pid) reaps the whole
283
- // group. win32: no POSIX group either way — windowsHide, kill side = taskkill tree (design D3).
183
+ env: self.mergeEnv(options?.env),
284
184
  ...spawnGroupOptions(),
285
185
  stdio: ["ignore", outFh.fd, errFh.fd],
286
186
  });
@@ -292,18 +192,10 @@ export class RemoteHostExecutionEnv {
292
192
  throw new BackgroundShellError("spawn_failed", e instanceof Error ? e.message : String(e), e);
293
193
  }
294
194
  const pid = child.pid;
295
- // 🔴 register close/error listeners SYNCHRONOUSLY (before any await) — a fast child must not exit before we
296
- // listen, else onTerminal never fires and the shell would lie "running" until the timeout wall.
297
195
  if (pid != null) {
298
- // core-baseline terminal semantics (Fable-5 review #1): ANY self-ended process is "exited" with its exit
299
- // code (signal death synthesizes the conventional 128+signo, like core's NodeExecutionEnv + the k8s/E2B
300
- // lanes) — a non-zero exit is a NORMAL result, NOT "failed". "failed" is reserved for spawn-level errors.
301
- // The old non-zero→failed classification hid the exit code from the model (BashOutput only prints
302
- // `exited(code N)`), and split the three lanes' semantics. Classify on `close` (not `exit`), also per
303
- // core baseline — stdio settled before the status flips (with fd-passed spool files close ≈ exit anyway).
304
196
  child.on("close", (code, signal) => ctx.onTerminal(false, code ?? (signal ? 128 + (signalNumber(signal) ?? 9) : 0)));
305
- child.on("error", () => ctx.onTerminal(true)); // post-pid spawn error (rare)
306
- child.unref(); // a detached background job must not keep the worker's event loop alive
197
+ child.on("error", () => ctx.onTerminal(true));
198
+ child.unref();
307
199
  }
308
200
  await outFh.close().catch(() => { });
309
201
  await errFh.close().catch(() => { });
@@ -314,8 +206,6 @@ export class RemoteHostExecutionEnv {
314
206
  return { pid, dir, outPath, errPath, stdoutCursor: 0, stderrCursor: 0 };
315
207
  },
316
208
  async read(state) {
317
- // ADOPT (memory) mode: drain the in-memory buffers (no files). Each read returns the queued chunks +
318
- // resets the queue; totalBytes/droppedBytes are cursor metadata (design/103 §3.4).
319
209
  if (state.mem) {
320
210
  const o = drainMemStream(state.mem.out);
321
211
  const e = drainMemStream(state.mem.err);
@@ -326,9 +216,6 @@ export class RemoteHostExecutionEnv {
326
216
  bytesDroppedBeforeCursor: o.dropped + e.dropped,
327
217
  };
328
218
  }
329
- // Returns the advanced cursor AND the TRUE on-disk size — the per-poll READ is capped at HOST_BG_READ_CAP,
330
- // but the disk guard must see the real file size (a slow poller advances the cursor ≤1 MiB/poll while the
331
- // file on the operator's real disk could already be GBs — review MED).
332
219
  const readForward = async (p, cursor, dec) => {
333
220
  let fh;
334
221
  try {
@@ -336,134 +223,93 @@ export class RemoteHostExecutionEnv {
336
223
  }
337
224
  catch {
338
225
  return { chunk: "", cursor, size: cursor };
339
- } // file gone (disposed) → no new bytes
226
+ }
340
227
  try {
341
228
  const { size } = await fh.stat();
342
229
  if (size <= cursor)
343
- return { chunk: "", cursor: size, size }; // no growth (or shrank) → cursor tracks size
230
+ return { chunk: "", cursor: size, size };
344
231
  const n = Math.min(size - cursor, HOST_BG_READ_CAP);
345
232
  const buf = Buffer.alloc(n);
346
233
  await fh.read(buf, 0, n, cursor);
347
- // per-shell StringDecoder([1116] 批):跨段码点保状态——逐段 toString 会在段界劈出 U+FFFD。
348
234
  return { chunk: dec.write(buf), cursor: cursor + n, size };
349
235
  }
350
236
  finally {
351
237
  await fh.close().catch(() => { });
352
238
  }
353
239
  };
354
- // File mode (launch + [1116] spool adoption): the memory-mode early-return above handled `state.mem`.
355
240
  state.decOut ??= new StringDecoder("utf8");
356
241
  state.decErr ??= new StringDecoder("utf8");
357
242
  const o = await readForward(state.outPath, state.stdoutCursor, state.decOut);
358
243
  const e = await readForward(state.errPath, state.stderrCursor, state.decErr);
359
244
  state.stdoutCursor = o.cursor;
360
245
  state.stderrCursor = e.cursor;
361
- // Disk guard: reap a runaway by its TRUE on-disk size (the host lane has no VM isolation to contain a
362
- // runaway output file; the BG-timeout also bounds wall-clock).
363
246
  let terminal;
364
247
  if (o.size > HOST_BG_FILE_CAP || e.size > HOST_BG_FILE_CAP) {
365
248
  try {
366
249
  killTreeHard(state.pid);
367
250
  }
368
- catch { /* already dead */ } // disk-guard熔断 (the 5th kill site) — POSIX kill(-pid) byte-identical; win32 taskkill /F /T
251
+ catch { }
369
252
  terminal = { failed: true };
370
253
  }
371
- // F2/F3([1116] 批,二轮复审):spool adoption 携带的 fg 期文件移除字节。preSpoolBase 每拍并入
372
- // bytesFromStart(单调不倒退);pendingDropped 只首拍并入 bytesDroppedBeforeCursor 后清零(launch
373
- // 形两者恒 undefined→0:本地文件不 head-evict,全量保留到 dispose)。
374
254
  const preBase = state.preSpoolBase ?? 0;
375
255
  const pending = state.pendingDropped ?? 0;
376
256
  state.pendingDropped = 0;
377
257
  return {
378
258
  stdout: o.chunk,
379
259
  stderr: e.chunk,
380
- bytesFromStart: o.size + e.size + preBase, // TRUE total from start(含 fg 已移除段;preBase 每拍恒在)
260
+ bytesFromStart: o.size + e.size + preBase,
381
261
  bytesDroppedBeforeCursor: pending,
382
262
  ...(terminal ? { terminal } : {}),
383
263
  };
384
264
  },
385
265
  async kill(state) {
386
- // host lane has NO container isolation — a SIGTERM-trapping group must still die at the hard wall. Send
387
- // SIGTERM, then escalate to SIGKILL after a short grace (mirrors kata's `kill -TERM; sleep 1; kill -KILL`).
388
- // This is the path for BOTH KillShell and the manager's BG-timeout wall, so the §3.6 "到点自杀" really kills.
389
- // POSIX byte-identical: killTreeSoft = kill(-pid, SIGTERM) incl. the throw-on-dead-group → return (no
390
- // escalation needed). win32 (design D2): soft = taskkill /T — a no-op for console trees ("can only be
391
- // terminated forcefully"), and it never throws, so the escalation timer ALWAYS arms there: the ladder
392
- // degrades to delay→hard-kill, which is exactly the accepted win32 semantics.
393
266
  try {
394
267
  killTreeSoft(state.pid);
395
268
  }
396
269
  catch {
397
270
  return;
398
- } // idempotent: already-dead group → no escalation needed
271
+ }
399
272
  setTimeout(() => { try {
400
273
  killTreeHard(state.pid);
401
274
  }
402
- catch { /* exited within grace */ } }, HOST_BG_KILL_GRACE_MS).unref?.();
275
+ catch { } }, HOST_BG_KILL_GRACE_MS).unref?.();
403
276
  },
404
277
  async disposeOne(state) {
405
- state.detachCleanup?.(); // adopt path: detach the extra pipe listeners (memory mode → no fds/dir)
278
+ state.detachCleanup?.();
406
279
  try {
407
280
  killTreeHard(state.pid);
408
281
  }
409
- catch { /* already dead */ }
282
+ catch { }
410
283
  if (state.dir)
411
- await fs.rm(state.dir, { recursive: true, force: true }).catch(() => { }); // file (launch) mode only
284
+ await fs.rm(state.dir, { recursive: true, force: true }).catch(() => { });
412
285
  },
413
286
  };
414
287
  }
415
288
  spawnBackground(command, options) {
416
289
  return this.bgManager.spawn(command, options);
417
290
  }
418
- /**
419
- * design/116 detach (core 1.207, clay 排 1.81): ADOPT a still-running FOREGROUND exec child as a background
420
- * shell — the mid-flight ctrl+b path. The exec child writes to pipes (not files, unlike a `launch`-spawned
421
- * bg shell), so adoption splices those live pipes into fresh bg spool files: pre-fill them with the output
422
- * captured so far (the detach TEE, fed from the exec's FIRST chunk → TaskOutput's first read has the full
423
- * history), then append every subsequent chunk. From then on the shell is indistinguishable from a spawned one
424
- * (poll by cursor, kill by pgid, the BG-timeout wall applies). Returns the opaque shellId, or `undefined` when
425
- * the live-shell limit is hit (detach refused → the exec keeps running in the foreground; NEVER a dropped process).
426
- *
427
- * `memOut`/`memErr` are the exec's tee-from-start buffers (Fable-5 review #3, mirrors the E2B tee): they were fed
428
- * every chunk since process start THROUGH THE SAME per-stream StringDecoder that keeps feeding post-detach, so a
429
- * multibyte codepoint straddling the detach instant decodes correctly (the old seed-from-fg-tail handoff decoded
430
- * the two halves in separate passes → U+FFFD at the seam).
431
- */
432
291
  adoptRunningExec(child, memOut, memErr) {
433
292
  const pid = child.pid;
434
293
  if (pid == null)
435
- return undefined; // no pid → nothing to adopt (exec keeps running / will settle normally)
436
- // 🔴 FULLY SYNCHRONOUS (double-review, 3 findings): memory buffers, no files/fds. The tee handover + the
437
- // live-pipe listener attach happen in ONE tick so no output is lost to an async window, and the exec-settle
438
- // can't race the adoption (mirrors core's `adoptRunningChild`).
439
- // 🔴 builder contract (Fable-5 review #5): everything below is NON-THROWING (listener attach + plain object
440
- // construction). adoptSync's catch returns undefined WITHOUT undoing side effects — any future logic that can
441
- // throw must go BEFORE the first side effect (listener/on) or clean up after itself.
294
+ return undefined;
442
295
  const adopted = this.bgManager.adoptSync((ctx) => {
443
296
  const mem = { out: memOut, err: memErr };
444
- // Bound un-drained memory (no VM on the host lane to contain a runaway): drop OLDEST, count as truncated.
445
297
  const onOut = (d) => feedMemStream(mem.out, d, HOST_BG_MEM_CAP);
446
298
  const onErr = (d) => feedMemStream(mem.err, d, HOST_BG_MEM_CAP);
447
299
  child.stdout?.on("data", onOut);
448
300
  child.stderr?.on("data", onErr);
449
- // core-baseline terminal semantics (Fable-5 review #1, same as the launch path): self-ended → "exited" with
450
- // the exit code (signal death = 128+signo), NEVER "failed" — matches core's adoptRunningChild verbatim.
451
- // Classify on `close` (not `exit`), also per core baseline: in pipe (adopt) mode the final data events can
452
- // trail `exit` — flipping the status early lets a poll in that window read "exited" with the tail undrained.
453
301
  const onClose = (code, signal) => ctx.onTerminal(false, code ?? (signal ? 128 + (signalNumber(signal) ?? 9) : 0));
454
302
  const onErrEvt = () => ctx.onTerminal(true);
455
303
  child.on("close", onClose);
456
304
  child.on("error", onErrEvt);
457
- // Race guard: the child may have exited between the detach request and adoption — a close that fired before
458
- // the listener attached won't re-fire, so classify terminal now (mirrors core's adoptRunningChild).
459
305
  if (child.exitCode !== null || child.signalCode !== null)
460
306
  ctx.onTerminal(false, child.exitCode ?? (child.signalCode ? 128 + (signalNumber(child.signalCode) ?? 9) : 0));
461
- child.unref?.(); // a detached background job must not keep the worker's event loop alive
307
+ child.unref?.();
462
308
  const detachCleanup = () => {
463
309
  child.stdout?.off("data", onOut);
464
310
  child.stderr?.off("data", onErr);
465
311
  child.off("close", onClose);
466
- child.off("error", onErrEvt); // no fds to close — memory mode
312
+ child.off("error", onErrEvt);
467
313
  };
468
314
  return { pid, stdoutCursor: 0, stderrCursor: 0, mem, detachCleanup };
469
315
  });
@@ -476,18 +322,8 @@ export class RemoteHostExecutionEnv {
476
322
  return this.bgManager.kill(shellId);
477
323
  }
478
324
  disposeBackgroundShells(opts) {
479
- // 签名带 opts——core runtask 尾调 `{except: keepAlive}`(timeout/session 驻留 Monitor
480
- // 留活名单),此前无参签名把它静默丢弃=kill-all。透传给 manager(无参=既有全灭)。
481
325
  return this._bgManager ? this._bgManager.dispose(opts) : Promise.resolve();
482
326
  }
483
- // ─────────────────────────────── Shell: buffered exec ───────────────────────────────
484
- //
485
- // [1116] fg spool 化(core 1.318 修的平行盲区,当班移植):管道 stdio + 等 `close` settle 的旧形,
486
- // 在孙进程(`nohup server &`)持有管道写端时 close 恒不发——exec 挂满 timeout → 组杀连坐孙进程。
487
- // spool 形:子进程 stdout/stderr 直写文件 fd(进程间零管道),`close` 仍是主 settle 路,但 `exit`
488
- // +500ms drain 兜底(close 不来也 settle);正常终态不进任何杀伤集(timeout/abort 才杀树)。
489
- // detach adoption 走 FILE 形 handover(spool 路径+游标直接进 HostBgState——launch 同形,poll 共码,
490
- // 单一字节流天然无 decoder 接缝)。win32 / spool 打开失败 → 回退管道旧形(core 同姿势)。
491
327
  async exec(command, options) {
492
328
  if (options?.abortSignal?.aborted)
493
329
  return { ok: false, error: new ExecutionError("aborted", "exec aborted before start") };
@@ -496,11 +332,7 @@ export class RemoteHostExecutionEnv {
496
332
  return ready;
497
333
  const cwd = this.resolve(options?.cwd ?? this.cwd);
498
334
  const timeoutMs = options?.timeout != null ? options.timeout * 1000 : this.cfg.commandTimeoutMs;
499
- // core 1.207 design/116 detach: the Bash tool threads a `detachSignal` when the model/operator mid-flight
500
- // ctrl+b's a running command. `ExecOpts` doesn't type it yet (core-side seam) — read it structurally.
501
335
  const detachSignal = options?.detachSignal;
502
- // [1116] spool 形优先(POSIX;HOST_EXEC_SPOOL_ENABLED=false 逃生舱)。打开失败=回退管道旧形,永不因
503
- // spool 基建挡 exec。
504
336
  if (process.platform !== "win32" && hostExecSpoolEnabled()) {
505
337
  const spool = this.openFgSpool();
506
338
  if (spool)
@@ -508,48 +340,25 @@ export class RemoteHostExecutionEnv {
508
340
  }
509
341
  return this.execViaPipes(command, options, cwd, timeoutMs, detachSignal);
510
342
  }
511
- /** 管道旧形(win32 / spool 打开失败的回退;行为=1.225 及以前的 exec 原样)。 */
512
343
  execViaPipes(command, options, cwd, timeoutMs, detachSignal) {
513
344
  return new Promise((resolve) => {
514
- // D2: bounded rolling-TAIL (core's RollingTailBuffer) — a model-driven `yes`/`cat big.bin` can't OOM the
515
- // durable worker; keep only the last 8MB (tail = errors+exit, what the model needs) + a dropped-byte marker. O(maxBytes).
516
345
  const outBuf = new RollingTailBuffer();
517
346
  const errBuf = new RollingTailBuffer();
518
- // design/116 detach TEE (mirrors the E2B tee-from-start): when the exec is detach-capable,
519
- // tee every chunk from process START into the pair of drain buffers a later adoption hands over as-is. The
520
- // per-stream StringDecoder inside BgMemStream then spans the whole stream — fg segment AND post-detach segment
521
- // decode through ONE stateful decoder, so a multibyte codepoint straddling the detach instant survives (the
522
- // old fg-tail→seed handoff decoded the halves separately → U+FFFD at the seam). Cost: one extra bounded
523
- // (drop-oldest, HOST_BG_MEM_CAP) buffer pair for the exec's lifetime, only when a detachSignal is present.
524
347
  const tee = detachSignal ? { out: seedMemStream(""), err: seedMemStream("") } : undefined;
525
348
  let settled = false;
526
- // D5 pipe-destroy grace: armed inside kill() (abort/timeout); destroys the stdout/stderr pipes if `close`
527
- // never fires (a re-parented grandchild holding the FD). Cleared in finish() — including the real `close`
528
- // path, which routes through finish() — so the timer never leaks. Does NOT change promise settle (the
529
- // abort/timeout handlers already finish() synchronously below).
530
349
  let forceSettleTimer;
531
- // Windows S1/S3 (design D1/D3): shell + group options are platform-resolved (POSIX byte-identical:
532
- // /bin/sh + detached:true so kill(-pid) reaps the whole group; win32: Git Bash + windowsHide, kill
533
- // side uses the taskkill tree instead of a group).
534
350
  const sh = hostShell();
535
351
  const child = spawn(sh.shell, [...sh.args, command], {
536
352
  cwd,
537
353
  env: this.mergeEnv(options?.env),
538
354
  ...spawnGroupOptions(),
539
355
  });
540
- // core 5.8.0:cut-kill registry seam(onSpawn/onDetachAdopted)随 tool-cut 机制退役(core 零生产者,
541
- // 本臂为死码删除;1.321 时代的 still-running 守卫语义随之谢幕)。
542
356
  const finish = (r) => {
543
357
  if (settled)
544
358
  return;
545
359
  settled = true;
546
360
  clearTimeout(timer);
547
- // NOTE: the D5 grace timer is cleared in the `close` handler (pipes closed naturally), NOT here — an
548
- // abort/timeout settle reaches finish() BEFORE `close`, so clearing here would cancel the grace before
549
- // it can free a leaked FD (the exact case it exists for). On abort/timeout it fires after the grace.
550
361
  options?.abortSignal?.removeEventListener("abort", onAbort);
551
- // A foreground settle must also release the never-fired detach listener — the hub's signal
552
- // can outlive this exec (bounded 256-entry hub), and the listener closes over the child + tee buffers.
553
362
  removeDetachListener?.();
554
363
  resolve(r);
555
364
  };
@@ -557,17 +366,14 @@ export class RemoteHostExecutionEnv {
557
366
  const kill = () => {
558
367
  try {
559
368
  if (child.pid != null)
560
- killTreeHard(child.pid); // POSIX: kill(-pid) whole group (byte-identical); win32: taskkill /F /T
369
+ killTreeHard(child.pid);
561
370
  }
562
371
  catch {
563
372
  try {
564
373
  child.kill("SIGKILL");
565
374
  }
566
- catch { /* noop */ }
375
+ catch { }
567
376
  }
568
- // D5: if the kill leaves a re-parented grandchild holding the pipes, `close` may never come — free the
569
- // stdout/stderr FDs after a short grace. Idempotent (armPipeDestroyGrace is a no-op if already settled →
570
- // we only ever arm one; reassigning is harmless since kill is called once per abort/timeout).
571
377
  forceSettleTimer ??= armPipeDestroyGrace(child);
572
378
  };
573
379
  const timer = setTimeout(() => {
@@ -580,51 +386,34 @@ export class RemoteHostExecutionEnv {
580
386
  };
581
387
  if (options?.abortSignal)
582
388
  options.abortSignal.addEventListener("abort", onAbort, { once: true });
583
- // core 1.207 design/116 detach (clay 排, 1.81): mid-flight ctrl+b — ADOPT the running child as a background
584
- // shell instead of killing it. On adoption the exec settles EARLY with `detached:{shellId}` (+ the output
585
- // captured so far); ownership (output capture → the bg files, close handling, the BG-timeout wall, kill)
586
- // moves to the bgManager entry. Fired after `settled` = too late (no-op). Refused over the live-shell limit
587
- // (or on a pid-less/failed adopt) → the exec keeps running in the foreground, settling normally on close.
588
389
  const onDetach = () => {
589
390
  if (settled)
590
391
  return;
591
392
  const out = outBuf.result();
592
393
  const err = errBuf.result();
593
- // 🔴 SYNCHRONOUS adopt (double-review): no `await` between the tee handover and the pipe-listener attach,
594
- // so no data chunk is lost and no exec close/timeout can settle mid-adoption. The exec's ORIGINAL stdout/
595
- // stderr listeners are removed FIRST so post-detach output flows only to the adopted memory buffers (no
596
- // double-count into the now-orphaned outBuf) and `close` won't re-drive them. The adopted spool is the
597
- // tee-from-start pair (already holds every chunk since process start through ONE stateful decoder — review
598
- // #3); the truncation marker rides on the exec's OWN result string only (review LOW).
599
394
  child.stdout?.off("data", onExecOut);
600
395
  child.stderr?.off("data", onExecErr);
601
396
  const shellId = this.adoptRunningExec(child, tee.out, tee.err);
602
397
  const seedOut = markTruncated(out.text, out.droppedBytes);
603
398
  const seedErr = markTruncated(err.text, err.droppedBytes);
604
399
  if (shellId === undefined) {
605
- // Refused (limit) or no pid → re-attach the exec listeners; the command keeps running in the foreground
606
- // and settles normally on close (NEVER a dropped process).
607
400
  child.stdout?.on("data", onExecOut);
608
401
  child.stderr?.on("data", onExecErr);
609
402
  return;
610
403
  }
611
- clearTimeout(timer); // the adopted process is the bgManager's now (its own BG-timeout wall applies)
404
+ clearTimeout(timer);
612
405
  if (forceSettleTimer)
613
406
  clearTimeout(forceSettleTimer);
614
407
  options?.abortSignal?.removeEventListener("abort", onAbort);
615
408
  settled = true;
616
409
  resolve(ok({ stdout: seedOut, stderr: seedErr, exitCode: 0, detached: { shellId } }));
617
410
  };
618
- // Named (not inline) so onDetach can `off` them when it hands the pipes to the adopted background shell.
619
411
  const onExecOut = (d) => { outBuf.push(d); if (tee)
620
412
  feedMemStream(tee.out, d, HOST_BG_MEM_CAP); options?.onStdout?.(d.toString()); };
621
413
  const onExecErr = (d) => { errBuf.push(d); if (tee)
622
414
  feedMemStream(tee.err, d, HOST_BG_MEM_CAP); options?.onStderr?.(d.toString()); };
623
415
  child.stdout?.on("data", onExecOut);
624
416
  child.stderr?.on("data", onExecErr);
625
- // 🔴 AFTER the exec listeners exist (TDZ): core's ToolDetachHub uses create-then-abort, so a detach that
626
- // landed BEFORE this exec started arrives as an ALREADY-aborted signal → onDetach runs synchronously right
627
- // here and must be able to `off`/re-`on` onExecOut/onExecErr. Same tick either way — no event can interleave.
628
417
  if (detachSignal) {
629
418
  if (detachSignal.aborted)
630
419
  onDetach();
@@ -634,16 +423,11 @@ export class RemoteHostExecutionEnv {
634
423
  }
635
424
  }
636
425
  child.on("error", (e) => {
637
- // Failure to even spawn the shell (ENOENT etc.) — never the command's own non-zero exit.
638
426
  finish({ ok: false, error: new ExecutionError("spawn_error", `host spawn failed: ${e.message}`, e) });
639
427
  });
640
428
  child.on("close", (code, signal) => {
641
- // `close` fired ⇒ the pipes closed naturally → clear the D5 grace (it only needs to force-destroy FDs when
642
- // `close` NEVER comes after a kill — the re-parented-grandchild leak). Clearing here, not in finish().
643
429
  if (forceSettleTimer)
644
430
  clearTimeout(forceSettleTimer);
645
- // A non-zero exit is a NORMAL value, not an error (ExecutionEnv contract). A kill-by-signal that
646
- // wasn't a timeout/abort still resolves with the conventional 128+signo exit code.
647
431
  const exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 0) : 1);
648
432
  const out = outBuf.result();
649
433
  const err = errBuf.result();
@@ -651,8 +435,6 @@ export class RemoteHostExecutionEnv {
651
435
  });
652
436
  });
653
437
  }
654
- /** [1116] fg spool pair:tmpdir 私有根下专属子目录(五轮复审迁址,workspace=命令可写区不可放)+ "ax"
655
- * 独占建(core openSpoolPair 同形)。失败=undefined(调用方回退管道形)。 */
656
438
  openFgSpool() {
657
439
  try {
658
440
  const dir = path.join(this.ensureSpoolRoot(), "fg-" + randomBytes(8).toString("hex"));
@@ -668,17 +450,16 @@ export class RemoteHostExecutionEnv {
668
450
  try {
669
451
  closeSync(fdOut);
670
452
  }
671
- catch { /* noop */ }
453
+ catch { }
672
454
  throw e;
673
455
  }
674
- this.liveFgSpools.add(dir); // 见 liveFgSpools 旁注:登记后 destroy() 才不会把它脚下的根删掉
456
+ this.liveFgSpools.add(dir);
675
457
  return { dir, outPath, errPath, fds: [fdOut, fdErr] };
676
458
  }
677
459
  catch {
678
460
  return undefined;
679
461
  }
680
462
  }
681
- /** [1116] spool 形 fg exec(core node-execution-env 1.318-1.321 机制移植;坑图=core CHANGELOG 同段)。 */
682
463
  execViaSpool(command, options, cwd, timeoutMs, detachSignal, spool) {
683
464
  const EXIT_SETTLE_DRAIN_MS = 500;
684
465
  const PUMP_MS = 200;
@@ -689,14 +470,10 @@ export class RemoteHostExecutionEnv {
689
470
  const outBuf = new RollingTailBuffer();
690
471
  const errBuf = new RollingTailBuffer();
691
472
  let settled = false;
692
- let handedOver = false; // detach adoption 拿走了 spool 文件(bg poll 续读)——settle 清理不删文件
473
+ let handedOver = false;
693
474
  let pump;
694
475
  let drainTimer;
695
476
  const cursor = { out: 0, err: 0 };
696
- // F2(二轮复审):从命名 spool **文件**移除的累计字节(仅轮转/硬帽 truncate;skipAhead 不算,
697
- // 字节还在文件里)——与
698
- // RollingTailBuffer.droppedBytes 独立(后者只反映内存尾窗的 head-evict,轮转 truncate 后尾窗仍满
699
- // =0 dropped,整代对文件消失却不计)。handover 从 0 读现存文件,这个数就是 bg 看不到的历史。
700
477
  const spoolRemoved = { out: 0, err: 0 };
701
478
  const lanes = [
702
479
  { path: spool.outPath, key: "out", buf: outBuf, cb: options?.onStdout },
@@ -732,33 +509,25 @@ export class RemoteHostExecutionEnv {
732
509
  return;
733
510
  const skipped = behind - keep;
734
511
  lane.buf.recordSkippedBytes(skipped);
735
- // 注意:skip 只推游标,字节仍留在命名文件里(handover 从 0 读**看得到**)——不计 spoolRemoved;
736
- // 它们随后被 truncate 抹掉时按 cursor 整段计入(三轮复审:此处预计会双计)。
737
512
  cursor[lane.key] += skipped;
738
513
  }
739
- catch { /* noop */ }
514
+ catch { }
740
515
  };
741
516
  const reclaim = (lane) => {
742
- // 轮转回收(core 坑图「配额绕过」形):游标推进到 ROTATE 以上且文件读平 → truncate 归零;文件冲破
743
- // 硬帽(泵落后)→ 记 skipped 后强制归零(磁盘上限=诚实丢最旧)。
744
517
  if (cursor[lane.key] < ROTATE_BYTES)
745
518
  return;
746
519
  try {
747
520
  const size = statSync(lane.path).size;
748
521
  if (size === cursor[lane.key] || size >= HARD_CAP_BYTES) {
749
- // F2 竞窗补账(三轮复审收紧):truncate 前复采 size2(stat→truncate 间 "ax" append 形可能又写)
750
- // → 记账全部挪到 truncateSync **成功之后**(throw=文件一字未动=不记 spoolRemoved,下拍重试);
751
- // 实际截除=size2 整段(cursor 已 pump 部分 + 未 pump 部分)。size2 采样与 truncate 之间的极窄
752
- // append 窗仍在(活跃写者下 truncate 语义固有;量级=两条相邻 syscall 间),接受为残余误差。
753
522
  const size2 = statSync(lane.path).size;
754
523
  truncateSync(lane.path, 0);
755
524
  if (size2 > cursor[lane.key])
756
- lane.buf.recordSkippedBytes(size2 - cursor[lane.key]); // 未 pump 段对内存尾窗=跳过
757
- spoolRemoved[lane.key] += size2; // 硬帽/轮转同式:文件真正归零的字节数
525
+ lane.buf.recordSkippedBytes(size2 - cursor[lane.key]);
526
+ spoolRemoved[lane.key] += size2;
758
527
  cursor[lane.key] = 0;
759
528
  }
760
529
  }
761
- catch { /* noop */ }
530
+ catch { }
762
531
  };
763
532
  const feed = (lane, inc) => {
764
533
  lane.buf.push(inc);
@@ -766,7 +535,7 @@ export class RemoteHostExecutionEnv {
766
535
  try {
767
536
  lane.cb?.(inc.toString("utf8"));
768
537
  }
769
- catch { /* callback 失败不挡 settle(host 旧形同容忍) */ }
538
+ catch { }
770
539
  }
771
540
  };
772
541
  const pumpOnce = () => {
@@ -779,7 +548,6 @@ export class RemoteHostExecutionEnv {
779
548
  }
780
549
  };
781
550
  const drainFinal = () => {
782
- // finalization 真尾部(core 坑图:快突发不再拿头丢尾)——先跳到只剩 FINAL_TAIL 窗,再最多 4 轮读平。
783
551
  for (const lane of lanes) {
784
552
  skipAhead(lane, FINAL_TAIL_BYTES);
785
553
  for (let i = 0; i < 4; i++) {
@@ -799,23 +567,21 @@ export class RemoteHostExecutionEnv {
799
567
  clearTimeout(drainTimer);
800
568
  drainTimer = undefined;
801
569
  }
802
- this.liveFgSpools.delete(spool.dir); // 注销:本 exec 已 settle,它不再需要这个根活着
570
+ this.liveFgSpools.delete(spool.dir);
803
571
  if (!handedOver) {
804
- // RB-26 同型记档(core 官方裁决,发版复审 F3 对拆):幸存孙进程持继承 fd 对 unlinked inode 的
805
- // 后续写=脱离进程自身的增长,接受不管(命名文件形=CC 同暴露且路径可见更差;进程退出即回收)。
806
572
  for (const lane of lanes) {
807
573
  try {
808
574
  unlinkSync(lane.path);
809
575
  }
810
- catch { /* noop */ }
576
+ catch { }
811
577
  }
812
578
  try {
813
579
  rmdirSync(spool.dir);
814
580
  }
815
- catch { /* noop */ }
581
+ catch { }
816
582
  }
817
583
  };
818
- let removeDetachListener; // F5(二轮复审):先声明——onAbort→finish 会读它,TDZ 会抛 ReferenceError
584
+ let removeDetachListener;
819
585
  const finish = (r) => {
820
586
  if (settled)
821
587
  return;
@@ -831,7 +597,7 @@ export class RemoteHostExecutionEnv {
831
597
  try {
832
598
  closeSync(fd);
833
599
  }
834
- catch { /* noop */ }
600
+ catch { }
835
601
  }
836
602
  };
837
603
  const sh = hostShell();
@@ -841,23 +607,15 @@ export class RemoteHostExecutionEnv {
841
607
  cwd,
842
608
  env: this.mergeEnv(options?.env),
843
609
  ...spawnGroupOptions(),
844
- stdio: ["ignore", spool.fds[0], spool.fds[1]], // 子进程直写文件——进程间零管道([1116] 病理根除点)
610
+ stdio: ["ignore", spool.fds[0], spool.fds[1]],
845
611
  });
846
612
  }
847
613
  catch (e) {
848
- // 同步 spawn throw(如 cwd=文件的 ENOTDIR)——与管道旧形字节级对齐:executor 内 re-throw=Promise
849
- // reject(消费方按 await 抛感知传输层失败;吞成 Result err 会让上层静默。⚠️ 引例更新:core 3.0.0
850
- // 起 pruneWorktrees 改回执形 {ok},其消费点已双臂消费——worktree-isolation.ts;本处 spawn 层契约不变)。
851
614
  closeSpoolFds();
852
615
  cleanupSpool();
853
616
  throw e;
854
617
  }
855
- closeSpoolFds(); // 父进程的 fd 副本立即关(文件 size/EOF 只反映子进程写;launch FILE 形同姿势)
856
- // core 5.8.0:cut-kill registry seam 退役(pipe 径同注)。
857
- // 七轮复审:'error' 监听必须在任何可提前 return 的路径(下方 F5 already-aborted 查)之前装——异步
858
- // spawn 失败(如 cwd 不存在)以 ChildProcess 'error' 事件发出,无监听=uncaught 直接打死 worker 进程
859
- // (plain-node 最小形实证 crash;vitest/tsx 宿主会吸收,故该面无法用测试钉住,靠此排序保证)。
860
- // settle 后到达的 'error' 经 finish 的 settled 门自然吞掉。
618
+ closeSpoolFds();
861
619
  child.on("error", (e) => {
862
620
  finish({ ok: false, error: new ExecutionError("spawn_error", `host spawn failed: ${e.message}`) });
863
621
  });
@@ -867,14 +625,10 @@ export class RemoteHostExecutionEnv {
867
625
  const e = errBuf.result();
868
626
  return { stdout: markTruncated(o.text, o.droppedBytes), stderr: markTruncated(e.text, e.droppedBytes) };
869
627
  };
870
- // F4(发版复审):exit 一到即 latch——命令已 completed,此后 timeout/abort 绝不再杀(否则 close
871
- // 延迟/缺席的 500ms drain 窗内,一个 timeout 会把幸存进程组连坐——恰是本修要消的杀伤面),detach
872
- // 也不再领养(completed 命令不是后台工作)。只剩 drain timer 与 close 竞争 settle。
873
628
  let exited = false;
874
629
  const timer = setTimeout(() => {
875
630
  if (exited)
876
- return; // F4:completed 命令不进杀伤集(drain settle 即将给出正常结果)
877
- // timeout 仍杀树(RUNNING 命令超时=合法杀伤面;[1116] 修的是 completed 命令被 close 不来拖进杀伤集)
631
+ return;
878
632
  try {
879
633
  if (child.pid != null)
880
634
  killTreeHard(child.pid);
@@ -883,14 +637,14 @@ export class RemoteHostExecutionEnv {
883
637
  try {
884
638
  child.kill("SIGKILL");
885
639
  }
886
- catch { /* noop */ }
640
+ catch { }
887
641
  }
888
642
  const p = partial();
889
643
  finish({ ok: false, error: new ExecutionError("timeout", `host exec exceeded ${timeoutMs}ms; partial stdout: ${p.stdout.slice(-2048)}`) });
890
644
  }, timeoutMs);
891
645
  const onAbort = () => {
892
646
  if (exited)
893
- return; // F4 同上:已 completed 的 abort=让 drain settle 正常返回,不杀幸存组
647
+ return;
894
648
  try {
895
649
  if (child.pid != null)
896
650
  killTreeHard(child.pid);
@@ -899,38 +653,27 @@ export class RemoteHostExecutionEnv {
899
653
  try {
900
654
  child.kill("SIGKILL");
901
655
  }
902
- catch { /* noop */ }
656
+ catch { }
903
657
  }
904
658
  finish({ ok: false, error: new ExecutionError("aborted", "host exec aborted") });
905
659
  };
906
- // F5(发版复审):AbortSignal 不重放 shellReady/workspace 建目录期间已发生的 abort——注册前必查。
907
660
  if (options?.abortSignal?.aborted) {
908
661
  onAbort();
909
662
  return;
910
663
  }
911
664
  if (options?.abortSignal)
912
665
  options.abortSignal.addEventListener("abort", onAbort, { once: true });
913
- // F1(发版复审):泵先装再挂 detach——already-aborted 的 detachSignal 会同步跑 onDetach→finish,
914
- // 若泵在其后才装,cleanupSpool 清不到=幽灵泵继续 truncate 已移交的文件+常驻泄漏。
915
666
  pump = setInterval(pumpOnce, PUMP_MS);
916
667
  pump.unref?.();
917
668
  const onDetach = () => {
918
669
  if (settled || exited)
919
- return; // F4:completed 命令不领养(它不是后台工作;drain settle 在途)
920
- pumpOnce(); // fg 段读平(exec 结果里的 seed 完整)
921
- // handover 游标=0(消费方契约,旧 mem 形同语义):core 的 Bash 工具在 detach 时只给模型
922
- // 「moved to background」marker、丢弃 exec 结果的 stdout——完整历史必须活在 bg spool 里由
923
- // BashOutput 给。spool FILE 形从 0 重读同一文件=fg+post 单一字节流零接缝零 U+FFFD(mem-tee
924
- // 为此专门修过的病结构性不存在);已被轮转 truncate 的头部(>8MB)在 exec 结果里已 markTruncated。
925
- // F2(发版复审):fg 期发生过轮转/skip 的丢失字节随 handover 记账(bg 首拍 poll 并入
926
- // bytesDroppedBeforeCursor → BashOutput 的 truncated 面诚实),从 0 读只覆盖现存代。
927
- // F2(二轮复审):handover 丢失=从命名 spool 文件真正移除的字节(spoolRemoved),不是 RollingTail
928
- // 尾窗的 head-evict 计数——后者在轮转 truncate 后仍报 0(尾窗满),会漏计整代。
670
+ return;
671
+ pumpOnce();
929
672
  const fgDropped = spoolRemoved.out + spoolRemoved.err;
930
673
  const shellId = this.adoptRunningExecSpool(child, spool, { out: 0, err: 0 }, fgDropped);
931
674
  if (shellId === undefined)
932
- return; // 拒收(limit/无 pid)→ 继续 fg,泵照跑,close 正常 settle
933
- handedOver = true; // spool 文件归 bg(dispose 面按 FILE 形清理)
675
+ return;
676
+ handedOver = true;
934
677
  const o = outBuf.result();
935
678
  const e = errBuf.result();
936
679
  finish(ok({ stdout: markTruncated(o.text, o.droppedBytes), stderr: markTruncated(e.text, e.droppedBytes), exitCode: 0, detached: { shellId } }));
@@ -952,7 +695,6 @@ export class RemoteHostExecutionEnv {
952
695
  const exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 0) : 1);
953
696
  finish(ok({ stdout: markTruncated(o.text, o.droppedBytes), stderr: markTruncated(e.text, e.droppedBytes), exitCode }));
954
697
  };
955
- // ('error' 监听已在 spawn 后立即装——七轮复审:F5 提前 return 前必须有监听,防 uncaught)
956
698
  child.on("close", (code, signal) => {
957
699
  if (drainTimer) {
958
700
  clearTimeout(drainTimer);
@@ -961,18 +703,12 @@ export class RemoteHostExecutionEnv {
961
703
  settleFromTermination(code, signal);
962
704
  });
963
705
  child.on("exit", (code, signal) => {
964
- // F4(二轮复审):exit **立即** latch——命令已 completed,此后 timeout/abort/detach 全 no-op(否则
965
- // close 延迟/缺席时,drain 窗内的 timeout 会 killTree 连坐幸存孙进程;一轮修漏了这行赋值)。
966
706
  exited = true;
967
- // [1116] 病理根除点:exit=子 shell 已终——孙进程(nohup 服务)可以继续持有 spool 文件 fd(写文件
968
- // 不牵连本 exec 的 settle),500ms drain 后兜底 settle;close 先来则上面清掉本 timer 直接 settle。
969
707
  drainTimer = setTimeout(() => settleFromTermination(code, signal), EXIT_SETTLE_DRAIN_MS);
970
708
  drainTimer.unref?.();
971
709
  });
972
710
  });
973
711
  }
974
- /** [1116] spool 形 detach adoption:spool 路径+fg 游标直接进 HostBgState 的 FILE 面(launch 同形,poll
975
- * 共码);同一文件单一字节流=天然无 decoder 接缝(mem-tee 版为此专门修过的病在此结构性不存在)。 */
976
712
  adoptRunningExecSpool(child, spool, cursors, fgRemovedBytes = 0) {
977
713
  const pid = child.pid;
978
714
  if (pid == null)
@@ -993,16 +729,12 @@ export class RemoteHostExecutionEnv {
993
729
  });
994
730
  return adopted?.shellId;
995
731
  }
996
- // ─────────────────────────────── streaming exec ───────────────────────────────
997
732
  async *execStream(command, options) {
998
733
  const ready = await this.shellReady();
999
734
  if (!ready.ok)
1000
735
  throw new RemoteExecutionError("connect_failed", ready.error.message, ready.error);
1001
736
  const cwd = this.resolve(options?.cwd ?? this.cwd);
1002
737
  const timeoutMs = options?.timeout != null ? options.timeout * 1000 : undefined;
1003
- // Liveness is opt-in here (unlike e2b's default-on idle cap). Deliberate divergence:
1004
- // host runs on the operator's own machine — no provider-unreachability hang class — and a missing
1005
- // readTimeoutMs means the caller's own AbortSignal is the abort path. So no default wall-clock cap.
1006
738
  const idleMs = options?.readTimeoutMs;
1007
739
  const cap = options?.maxOutputBytes;
1008
740
  const queue = [];
@@ -1013,7 +745,7 @@ export class RemoteHostExecutionEnv {
1013
745
  let lastChunk = Date.now();
1014
746
  let wake;
1015
747
  const signalReady = () => { const w = wake; wake = undefined; w?.(); };
1016
- const sh = hostShell(); // Windows S1/S3: platform-resolved shell + group options (POSIX byte-identical)
748
+ const sh = hostShell();
1017
749
  const child = spawn(sh.shell, [...sh.args, command], { cwd, env: this.mergeEnv(options?.env), ...spawnGroupOptions() });
1018
750
  let pipeDestroyTimer;
1019
751
  const kill = () => {
@@ -1025,12 +757,8 @@ export class RemoteHostExecutionEnv {
1025
757
  try {
1026
758
  child.kill("SIGKILL");
1027
759
  }
1028
- catch { /* noop */ }
760
+ catch { }
1029
761
  }
1030
- // S2b: same D5 pipe-destroy grace the buffered exec has — a kill-escaping process (POSIX
1031
- // re-parented grandchild; win32 taskkill out-of-tree escapee) holding our pipes means `close` never
1032
- // fires and the stream hangs on its own idle/abort path forever. Force-free the FDs after the grace so
1033
- // the generator's close-driven finish always arrives. Idempotent (one timer; cleared on close).
1034
762
  pipeDestroyTimer ??= armPipeDestroyGrace(child);
1035
763
  };
1036
764
  const enqueue = (type, data) => {
@@ -1052,7 +780,7 @@ export class RemoteHostExecutionEnv {
1052
780
  });
1053
781
  child.on("close", (code, signal) => {
1054
782
  if (pipeDestroyTimer)
1055
- clearTimeout(pipeDestroyTimer); // pipes closed naturally → the S2b grace is moot
783
+ clearTimeout(pipeDestroyTimer);
1056
784
  if (finished)
1057
785
  return;
1058
786
  exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 0) : 1);
@@ -1073,7 +801,6 @@ export class RemoteHostExecutionEnv {
1073
801
  else
1074
802
  options.signal.addEventListener("abort", onAbort, { once: true });
1075
803
  }
1076
- // Optional total wall-clock cap (host: only when the caller asks).
1077
804
  const wallTimer = timeoutMs != null
1078
805
  ? setTimeout(() => {
1079
806
  if (finished)
@@ -1100,10 +827,7 @@ export class RemoteHostExecutionEnv {
1100
827
  throw failure;
1101
828
  if (finished)
1102
829
  break;
1103
- // idle/liveness bound: no chunk within readTimeoutMs → typed timeout (a hung command).
1104
830
  const waitMs = idleMs != null ? Math.max(1, idleMs - (Date.now() - lastChunk)) : 30_000;
1105
- // [4947] A-002.11 sibling: the losing side of this race must be cleared — wake() firing first
1106
- // used to leave the timer armed for up to waitMs (30s), one per drain round on long streams.
1107
831
  let waitTimer;
1108
832
  try {
1109
833
  await new Promise((r) => { wake = r; waitTimer = setTimeout(r, waitMs); });
@@ -1128,13 +852,12 @@ export class RemoteHostExecutionEnv {
1128
852
  if (!finished) {
1129
853
  finished = true;
1130
854
  kill();
1131
- } // consumer abandoned early → don't leak the child
855
+ }
1132
856
  if (wallTimer)
1133
857
  clearTimeout(wallTimer);
1134
858
  options?.signal?.removeEventListener("abort", onAbort);
1135
859
  }
1136
860
  }
1137
- // ─────────────────────────────── FileSystem ───────────────────────────────
1138
861
  async absolutePath(p) {
1139
862
  return ok(this.resolve(p));
1140
863
  }
@@ -1183,7 +906,6 @@ export class RemoteHostExecutionEnv {
1183
906
  return ready;
1184
907
  const abs = this.resolve(p);
1185
908
  try {
1186
- // Create parent dirs automatically (parity with the E2B adapter).
1187
909
  await fs.mkdir(path.dirname(abs), { recursive: true });
1188
910
  const data = typeof content === "string" ? content : Buffer.from(content);
1189
911
  await fs.writeFile(abs, data, { signal: abortSignal });
@@ -1194,9 +916,6 @@ export class RemoteHostExecutionEnv {
1194
916
  }
1195
917
  }
1196
918
  async appendFile(p, content, abortSignal) {
1197
- // `lingeringBgWrite`:destroy 之后**仍在册**的后台 shell 还要把输出镜像到文件 —— 见 fsReady 旁注。
1198
- // 只有 append 开这个口:core 的后台输出镜像唯一走 `env.appendFile(handle.outputFile, …)`,别的文件动作
1199
- // 在 destroy 之后没有正当理由(读面留给下一轮的新 env)。
1200
919
  const ready = await this.fsReady(p, { lingeringBgWrite: true });
1201
920
  if (!ready.ok)
1202
921
  return ready;
@@ -1219,7 +938,7 @@ export class RemoteHostExecutionEnv {
1219
938
  return ready;
1220
939
  const abs = this.resolve(p);
1221
940
  try {
1222
- const st = await fs.lstat(abs); // no follow (FileInfo contract)
941
+ const st = await fs.lstat(abs);
1223
942
  return ok({
1224
943
  name: path.basename(abs),
1225
944
  path: abs,
@@ -1250,7 +969,6 @@ export class RemoteHostExecutionEnv {
1250
969
  mtimeMs = st.mtimeMs;
1251
970
  }
1252
971
  catch {
1253
- /* a child that vanished between readdir and lstat → keep kind from Dirent, size/mtime 0 */
1254
972
  }
1255
973
  out.push({ name: e.name, path: child, kind: kindFromDirent(e), size, mtimeMs });
1256
974
  }
@@ -1261,10 +979,6 @@ export class RemoteHostExecutionEnv {
1261
979
  }
1262
980
  }
1263
981
  async canonicalPath(p, abortSignal) {
1264
- // 🔴 只读面也过 fsReady(2026-07-25 统一):`exists`/`canonicalPath`/`readLink` 此前**不查** destroyed,
1265
- // 于是拆除之后「exists 答 true、紧接着 readTextFile 答 already destroyed」这一对自相矛盾 —— 调用方按
1266
- // exists 的结果去读就撞一个措辞完全不同的错。同批把 adb 腿的读面也补上了门(它此前连 connected 都不查),
1267
- // 这里是同一件事:**同族一致性**。只读探测本身无害,但"有害/无害"不该是各方法自行其是的判断。
1268
982
  const ready = await this.fsReady(p);
1269
983
  if (!ready.ok)
1270
984
  return ready;
@@ -1277,10 +991,6 @@ export class RemoteHostExecutionEnv {
1277
991
  }
1278
992
  }
1279
993
  async readLink(p, abortSignal) {
1280
- // 🔴 只读面也过 fsReady(2026-07-25 统一):`exists`/`canonicalPath`/`readLink` 此前**不查** destroyed,
1281
- // 于是拆除之后「exists 答 true、紧接着 readTextFile 答 already destroyed」这一对自相矛盾 —— 调用方按
1282
- // exists 的结果去读就撞一个措辞完全不同的错。同批把 adb 腿的读面也补上了门(它此前连 connected 都不查),
1283
- // 这里是同一件事:**同族一致性**。只读探测本身无害,但"有害/无害"不该是各方法自行其是的判断。
1284
994
  const ready = await this.fsReady(p);
1285
995
  if (!ready.ok)
1286
996
  return ready;
@@ -1293,10 +1003,6 @@ export class RemoteHostExecutionEnv {
1293
1003
  }
1294
1004
  }
1295
1005
  async exists(p, abortSignal) {
1296
- // 🔴 只读面也过 fsReady(2026-07-25 统一):`exists`/`canonicalPath`/`readLink` 此前**不查** destroyed,
1297
- // 于是拆除之后「exists 答 true、紧接着 readTextFile 答 already destroyed」这一对自相矛盾 —— 调用方按
1298
- // exists 的结果去读就撞一个措辞完全不同的错。同批把 adb 腿的读面也补上了门(它此前连 connected 都不查),
1299
- // 这里是同一件事:**同族一致性**。只读探测本身无害,但"有害/无害"不该是各方法自行其是的判断。
1300
1006
  const ready = await this.fsReady(p);
1301
1007
  if (!ready.ok)
1302
1008
  return ready;
@@ -1308,7 +1014,7 @@ export class RemoteHostExecutionEnv {
1308
1014
  catch (e) {
1309
1015
  const fe = this.fileError(e, abs, abortSignal);
1310
1016
  if (fe.code === "not_found")
1311
- return ok(false); // missing path is `false`, not an error (contract)
1017
+ return ok(false);
1312
1018
  return { ok: false, error: fe };
1313
1019
  }
1314
1020
  }
@@ -1326,13 +1032,12 @@ export class RemoteHostExecutionEnv {
1326
1032
  }
1327
1033
  }
1328
1034
  async remove(p, options) {
1329
- const ready = await this.fsReady(p); // mutation: fail closed after destroy() — parity with writeFile/createDir + e2b/ssh
1035
+ const ready = await this.fsReady(p);
1330
1036
  if (!ready.ok)
1331
1037
  return ready;
1332
1038
  const abs = this.resolve(p);
1333
1039
  try {
1334
1040
  if (!options?.force) {
1335
- // Surface a missing path as not_found (fs.rm with force:false already throws ENOENT, but be explicit).
1336
1041
  await fs.lstat(abs);
1337
1042
  }
1338
1043
  await fs.rm(abs, { recursive: options?.recursive ?? false, force: options?.force ?? false });
@@ -1345,18 +1050,6 @@ export class RemoteHostExecutionEnv {
1345
1050
  return { ok: false, error: fe };
1346
1051
  }
1347
1052
  }
1348
- /** 🔴 临时文件/目录**不放进 workspace**(2026-07-25 修)。
1349
- * 原来两者都建在 `this.workspaceDir` 下,而 **TOC/CC 形的 workspaceDir 就是用户自己的目录**,且 `destroy()`
1350
- * 明文永不删它 ⇒ core 给**每个**后台 bash 都调一次 `createTempFile({prefix:"bash-task-", suffix:".output"})`
1351
- * ⇒ 用户仓库根目录按后台任务数累积 `bash-task-<hex>.output`(内容=命令输出)+ 每次 `createTempDir` 一个
1352
- * `tmp-XXXXXX/`,**永久**留在他们的 git 树里。两条独立判据说明这是偏差不是设计:①core 自己的 TOC 参照实现
1353
- * `NodeExecutionEnv.createTempFile` 放的是 `os.tmpdir()`;②**本适配器早就为了同一个理由把 spool 根搬去了
1354
- * tmpdir**([1116] 五轮复审明写"不再弄脏 user-owned workspace"),只是 createTempFile/Dir 没跟上。
1355
- * 改用本 env 自己的 tmp 根(`ensureSpoolRoot()`,已在 `os.tmpdir()` 下):
1356
- * · 用户目录不再被写入;
1357
- * · 镜像写仍然可用 —— `isLingeringMirrorTarget` 同批放行这个根(见那里的旁注);
1358
- * · ephemeral 形反而更好:`destroy()` 在**有后台 shell 在册**时不删这个根,而 workspace 是会被 rm 的。
1359
- * ⚠️ 这是 **host-only**:k8s/local-docker 的临时文件必须落在**容器内**可见的 cwd,ssh/e2b 本来就用远端系统 tmp。 */
1360
1053
  async createTempDir(prefix = "tmp-", abortSignal) {
1361
1054
  const ready = await this.fsReady(".");
1362
1055
  if (!ready.ok)
@@ -1376,29 +1069,23 @@ export class RemoteHostExecutionEnv {
1376
1069
  const suffix = options?.suffix ?? "";
1377
1070
  try {
1378
1071
  const name = `${prefix}${randomBytes(8).toString("hex")}${suffix}`;
1379
- const abs = path.join(this.ensureSpoolRoot(), name); // 见 createTempDir 上方旁注:不放进用户的 workspace
1072
+ const abs = path.join(this.ensureSpoolRoot(), name);
1380
1073
  if (options?.abortSignal?.aborted)
1381
1074
  throw Object.assign(new Error("aborted"), { name: "AbortError" });
1382
- await fs.writeFile(abs, "", { flag: "wx" }); // wx = fail if exists (random → won't)
1075
+ await fs.writeFile(abs, "", { flag: "wx" });
1383
1076
  return ok(abs);
1384
1077
  }
1385
1078
  catch (e) {
1386
1079
  return { ok: false, error: this.fileError(e, this.workspaceDir, options?.abortSignal) };
1387
1080
  }
1388
1081
  }
1389
- // ─────────────────────────────── internals ───────────────────────────────
1390
- /** Resolve a (possibly relative) path against {@link cwd} like the other adapters. Host paths are platform-native. */
1391
1082
  resolve(p) {
1392
1083
  return path.isAbsolute(p) ? path.normalize(p) : path.normalize(path.resolve(this.cwd, p));
1393
1084
  }
1394
- /** Lazily create the per-task workspace exactly once (memoized; concurrent first-use shares it). */
1395
1085
  ensurePrepared() {
1396
1086
  if (this.prepared)
1397
1087
  return Promise.resolve();
1398
1088
  if (!this.preparing) {
1399
- // Reset the memo on rejection so a later call can re-attempt after a transient mkdir
1400
- // failure (ENOSPC / racy EACCES / rm race) — mirrors e2b's reset-on-failed-provision.
1401
- // Re-throw (don't swallow) so shellReady/fsReady/connect still surface the original error.
1402
1089
  this.preparing = fs
1403
1090
  .mkdir(this.workspaceDir, { recursive: true })
1404
1091
  .then(() => {
@@ -1415,11 +1102,6 @@ export class RemoteHostExecutionEnv {
1415
1102
  if (this.destroyed)
1416
1103
  return { ok: false, error: new ExecutionError("shell_unavailable", "execution env already destroyed") };
1417
1104
  try {
1418
- // Windows S1 (design D1): resolve the host shell ONCE (POSIX = /bin/sh, sync constant; win32 = Git Bash
1419
- // via core getShellConfig, fail-loud when absent). Every spawn site is behind this gate, so the sync
1420
- // `hostShell()` reads below are always post-resolution. A missing Git Bash surfaces as its own
1421
- // shell_unavailable error with the install hint — never a silent cmd fallback, never mislabeled as a
1422
- // workspace failure.
1423
1105
  try {
1424
1106
  await resolveHostShell();
1425
1107
  }
@@ -1433,48 +1115,10 @@ export class RemoteHostExecutionEnv {
1433
1115
  return { ok: false, error: new ExecutionError("shell_unavailable", `failed to create host workspace: ${errMsg(e)}`, asError(e)) };
1434
1116
  }
1435
1117
  }
1436
- /**
1437
- * @param opts.lingeringBgWrite 只由 {@link appendFile} 传 true —— 允许「destroy 之后、仍在册的后台 shell
1438
- * 的输出镜像写」。
1439
- *
1440
- * 🔴 为什么需要这个口(2026-07-25,cli dogfood 报的现象追出来的真因):`destroy()` 无条件置
1441
- * `destroyed = true`,于是文件面**全拒**;而 `pollBackground` 直通 bgManager、**没有** destroyed 门。
1442
- * 这与本适配器自己遵守的 core 契约(1.258.2「destroy 不得收割后台进程」,上方 destroy() 里也为
1443
- * 「dispose 前仍可轮询」的残余刻意保留了 spool 根)直接打架:我们**刻意**让后台 shell 跨 destroy 存活并
1444
- * 继续被轮询,却拒掉它们要写的那个文件 ⇒ core 的 `appendFile` 一失败即置 `spool.mirrorFailed`,用户在一个
1445
- * 其实成功的后台任务上看到「[!] The output file is INCOMPLETE」。已用真子进程执行验证过这个不对称。
1446
- *
1447
- * 放行判据刻意收紧到三条同时成立,少一条都拒:
1448
- * ① `persistent` —— 只有 TOC/CC 形(构造时给了 `workspaceDir` = 用户自己的目录)。那一形 destroy() 明文
1449
- * 「NEVER rm -rf it」,什么都没删,文件确实还在,core 还告诉用户「Read it any time」⇒ 报 INCOMPLETE 是谎。
1450
- * **ephemeral 形不放行**:它的 destroy() 真的 rm -rf 了 workspace,而 `createTempFile` 恰把 outputFile
1451
- * 建在 workspace 里 ⇒ 镜像目标已被删,拒写是**诚实**的;放行反而会让 appendFile 的 mkdir -p 把一个已拆除
1452
- * 的工作目录**复活**成孤儿目录。
1453
- * ② 还有后台 shell **在册** —— 判据与 destroy() 里"跳过删 spool 根"用的是同一个 `hasShells()`,即
1454
- * 「还有人在读写这些文件」这一个事实,不是两套规则。没有 shell 在册时 `destroyed` 照旧是硬护栏。
1455
- * ③ 只对 append 一个动作开 —— 不是把整个文件面在 destroy 之后重新开门。
1456
- * 用 `this._bgManager?.` 而不是惰性 getter:拆除路径上不该顺手 **新建** 一个 manager。
1457
- */
1458
- /** destroy 后「残余镜像写」的**路径**判据 —— 与方法判据(`lingeringBgWrite`)并列,两者都满足才放行。
1459
- *
1460
- * 🔴 为什么需要它(深挖第一轮打在我自己头上的一条,2026-07-25):我原先的放行只看方法标志,于是 destroy 之后
1461
- * `appendFile` 能**新建**文件、`mkdir -p` 建整棵树、甚至写 workspace **之外**的绝对路径 —— 而我在 fsReady
1462
- * 头注里写的是「别的文件动作在 destroy 之后没有正当理由」。探针实测把这个不一致钉了出来:说是"窄",在路径
1463
- * 维度上一点都不窄。
1464
- *
1465
- * 收紧到恰好贴合唯一的正当调用方(core 的后台输出镜像):
1466
- * ① 目标必须在 **workspace 之内**(`createTempFile` 就把 outputFile 建在这儿)—— 挡住"写到工作区外面";
1467
- * ② 目标**必须已经存在** —— 镜像写的文件是 destroy **之前**建好的,所以"新建"从来不是这条路的需求;
1468
- * 这一条同时挡掉 `mkdir -p` 建新目录树的副作用。
1469
- * 同步 `existsSync` 而不是 await:这是拆除路径上的判据,不该再引入一次异步让出。 */
1470
1118
  isLingeringMirrorTarget(p) {
1471
1119
  const abs = this.resolve(p);
1472
1120
  if (!existsSync(abs))
1473
- return false; // 必须是既存文件(镜像目标在 destroy 前就已建好)
1474
- // 🔴 必须在**同一路径空间**里比 containment:core 传进来的是 `canonicalPath()` 的产物(realpath),
1475
- // 而 `workspaceDir` 是构造时给的原样串 —— macOS 上 `/var/...` 与 `/private/var/...` 是同一目录的两种写法,
1476
- // 直接 `path.relative` 会把合法目标判成"在 workspace 之外"。(这条是我自己的行为面测试当场按住的:
1477
- // 收紧判据的第一版让 `host-env-bg-mirror-after-destroy` 立刻红。)两侧都过 realpath 再比。
1121
+ return false;
1478
1122
  const real = (x) => { try {
1479
1123
  return realpathSync(x);
1480
1124
  }
@@ -1485,23 +1129,13 @@ export class RemoteHostExecutionEnv {
1485
1129
  const rel = path.relative(real(root), real(abs));
1486
1130
  return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
1487
1131
  };
1488
- // workspace **或**本 env 自己的 tmp 根:后者是 `createTempFile` 现在的落点(同批把临时文件搬出用户目录,
1489
- // 见 createTempDir 上方旁注)。两个根都是"本 env 自有"的面,放行它们不会让残余写触及无关路径。
1490
- // 用 `this.spoolRootDir` 而不是 `ensureSpoolRoot()`:拆除路径上不该顺手**新建**一个 tmp 根。
1491
1132
  return inside(this.workspaceDir) || (this.spoolRootDir !== undefined && inside(this.spoolRootDir));
1492
1133
  }
1493
1134
  async fsReady(p, opts) {
1494
1135
  if (this.destroyed) {
1495
- // 🔴 判据从三条收成两条 +路径判据(2026-07-25 同批):原先还要求 `this.persistent`,理由是
1496
- // 「ephemeral 形的 workspace 会被 rm,放行会让 mkdir -p **复活**一个已拆除的目录」。同批把临时文件搬到本 env
1497
- // 的 tmp 根之后,那个理由**已被 `isLingeringMirrorTarget` 里的 `existsSync` 覆盖**:workspace 被删 ⇒ 目标不
1498
- // 存在 ⇒ 自然拒;而"必须是既存文件"也让 mkdir -p 无从复活任何东西。继续要求 `persistent` 只会挡住
1499
- // ephemeral 形**本该成功**的镜像写(目标在 tmp 根、destroy 在有 shell 在册时不删它)。
1500
1136
  const lingering = opts?.lingeringBgWrite === true && this._bgManager?.hasShells() === true && this.isLingeringMirrorTarget(p);
1501
1137
  if (!lingering)
1502
1138
  return { ok: false, error: new FileError("unknown", "execution env already destroyed", this.resolve(p)) };
1503
- // 放行:不再走 ensurePrepared() —— destroy 已清空 preparing memo,而 persistent 形的目录本来就是用户的、
1504
- // 必然存在(后台 shell 正在里面跑),且 appendFile 自己还会 mkdir -p 父目录。
1505
1139
  return ok(undefined);
1506
1140
  }
1507
1141
  try {
@@ -1512,10 +1146,6 @@ export class RemoteHostExecutionEnv {
1512
1146
  return { ok: false, error: new FileError("unknown", `failed to create host workspace: ${errMsg(e)}`, this.resolve(p), asError(e)) };
1513
1147
  }
1514
1148
  }
1515
- /** Base env (process.env when inheriting) merged with the adapter's secret env, then per-command overrides win. */
1516
- /** D1 secure default = `"scrub"`: inherit `process.env` minus secret-pattern keys (core's `scrubSecretEnv`, single
1517
- * source of truth). `"all"` = full inherit (opt-out); a `string[]` = strict allowlist. Legacy `inheritProcessEnv`
1518
- * maps `true`→`"all"` / `false`→`[]`. */
1519
1149
  inheritMode() {
1520
1150
  if (this.cfg.inheritEnv !== undefined)
1521
1151
  return this.cfg.inheritEnv;
@@ -1537,23 +1167,14 @@ export class RemoteHostExecutionEnv {
1537
1167
  base[k] = process.env[k];
1538
1168
  }
1539
1169
  else {
1540
- // "scrub" — core's first-party D1 secret-env filter. A-033.3(#256):带 `findings` 收集器调用,
1541
- // 把「这一次丢了哪些密钥形 key、按哪条规则、置信度多少」交给既有观测面留痕。零留痕的保护证明不了
1542
- // 自己生效过,也发现不了自己不再生效(词表漂/新命名法穿缝/这条腿被改成 inheritEnv:"all")。
1543
- // 收集器**不改变**剥除行为一个字节(core: 省略它 ⇒ byte-for-byte 与之前相同)。
1544
1170
  const findings = [];
1545
1171
  base = scrubSecretEnv(process.env, findings);
1546
1172
  recordSecretEnvScrub("server.remote-env-host.inherit-scrub", findings);
1547
1173
  }
1548
1174
  if (this.cfg.env)
1549
- Object.assign(base, this.cfg.env); // caller-supplied (control-plane secrets) verbatim
1175
+ Object.assign(base, this.cfg.env);
1550
1176
  if (perCommand)
1551
- Object.assign(base, perCommand); // per-call wins
1552
- // Windows S2b (design D1): win32 env keys are case-insensitive — a case-sensitive merge that
1553
- // produced BOTH `Path` (inherited) and `PATH` (override) reaches CreateProcess as one-undefined-which.
1554
- // Pin SHELL to the resolved Git Bash (subshell scripts read $SHELL) BEFORE the collapse (so an inherited
1555
- // case-variant `Shell` folds into one entry), then collapse case-duplicates (later = higher-precedence
1556
- // value, first-seen = native casing). POSIX: both are identity/no-ops — byte-identical.
1177
+ Object.assign(base, perCommand);
1557
1178
  if (process.platform === "win32")
1558
1179
  base.SHELL = hostShell().shell;
1559
1180
  return collapseWin32EnvKeys(base);
@@ -1579,7 +1200,6 @@ export class RemoteHostExecutionEnv {
1579
1200
  return new FileError("unknown", msg, p);
1580
1201
  }
1581
1202
  }
1582
- /** Strip anything but [A-Za-z0-9._-] from an id so it is safe in a directory name. */
1583
1203
  function sanitizeId(id) {
1584
1204
  return id.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 64) || "task";
1585
1205
  }
@@ -1596,57 +1216,17 @@ function errMsg(e) {
1596
1216
  function asError(e) {
1597
1217
  return e instanceof Error ? e : undefined;
1598
1218
  }
1599
- /** Conventional exit code for a process killed by a signal (128 + signal number); falls back to undefined.
1600
- * Uses Node's authoritative platform table (`os.constants.signals`) — a hand-written table missed SIGABRT/SIGPIPE
1601
- * (`kill -ABRT` reported 137 instead of 134, impersonating SIGKILL). Callers keep the `?? 9`
1602
- * fallback for a name the platform doesn't define, matching core's `SIGNUM[signal] ?? 9`. */
1603
1219
  function signalNumber(signal) {
1604
1220
  return os.constants.signals[signal];
1605
1221
  }
1606
- /**
1607
- * `ExecutionEnvFactory` for the TOC `host` backend (DUAL-MODE-DESIGN §5). Wiring this onto a deployment makes
1608
- * its agent run DIRECTLY on the worker's machine (no container) — the local-coding-agent default. One fresh
1609
- * env per task; the Runner owns the lifetime and calls `destroy()` (= `rm -rf` the workspace) on task end.
1610
- *
1611
- * Mirrors `e2bExecutionEnvFactory`: returns the env **unconnected** (lazy) and folds the per-task
1612
- * `ctx.sessionId` into the workspace dir id so two concurrent tasks never share a working directory.
1613
- */
1614
1222
  export function hostExecutionEnvFactory(config = {}) {
1615
1223
  return (ctx) => new RemoteHostExecutionEnv({
1616
1224
  ...config,
1617
1225
  id: config.id ?? ctx.taskId ?? ctx.sessionId,
1618
1226
  });
1619
1227
  }
1620
- // ─────────────────────────────── remoteExec superset: docker providers ───────────────────────────────
1621
- //
1622
- // DUAL-MODE-DESIGN §5 defines a `remoteExec.provider` superset. `host` (above) is implemented; `local-docker`
1623
- // (the isolation lane) and `remote-docker` (that same container lane aimed at a REMOTE daemon) are BOTH
1624
- // implemented by remote-env-local-docker.ts and re-exported / wrapped here so the discriminator entry points +
1625
- // the historical import sites stay stable.
1626
- // `local-docker` (the isolation lane): real implementation moved to remote-env-local-docker.ts. Re-export the
1627
- // factory + config from here so existing wiring (`import { localDockerExecutionEnvFactory } from "./remote-env-host"`)
1628
- // keeps working and the provider discriminator has one canonical entry point.
1629
1228
  export { localDockerExecutionEnvFactory, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js";
1630
1229
  import { localDockerExecutionEnvFactory } from "./remote-env-local-docker.js";
1631
- /**
1632
- * `ExecutionEnvFactory` for the TOC `remote-docker` backend (DUAL-MODE-DESIGN §5): a per-task container on
1633
- * SOMEONE ELSE'S docker daemon (offload — the operator's box stays free; isolation:true, suspendable:false).
1634
- * Same lifetime contract as local-docker: unconnected/lazy, one container per task, `destroy()` = `docker rm -f`.
1635
- *
1636
- * 🔴 **Endpoint credentials are the docker CLI's own** — this adapter deliberately carries none. The docker CLI
1637
- * reads `DOCKER_CERT_PATH` + `DOCKER_TLS_VERIFY` from the worker's environment (inherited by every spawn) for a
1638
- * `tcp://` + TLS daemon, and uses `ssh-agent` / `~/.ssh/config` for an `ssh://` one. That keeps key material out
1639
- * of this process's config objects entirely. Consequence to know: `tcp://…:2376` WITHOUT those env vars fails
1640
- * loud at connect (the daemon refuses plaintext) — it does not silently downgrade; a plaintext `tcp://…:2375`
1641
- * daemon is unauthenticated and remains the operator's decision, exactly as in the registry-core schema.
1642
- * (The registry-core `remote-docker` arm additionally models `tlsCertEnv`/`tlsKeyEnv`/`tlsCaEnv`/`sshKeyEnv` =
1643
- * env-NAMEs the control plane resolves to PEM BYTES. Materializing those bytes to short-lived 0600 files and
1644
- * passing `--tlsverify --tlscert/--tlskey/--tlscacert` is the remaining config-surface follow-up, together with
1645
- * the `provider:"remote-docker"` arm on `AppConfig.remoteExec` — see the note on that union in config-types.ts.)
1646
- *
1647
- * Throws at factory-BUILD time on a missing/blank endpoint: a "remote" lane with no endpoint would otherwise
1648
- * quietly become the LOCAL daemon — an isolation-topology surprise, so it fails loud instead.
1649
- */
1650
1230
  export function remoteDockerExecutionEnvFactory(config, deps = {}) {
1651
1231
  const dockerHost = config.dockerHost?.trim();
1652
1232
  if (!dockerHost) {