@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.
- package/MIGRATION.md +74 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +391 -0
- package/dist/boot/budget-tracing.d.ts +48 -0
- package/dist/boot/budget-tracing.js +86 -0
- package/dist/boot/config-center.d.ts +62 -0
- package/dist/boot/config-center.js +1002 -0
- package/dist/boot/coordinators.d.ts +33 -0
- package/dist/boot/coordinators.js +97 -0
- package/dist/boot/execution-env.d.ts +26 -0
- package/dist/boot/execution-env.js +370 -0
- package/dist/boot/leader.d.ts +27 -0
- package/dist/boot/leader.js +81 -0
- package/dist/boot/reapers.d.ts +53 -0
- package/dist/boot/reapers.js +252 -0
- package/dist/boot/resolve-spec.d.ts +70 -0
- package/dist/boot/resolve-spec.js +1072 -0
- package/dist/boot/runner-deps.d.ts +101 -0
- package/dist/boot/runner-deps.js +343 -0
- package/dist/boot/runtime-caps.d.ts +21 -0
- package/dist/boot/runtime-caps.js +62 -0
- package/dist/boot/session-faces.d.ts +57 -0
- package/dist/boot/session-faces.js +157 -0
- package/dist/boot/shutdown.d.ts +50 -0
- package/dist/boot/shutdown.js +129 -0
- package/dist/boot/stores.d.ts +32 -0
- package/dist/boot/stores.js +361 -0
- package/dist/boot/workflow-orchestration.d.ts +46 -0
- package/dist/boot/workflow-orchestration.js +150 -0
- package/dist/capabilities/scenarios.d.ts +5 -3
- package/dist/capabilities/scenarios.js +5 -3
- package/dist/config-center/apply-effective.js +4 -3
- package/dist/config-lkg.d.ts +2 -1
- package/dist/config-lkg.js +2 -1
- package/dist/config-types.d.ts +47 -7
- package/dist/config.d.ts +30 -13
- package/dist/config.js +562 -387
- package/dist/hooks/hook-llm.js +9 -0
- package/dist/http/routes/approvals-assistant.js +1 -1
- package/dist/http/routes/attachments.js +2 -2
- package/dist/http/routes/memory-policy.js +3 -3
- package/dist/http/routes/runs.js +1 -1
- package/dist/http/routes/session-sync.js +2 -2
- package/dist/http/routes/sessions.js +2 -2
- package/dist/http/routes/tasks.js +2 -2
- package/dist/http/routes/trace-usage.js +2 -2
- package/dist/http/routes/workflows.js +3 -1
- package/dist/http/server.d.ts +1 -1
- package/dist/http/server.js +25 -5
- package/dist/http/sse-log.js +1 -1
- package/dist/main.js +164 -3799
- package/dist/model-select.d.ts +1 -1
- package/dist/model-select.js +1 -1
- package/dist/plugins/checkpoint-store-sql.d.ts +13 -5
- package/dist/plugins/checkpoint-store-sql.js +10 -3
- package/dist/plugins/local-checkpoint-store.js +8 -2
- package/dist/plugins/remote-env-host.d.ts +2 -1
- package/dist/run-local.js +2 -1
- package/dist/session-titler.d.ts +3 -1
- package/dist/session-titler.js +2 -2
- package/dist/trace/project.js +4 -1
- 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
|