@sema-agent/server 2.0.0 → 3.0.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 (63) hide show
  1. package/MIGRATION.md +74 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +391 -0
  5. package/dist/boot/budget-tracing.d.ts +48 -0
  6. package/dist/boot/budget-tracing.js +86 -0
  7. package/dist/boot/config-center.d.ts +62 -0
  8. package/dist/boot/config-center.js +1002 -0
  9. package/dist/boot/coordinators.d.ts +33 -0
  10. package/dist/boot/coordinators.js +97 -0
  11. package/dist/boot/execution-env.d.ts +26 -0
  12. package/dist/boot/execution-env.js +370 -0
  13. package/dist/boot/leader.d.ts +27 -0
  14. package/dist/boot/leader.js +81 -0
  15. package/dist/boot/reapers.d.ts +53 -0
  16. package/dist/boot/reapers.js +252 -0
  17. package/dist/boot/resolve-spec.d.ts +70 -0
  18. package/dist/boot/resolve-spec.js +1072 -0
  19. package/dist/boot/runner-deps.d.ts +101 -0
  20. package/dist/boot/runner-deps.js +343 -0
  21. package/dist/boot/runtime-caps.d.ts +21 -0
  22. package/dist/boot/runtime-caps.js +62 -0
  23. package/dist/boot/session-faces.d.ts +57 -0
  24. package/dist/boot/session-faces.js +157 -0
  25. package/dist/boot/shutdown.d.ts +50 -0
  26. package/dist/boot/shutdown.js +129 -0
  27. package/dist/boot/stores.d.ts +32 -0
  28. package/dist/boot/stores.js +361 -0
  29. package/dist/boot/workflow-orchestration.d.ts +46 -0
  30. package/dist/boot/workflow-orchestration.js +150 -0
  31. package/dist/capabilities/scenarios.d.ts +5 -3
  32. package/dist/capabilities/scenarios.js +5 -3
  33. package/dist/config-center/apply-effective.js +4 -3
  34. package/dist/config-lkg.d.ts +2 -1
  35. package/dist/config-lkg.js +2 -1
  36. package/dist/config-types.d.ts +47 -7
  37. package/dist/config.d.ts +30 -13
  38. package/dist/config.js +562 -387
  39. package/dist/hooks/hook-llm.js +9 -0
  40. package/dist/http/routes/approvals-assistant.js +1 -1
  41. package/dist/http/routes/attachments.js +2 -2
  42. package/dist/http/routes/memory-policy.js +3 -3
  43. package/dist/http/routes/runs.js +1 -1
  44. package/dist/http/routes/session-sync.js +2 -2
  45. package/dist/http/routes/sessions.js +2 -2
  46. package/dist/http/routes/tasks.js +2 -2
  47. package/dist/http/routes/trace-usage.js +2 -2
  48. package/dist/http/routes/workflows.js +3 -1
  49. package/dist/http/server.d.ts +1 -1
  50. package/dist/http/server.js +25 -5
  51. package/dist/http/sse-log.js +1 -1
  52. package/dist/main.js +164 -3799
  53. package/dist/model-select.d.ts +1 -1
  54. package/dist/model-select.js +1 -1
  55. package/dist/plugins/checkpoint-store-sql.d.ts +13 -5
  56. package/dist/plugins/checkpoint-store-sql.js +10 -3
  57. package/dist/plugins/local-checkpoint-store.js +8 -2
  58. package/dist/plugins/remote-env-host.d.ts +2 -1
  59. package/dist/run-local.js +2 -1
  60. package/dist/session-titler.d.ts +3 -1
  61. package/dist/session-titler.js +2 -2
  62. package/dist/trace/project.js +4 -1
  63. package/package.json +5 -3
@@ -0,0 +1,1072 @@
1
+ /**
2
+ * design/158 A10:composition root 分段 —— `resolveSpec`(部署方拥有的 body→TaskSpec 映射)。
3
+ *
4
+ * 纯搬运:函数体逐字来自 `main.ts`(原 3347-4313 行),缩进不变;新增的只有本文件的 import 与
5
+ * 下面这层 `createResolveSpec(ctx)` 包壳(捕获原先靠闭包拿到的 boot 局部量)。
6
+ *
7
+ * ⚠️ **位置即契约**:本函数在 boot 次序里**必须晚于** scenarios 装配(`scenarios` 已含 center overlay)
8
+ * 且早于 `createHttpServer`;它读的两个量是 boot 后仍会变的活引用,故经函数取值而非值捕获:
9
+ * - `getKeyResolver()` —— registry 热应用会整个换 `keyResolver` 引用(createHookLlm 同款姿势);
10
+ * - `getCenterPrompts()` —— `adoptCenterPrompts` 每次采用都换 `centerPrompts` 引用。
11
+ * 这两处是本次搬运里**仅有的**两行改写(值 → 取值调用),其余逐字。
12
+ *
13
+ * design/158 A10 留档件#2(阶段化):原先 967 行的匿名 `resolveSpec` 体按其**真实六阶段**提为具名阶段函数
14
+ * (①场景/append 门 → ②settings·cwd·env → ③anchors → ④spec 字面量 → ⑤governance 折叠 → ⑥image·envFacts·router)。
15
+ * 这是可读性刀不是行为刀:各阶段体**逐字搬运**(判别顺序/短路语义/传参不变),原先靠闭包拿的 boot 局部量仍由
16
+ * 同一层闭包捕获(完全同一批),仅把**请求级**量(body/auth/opts + 前序阶段产出)改成显式形参、在阶段头解构。
17
+ * 唯一的非搬运改动:一段游离注释(`[#40 / TOC cwd seam]`,原文夹在附件块之上却描述其**下面**的 cwd 门)随它
18
+ * 描述的代码下移进阶段②。
19
+ */
20
+ import { realpathSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { NodeExecutionEnv, QUESTION_AWAITS_RESUME, combinePolicies, createAllowDenyPolicy, createDurableQuestionPolicy, expandTiers, tightenTaskSpec } from "@sema-agent/core";
23
+ import { createOaApprovalPolicy, createDurableAskPolicy } from "../approval.js";
24
+ import { cappedCeiling } from "../budget.js";
25
+ import { centerPromptProvider, centerIdentityAssembled } from "../capabilities/center-prompts.js";
26
+ import { SessionEnvironmentSelection, selectEnvironmentTool } from "../capabilities/select-environment-tool.js";
27
+ import { sendUserFileTool } from "../capabilities/send-user-file-tool.js";
28
+ import { gateScenarioRequest, mergeUserSkills, selectScenario } from "../capabilities/scenarios.js";
29
+ import { applyLongtailDefer } from "../capabilities/tool-defer.js";
30
+ import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScratchpadDir, resumeFactsForLane } from "../env-facts.js";
31
+ import { FleetEventBus } from "../fleet/fleet-bus.js";
32
+ import { composeHooks, createTaskHooks } from "../hooks/hook-runner.js";
33
+ import { explicitOperator } from "../http/server.js";
34
+ import { matchCatalogModel, modelSupportsImages, resolveTaskModel } from "../model-select.js";
35
+ import { PerTaskImageRegistry, resolveSandboxImageRef } from "../per-task-image.js";
36
+ import { isRemoteScratchpadLane, remoteScratchpadDirFor } from "../plugins/remote-scratchpad.js";
37
+ import { bindAttachmentsForTask } from "../plugins/task-attachment-store.js";
38
+ import { resourceSuspendOptIn } from "../resource-suspend.js";
39
+ import { isIsolatedExecEnv, routeServiceTask, supPostureOverrides } from "../router/route-orchestration.js";
40
+ import { gateExecutionLane } from "../runtime-caps-resolver.js";
41
+ import { applyRuntimeGovernance, stripDelegationTools } from "../runtime-governance.js";
42
+ import { HttpError, memorySpecForRequest } from "../security.js";
43
+ import { mcpForScenario } from "../sema-registry.js";
44
+ import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, normalizeSuggestNextPrompts, promptProfileFromBody, resolveTaskLimits, retainBackgroundProcessesFromBody, taskAgentsSpecFragment, toolNameListFromBody } from "../spec-fields.js";
45
+ import { cwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
46
+ import { resolveRequestMcp } from "../task-mcp.js";
47
+ import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, coercePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, providerDropsAppend, withPermissionMode } from "../task-settings.js";
48
+ import { enableForkFromBody, normalizeRetainSubagentSessions, selfOrchestrationFromBody } from "../task-workflow.js";
49
+ import { redactSecrets } from "../trace/redact.js";
50
+ export function createResolveSpec(ctx) {
51
+ const { config, logger, metrics, localRoot, scenarios, principalCaps, centerRuntimeCapsResolver, getCenterPrompts, getKeyResolver, taskAttachmentStore, perSessionCwd, setSessionCwd, setSessionShellEnv, hookLlm, hookAgent, fleetBus, hookWakeBus, resumeAnchorStore, ownerAware, taskLimitCaps, taskTimeoutSec, selectEnvTool, sendUserFileToolSpec, memoryEngine, durableEnabled, approvalExemptionStore, approvalEnabled, approvalStore, singleUserAutoAcceptBaseline, checkpointStore, deploymentHooks, imageIndex, perTaskImage, sessionEnvSelection, } = ctx;
52
+ // Deployment-owned mapping. Tools/prompt/skills come from the selected scenario (assembled once,
53
+ // bound per request); identity/session/policy stay server-side. Never taken from the request body.
54
+ // design/158 A8:从 createHttpServer 实参里提出来(原地占该字面量 1,093 行中的 967 行)——
55
+ // 类型标注 `ServiceDeps["resolveSpec"]` 顶替了原来的上下文推导,签名不变。
56
+ const resolveSpec = async (body, _req, auth, opts) => {
57
+ // A10 留档件#2:六阶段流水线。顺序=原文顺序(每一阶段的体逐字搬运),阶段之间只传显式产出;
58
+ // 六个阶段函数定义在本 `return` 之后(自上而下先读骨架、再读各阶段)。
59
+ const gated = await gateScenarioAndAppend(body, auth, opts);
60
+ const lane = bindSettingsCwdEnvAndModel(body, auth, gated);
61
+ const anchors = await resolveHistoryAnchors(body, auth);
62
+ const spec = assembleSpecLiteral(body, auth, opts, { ...gated, ...lane, ...anchors });
63
+ const folded = await foldGovernanceAndSettings(body, auth, spec, gated);
64
+ return applyImageFactsAndRouting(body, auth, folded.governed, {
65
+ scenarioName: gated.scenarioName,
66
+ scratchpadDir: folded.scratchpadDir,
67
+ hostSemanticsLane: folded.hostSemanticsLane,
68
+ });
69
+ };
70
+ /** 阶段①(场景/append 门):吃 请求体 + auth + `opts.leg`,吐 本请求的场景绑定(scenarioName/cap)、center-pack
71
+ * 的**单次**快照与 append 门结论(centerDecls/appendLessPack/acceptedAppend)、objective、解析后的 settings 戳,
72
+ * 以及 D-1 附件绑定产出的 attachmentNotice;沿途的准入拒绝(scenario 400 / lane 403 / append 400 / 附件 4xx·501)
73
+ * 与 warn 原样在此发生。 */
74
+ const gateScenarioAndAppend = async (body, auth, opts) => {
75
+ // per-principal scenario governance (assignment-as-default; an explicit
76
+ // body.scenario is bounded to the allowlist — outside ⇒ typed 400 `scenario_not_allowed`). The ruling
77
+ // {scenario, allowlist} is center-RESOLVED and rides the caps view (TTL-cached, same fetch as runtimeCaps);
78
+ // no center / dry-run / anonymous / older center / caps blip ⇒ undefined ⇒ legacy body-or-default chain.
79
+ const requested = gateScenarioRequest(await principalCaps?.scenarioRuling(auth?.principal), body.scenario, config.defaultScenario);
80
+ // per-principal execution-lane policy — this worker's lane is a
81
+ // PROCESS identity (REMOTE_EXEC boot wiring, no per-task switch), so admission is the gate: lane ∉
82
+ // allowedLanes ⇒ typed 403 `execution_lane_not_allowed`. Same caps car as the scenario ruling (zero extra
83
+ // RTT); fail-open + audit on blip. "in-process" = fleet's REMOTE_EXEC-unset core stub lane
84
+ // (named distinctly from the explicit `host` lane — same machine, different posture).
85
+ gateExecutionLane(await principalCaps?.executionRuling(auth?.principal), config.remoteExec?.provider ?? "in-process");
86
+ // Resolve an unknown name to "default" so the WHOLE bundle stays coherent — tools/prompt AND
87
+ // skills/mcp fall back together. (selectScenario already falls back for tools/prompt; without
88
+ // resolving here, skills/mcp would still be filtered by the raw unknown name → any skill/MCP tagged
89
+ // ["default"] silently drops. council finding.)
90
+ const scenarioName = scenarios[requested] ? requested : "default";
91
+ // [891] deprecation(一版过渡窗):"autonomous" 已改名 "code"(persona 同一,唯一差别=alias 仍
92
+ // 定死 finalVerification:true 保存量行为)。warn 不 400——TB harness/存量部署平滑迁移。
93
+ if (scenarioName === "autonomous")
94
+ logger.warn("scenario_autonomous_deprecated", { alias: "code", note: "renamed by [891]; alias keeps finalVerification pinned — pass scenario:'code' (+ explicit finalVerification if wanted)" });
95
+ const cap = selectScenario(scenarios, scenarioName)(body, auth?.principal);
96
+ // codex R3/R4 (appendSystemPrompt 案): ONE center-pack snapshot per request — the append-less gate below
97
+ // AND the provider pick (promptProvider field) both read THIS capture, never the mutable centerPrompts ref
98
+ // again (R4 TOCTOU: a refresh between the two reads could make the final provider assembled while the gate
99
+ // judged safe, restoring the in-core silent drop).
100
+ const centerDecls = getCenterPrompts()?.declarations; // A10 搬运改写①:活引用取值(原 `centerPrompts?.declarations`)
101
+ // TRUE when the EFFECTIVE assembly for this request takes core's pass-through arm (no core/role.append
102
+ // section ⇒ any append rider/outputStyle silently discarded in core). codex R9: PROVIDER-AWARE — mirrors
103
+ // core's actual triggers (stableBlocks identity decls / stableSystem OUTPUT carrying the anchors), because
104
+ // a raw body.systemPrompt anchor heuristic false-positives on default-provider scenarios (unbranded
105
+ // default / scan: no provider ⇒ core defaultPromptProvider retains role.append and the rider mounts even
106
+ // with an anchored systemPrompt). center-wins mirrors the provider pick below (same snapshot, same
107
+ // scenario derivation — principal ruling + defaultScenario already folded into scenarioName above).
108
+ const appendLessPack = centerDecls
109
+ ? centerIdentityAssembled(centerDecls, scenarioName, hasConstitutionAnchors)
110
+ : providerDropsAppend(cap.promptProvider, typeof body.systemPrompt === "string" ? body.systemPrompt : undefined);
111
+ // Accepted rider (defensive resume mirror inside; fresh malformed/over-cap shapes already 400'd in
112
+ // prepareSpec). Hoisted to a const so the R14 combined-cap check below can see the accepted length.
113
+ const acceptedAppend = acceptAppendSystemPrompt(body.appendSystemPrompt, (detail) => logger.warn("append_system_prompt_dropped", { detail, sessionId: auth?.sessionId ?? null }), appendLessPack);
114
+ // @-model (1.24) + explicit picker: an end user may pick a CONFIGURED model via the CLI
115
+ // `/model` picker / `run --model` flag (body.model) OR an inline `@name` in the objective. resolveTaskModel
116
+ // gates BOTH against the catalog allow-list (config.models keys) — pick WHICH model, never inject one — with
117
+ // body.model winning over @mention winning over `default`. Returns the objective with the @mention stripped.
118
+ const objective = typeof body.objective === "string" ? body.objective : "";
119
+ // TaskRequest.settings (client per-request SemaSettings stamp). Parsed DEFENSIVELY off the untrusted body
120
+ // (every field shape-checked; `bypassPermissions`/unknown modes dropped). `env`/`hooks` are recorded as
121
+ // received-but-deferred (never silent-dropped). Folded into the spec TIGHTEN-ONLY after applyRuntimeGovernance.
122
+ const parsedSettings = parseTaskSettings(body.settings);
123
+ // codex R4 (appendSystemPrompt 案) fresh-leg fail-loud: a NEW submit whose append payload (top-level rider
124
+ // or settings.outputStyle) meets an append-less pack 400s HERE — inside resolveSpec, where the effective
125
+ // scenario (principal ruling + defaultScenario) and the center-pack snapshot are the SAME ones the provider
126
+ // pick uses (an HTTP-side gate could disagree with the actual assembly). Resume legs (opts.leg absent)
127
+ // keep the drop+warn mirror below — a 4xx would brick a pre-1.243 stored task.
128
+ if (opts?.leg === "fresh" && appendLessPack) {
129
+ const riderPresent = typeof body.appendSystemPrompt === "string" && body.appendSystemPrompt.length > 0;
130
+ if (riderPresent || parsedSettings.settings?.outputStyle) {
131
+ throw new HttpError(400, `${riderPresent ? "appendSystemPrompt" : "settings.outputStyle"} is not supported when the effective prompt is already assembled (constitution anchors in systemPrompt, or a center assembled-identity pack for this scenario): the assembler's pass-through path cannot mount the append slot — fold it into the assembled prompt instead`);
132
+ }
133
+ }
134
+ if (parsedSettings.deferred.length > 0) {
135
+ // No-silent-drop: a caller SENT env/hooks but this version does not wire them on remote (need a core seam /
136
+ // a remote hook-runner — see task-settings.ts + capabilities). Log so the gap is visible, never silent.
137
+ logger.warn("task_settings_deferred", { fields: parsedSettings.deferred, sessionId: auth?.sessionId ?? null });
138
+ }
139
+ // ── D-1 附件绑定半场(②):TaskRequest.attachments = 上传句柄数组 ──
140
+ // fresh 腿硬验证(未知/他人 id → 400 typed;超数 → 400)——「引用了不存在的附件」必须在提交时
141
+ // 拒,不能等 env 建立时才炸。resume 腿降级(acceptedAppend 同姿):缺行 warn+跳过,余下照常
142
+ // (4xx 会砖存量 task;记录不可变 ⇒ 在场行的 notice 字节恒稳)。绑定=把行钉到 auth.sessionId
143
+ // (authorizer 恒解析出一个;绑定幂等,重复提交/resume 重绑同值)。
144
+ let attachmentNotice;
145
+ {
146
+ const reqAtt = body.attachmentIds;
147
+ if (reqAtt !== undefined) {
148
+ if (!Array.isArray(reqAtt) || !reqAtt.every((x) => typeof x === "string" && x.length > 0 && x.length <= 64)) {
149
+ throw new HttpError(400, "`attachmentIds` must be an array of attachment ids (strings)");
150
+ }
151
+ if (reqAtt.length > 16)
152
+ throw new HttpError(400, "`attachmentIds` exceeds the per-task limit (16)");
153
+ if (reqAtt.length > 0) {
154
+ if (!taskAttachmentStore)
155
+ throw new HttpError(501, "attachmentIds require a store backend (DB_BACKEND=tidb|pg|local)");
156
+ if (!auth?.sessionId)
157
+ throw new HttpError(400, "`attachmentIds` requires a session-resolving deployment (no authorizer session)");
158
+ const sid = auth.sessionId;
159
+ const r = await bindAttachmentsForTask({
160
+ store: taskAttachmentStore,
161
+ owner: auth?.principal ?? "default",
162
+ ids: reqAtt,
163
+ sessionId: sid,
164
+ leg: opts?.leg === "fresh" ? "fresh" : "resume",
165
+ nowMs: Date.now(),
166
+ onMissing: (id) => {
167
+ if (opts?.leg === "fresh")
168
+ throw new HttpError(400, `attachment not found: ${id}`);
169
+ logger.warn("attachment_missing_on_resume", { sessionId: sid, id });
170
+ },
171
+ });
172
+ attachmentNotice = r.notice;
173
+ }
174
+ }
175
+ }
176
+ return { scenarioName, cap, centerDecls, appendLessPack, acceptedAppend, objective, parsedSettings, attachmentNotice };
177
+ };
178
+ /** 阶段②(settings·cwd·env):吃 请求体 + auth + 阶段①的 objective/parsedSettings,吐 taskHooks、
179
+ * additionalDirectories、档位展开后的 wireCatalog 与选中的 picked;副作用=per-session cwd/shellEnv 注册与
180
+ * honored/ignored 日志、未知模型回落与无视觉模型降级的计数/告警。 */
181
+ const bindSettingsCwdEnvAndModel = (body, auth, gated) => {
182
+ const { objective, parsedSettings } = gated;
183
+ // [#40 / TOC cwd seam] register the caller's launch dir so the HOST factory runs the agent there (read by
184
+ // ctx.sessionId). 🔒 GATED: only the single-user host lane (cwdHonored) honors it; on any other lane / multi-tenant
185
+ // a sent cwd is IGNORED (logged once, never silently — the shell learns via capabilities.projectContext). isValidCwd
186
+ // re-checked here too (RESUME re-runs resolveSpec WITHOUT the prepareSpec 400 validation). Re-applies on resume.
187
+ // [851]P3a 信号真实化:cwdHonored 只认显式 REMOTE_EXEC=host;provider 未设的 in-process 单用户 lane 没有
188
+ // shell hands(core prepare-task:无 factory/executionEnv ⇒ StubExecutionEnv,亲读 dist 坐实),但进程级
189
+ // 消费者(hooks 命令、C4 projectContext 本地读取)仍看引擎自身的 cwd/env——壳把引擎 spawn 在用户目录/用户
190
+ // 环境时,请求的 override 往往「已被进程状态满足」,此前每任务打 "ignored" warn 与实际状态矛盾(飞轮双平台
191
+ // 观测)。改法:已满足 ⇒ debug;真失配 ⇒ 保留 warn 且带 lane 字段(值与 run_local_start exec 口径一致:
192
+ // provider ?? "in-process")。真 per-session honor 之路仍是 REMOTE_EXEC=host(host factory 消费
193
+ // perSessionCwd/perSessionShellEnv)。
194
+ const execLane = config.remoteExec?.provider ?? "in-process";
195
+ if (typeof body.cwd === "string" && body.cwd.length > 0 && auth?.sessionId) {
196
+ if (cwdHonored(config) && isValidCwd(body.cwd))
197
+ setSessionCwd(auth.sessionId, body.cwd);
198
+ else if (inProcessSingleUserLane(config) && isValidCwd(body.cwd) && satisfiedByProcessCwd(body.cwd, realpathSync))
199
+ logger.debug("task_cwd_inherited", { lane: execLane, sessionId: auth.sessionId });
200
+ else
201
+ logger.warn("task_cwd_ignored", { honored: cwdHonored(config), lane: execLane, sessionId: auth.sessionId });
202
+ }
203
+ // [R-survey / TOC shellEnv seam] register the caller's `settings.env` so the HOST factory injects it into the
204
+ // agent's shell env. 🔒 GATED identically to cwd (single-user host lane only — design/107 env=capability axis);
205
+ // on any cloud/multi-tenant lane a sent env is IGNORED (logged once, never silent). Re-applies on resume.
206
+ if (parsedSettings.settings?.shellEnv && auth?.sessionId) {
207
+ if (cwdHonored(config))
208
+ setSessionShellEnv(auth.sessionId, parsedSettings.settings.shellEnv);
209
+ else if (inProcessSingleUserLane(config)) {
210
+ // 只记失配键「数量」,绝不记键值(秘密类)与键名(名字本身可带敏感语义)——shellEnvMismatchCount 文档同款红线。
211
+ const requested = parsedSettings.settings.shellEnv;
212
+ const mismatched = shellEnvMismatchCount(requested, process.env);
213
+ if (mismatched === 0)
214
+ logger.debug("task_shell_env_inherited", { lane: execLane, keys: Object.keys(requested).length, sessionId: auth.sessionId });
215
+ else
216
+ logger.warn("task_shell_env_ignored", { honored: false, lane: execLane, mismatchedKeys: mismatched, sessionId: auth.sessionId });
217
+ }
218
+ else
219
+ logger.warn("task_shell_env_ignored", { honored: false, lane: execLane, sessionId: auth.sessionId });
220
+ }
221
+ // hooks hook-runner(阶段一):settings.hooks(契约校验过)→ core TaskSpec.hooks 进程内回调。
222
+ // 🔒 hook 命令跑在 WORKER HOST(CC 语义:hooks 跑在引擎所在机,哪怕工具在远端沙箱)= design/107 class ②
223
+ // 能力授予 → 单用户闸 `requirePrincipal !== true`,与 scheduler/backgroundShell/lspHost/MCP 注入同一姿势;
224
+ // 多租户 lane 收到只警告忽略(`task_hooks_ignored`,capabilities.taskSettings.hooks 同步 advertise),绝不执行。
225
+ // Re-applies on resume(从 stored body 重解析,幂等)。
226
+ const taskHooks = (() => {
227
+ const hc = parsedSettings.settings?.hooks;
228
+ if (!hc)
229
+ return undefined;
230
+ if (config.requirePrincipal === true) {
231
+ logger.warn("task_hooks_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
232
+ return undefined;
233
+ }
234
+ // payload 的 permission_mode 用【生效】模式——body.permissionMode(显式请求)优先于
235
+ // settings.permissions.defaultMode(与 spec 的 withPermissionMode 同一优先序),否则钩子读到的模式与
236
+ // 实际裁决模式不一致(如 body=plan 时钩子仍看到 default)。
237
+ const effMode = coercePermissionMode(body.permissionMode) ?? parsedSettings.settings?.permissions?.defaultMode;
238
+ return createTaskHooks(hc, {
239
+ logger,
240
+ sessionId: auth?.sessionId ?? "",
241
+ cwd: (auth?.sessionId ? perSessionCwd.get(auth.sessionId) : undefined) ?? process.cwd(),
242
+ ...(effMode ? { permissionMode: effMode } : {}),
243
+ // 用户变量通道(hook 进程 env + http $NAME 插值源):同请求的 settings.env——hookEnv 绝不读
244
+ // 裸 process.env 的非 allowlist 键(服务密钥不可被 allowedEnvVars 点名)。
245
+ ...(parsedSettings.settings?.shellEnv ? { shellEnv: parsedSettings.settings.shellEnv } : {}),
246
+ // 阶段三b:prompt/agent 条目的模型载体(boot 组装,见 hookLlm/hookAgent 定义处的影响范围说明)。
247
+ hookLlm,
248
+ hookAgent,
249
+ // cli [1786] 点名的观测位:一次 hook 判定**未能完成**时,往常开的 fleet 流推一帧。
250
+ // ⚠️ **纯 observe**(不拦、不续跑、不注入)——`additionalContext` 那条通道会让这一轮不结束,
251
+ // 所以不能拿它当"提示一下"用(见 hook-runner 折叠处旁注)。
252
+ // ⚠️ 带 ownerScope/ownerSessionId 供流侧做 **fail-CLOSED** 过滤(与 bg_notification 同一姿势):
253
+ // 一条关于别人会话的通知落到这个壳上,会让用户以为是自己这轮出了问题。
254
+ onHookNotice: (n) => fleetBus.publishHookNotice({
255
+ ...n,
256
+ ...(auth?.principal ? { ownerScope: auth.principal } : {}),
257
+ ...(auth?.sessionId ? { ownerSessionId: auth.sessionId } : {}),
258
+ }),
259
+ // asyncRewake wake 管道(契约:exit 2 唤醒模型):session→本副本活流 steer(hookWakeBus,server
260
+ // 启动装 deliver)。stderr 过 redactSecrets 再注入(hook 进程可能回显 env 密钥;与 trace 出口同纪律)。
261
+ // 无 session(匿名一次性任务)不给 wake——没有可稳定寻址的流。
262
+ ...(auth?.sessionId
263
+ ? {
264
+ wake: async (text) => hookWakeBus.deliver
265
+ ? // provenance 围栏:注入内容标明来源=asyncRewake hook 的运行时输出,
266
+ // 模型按 hook 反馈对待而非操作者指令(trusted:false 在 server 侧同批落定)。
267
+ hookWakeBus.deliver(auth.sessionId, `[hook asyncRewake] ${redactSecrets(text)}`)
268
+ : false,
269
+ }
270
+ : {}),
271
+ });
272
+ })();
273
+ // design/119 (CC --add-dir parity, core 1.215 `TaskSpec.additionalDirectories`): extra host dirs the FILE tools
274
+ // may access beyond the containment root. 🔒 GATED identically to cwd/shellEnv (single-user host lane only —
275
+ // these are the caller's own-machine paths; a cloud/multi-tenant lane must not let a caller widen the file-tool
276
+ // containment onto operator/other-tenant host paths). Unlike cwd/shellEnv (registered per-session for the host
277
+ // FACTORY to read at env construction), additionalDirectories is a plain TaskSpec field core reads directly, so
278
+ // it rides onto the spec below. Ignored (logged, never silent) off the host lane. Re-applies on resume (from body).
279
+ const rawAddDirs = body.additionalDirectories;
280
+ const additionalDirectories = cwdHonored(config) ? parseAdditionalDirectories(rawAddDirs) : undefined;
281
+ if (rawAddDirs !== undefined && !cwdHonored(config)) {
282
+ logger.warn("task_additional_directories_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
283
+ }
284
+ // body.model (explicit /model picker or run --model) WINS over settings.model (the config default); both gate
285
+ // against the catalog allow-list via resolveTaskModel (a settings.model not in the catalog is ignored, never injected).
286
+ // 🔴 TOB 档位联跑曾 FAIL(坐标级):the gate must see the TIER-EXPANDED catalog — expandTiers
287
+ // runs inside core's Runner on a PRIVATE deps copy (runtask.ts), so gating on bare config.models silently
288
+ // degraded every tier word (pro/flash/@mention/CC alias) to default BEFORE the Runner could resolve it.
289
+ // Expand per request (cheap: empty tiers returns the same reference; non-empty = one small spread) so the
290
+ // gate's key set is exactly what a freshly-constructed Runner resolves — tier words, degrade chains, CC
291
+ // aliases, catalog-SHADOW all from core's ONE implementation. Window note: tiers are restart-to-apply at
292
+ // the Runner; a refresh-time tier change makes this gate briefly AHEAD of the old Runner snapshot (a new
293
+ // tier word then fails loud in core instead of silently degrading — honest during the restart window).
294
+ const wireCatalog = expandTiers(config.models, config.tiers) ?? config.models;
295
+ const picked = resolveTaskModel(body.model ?? parsedSettings.settings?.model, objective, wireCatalog);
296
+ // [865]② 降级永远可见:fresh submit 的未知 body.model 已在 HTTP 门 400(不会到这);走到这的未知 ref
297
+ // 只剩 RESUME 重放(模型事后被移出目录)与 settings.model(lenient 文档面)——落 default 可跑,但必须
298
+ // 有声(session 中途换模型是最恶性的上下文污染路径,静默=病灶本体)。
299
+ if (picked.unknownExplicit !== undefined) {
300
+ metrics.inc("task_model_unknown_fallback_total");
301
+ logger.warn("task_model_unknown_fallback", { requested: picked.unknownExplicit.slice(0, 120), fallback: picked.model, sessionId: auth?.sessionId ?? null });
302
+ }
303
+ // no-vision images = PLACEHOLDER, not 422(会话可继续的 CC 心智). The old
304
+ // fail-loud 422 here BLOCKED core's own degradation: since core 1.233.1 a serving model whose `input`
305
+ // has no "image" gets image parts degraded to a bounded text placeholder ("[image omitted: …]"), per
306
+ // CURRENT model (degrade chains stay correct) and re-applied on every history replay — so a previously
307
+ // poisoned session self-heals. We pin ≥1.240.1, so pass the images through and let core do exactly that.
308
+ // The GET /v1/models vision flag stays advertised (the shell's composer-warning half). A model
309
+ // that never declared `input` keeps sending images verbatim (deliberate default — never wrongly strip
310
+ // for a vision-capable deployment that skipped the metadata; same posture).
311
+ // S6 (SILENT-FALLBACK P0-c): images headed for an EXPLICITLY text-only model — core (>=1.233.1) degrades
312
+ // them to bounded text placeholders per current model (the pass-through posture above). That modality drop
313
+ // was invisible fleet-side; count + warn at submit. modelSupportsImages is false-safe (undeclared input →
314
+ // true → no signal), matching core's "never wrongly strip" posture — no false positives for verbatim
315
+ // pass-through models. ⚠️ sema-registry lane caveat (review LOW-2): the roster maps `vision?: boolean` to
316
+ // an EXPLICIT input list (sema-registry.ts) — a vision-capable roster model that FORGOT the flag becomes
317
+ // declared text-only, so this signal fires AND core really does degrade its images (signal matches
318
+ // behavior; the fix for that footgun is the roster entry, not this counter). The per-run durable event rides core's vision.placeholder trace kind once we
319
+ // consume >=1.245 (the authoritative replacement-time signal), not this submit-time predictor.
320
+ if (Array.isArray(body.images) && body.images.length > 0 && !modelSupportsImages(wireCatalog[picked.model])) {
321
+ metrics.inc("images_omitted_total", { model: picked.model });
322
+ logger.warn("images_omitted_no_vision_model", { model: picked.model, count: body.images.length });
323
+ }
324
+ return { taskHooks, additionalDirectories, wireCatalog, picked };
325
+ };
326
+ /** 阶段③(anchors):吃 请求体 + auth,吐 两个历史锚解析后的 entryId(resumeAt / rewindFilesTo —— eventId→entryId
327
+ * 过 anchor store,未知锚在此 4xx 而不是交给 core)与 142-S4 项目登记簿种子 s4DefaultScopes。 */
328
+ const resolveHistoryAnchors = async (body, auth) => {
329
+ // E18 resume-at: the shell sends body.resumeAt as the E2 message eventId; core's TaskSpec.resumeAt takes the
330
+ // PERSISTED SessionTreeEntry.id, NOT the eventId ("a deployment that holds eventIds owns the eventId→entryId
331
+ // map" — core never persists eventIds). Resolve eventId→entryId via the anchor store HERE, 4xx-ing BEFORE the
332
+ // run on an UNKNOWN anchor (never hand core an unresolvable id). A KNOWN anchor whose entry was later compacted
333
+ // away surfaces as a failed run RESULT (resume_at.not_found) — 4xx on the sync path, the run result on async (the
334
+ // async contract is poll-the-result). RESUME re-runs resolveSpec from the stored body — the resume paths strip
335
+ // resumeAt before re-resolving (else core would reject resumeAt + a durable resume), so this branch only fires on
336
+ // a fresh submit. NEVER silent-drop (mirrors the body.model silent-drop bug class).
337
+ let resumeAtEntryId;
338
+ if (typeof body.resumeAt === "string" && body.resumeAt.length > 0) {
339
+ if (!auth?.sessionId)
340
+ throw new HttpError(422, "resumeAt requires a session to branch (resume_at.no_session)");
341
+ // 501 gated on the SAME pair the `resumeAt` capability advertises (anchor store AND getLeafId) — capture is a
342
+ // no-op without getLeafId, so a getLeafId-less backend has a permanently-empty map; report "not available"
343
+ // (honest 501) rather than a misleading 404. Keeps "capability says yes ⟺ the route resolves" by construction.
344
+ if (!resumeAnchorStore || !ownerAware.getLeafId)
345
+ throw new HttpError(501, "resume-at is not available on this worker (no session-store backend for the anchor map)");
346
+ resumeAtEntryId = await resumeAnchorStore.resolve(auth.sessionId, body.resumeAt, auth.principal ?? null);
347
+ if (resumeAtEntryId === undefined)
348
+ throw new HttpError(404, "resumeAt: no such message in this session (resume_at.unknown_event)");
349
+ }
350
+ // R8 code-only rewind (CC "Restore code" mode, core 1.166 `rewindFilesTo`): restore the working tree to a prior
351
+ // USER message's snapshot WITHOUT forking the conversation. Resolve the SAME handle (the prompt's taskId) → entryId
352
+ // via the SAME anchor store. Honored ONLY when resumeAt is ABSENT (core's contract: the two are mutually exclusive —
353
+ // with resumeAt set, that branch's own rewindFiles governs); so we only resolve it on the code-only path. Same
354
+ // fail-loud discipline as resumeAt (501 no-store / 404 unknown anchor — never silent-drop).
355
+ let rewindFilesToEntryId;
356
+ if (resumeAtEntryId === undefined && typeof body.rewindFilesTo === "string" && body.rewindFilesTo.length > 0) {
357
+ if (!auth?.sessionId)
358
+ throw new HttpError(422, "rewindFilesTo requires a session (rewind_files_to.no_session)");
359
+ if (!resumeAnchorStore || !ownerAware.getLeafId)
360
+ throw new HttpError(501, "rewind-files-to is not available on this worker (no session-store backend for the anchor map)");
361
+ rewindFilesToEntryId = await resumeAnchorStore.resolve(auth.sessionId, body.rewindFilesTo, auth.principal ?? null);
362
+ if (rewindFilesToEntryId === undefined)
363
+ throw new HttpError(404, "rewindFilesTo: no such message in this session (rewind_files_to.unknown_event)");
364
+ }
365
+ // 142-S4 defaultScopes 种子:projectId → center 项目登记簿(config.projects,registry 喂)→ 该项目
366
+ // 声明的默认记忆 scope 词表(memory.scopes 额外 READ 层;写路由仍钉在派生 scope — memorySpecForRequest)。
367
+ // projectId 优先取 auth.resolvedProjectId(authorizer 的 PROJECT_ID_REGEX 422 门通过后回传);RESUME 腿
368
+ // 的 auth 从 checkpoint 重建、无此字段 → 回落持久化 body.projectId(原始提交已过形状门,与 memoryWrite
369
+ // 的 rides-the-persisted-body 姿势一致),让种子在 resume 腿等价重放。查表 miss(未登记/已下架)= 无种子,
370
+ // 不 fail(登记簿是加性配置面,不是准入门)。
371
+ const s4ProjectId = auth?.resolvedProjectId ?? (typeof body.projectId === "string" && body.projectId ? body.projectId : undefined);
372
+ const s4DefaultScopes = s4ProjectId ? config.projects[s4ProjectId]?.defaultScopes : undefined;
373
+ return { resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes };
374
+ };
375
+ /** 阶段④(spec 字面量):吃 请求体 + auth + `opts.leg` + 前三阶段的全部产出,吐**尚未过治理层**的 TaskSpec
376
+ * 字面量 —— 部署方拥有的 body→TaskSpec 映射本体(compactionModel 的 fresh 腿 400 也在这一段)。 */
377
+ const assembleSpecLiteral = (body, auth, opts, parts) => {
378
+ const { scenarioName, cap, centerDecls, acceptedAppend, parsedSettings, attachmentNotice, taskHooks, additionalDirectories, wireCatalog, picked, resumeAtEntryId, rewindFilesToEntryId, s4DefaultScopes, } = parts;
379
+ const spec = {
380
+ // D-1:附件告知随 objective 进 durable 流(只有名字/mime/尺寸——**内容永不进流**,这正是
381
+ // 「拼进 objective 是伪方案」的账要划清的线;文件名已消毒为安全字符集,无注入面)。
382
+ objective: attachmentNotice ? `${picked.cleanedObjective}\n\n${attachmentNotice}` : picked.cleanedObjective,
383
+ // Decoupling seam: a trusted client (token-holder) injects its OWN business/UX system prompt
384
+ // (persona, UI markers like ::ISSUE_FORM::, workflow rules). The service stays business-agnostic
385
+ // — integrators evolve their UX here, not by changing scenario prompts in this repo. core's
386
+ // stableSystem appends it after the scenario base, before <user_memory>.
387
+ systemPrompt: typeof body.systemPrompt === "string" ? body.systemPrompt : undefined,
388
+ // [1476] R1 / [1478] R2: top-level append rider → core TaskSpec.appendSystemPrompt (stable block after the
389
+ // scenario base + systemPrompt, before the volatile tail). settings.outputStyle composes AFTER this via
390
+ // applyTaskSettings (`append\n\nstyle`) — byte-stable order: knowledge block always first, style always
391
+ // second (pinned by test; order flapping would shred the prefix cache). Resume re-enters here off the
392
+ // persisted body, so the rider re-applies on resumed legs like systemPrompt does — DEFENSIVELY (codex F2):
393
+ // the resume families skip prepareSpec's 400 gate and a pre-1.243 store kept unknown keys verbatim, so an
394
+ // over-cap legacy value is dropped+warned here instead of becoming active prompt content.
395
+ appendSystemPrompt: acceptedAppend,
396
+ // sessionId comes from `auth` (ownership-checked / principal-derived), NEVER the body — the
397
+ // authorizer always resolves one. (No `?? body.sessionId` fallback: that would re-open the
398
+ // body-as-capability hole S6 closed if a deployment ran without an authorizer.)
399
+ sessionId: auth?.sessionId,
400
+ // The authenticated end-user principal (design/62, core 1.78). Read-only on the TaskSpec — core injects
401
+ // it into a consumer MCP's `principalHeader` per-task so the MCP can enforce per-user RBAC (oa-mcp). It
402
+ // comes from the auth channel (never the body/model), so the model can't substitute another identity.
403
+ principal: auth?.principal,
404
+ images: body.images,
405
+ // design/112 C1: pass the client/gateway-supplied user context through to core (localizes the env block's date
406
+ // to the user's zone + surfaces who the agent acts for). HTTP-validated + capped at submit (prepareSpec); on a
407
+ // resume the body was already validated at its original submit. core re-validates timeZone (invalid → UTC) and
408
+ // SANITIZES userEmail (inlineUntrusted: folds newlines, neutralizes <system-reminder>, caps 160cp — no injection;
409
+ // double-review-confirmed). PICK only the two fields core reads — don't passthrough stray keys (a caller could
410
+ // otherwise bloat the persisted run body with an uncapped junk field; core ignores them but the body stays tight).
411
+ ...(body.clientContext ? { clientContext: { timeZone: body.clientContext.timeZone, userEmail: body.clientContext.userEmail } } : {}),
412
+ // Structured output (CC --json-schema): a JSON Schema constraining the model's FINAL
413
+ // answer. core injects a built-in `submit_output` tool from it + surfaces the validated object as
414
+ // TaskResult.structuredOutput. HTTP layer shape+size-validated it (prepareSpec); core does deep validation.
415
+ // RESUME re-runs resolveSpec ⇒ the structured-output constraint re-applies on resumed legs too.
416
+ // 历史复审轴B #5(1.254):compactionModel 已接([1479]①)而 design/145 配套旋钮 clampTolerance
417
+ // 够不着=不对称。窄收单键(0..1 数值;其余 compaction 键仍是操作方轴,有意不开)。
418
+ ...(() => {
419
+ const ct = body.compaction?.clampTolerance;
420
+ return typeof ct === "number" && Number.isFinite(ct) && ct >= 0 && ct <= 1 ? { compaction: { clampTolerance: ct } } : {};
421
+ })(),
422
+ outputSchema: body.outputSchema,
423
+ // 历史复审轴A #2(1.254):outputRetries 与 outputSchema 是成对的 caller 面旋钮(retry-on-invalid
424
+ // 轮数),此前只接了 schema 半边。窄校验:1..10 整数,schema 在场才有意义(core 无 schema 时忽略)。
425
+ ...(typeof body.outputRetries === "number" && Number.isFinite(body.outputRetries) && body.outputRetries >= 1
426
+ ? { outputRetries: Math.min(10, Math.floor(body.outputRetries)) }
427
+ : {}),
428
+ // E18 resume-at: the resolved persistent SessionTreeEntry.id (from the eventId the shell sent). core branches
429
+ // the session at this prior entry before running the new objective ("rewind to this message, ask differently").
430
+ resumeAt: resumeAtEntryId,
431
+ // [833] rewind exclusive mode (core 1.292): "before" branches at the target's PARENT (excludes the target —
432
+ // "remove this prompt and everything after it"); absent/"at" = core default, zero regression. Defensive
433
+ // normalize (RESUME re-enters resolveSpec without the HTTP 400 gate): only the two enum values ride, and only
434
+ // WITH a resolved resumeAt (core ignores the field otherwise; keep the persisted spec tight). core's "before"
435
+ // edge rejections (resume_at.before_target_not_user / resume_at.before_root_unsupported /
436
+ // rewind_snapshot.unresolvable) surface as failed-result errorCodes mapped to 4xx by resumeAtHttpStatus,
437
+ // errorCode passed through UNCHANGED for the shell to render.
438
+ resumeAtMode: normalizeResumeAtMode(body.resumeAtMode, resumeAtEntryId !== undefined),
439
+ // design/114 Phase3 (reuse-path warm-resume): require the session to already exist — core's acquire fails loud
440
+ // (`resume.session_not_found`) on a genuinely-missing/purged session instead of silently starting a fresh one.
441
+ // The service authorizer ALSO stops pre-registering a missing id under this flag (security.ts), so the store's
442
+ // fail-loud actually fires (else the authorizer's claim-create would materialize the session first). Rides onto
443
+ // resume legs (resolveSpec re-runs); a resumed session exists, so it's satisfied there.
444
+ requireExistingSession: body.requireExistingSession === true ? true : undefined,
445
+ // §4 (Fork): CC `/fork` — core 1.257 (design/136 BREAKING) retired the standalone Fork tool; a fork is now
446
+ // Agent(subagent_type:"fork") gated by `enableFork`. 🔴 core FLIPPED enableFork to opt-OUT (undefined = available),
447
+ // so enableForkFromBody now returns an EXPLICIT boolean (never undefined) — false where the service denies, else
448
+ // fail-OPEN. DEFAULT ON single-user turnkey (clay 2026-07-01); per-task opt-OUT via body.enableFork:false;
449
+ // multi-tenant honored ONLY with a per-principal entitlement resolver wired (core enforces `allowFork`), else
450
+ // explicit false (fail-closed). core still requires a fork-capable store (`hasSessionFork`; TOC file backend
451
+ // satisfies it via LocalSessionStore.fork), else inert.
452
+ // NB: `centerRuntimeCapsResolver`(非合成后的 runtimeCapsResolver)—— env observer 基线不是
453
+ // center 背书的 entitlement 源,多租户 fail-close 判别只认 center caps client。
454
+ enableFork: enableForkFromBody(body, config, Boolean(centerRuntimeCapsResolver)),
455
+ // E12 (shell-host contract): opt-in post-completion prompt suggestions. core runs ONE extra fire-and-forget LLM pass
456
+ // after the task completes; the service surfaces the strings (redacted, UNTRUSTED UI-only) as a `suggestions`
457
+ // event. Defensive normalize (RESUME re-runs resolveSpec without the HTTP 400 validation). OFF ⇒ zero extra LLM.
458
+ suggestNextPrompts: normalizeSuggestNextPrompts(body.suggestNextPrompts),
459
+ // E19 (shell-host contract): opt-in working-tree rewind. core snapshots each completed turn + restores the files on a
460
+ // resumeAt branch, for any env when fileSnapshotStore is wired (gate-split 1.134.0). OFF ⇒ no snapshot.
461
+ rewindFiles: body.rewindFiles === true ? true : undefined,
462
+ // R8 code-only (core 1.166): the resolved target entry id for "Restore code" — core restores the working tree to
463
+ // its snapshot WITHOUT branching the session. Mutually exclusive with resumeAt (only set on the code-only path).
464
+ rewindFilesTo: rewindFilesToEntryId,
465
+ // design/119 (CC --add-dir): extra host dirs the FILE tools may access (core canonicalizes each into the
466
+ // containment allowlist + lists them in the `# Environment` block). Single-user host lane only (gated above).
467
+ additionalDirectories,
468
+ // EnterPlanMode (core 1.167): MODEL-DRIVEN plan — `enablePlanMode:true` alone (writable
469
+ // start, NO handsReadOnly) auto-mounts `enter_plan_mode`/`present_plan` so the model can self-enter read-only
470
+ // plan AT ANY TIME it judges a task needs planning. 🆕 DEFAULT ON (clay: resident; PLAN_MODE_ENABLED=false opts
471
+ // out). USER-driven plan (permissionMode=plan) ADDS handsReadOnly on top via applyTaskSettings (which also sets
472
+ // enablePlanMode, idempotent). Additive — widens nothing; the model self-selects when to use it.
473
+ enablePlanMode: config.planModeEnabled ? true : undefined,
474
+ // Workflow super-set unlock (same shape as the rank-1 roster gap): per-task activation of the
475
+ // LLM-authored workflow engine (core `run_workflow` + the workflow.ts agent/parallel/pipeline orchestration).
476
+ // The engine is BUILT but the HTTP API never mapped a body field to `spec.selfOrchestration`, so run_workflow
477
+ // was per-task unreachable (capabilities advertised `workflows:true` but the LLM could never call it). Gated on
478
+ // the DEPLOYMENT enabling it (`selfOrchestrationEnabled` → workflowScriptRunner/stores wired; else a set flag is
479
+ // a harmless no-op core fail-closes). 🔒 Multi-tenant entitlement is NOT blanket-closed here (unlike mcp, which
480
+ // has no engine entitlement) — core's `runtimeCapsResolver` enforces `allowWorkflows` (the third-stage
481
+ // per-principal cap) ON THE ENGINE, so a multi-tenant principal WITHOUT the entitlement gets run_workflow
482
+ // fail-closed by core (no resolver ⇒ fail-closed). Single-user honors directly. Three-gate: engine-can ∧
483
+ // center-may(allowWorkflows) ∧ shell-show. (Logic + gates documented in `selfOrchestrationFromBody`.) 🔒 The
484
+ // multi-tenant floor: pass whether an entitlement RESOLVER is wired — core's allowWorkflows is TIGHTEN-ONLY
485
+ // (no resolver ⇒ fail-OPEN per-principal), so the helper fail-closes multi-tenant when none is present.
486
+ // L2 ultracode (design/111): OR the preset's selfOrchestration:true into the body intent, passed THROUGH the
487
+ // existing gate — it grants nothing a raw body.selfOrchestration:true couldn't (multi-tenant still fail-closes
488
+ // without an entitlement resolver). The preset never bypasses the闸.
489
+ selfOrchestration: selfOrchestrationFromBody({ selfOrchestration: body.selfOrchestration === true || parsedSettings.settings?.ultracode === true }, config, Boolean(centerRuntimeCapsResolver)),
490
+ // C1 (core 1.219, subagent viewing pane): opt-in widening of the forward sink from
491
+ // task_progress-only to a delegated child's live CONTENT events (text_delta/reasoning_delta/tool_start/
492
+ // tool_end, each carrying `parentToolCallId` attribution). Purely a RENDER channel — core never merges the
493
+ // child stream into the parent's model context. The service's forward-sink consumers redact per §E1 (shared
494
+ // builders); default OFF = prior progress-only behavior. Strict `=== true` (never a truthy coercion).
495
+ forwardSubagentEvents: body.forwardSubagentEvents === true ? true : undefined,
496
+ // design/122 (core 1.225): opt-in retention of SETTLED sub-agent sessions so the operator/
497
+ // shell can REVIVE them (POST /v1/runs/:id/subagents/:target/resume). Defensive normalize + clamp
498
+ // (RESUME re-runs resolveSpec without HTTP validation): boolean passes; an object's ttlMs/max are clamped
499
+ // to sane ceilings (retention pins live sessions in memory — a caller must not turn the knob into a
500
+ // resource hold; core's own defaults are 30min/16, the run-scoped ledger releases everything at parent
501
+ // end either way). Malformed values fall to undefined = OFF (core default), never a throw.
502
+ retainSubagentSessions: normalizeRetainSubagentSessions(body.retainSubagentSessions),
503
+ // [876] per-task custom subagents (core 1.295 TaskSpec.agents — CC `.claude/agents/` parity): the shell's
504
+ // resolved AgentDefinition[] rides the spec verbatim (model already a real name/Model object — no alias
505
+ // translation here). 🔒 TOB tenant gate + defensive per-item normalize live in taskAgentsFromBody
506
+ // (spec-fields.ts): multi-tenant is fail-closed (warn task_agents_ignored — no per-tenant caps face yet,
507
+ // capabilities.taskAgents advertises false); RESUME replays skip the HTTP whitelist 400 → illegal items
508
+ // DROP+warn per entry (task_agent_dropped), never a bricked resume. Absent/empty ⇒ no key (byte-compat).
509
+ // [1.211 codex L] 内联闭包换 spec-fields 真出口(taskAgentsSpecFragment)——测试直接 import 调真
510
+ // fold,SOURCE PIN 收敛为「resolveSpec 内恰一次调用点」单锚(文本重写同构表达式的假绿面就此关)。
511
+ ...taskAgentsSpecFragment(body.agents, config.requirePrincipal, (event, fields) => logger.warn(event, { ...fields, sessionId: auth?.sessionId ?? null })),
512
+ // [854]② (core 1.295): opt-out of task-end session-background reaping ("keep my dev server running").
513
+ // Resource-residency grant on the host lane → single-user gate like backgroundShell/hooks (multi-tenant
514
+ // warns task_retain_bg_ignored + ignores; capability advertises false). Only literal `true` rides (false =
515
+ // core default, key omitted); non-boolean is 400 at submit, defensively dropped on a resume replay.
516
+ ...(retainBackgroundProcessesFromBody(body.retainBackgroundProcesses, config.requirePrincipal, (event, fields) => logger.warn(event, { ...fields, sessionId: auth?.sessionId ?? null })) === true
517
+ ? { retainBackgroundProcesses: true }
518
+ : {}),
519
+ // [1052]② (core 1.314): per-run 工具面收窄两位——excludeTools=roster 真卸载,deferTools=延迟披露
520
+ // (cli「Workflow 默认开不暴露」承载位)。tighten-only(core 继承 union 不变量),无租户门;defensive
521
+ // normalize(resume 重放),提交面 400 在 HTTP 门。
522
+ ...(() => { const v = toolNameListFromBody(body.excludeTools); return v ? { excludeTools: v } : {}; })(),
523
+ ...(() => { const v = toolNameListFromBody(body.deferTools); return v ? { deferTools: v } : {}; })(),
524
+ // [1144]/[1146] (core 1.328 R2): promptProfile 双形轴直通——纯呈现轴无租户门(deferTools 姿势);
525
+ // 缺省不挂键(引擎缺省 simple);非法值由 HTTP 门 400,resume 重放 defensive DROP。
526
+ ...(() => { const v = promptProfileFromBody(body.promptProfile); return v ? { promptProfile: v } : {}; })(),
527
+ // [922]①/[915]② (core 1.296): interactiveTools 三态旋钮直通——boolean 上 spec(壳 -p 恒 stamp false=
528
+ // A3 类「headless 提问 park」根治腿;plan 显式 true 恒赢),undefined=core 自动判据(送达面探测)。
529
+ // per-run 工具面选择无跨租户面 ⇒ 无租户门;非 boolean 由 HTTP 门 400,resume 重放 typeof 检查天然 DROP。
530
+ ...(typeof body.interactiveTools === "boolean"
531
+ ? { interactiveTools: body.interactiveTools }
532
+ : {}),
533
+ // design/131 (core 1.246): per-task resilience INTENT flags. allowDegrade/allowFailover are
534
+ // caller-facing (a bench/eval run wants true failure shapes); bypassBreaker is operator-only (normalizer
535
+ // drops it for non-operators — it punches through a SHARED breaker). All-absent = byte-compat.
536
+ resilience: normalizeResilience(body.resilience, explicitOperator(auth?.principal, config.operatorPrincipals)),
537
+ // design/132 (core 1.249): one-shot end-game verification nudge ("re-run the final artifact through
538
+ // its real entrypoint before finishing"). OPT-IN by core's own judgment (default ON lost the evidence case:
539
+ // +1 turn on every interactive write task) — the AUTONOMY caller declares it (harness/scheduler lanes).
540
+ // [849] scenario-declared leg: the `autonomous` scenario pins it in its bundle (cap.finalVerification —
541
+ // picking that scenario IS the autonomy declaration, so this is not "inferring from scenario"); OR-folded
542
+ // with the caller's explicit flag — a scenario can only ADD the closing verification turn, never strip a
543
+ // caller's request. Children do not inherit (core semantics).
544
+ finalVerification: cap.finalVerification === true || body.finalVerification === true ? true : undefined,
545
+ // design/133 (core 1.251): turn-boundary attachment reminders (todo/changed-files/plan-mode),
546
+ // literal-true unions — the normalizer drops `false`/garbage keys instead of forwarding them (core contract:
547
+ // "off = delete the key, never send false"). Resume-safe by construction: the persisted body is full JSON
548
+ // and this is the single translation point, so this line IS the whitelist inclusion.
549
+ attachments: normalizeAttachments(body.attachments),
550
+ // hooks(阶段一,gate 见上面 taskHooks 装配):core 是整槽覆盖(`spec.hooks ?? deps.hooks`,
551
+ // runtask gateBaseline 注释点名),task hooks 一挂会 shadow deps.hooks 上的 TOOL_TRACE 观测 → composeHooks
552
+ // 把部署基线折进来(部署槽先跑=观测看到真实执行)。无 task hooks 时不挂字段,deps 路径原样。
553
+ ...(taskHooks ? { hooks: composeHooks(deploymentHooks, taskHooks) } : {}),
554
+ model: picked.model,
555
+ // [1479]① compactionModel (design/145 cheap compaction gear): catalog-gated to the SAME expanded view as
556
+ // body.model (canonical name form). resolveSpec is the AUTHORITY (codex R5 — the prepareSpec 400 is only
557
+ // a fast-fail UX layer; the catalog hot-refreshes in place, so a ref valid at the HTTP gate can be gone
558
+ // after the authorize/resolve awaits): unknown on a FRESH leg → 400 fail-loud (the documented guarantee —
559
+ // never a silent price change); unknown on a RESUME replay → drop + warn (core's resolveModel THROWS on
560
+ // an unknown compactionModel, which would brick the resume; the fallback is the summarize role / main
561
+ // model, the pre-field behavior).
562
+ // Window note (codex R6, requalified): the resolveSpec→Runner handoff shares spec.model's DOCUMENTED
563
+ // restart-window class ([865] / the wireCatalog note above) — the Runner resolves the canonical string
564
+ // against ITS OWN constructor-time expanded snapshot, so a hot refresh between here and prepare can
565
+ // desync the two views. Inside that window the failure mode is core's resolveModel TYPED THROW
566
+ // ("Unknown model ref", roles.js dist-read) = a fail-loud task error, never a silent price change —
567
+ // the same honest posture the sibling field ships with. Binding a Model OBJECT here instead would
568
+ // diverge from spec.model's string-intent contract and freeze catalog bytes into the persisted body.
569
+ ...((() => {
570
+ if (typeof body.compactionModel !== "string" || body.compactionModel.length === 0)
571
+ return {};
572
+ const cm = matchCatalogModel(body.compactionModel, wireCatalog);
573
+ if (cm === undefined) {
574
+ if (opts?.leg === "fresh") {
575
+ throw new HttpError(400, `unknown compactionModel "${body.compactionModel.slice(0, 120)}" — not in the configured catalog (name, tier word, or id; a catalog refresh may have removed it mid-request)`);
576
+ }
577
+ metrics.inc("task_model_unknown_fallback_total");
578
+ logger.warn("compaction_model_unknown_fallback", { requested: body.compactionModel.slice(0, 120), sessionId: auth?.sessionId ?? null });
579
+ return {};
580
+ }
581
+ return { compactionModel: cm };
582
+ })()),
583
+ // E7 (shell-host contract): reasoning-effort selection threaded to core's ThinkingLevel. Defensive
584
+ // isThinkingLevel guard — RESUME re-runs resolveSpec WITHOUT the HTTP-layer 400 validation, so an invalid/
585
+ // absent value falls through to the resolved role's default thinking (core's RoleSpec.thinking), never throws.
586
+ // L2 ultracode (design/111): the preset FLOORS thinking at the ultra tier (= max, via core resolveReasoningProfile);
587
+ // awareness rides for free (core composes it from thinking∈{xhigh,max}). Else the explicit reasoningEffort.
588
+ thinking: effectiveThinking(body.reasoningEffort, parsedSettings.settings?.ultracode === true),
589
+ // Per-model auth (sema-registry apiKeyEnv): core calls this per brain call / cascade rung so each
590
+ // model uses its own upstream key; a model without one falls back to the gateway key. undefined
591
+ // when no per-model keys are configured → unchanged single-key behavior.
592
+ getApiKeyAndHeaders: getKeyResolver(), // A10 搬运改写②:活引用取值(原 `keyResolver`)
593
+ // [854]④ per-request 配速:body.limits.{timeoutSec,maxOutputTokens,maxTurns} 现在被收下(核对上游
594
+ // TB2.0 实测诉求;旧姿势「body limits 一律忽略」作废)。合成规则在 resolveTaskLimits(spec-fields.ts):
595
+ // - body.timeoutSec 给了就用 body(caller 显式配速,可低于内建墙;已被可选 TASK_TIMEOUT_MAX_SEC 封顶);
596
+ // - body 缺席保持既有姿势 = tenancy 墙钟(单用户 turnkey 无墙 / 多租 2400s、大任务 3600s,
597
+ // clay 2026-07-04 make-real 教训①)+ env TASK_TIMEOUT_SEC 只抬不降(taskWallClockSec 内 Math.max);
598
+ // - maxOutputTokens/maxTurns 直透传(可选 TASK_MAX_OUTPUT_TOKENS_MAX / TASK_MAX_TURNS_MAX 同款封顶)。
599
+ // resume 重放持久化 body 不过 HTTP 400 门 → normalizeLimits defensive(0/负/垃圾按键 DROP,不 throw)。
600
+ ...(() => {
601
+ const limits = resolveTaskLimits(body.limits, taskLimitCaps, taskTimeoutSec, config.requirePrincipal, body.council === true || body.debate === true || scenarioName === "team");
602
+ // [1301]③ config catalog server 半场:env 封顶不再是「五层五值互不知情」的暗手——每个真在场
603
+ // 的运营方旋钮以 configOverrides 声明进 spec(advisory,core 折进 config.assembled 的
604
+ // overrideReasons;「谁设的顶」变成读帧不考古)。只声明 SET 了的键(缺省不设=不污染帧)。
605
+ const declarations = [];
606
+ if (taskLimitCaps.timeoutSec !== undefined)
607
+ declarations.push({ key: "server.limits.timeoutSecCap", value: String(taskLimitCaps.timeoutSec), reason: "env TASK_TIMEOUT_MAX_SEC (operator ceiling on caller limits.timeoutSec)" });
608
+ if (taskLimitCaps.maxOutputTokens !== undefined)
609
+ declarations.push({ key: "server.limits.maxOutputTokensCap", value: String(taskLimitCaps.maxOutputTokens), reason: "env TASK_MAX_OUTPUT_TOKENS_MAX (operator ceiling)" });
610
+ if (taskLimitCaps.maxTurns !== undefined)
611
+ declarations.push({ key: "server.limits.maxTurnsCap", value: String(taskLimitCaps.maxTurns), reason: "env TASK_MAX_TURNS_MAX (operator ceiling)" });
612
+ // PAIR-REVIEW F-4:budget 族与墙钟基值的声明补齐——[1301]③ 首批只做了 limits 三键,而
613
+ // MAX_TASK_COST_USD/MAX_TASK_TOKENS 在 caller 缺席时**直接成为** spec 值(cappedCeiling),core
614
+ // 便记 provenance:"spec"=归因谎报(「谁设的 5 刀」重回考古)。同姿势:只声明 SET 了的键,
615
+ // server 命名空间,绝不重复 core 目录键。
616
+ if (config.maxTaskCostUsd > 0)
617
+ declarations.push({ key: "server.budget.maxCostUsdCap", value: String(config.maxTaskCostUsd), reason: "env MAX_TASK_COST_USD (operator ceiling; becomes the effective value when the caller sends none)" });
618
+ if (config.maxTaskTokens > 0)
619
+ declarations.push({ key: "server.budget.maxTokensCap", value: String(config.maxTaskTokens), reason: "env MAX_TASK_TOKENS_MAX (operator ceiling; becomes the effective value when the caller sends none)" });
620
+ // F-4 附:tenancy 墙钟基值(taskWallClockSec 在 multi-tenant 时注入 2400/3600 基值——同为
621
+ // 「server 设的顶」,声明其来源;single-user 无墙=不声明)。
622
+ if (config.requirePrincipal === true)
623
+ declarations.push({ key: "server.limits.wallClockBaseSec", value: "2400/3600", reason: "tenancy wall-clock base (multi-tenant; big tasks 3600) — TASK_TIMEOUT_SEC raises, never shrinks" });
624
+ // codex F1:core 目录自有键(limits.timeoutSec 等)**绝不重复声明**——core 已按真 provenance 发
625
+ // 该字段,叠一条 host-declared 字符串值=同键双条矛盾帧(by-key 投影抹掉权威来源)。server 只
626
+ // 声明自己命名空间的键;来源语义(caller vs 墙钟)由 timeoutSec 的 spec provenance + 上面的
627
+ // Cap 声明组合可读。
628
+ return { ...(limits !== undefined ? { limits } : {}), ...(declarations.length > 0 ? { configOverrides: declarations } : {}) };
629
+ })(),
630
+ // design/129: the TOC/interactive posture (single-user turnkey — the shell's
631
+ // session lane) defaults background children to SESSION scope = CC Backgrounded semantics (a bg Agent/
632
+ // Fork outlives the turn; completion arrives via the durable-inbox push). Multi-tenant / CI / workflow
633
+ // deployments keep core's "task" default (no orphans burning tokens). Caller-trusted spec field
634
+ // (systemPrompt tier), NEVER read from the request body. Same tenancy predicate as the wall clock above.
635
+ ...(config.requirePrincipal !== true ? { backgroundScope: "session" } : {}),
636
+ // ⑤ Per-task budget gate (1.37): honor a caller's requested ceiling but CAP it to the operator
637
+ // ceiling (a request can ask for less, never more). core fails the task with errorCode budget.*
638
+ // when crossed. budgetStreamCancel defaults true when maxCostUsd is set.
639
+ maxCostUsd: cappedCeiling(body.maxCostUsd, config.maxTaskCostUsd),
640
+ maxTokens: cappedCeiling(body.maxTokens, config.maxTaskTokens),
641
+ // 1.40 near-budget degradation: only meaningful with a cost ceiling (the fraction is of it).
642
+ // When this task has one, switch to the cheaper model at atCostFraction instead of hard-failing.
643
+ // vision precheck (adversarial-review finding): DROP degrade for an image-carrying task when the
644
+ // degrade TARGET can't read images (toSupportsImages=false). The precheck only sees the picked model, not the
645
+ // external degrade target — so without this a runtime degrade would send the images to a text-only gateway (the
646
+ // opaque 400 the precheck prevents). The task keeps its vision-capable main model; near-budget it hard-fails on
647
+ // cost instead of image-failing. MODEL_DEGRADE_TO_VISION=true opts back in.
648
+ degrade: (() => {
649
+ if (!config.degrade || cappedCeiling(body.maxCostUsd, config.maxTaskCostUsd) === undefined)
650
+ return undefined;
651
+ if (Array.isArray(body.images) && body.images.length > 0 && !config.degrade.toSupportsImages) {
652
+ // S14 (SILENT-FALLBACK P0-f): this drop was undetectable — the task keeps its vision-capable main
653
+ // model and near-budget it hard-fails on cost instead of degrading (rationale above). Surface it.
654
+ metrics.inc("degrade_dropped_total", { reason: "vision_target" });
655
+ logger.warn("degrade_dropped", { reason: "vision_target", model: picked.model, images: body.images.length });
656
+ return undefined;
657
+ }
658
+ return { to: config.degrade.to, atCostFraction: config.degrade.atCostFraction };
659
+ })(),
660
+ // Long-term memory (design/138 S1): enabled when the memory ENGINE is wired AND a scope was derived
661
+ // (single-user only — multi-tenant derives none, memory dark). MF-30 PAUSE (option B, per-request —
662
+ // clay 2026-06-27 confirmed with core): `body.memoryWrite:false` makes THIS run read-only over memory
663
+ // (`writeScope:null` — the engine materializes/reads but harvest commits nothing). Per-request (not a
664
+ // stored flag) → no new per-session state; the shell carries the toggle. RESUME re-runs resolveSpec
665
+ // from the persisted body, so a paused run stays paused across resume legs.
666
+ memory: memoryEngine ? memorySpecForRequest(auth?.memoryScope, body.memoryWrite, s4DefaultScopes) : undefined,
667
+ // Scenario-provided capabilities (e.g. code-review = repo tools + reviewer subagents + prompt).
668
+ // RFC A2: the SelectEnvironment tool rides after the scenario's tools (spec.tools is ADDITIVE to core's
669
+ // built-in roster — prepare-task mounts first-party tools separately). Only when the image chain is live.
670
+ tools: ((base) => {
671
+ const extra = [...(selectEnvTool ? [selectEnvTool] : []), ...(sendUserFileToolSpec ? [sendUserFileToolSpec] : [])];
672
+ const merged = extra.length > 0 ? [...(base ?? []), ...extra] : base;
673
+ // [803]④ defer face (EXPERIMENTAL, default OFF): long-tail first-party tools get per-tool defer:true
674
+ // (assessment + why NOT deferMode:"auto"/SendUserFile: src/capabilities/tool-defer.ts header).
675
+ return merged && config.toolDeferLongtail ? applyLongtailDefer(merged, true) : merged;
676
+ })(cap.tools),
677
+ // Personalization: the caller's own skills ride after the scenario's; scenario
678
+ // wins on a name clash (mergeUserSkills — the security baseline can't be shadowed). HTTP layer
679
+ // already validated shape + caps (validateUserSkills).
680
+ skills: mergeUserSkills(cap.skills, body.skills, logger),
681
+ // Sema-registry MCP servers (B1) filtered to this scenario (env-NAME refs already resolved at startup),
682
+ // PLUS the caller's gated per-request MCP (R3 — TOC local `.mcp.json`). resolveRequestMcp honors body.mcpServers
683
+ // on any SINGLE-USER deployment (`requirePrincipal!==true` — the requester is the super-admin of their own
684
+ // worker; the stdio MCP runs on the worker, not the exec env, so the lane is irrelevant → seamless TOC↔cloud);
685
+ // a multi-tenant deployment returns the baseline unchanged (gate closed → ignored, advertised via
686
+ // capabilities.mcpInjection). Baseline wins on a name clash (a caller can ADD a server, never SHADOW a configured one).
687
+ mcp: resolveRequestMcp(mcpForScenario(config.mcpServers, scenarioName), body.mcpServers, config, logger),
688
+ // center prompts 投影([1057]①f):center 下发的场景终形(overrides[s] ?? 基线)赢过内置 provider
689
+ // (center-wins,scenario overlay 同先例);无 pack/该场景空声明 ⇒ 内置照旧。声明数组逐字喂 typed
690
+ // 钩子(contentHash 透传,core 1.315 校验入 manifest);packId 归因在采用日志(center_prompts_adopted)。
691
+ // codex R4:读请求级快照 centerDecls(与 append-less 门同一份),不再回读活 ref——刷新竞态下门与
692
+ // provider 才不会各看一版(TOCTOU:门判安全、pick 到已组装包=core 侧静默丢 rider 复活)。
693
+ promptProvider: centerDecls ? centerPromptProvider(centerDecls, scenarioName) : cap.promptProvider,
694
+ // F4: high-risk write approval gate. DURABLE (design/45, opt-in): the gated `ask` SUSPENDS the task
695
+ // (durable checkpoint, resumable on any replica). Else the legacy POLL gate: the call waits on a
696
+ // durable pending row any instance can decide via /v1/approvals, releasing at the task deadline.
697
+ // Durable ask (TC-5.4, core 1.95): in durable mode an AskUserQuestion call suspends like an F4 gate —
698
+ // the question policy adjudicates it `ask`, the operator answers out-of-band, and the resume carries
699
+ // the QuestionAnswer (server.ts `body.answer` → onQuestion closure). Suspend side mounts the tool with
700
+ // QUESTION_AWAITS_RESUME (it must never run pre-suspend; reaching it = wiring bug, typed throw).
701
+ toolPolicy: durableEnabled
702
+ ? combinePolicies(createDurableQuestionPolicy(), createDurableAskPolicy({
703
+ requireApproval: config.approvalRequire, deny: config.approvalDeny, autoBudget: config.approvalAutoBudget, neverAuto: config.approvalNeverAuto,
704
+ // The probe key is the CONTINUED session (auth.sessionId — the same id that keys the
705
+ // durable checkpoint /decide route). A fresh session (no body.sessionId) has no exemptions by
706
+ // construction; fail-closed inside the policy on a store error.
707
+ ...(approvalExemptionStore && auth?.sessionId
708
+ ? {
709
+ exempt: (toolName) => approvalExemptionStore.has(auth.sessionId, toolName),
710
+ onExempted: (toolName, rawToolName) => logger.info("approval_exempted", { sessionId: auth.sessionId, toolName, rawToolName }),
711
+ }
712
+ : {}),
713
+ }))
714
+ : approvalEnabled && approvalStore
715
+ ? createOaApprovalPolicy({
716
+ store: approvalStore,
717
+ requireApproval: config.approvalRequire,
718
+ deny: config.approvalDeny,
719
+ pollMs: config.approvalPollMs,
720
+ context: () => ({ sessionId: auth?.sessionId ?? null, owner: auth?.principal ?? null }),
721
+ neverAuto: config.approvalNeverAuto, // workflow audit 2026-07-13: the ordering lock must hold on the OA leg too
722
+ // Same probe on the legacy poll gate (sessionId threaded via ctx here).
723
+ ...(approvalExemptionStore
724
+ ? {
725
+ exempt: (sessionId, toolName) => approvalExemptionStore.has(sessionId, toolName),
726
+ onExempted: (sessionId, toolName) => logger.info("approval_exempted", { sessionId, toolName }),
727
+ }
728
+ : {}),
729
+ })
730
+ : singleUserAutoAcceptBaseline
731
+ // 🔴 (live finding: single-user host-lane Bash ran UNGATED, no adjudication): a
732
+ // single-user turnkey worker with NO expressed gate intent previously left `toolPolicy` UNDEFINED →
733
+ // core's write-capable hand tools mount UNADJUDICATED (core error-logs it every task; host lane has NO
734
+ // sandbox to fall back on). CC's trust model is auto-accept, but the GATE MECHANISM must be PRESENT
735
+ // (core 原则: "机制留、默认可更宽 auto-accept"). So wire an adjudicated auto-accept BASELINE
736
+ // (`createAllowDenyPolicy({})` = a present, effect-aware policy that allows all): satisfies core's
737
+ // `hasEffectAwareGate`, restores observability + a hook/tighten point, and the operator STILL tightens
738
+ // irreversible ops via `AUTONOMY` / `commandPolicy` (layered TIGHTEN-ONLY by applyRuntimeGovernance).
739
+ // Interactive approval routed to the shell HITL = follow-on. 🔴 `singleUserAutoAcceptBaseline` requires
740
+ // ZERO gate intent — a single-user op who SET approval flags but wired no store falls to `undefined`
741
+ // (core warns = real misconfig, not masked); multi-tenant likewise stays `undefined` (approval required).
742
+ ? createAllowDenyPolicy({})
743
+ : undefined,
744
+ // Durable suspend needs both the store (here, per-task) and the opt-in scope key (multi-tenant =
745
+ // principal; "_" when auth is off). core suspends on a policy `ask` only when these are present.
746
+ ...(durableEnabled
747
+ ? {
748
+ checkpointStore,
749
+ durableApproval: {
750
+ scope: auth?.principal ?? "_",
751
+ ...(config.approvalTimeoutSec > 0 ? { ttlMs: config.approvalTimeoutSec * 1000 } : {}),
752
+ },
753
+ // design/80 seam #2: opt this task into resource/preempt durable-suspend (design/74 third state)
754
+ // when RESOURCE_SUSPEND=true — a budget/turns/walltime limit OR a scheduler preempt durably SUSPENDS
755
+ // (resumable) instead of failing. SAME scope key as durableApproval (the principal). The decision
756
+ // (incl. the 🔴 verify/cascade EXCLUSION that keeps eligibility aligned with the preemptSignal wiring,
757
+ // so an inner suspend can't orphan a VM+checkpoint) lives in the pure, unit-tested `resourceSuspendOptIn`.
758
+ ...((rs) => (rs ? { resourceSuspend: rs } : {}))(resourceSuspendOptIn({
759
+ enabled: config.resourceSuspend,
760
+ ttlSec: config.resourceSuspendTtlSec,
761
+ isVerify: body.verify === true,
762
+ isCascade: body.cascade === true,
763
+ scope: auth?.principal ?? "_",
764
+ })),
765
+ onQuestion: QUESTION_AWAITS_RESUME,
766
+ }
767
+ : {}),
768
+ };
769
+ return spec;
770
+ };
771
+ /** 阶段⑤(governance 折叠):吃 阶段④的 spec + 请求体 + auth + 阶段①的 append/settings 三量,吐 折完运营方
772
+ * governance 与客户端 settings(tighten-only)后的 governed,外加两个下游还要用的量:本请求的 scratchpadDir
773
+ * 与 hostSemanticsLane 判别。 */
774
+ const foldGovernanceAndSettings = async (body, auth, spec, gated) => {
775
+ const { appendLessPack, acceptedAppend, parsedSettings } = gated;
776
+ // Runtime governance "second baton" (center §10): compile the operator's autonomy + commandPolicy onto the
777
+ // spec TIGHTEN-ONLY. commandPolicy layers via combinePolicies onto the approval/durable baseline above (NOT a
778
+ // bare overwrite — TRAP #1), autonomy expands to handsReadOnly/shellGate (TRAP #2). Applied here so it also
779
+ // re-applies identically on the resume paths (which rebuild via resolveSpec) — core requires shellGate/
780
+ // toolPolicy be re-supplied on resume (omitting shellGate would leave a resumed run's bash UNGATED).
781
+ // tightenTaskSpec THROWS on a loosening misconfig (fail-loud).
782
+ //
783
+ // 🔴 RESUME uses LIVE config (not a per-task frozen snapshot), IDENTICAL to how the approval baseline above
784
+ // reads live `config.approvalRequire`. So if an operator LOOSENS the fleet governance while a task is
785
+ // suspended, the resumed task picks up the looser policy (governance stays per-call tighten-only, so a
786
+ // mid-suspend TIGHTENING is honored; a loosening is an explicit operator action). Freezing deployment policy
787
+ // per-task across suspend is a larger, separate decision that must cover the approval baseline too — tracked
788
+ // for center (adversarial-review HIGH-1), deliberately NOT a governance-only partial freeze here (that would
789
+ // be an inconsistent false-safety: approval would still be live).
790
+ const governedBase = applyRuntimeGovernance(spec, { autonomy: config.autonomy, commandPolicy: config.commandPolicy });
791
+ // Fold the client's per-request settings stamp onto the governed spec, TIGHTEN-ONLY (deny-wins) — AFTER
792
+ // the approval baseline + operator governance, so the order is deployment ⊇ operator ⊇ client (each can only
793
+ // narrow). tightenTaskSpec THROWS if the client settings would LOOSEN a stricter baseline → a 422 client error
794
+ // (a settings stamp is tighten-only by contract; never a silent weakening). Re-applies idempotently on resume.
795
+ let governed = governedBase;
796
+ // R4: fold a LIGHT top-level `body.permissionMode` onto the parsed settings (the explicit
797
+ // per-turn intent WINS over a bundle `defaultMode`) → the SAME tighten-only governance path. Post-[816] all
798
+ // five CC modes are honored as gate-SHAPE choices (see coercePermissionMode/deriveSettingsPolicy): `plan` ⇒
799
+ // read-only hands + present_plan; `default`/`auto` ⇒ the fs-write ask gate; `acceptEdits` ⇒ the cwd-domain
800
+ // variant; `bypassPermissions` ⇒ no mode gate (the deployment baseline is composed above and untouchable).
801
+ const bodyMode = coercePermissionMode(body.permissionMode);
802
+ let effectiveSettings = bodyMode ? withPermissionMode(parsedSettings.settings, bodyMode) : parsedSettings.settings;
803
+ // codex R3 second bypass: settings.outputStyle ALSO lands in spec.appendSystemPrompt (applyTaskSettings
804
+ // fold) — on an append-less pack core would discard it silently, exactly like the top-level rider. Same
805
+ // disposition: fresh submits 400 at the HTTP gate; here (the resume mirror + any leg the gate didn't see)
806
+ // strip + warn. Spread-copy, never mutate parsedSettings (it may be consulted elsewhere).
807
+ if (appendLessPack && effectiveSettings?.outputStyle) {
808
+ logger.warn("output_style_dropped", { detail: "the effective prompt pack cannot mount the append slot (already-assembled systemPrompt or center assembled-identity declaration)", sessionId: auth?.sessionId ?? null });
809
+ const { outputStyle: _dropped, ...rest } = effectiveSettings;
810
+ effectiveSettings = Object.keys(rest).length > 0 ? rest : undefined;
811
+ }
812
+ // codex R14 resume mirror of the aggregate cap: both carriers fold into ONE spec field — a pre-cap stored
813
+ // body can be individually valid but combined over the bound (fresh submits 400'd this in prepareSpec).
814
+ // Drop the STYLE (it composes second; the knowledge rider keeps priority) + warn, never silently oversize.
815
+ if (effectiveSettings?.outputStyle && acceptedAppend && acceptedAppend.length + 2 + effectiveSettings.outputStyle.length > MAX_SETTINGS_OUTPUT_STYLE_CHARS) {
816
+ logger.warn("output_style_dropped", { detail: `combined append carriers exceed the ${MAX_SETTINGS_OUTPUT_STYLE_CHARS} cap (rider ${acceptedAppend.length} + style ${effectiveSettings.outputStyle.length}) — style dropped (pre-cap stored body on a resume leg?)`, sessionId: auth?.sessionId ?? null });
817
+ const { outputStyle: _dropped2, ...rest2 } = effectiveSettings;
818
+ effectiveSettings = Object.keys(rest2).length > 0 ? rest2 : undefined;
819
+ }
820
+ // [820]③ scratchpadDir, hoisted from the envFacts block below so the SAME per-session dir feeds BOTH the
821
+ // `# Environment` fact and the fs-write gate's exemptDirs (single-source path via scratchpadDirFor inside
822
+ // ensureScratchpadDir). HOST-semantics lanes only + session required — rationale at the envFacts consumer.
823
+ const hostSemanticsLane = config.remoteExec === undefined || config.remoteExec.provider === "host";
824
+ // G3([816]③/[820]③,[1840]§四):壳供 body.scratchpadDir 优先——壳(CC 形)有自己的 per-session
825
+ // scratchpad 约定路径,接受后事实/豁免/根围栏三处同源指向壳的目录(壳侧提示词与 server 写门不再
826
+ // 两个 scratchpad)。验收门 fail-closed(多租户/远程 lane/浅路径全拒收,warn 后回落自算)——
827
+ // 规则与理由集中在 acceptShellScratchpadDir。
828
+ const shellScratchpad = await acceptShellScratchpadDir(body.scratchpadDir, {
829
+ requirePrincipal: config.requirePrincipal,
830
+ hostSemanticsLane,
831
+ warn: (msg, meta) => logger.warn(msg, { ...(meta ?? {}), sessionId: auth?.sessionId ?? null }),
832
+ });
833
+ const scratchpadDir = shellScratchpad ??
834
+ (hostSemanticsLane && auth?.sessionId ? await ensureScratchpadDir(config.localDataRoot ?? localRoot, auth.sessionId) : undefined);
835
+ if (effectiveSettings) {
836
+ // [816]/[820]① fs-write gate wiring — HOST-semantics lanes only: core's createFsWriteGatePolicy
837
+ // canonicalizes每一个 target/dir 走给定 env 的真实 fs(exists/canonicalPath/readLink,dist 亲读),所以
838
+ // env 必须就是 hand 工具真正写的那块盘。e2b/k8s/ssh/adb/local-docker 的沙箱 env 由 core 在 spec 之后
839
+ // 才铸(executionEnvFactory),这里给 worker 本机 env 会拿错误的 fs 裁决(symlink/exists 全答错)——
840
+ // 沙箱 lane 诚实不挂(gate=undefined ⇒ derive 回落 base 规则,pre-[816] 行为)。
841
+ // NodeExecutionEnv 构造是纯字段赋值(不 spawn)。
842
+ //
843
+ // 修5(三路复审 W4,cwd 基准漂移):gate 的 cwd 基准=与 hands 真跑处同源,分三形:
844
+ // · session 注册过 cwd(cwdHonored 已在注册处把关)⇒ 用它——与 host factory 的 effectiveHostWorkspace
845
+ // 第一优先级同源(顶层 HTTP 任务无 parentCwd,resolver 即 session cwd)。
846
+ // · `host` factory lane 且未注册 ⇒ hands 落在 factory 才铸的 EPHEMERAL per-task 目录(spec 期不可知)。
847
+ // 此前回退 process.cwd()(服务自身启动目录)——acceptEdits 会把服务目录当 auto-allow 域、多任务共享
848
+ // 进程时判定漂移(W4)。改为 fail-safe 哨兵:一个从不创建的目录 ⇒ acceptDirs/相对路径 canonicalize
849
+ // 失败即 ask(fs-write-gate-policy dist 亲读:dir.ok=false 跳过、target 解析失败=ask);绝对路径裁决
850
+ // (hand 工具的书面契约形)与 scratchpad exempt 均不受影响。
851
+ // · provider 未设(in-process host / run-local)⇒ hands 就在本进程 cwd 跑(无 factory),process.cwd()
852
+ // 正是真工作目录,保留。
853
+ const fsWriteGate = hostSemanticsLane
854
+ ? (() => {
855
+ const sessionCwd = auth?.sessionId ? effectiveHostWorkspace(perSessionCwd.get(auth.sessionId), {}) : undefined;
856
+ const gateCwd = sessionCwd ??
857
+ (config.remoteExec === undefined
858
+ ? process.cwd()
859
+ : join(config.localDataRoot ?? localRoot, "fs-write-gate-unrooted")); // never created — see above
860
+ return {
861
+ env: new NodeExecutionEnv({ cwd: gateCwd }),
862
+ cwd: gateCwd,
863
+ ...(scratchpadDir ? { scratchpadDir } : {}),
864
+ // ③ (core 1.295) sensitive-path write deny set:组合进 gate 腿的同一 combinePolicies 折叠
865
+ // (deny 恒赢 —— 豁免/acceptDirs 越不过;集合取舍 core 成文,server 只透传 config 旋钮:
866
+ // 缺省 = core RECOMMENDED_SENSITIVE_PATTERNS,SENSITIVE_WRITE_PATTERNS 显式替换/off 关闭)。
867
+ ...(config.sensitiveWritePatterns.length > 0 ? { sensitivePatterns: config.sensitiveWritePatterns } : {}),
868
+ // [1557]§四 opt-in(cli[1555]②「Bash echo > file 绕写门」缺口的部署侧补丁):MANUAL_MODE_SHELL_GATE
869
+ // 未设置时 config.manualModeShellGate 缺席,本行不传字段——deriveSettingsPolicy 的 default/
870
+ // auto/acceptEdits 分支照旧不碰 TaskSpec.shellGate,零行为变化。
871
+ ...(config.manualModeShellGate ? { shellGate: config.manualModeShellGate } : {}),
872
+ // [841]① / core 1.294 exemption seam:gate 产 ask 前查同一只 approval_exemption 店(与 decide
873
+ // remember="session" 的授予、ask 政策层探针同店同 canonical toolName 键空间)——「本会话不再询问」
874
+ // 对 fs-write 门同 turn 即时生效(父+继承子任务,ask 路径不再重入)。has() reject ⇒ core 按
875
+ // 未豁免处理(fail-closed 照 ask),与店契约的调用方纪律一致;命中打 info 审计(对齐
876
+ // approval_exempted 的短路留痕,不记路径,路径留在 core 的 allow message 里)。
877
+ ...(approvalExemptionStore && auth?.sessionId
878
+ ? {
879
+ isExempt: async (toolName) => {
880
+ const hit = await approvalExemptionStore.has(auth.sessionId, toolName);
881
+ if (hit)
882
+ logger.info("fs_write_gate_exempted", { toolName, sessionId: auth.sessionId });
883
+ return hit;
884
+ },
885
+ }
886
+ : {}),
887
+ };
888
+ })()
889
+ : undefined;
890
+ // [1248]②/codex F2 — the workflow ask leg's session-exemption probe, on EVERY lane (unlike fsWriteGate:
891
+ // the workflow gate is name-keyed, not fs-adjudicated). Same store + canonical toolName key space as the
892
+ // fs-gate probe — one remember="session" grant serves both; without it the durable lane re-parks every
893
+ // later run_workflow call after an operator already granted "don't ask again this session".
894
+ const workflowGate = approvalExemptionStore && auth?.sessionId
895
+ ? {
896
+ isExempt: async (toolName) => {
897
+ const hit = await approvalExemptionStore.has(auth.sessionId, toolName);
898
+ if (hit)
899
+ logger.info("workflow_gate_exempted", { toolName, sessionId: auth.sessionId });
900
+ return hit;
901
+ },
902
+ }
903
+ : undefined;
904
+ try {
905
+ governed = applyTaskSettings(governedBase, effectiveSettings, fsWriteGate, workflowGate);
906
+ }
907
+ catch (e) {
908
+ throw new HttpError(422, `settings are tighten-only and cannot loosen the deployment policy: ${e.message}`);
909
+ }
910
+ }
911
+ // subagent-hands follow-on (adversarial-review finding): core does NOT propagate
912
+ // handsReadOnly/shellGate to a delegated subagent (SubagentToolOptions has NO read-only knob; subagent.js's child
913
+ // runTask omits it). So a read-only (permissionMode=plan) parent that mounts the Task tool would let the CHILD get
914
+ // WRITABLE hands — now reachable because subRunner has an executionEnvFactory (the hands fix). That defeats plan
915
+ // mode's read-only contract (a within-tenant gap, not a cross-tenant breach). Until core propagates it (relayed),
916
+ // strip the delegation tools when this task is hands-read-only — a read-only run delegates nothing writable.
917
+ // `stripDelegationTools` (runtime-governance.ts, tested there) matches the subagent tool ("Agent", core 1.202
918
+ // canonical; legacy alias "Task") + Fork in CANONICAL space — the pre-1.202 raw `!== "Task"` filter here
919
+ // silently stopped matching when core renamed the tool (fail-open: a plan-mode parent could delegate a
920
+ // WRITABLE-hands child).
921
+ if (governed.handsReadOnly === true && Array.isArray(governed.tools)) {
922
+ governed = { ...governed, tools: stripDelegationTools(governed.tools) };
923
+ }
924
+ return { governed, scratchpadDir, hostSemanticsLane };
925
+ };
926
+ /** 阶段⑥(image·envFacts·router):吃 阶段⑤的 governed + scratchpadDir/hostSemanticsLane + 场景名,吐**最终**
927
+ * TaskSpec —— per-task 镜像 fail-closed 解析并按 session 登记、envFacts 组装、scratchpad 根围栏折叠,以及 A
928
+ * 价值路由(命中 supervisor 时以 supPostureOverrides 收紧后返回)。 */
929
+ const applyImageFactsAndRouting = async (body, auth, governed, folded) => {
930
+ const { scenarioName, scratchpadDir, hostSemanticsLane } = folded;
931
+ // §7 P0.5 per-task sandbox image(用户按需选像): the caller sends a PROFILE
932
+ // (intent) — NEVER a digest (a sha256 is enumerable + caller-unbound → trusting a caller digest is fail-OPEN;
933
+ // a user could pass another tenant's digest). The TRUSTED control plane resolves profile→digest with the
934
+ // caller's principal, FAIL-CLOSED (same visibility the /v1/images/select admission uses), and registers it by
935
+ // sessionId for the k8s factory to apply per-pod. RESUME re-runs resolveSpec ⇒ re-resolves LIVE (no stale
936
+ // frozen digest), consistent with the governance live-config discipline above.
937
+ // RFC A2: a session-level SelectEnvironment binding (PROFILE intent, tool-written) is the fallback when the
938
+ // request body carries no explicit profile. Body wins (an explicit caller choice for THIS task); either way
939
+ // the profile is re-resolved FAIL-CLOSED right here — a session binding never skips re-admission, and the
940
+ // F3 boundary contract holds structurally (binding is only read at task boundaries, children inherit
941
+ // the registered ref at spawn).
942
+ const bodyProfile = typeof body.sandboxImageProfile === "string" && body.sandboxImageProfile.length > 0 ? body.sandboxImageProfile : undefined;
943
+ // cascade/verify mirror the HTTP-layer v1 reject (server.ts): those flows strip/replace the session, so a
944
+ // session-level binding would not reach the rungs/verifier sub-run — silently mixed environments. A body
945
+ // profile on cascade/verify is already 400-rejected up front; the session fallback simply does not apply.
946
+ const taskImageProfile = bodyProfile ?? (body.cascade === true || body.verify === true ? undefined : sessionEnvSelection.get(auth?.sessionId));
947
+ let resolvedImageCaps; // RFC A1: rides into envFacts
948
+ if (taskImageProfile !== undefined) {
949
+ if (config.remoteExec?.provider !== "k8s") {
950
+ throw new HttpError(400, "sandboxImageProfile is only supported on the k8s sandbox backend");
951
+ }
952
+ if (!imageIndex) {
953
+ throw new HttpError(400, "sandboxImageProfile requires the image index (sema-registry backend not configured)");
954
+ }
955
+ if (!auth?.sessionId) {
956
+ throw new HttpError(400, "sandboxImageProfile requires a resolved session");
957
+ }
958
+ const capsNeeded = Array.isArray(body.capabilitiesNeeded)
959
+ ? body.capabilitiesNeeded.filter((c) => typeof c === "string")
960
+ : undefined;
961
+ const resolved = await resolveSandboxImageRef({
962
+ profile: taskImageProfile,
963
+ ...(capsNeeded && capsNeeded.length > 0 ? { capabilitiesNeeded: capsNeeded } : {}),
964
+ // principal comes from the auth channel (trusted header / verified JWT), NEVER the body — so a caller
965
+ // cannot widen its own visibility. explicitOperator (NOT isOperator): an empty OPERATOR_PRINCIPALS must
966
+ // yield operator=false here — isOperator([],p)=true-for-all would let any caller resolve tenant-scoped
967
+ // images on an operator-less deployment (adversarial-review HIGH-1; the bake/direct-door boot guards do
968
+ // NOT cover per-task image selection).
969
+ viewer: { operator: explicitOperator(auth.principal, config.operatorPrincipals), tenantId: auth.principal ?? null },
970
+ index: imageIndex,
971
+ });
972
+ if (!resolved.ok)
973
+ throw new HttpError(resolved.status, resolved.message);
974
+ // Register by sessionId — the factory reads it by ctx.sessionId (stable on every path; see the factory
975
+ // wrapper). RESUME re-runs resolveSpec ⇒ re-resolves LIVE + re-registers (no stale frozen digest).
976
+ // ⚠️ KNOWN narrow edge (NOT a security breach): two CONCURRENT requests for the SAME session (same owner —
977
+ // session ownership is enforced) race this set(); a request the run-claim later rejects (409) can leave its
978
+ // image bound for the active run. Both images are the SAME principal's own admitted images, so the worst
979
+ // case is the user's run using the user's other selected image (a correctness glitch, not cross-tenant).
980
+ // Hardening (register-after-claim, keyed by the durable run id) needs the registry threaded into the server
981
+ // claim path — tracked as a follow-up; cascade/verify are rejected with a profile (see prepareSpec) because
982
+ // they strip/replace the session and would silently fall back to the default image.
983
+ perTaskImage.set(auth.sessionId, resolved.ref);
984
+ resolvedImageCaps = resolved.capabilities;
985
+ }
986
+ // RFC A1 (core 1.240.0 `TaskSpec.envFacts`): compose the deployment-trusted sandbox facts
987
+ // (profile/capabilities from the fail-closed resolution above, region pkgSource, exec-lane egress posture)
988
+ // and ride them on the spec — core renders the `# Environment` block (sanitized+bounded, egress=none gets
989
+ // the "downloads will fail" caveat). Facts only ride when known; nothing known ⇒ no field ⇒ block unchanged.
990
+ if (config.envFactsEnabled) {
991
+ // pkgSource is a FACT only where the derivePkgSourceEnv injection actually lands (the e2b/k8s sandbox
992
+ // lanes — main.ts wiring above). host/ssh/adb/run-local get NO injection, so with the global default
993
+ // (1.180.0) an unconditional read would tell the model "official sources" about an environment we never
994
+ // touched (workflow audit 2026-07-13: honest-facts posture, same as egress's "don't declare what you
995
+ // don't know").
996
+ const pkgSourceLane = config.remoteExec?.provider === "e2b" || config.remoteExec?.provider === "k8s";
997
+ // [820]③ scratchpadDir (core field + prompt chain in since 1.257.3; this fill was the missing half).
998
+ // HOST-semantics lanes only (provider unset = in-process host, or explicit "host"): the advertised path
999
+ // must be REAL to the executing hands — on e2b/k8s/ssh/adb/local-docker the tools run off this box, so a
1000
+ // worker-local path would be a lie (same honest-facts axis as pkgSource above; the remote lanes get their
1001
+ // IN-SANDBOX path via the [848] branch below instead). Session-scoped dir under
1002
+ // localDataRoot, mkdir'd at the HOISTED compute above the settings fold (fail → fact omitted) — the SAME
1003
+ // dir now feeds createFsWriteGatePolicy's exemptDirs ([820]① landed at core 1.290, wired in the
1004
+ // applyTaskSettings gate above). No sessionId (adhoc no-session task) ⇒ no per-session home ⇒ fact
1005
+ // omitted. Lifecycle/reaping: see ensureScratchpadDir docs (deferred).
1006
+ // [848] remote lanes' counterpart: on e2b/k8s/local-docker/ssh the withRemoteScratchpad-decorated
1007
+ // factory `mkdir -p`s exactly this path on the env's first exec, so advertising it is honest — with
1008
+ // the one best-effort caveat (a failed mkdir leaves the advertised path absent; see the decorator
1009
+ // header). Mutually exclusive with the host-lane fact above by construction (hostSemanticsLane owns
1010
+ // scratchpadDir; this branch is remote lanes only), and it applies the decorator's OWN validity rule
1011
+ // (remoteScratchpadDirFor: absent/unsafe sessionId ⇒ no decoration ⇒ no fact). adb stays factless.
1012
+ const remoteScratchpadDir = !hostSemanticsLane && isRemoteScratchpadLane(config.remoteExec?.provider)
1013
+ ? remoteScratchpadDirFor(auth?.sessionId)
1014
+ : undefined;
1015
+ const factScratchpadDir = scratchpadDir ?? remoteScratchpadDir;
1016
+ const facts = buildEnvFacts({
1017
+ profile: taskImageProfile,
1018
+ capabilities: resolvedImageCaps,
1019
+ ...(pkgSourceLane ? { pkgSource: config.sandboxPkgSource } : {}),
1020
+ egress: egressForRemoteExec(config.remoteExec),
1021
+ ...(factScratchpadDir ? { scratchpadDir: factScratchpadDir } : {}),
1022
+ // [1467]②(core 1.360)durable resume 铸句真值:按车道能力面,单真源 resumeFactsForLane
1023
+ // (e2b=双 preserved/k8s=按 s3Snapshot 有效能力分叉/ssh·adb·host=scratch preserved/
1024
+ // local-docker=不声明)。
1025
+ resumeFacts: resumeFactsForLane(config.remoteExec?.provider, config.remoteExec?.provider === "k8s" ? { k8sSnapshot: Boolean(config.remoteExec.s3Snapshot) } : undefined),
1026
+ });
1027
+ if (facts)
1028
+ governed = { ...governed, envFacts: facts };
1029
+ }
1030
+ // [1339] scratchpad 写门失效真因:提示词(envFacts.scratchpadDir)和 policy 层豁免(exemptDirs)都
1031
+ // 接了,但 core fs 工具的**根围栏**(resolveKey:taskRootPath+additionalDirectories)在 policy 之前
1032
+ // 短路——Write/Edit 到 scratchpad 直接 path_not_in_root,exemptDirs 根本没被问到(Bash 无根围栏,
1033
+ // 反而能写=口径矛盾还教模型绕门)。修:host 车道把同一 scratchpadDir 折进 spec.additionalDirectories
1034
+ // (server 自算的 per-session 路径,非租户输入,不走 cwdHonored 门限;compute 时已 mkdir,core 的
1035
+ // canonicalPath 必过)。remote 车道暂不折:in-sandbox 路径由 [848] 装饰器首 exec 才 mkdir,prepare 期
1036
+ // canonicalize 会失败被 core 静默跳过——那半场随 core 排序案另行。
1037
+ // codex R3(M2):**独立于 envFactsEnabled 门**——SANDBOX_ENV_FACTS=false 只关事实展示,写门围栏
1038
+ // 豁免是行为契约,关展示不得顺带关围栏(否则该配置下 [1339] 原病复发)。
1039
+ if (scratchpadDir !== undefined) {
1040
+ const dirs = governed.additionalDirectories ?? [];
1041
+ if (!dirs.includes(scratchpadDir))
1042
+ governed = { ...governed, additionalDirectories: [...dirs, scratchpadDir] };
1043
+ }
1044
+ // A value router (S1 verdict §6.3 cash-out, src/router/route-orchestration.ts): auto-decide this task's
1045
+ // orchestration. CONSERVATIVE + DETERMINISTIC — default single; escalate to the SUP prevention posture only
1046
+ // on a capability-danger signal (write/exec on a NON-isolated env — an isolated sandbox CONTAINS the harm,
1047
+ // so isolated tasks stay single, matching the verdict's default). caller-explicit team (council/debate/team)
1048
+ // is honored, never auto-produced. Flag-gated (ROUTER_ENABLED) — ships dark, enable after live validation.
1049
+ if (config.routerEnabled) {
1050
+ // Resolve isolation from provider AND runtimeClass — a k8s worker on plain runc is NOT VM-isolated
1051
+ // (review HIGH); fail-closed for runc/empty/unknown.
1052
+ const isolatedExecEnv = isIsolatedExecEnv(config.remoteExec?.provider, config.remoteExec?.provider === "k8s" ? config.remoteExec.runtimeClass : undefined);
1053
+ const decision = routeServiceTask({
1054
+ isolatedExecEnv,
1055
+ hasExecutionEnv: config.remoteExec != null,
1056
+ ...(config.autonomy ? { autonomy: config.autonomy } : {}),
1057
+ explicitTeam: body.council === true || body.debate === true || scenarioName === "team",
1058
+ });
1059
+ logger.info("orchestration_routed", { mode: decision.mode, reason: decision.reason, sessionId: auth?.sessionId ?? null });
1060
+ if (decision.mode === "supervisor") {
1061
+ // SUP prevention posture: gate every bash (shellGate:"always") AND the direct write hand tools
1062
+ // (edit_file/write_file via toolPolicy) — the verdict's gate-deny on capability-danger. tighten-only
1063
+ // (composes with the governance/approval baseline; never loosens). The worker's existing approval
1064
+ // machinery (durable/poll) enforces the suspend — same precondition as autonomy "ask".
1065
+ return tightenTaskSpec(governed, supPostureOverrides());
1066
+ }
1067
+ }
1068
+ return governed;
1069
+ };
1070
+ return resolveSpec;
1071
+ }
1072
+ //# sourceMappingURL=resolve-spec.js.map