@sema-agent/server 7.78.0 → 7.78.2
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/README.md +6 -0
- package/USAGE.md +4 -0
- package/dist/approval-card.d.ts +37 -4
- package/dist/approval-card.js +22 -2
- package/dist/boot/resolve-spec.js +6 -3
- package/dist/config-catalog.d.ts +18 -1
- package/dist/config-catalog.js +3 -3
- package/dist/governance-http-status.d.ts +27 -0
- package/dist/governance-http-status.js +11 -0
- package/dist/http/routes/approvals-assistant.d.ts +35 -0
- package/dist/http/routes/approvals-assistant.js +39 -1
- package/dist/http/routes/tasks.js +4 -9
- package/dist/http/server.d.ts +28 -4
- package/dist/http/server.js +33 -13
- package/dist/observability/fail-open.d.ts +4 -0
- package/dist/observability/fail-open.js +4 -0
- package/dist/parked-decide.d.ts +7 -2
- package/dist/plugins/host-workspace-registry.d.ts +27 -0
- package/dist/plugins/host-workspace-registry.js +27 -0
- package/dist/plugins/one-time-migrations.d.ts +3 -0
- package/dist/plugins/one-time-migrations.js +18 -11
- package/dist/plugins/remote-env-host.d.ts +15 -0
- package/dist/plugins/remote-env-host.js +41 -1
- package/dist/request-key-closure.d.ts +30 -0
- package/dist/request-key-closure.js +56 -0
- package/dist/runs.d.ts +51 -0
- package/dist/runs.js +4 -11
- package/dist/runtime-governance.d.ts +18 -4
- package/dist/runtime-governance.js +2 -2
- package/dist/spec-fields.d.ts +4 -2
- package/dist/spec-fields.js +4 -5
- package/dist/task-a2a.js +8 -6
- package/dist/task-mcp.js +5 -3
- package/dist/task-settings.d.ts +49 -17
- package/dist/task-settings.js +37 -38
- package/dist/tool-approval.d.ts +35 -3
- package/dist/tool-approval.js +41 -19
- package/dist/tool-axis-words.d.ts +30 -0
- package/dist/tool-axis-words.js +9 -0
- package/dist/trace/core-keyset-guard.d.ts +3 -3
- package/dist/trace/ledger-sink.js +12 -5
- package/dist/trace/model-segment.d.ts +32 -2
- package/dist/trace/model-segment.js +15 -5
- package/dist/trace/project.d.ts +37 -3
- package/dist/trace/project.js +14 -10
- package/package.json +3 -3
|
@@ -237,6 +237,10 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
237
237
|
readonly cls: "F";
|
|
238
238
|
readonly note: "S-107 第三十二轮重扫:PuTTY .ppk 容器扫描器(redact.ts redactPpkContainers)在「无合格终止 / 收口后同容器范围再见私钥字段 / 收口后到下一版本头之间夹非空白」三形下从头**吃到输入末尾**(fail-closed 取证丢弃)——终态 errorMessage 座上=整条错误文案变成 «redacted:private-key»,与 redactions_applied_total{private-key} 的诚实替换同形不可分,故要有自己的计数/探针;影子扫不计;计数=多少次整段吃尾。";
|
|
239
239
|
};
|
|
240
|
+
readonly "server.approval-card.closed-word-out-of-set": {
|
|
241
|
+
readonly cls: "F";
|
|
242
|
+
readonly note: "S-249(`approval-card.ts` 的边界窄读族;今天的唯一成员是 `readRuleOffersAbsence`):活卡帧 / `card_json` 的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40)。";
|
|
243
|
+
};
|
|
240
244
|
readonly "server.pg-pool.idle-client-error": {
|
|
241
245
|
readonly cls: "F";
|
|
242
246
|
readonly note: "S-55(test [5864] 独立复现):pg 连接池里一条连接被外部终止(pg_terminate_backend / 云端故障切换 / LB 空闲回收)或自身网络错误。**两态一 tag,detail 判别**(`state=idle` / `state=checked-out`;先例=park 墓碑行的两臂 detail 判别):①idle 态 —— node-postgres 语义(pg-pool@3.14.0 makeIdleListener):池在 emit 'error' **之前**已 `_remove` 掉该 client,坏连接自然淘汰,下次 acquire 发新连接,在飞查询零影响;修前 Pool 上无 'error' 监听 ⇒ EventEmitter 把事件转 throw ⇒ uncaughtException ⇒ 整进程 exit 1。②checked-out 态(codex R1-[high] 补,同批修)—— pg-pool 借出时摘 idle 监听(index.js:344),`pool.connect()` 显式持有的连接(事务)在 checkout 期死时 client 直接 emit 'error' 同样打死进程;守卫只观察:中毒 client 的后续查询由 pg 以 `_queryable=false` 响亮拒,归还时池淘汰;同一次 checkout 的死可能双发事件(FATAL 消息 + socket end),warn 逐枚、计数按 checkout 去重。放行的最坏后果 = 下一次 acquire 多付一次建连,故 F 类。必须留痕:「连接不断被外部杀」(故障切换风暴 / 回收策略过激 / 有人在库上清连接)与「一切正常」在服务面同形;每次事件另有逐次结构化 warn(`pg_pool_idle_client_error` / `pg_pool_checked_out_client_error`,含 code/severity),本计数让频率曲线可读。`pool.query()` 快路不在洞内(pg-pool 自带 checkout 期 once('error') 守卫)。mysql2 孪生(tidb-pool)**不同病**:PoolConnection 构造器恒挂 once('error') 库内吞并淘汰、借出期同在(坐标成文在 createTidbPool 头注),故无同族 tag。";
|
|
@@ -207,6 +207,10 @@ export const FAIL_OPEN_TAGS = {
|
|
|
207
207
|
"server.run-terminal.output-truncated": { cls: "F", note: "S-107:持久 error 脱后 UTF-8 字节 > 65,000(TiDB TEXT 65,535 字节)⇒ 脱后切并带标记(切在占位里不是泄漏);计数=多少条终态文案被字节封顶截断" },
|
|
208
208
|
"server.runs.terminal-ledger-write-failed": { cls: "F", note: "S-107(全量门 silent-degradation 棘轮抓出的五处静默 `catch {}`):run 终局链里**账本腿**(flush 缓冲 text/reasoning、append `failed` 行)与终态翻转(setTerminal)已不共命运——账本 INSERT 抛不再让 run 卡 running 到 reaper;但那条抛掉的账本行之前是**静默**吞的。detail `arm=` 四臂(flush-clean-cancel / append-clean-cancel / flush-catch / append-thrown-cancel / append-failed):缺的是 run_events 尾巴(缓冲文本或 failed 事件),run 行本身仍是 durable truth,读面/reaper 容忍缺失,故 F 类;计数=多少次终局账本写失败。" },
|
|
209
209
|
"server.redact.ppk-eat-to-end": { cls: "F", note: "S-107 第三十二轮重扫:PuTTY .ppk 容器扫描器(redact.ts redactPpkContainers)在「无合格终止 / 收口后同容器范围再见私钥字段 / 收口后到下一版本头之间夹非空白」三形下从头**吃到输入末尾**(fail-closed 取证丢弃)——终态 errorMessage 座上=整条错误文案变成 «redacted:private-key»,与 redactions_applied_total{private-key} 的诚实替换同形不可分,故要有自己的计数/探针;影子扫不计;计数=多少次整段吃尾。" },
|
|
210
|
+
"server.approval-card.closed-word-out-of-set": {
|
|
211
|
+
cls: "F",
|
|
212
|
+
note: "S-249(`approval-card.ts` 的边界窄读族;今天的唯一成员是 `readRuleOffersAbsence`):活卡帧 / `card_json` 的一个**闭集词**座上,键在场而词不在本仓认得的那张表里(世代差,或第三方 producer)⇒ 该座**按缺席**处置。放行的最坏后果=卡上少一行「为什么这次没有规则候选」的解释(壳退回不显示该行),它**不改变任何裁决**——门在引擎里早已判完,本座是展示/观测面;反方向(把一个消费端词表外的值原样上卡)更坏:下游按闭集写的分支会落进 default 臂或渲染出生词。本臂存在的全部理由是**判别**:修前「上游发了个生词」与「上游根本没发这个键」在遥测上同形,而前者意味着本仓的词表该跟着 core 加词了(型面另有两向编译围栏,但那只在**重新编译**时说话,跑在线上的那一版不会自己红)。detail 带座名与那个词(经 redactSecrets 截 40)。",
|
|
213
|
+
},
|
|
210
214
|
"server.pg-pool.idle-client-error": {
|
|
211
215
|
cls: "F",
|
|
212
216
|
note: "S-55(test [5864] 独立复现):pg 连接池里一条连接被外部终止(pg_terminate_backend / 云端故障切换 / LB 空闲回收)或自身网络错误。**两态一 tag,detail 判别**(`state=idle` / `state=checked-out`;先例=park 墓碑行的两臂 detail 判别):①idle 态 —— node-postgres 语义(pg-pool@3.14.0 makeIdleListener):池在 emit 'error' **之前**已 `_remove` 掉该 client,坏连接自然淘汰,下次 acquire 发新连接,在飞查询零影响;修前 Pool 上无 'error' 监听 ⇒ EventEmitter 把事件转 throw ⇒ uncaughtException ⇒ 整进程 exit 1。②checked-out 态(codex R1-[high] 补,同批修)—— pg-pool 借出时摘 idle 监听(index.js:344),`pool.connect()` 显式持有的连接(事务)在 checkout 期死时 client 直接 emit 'error' 同样打死进程;守卫只观察:中毒 client 的后续查询由 pg 以 `_queryable=false` 响亮拒,归还时池淘汰;同一次 checkout 的死可能双发事件(FATAL 消息 + socket end),warn 逐枚、计数按 checkout 去重。放行的最坏后果 = 下一次 acquire 多付一次建连,故 F 类。必须留痕:「连接不断被外部杀」(故障切换风暴 / 回收策略过激 / 有人在库上清连接)与「一切正常」在服务面同形;每次事件另有逐次结构化 warn(`pg_pool_idle_client_error` / `pg_pool_checked_out_client_error`,含 code/severity),本计数让频率曲线可读。`pool.query()` 快路不在洞内(pg-pool 自带 checkout 期 once('error') 守卫)。mysql2 孪生(tidb-pool)**不同病**:PoolConnection 构造器恒挂 once('error') 库内吞并淘汰、借出期同在(坐标成文在 createTidbPool 头注),故无同族 tag。",
|
package/dist/parked-decide.d.ts
CHANGED
|
@@ -184,8 +184,13 @@ export interface ParkedDecideResult {
|
|
|
184
184
|
*
|
|
185
185
|
* · D-1 绑定:操作员在 GET 看到的 `boundCallId`/`boundInputHash` 原样透传,server **绝不重算**;
|
|
186
186
|
* 缺席时回落到行上 persisted 的那两位(core resume 侧做 fail-closed 等值校验)。
|
|
187
|
-
* · `reason`
|
|
188
|
-
* (
|
|
187
|
+
* · `reason` 走**存在性**判定(`!== undefined`),不是真值判定 —— 本铸点不折叠,原样上送。
|
|
188
|
+
* 🔴 但**别把这条读成「空串与缺席在结算上是两件事」**(7.19.0 / [ref] 之前那句话是真的,之后不是):
|
|
189
|
+
* 上送域的属主是引擎的单铸点,它把空文本折成缺席 ⇒ 到了消费端,`reason:""`、不传 `reason`、
|
|
190
|
+
* 与一条裸拒**同形**。本腿仍不在这里折,理由是「源头修复禁下游旁路」(下游再折一次就是同一语义的
|
|
191
|
+
* 第二个写者),不是因为两者可分辨。真正按「空串 ≠ 缺席」办事的只有两个**别的**域:验签载荷
|
|
192
|
+
* (approval-hmac.ts 的 `env.reason ?? null` —— 线上原值,折一刀就恒 401)与幂等回放的**身份**判定
|
|
193
|
+
* (`decisionReasonOf`,属主是本仓)。三域各有属主,逐域的射程写在 `decisionReasonOf` 的顶注里。
|
|
189
194
|
* · `hostDecision` = 结算**事实**逐字上 outcome;`gateOutcome` 一格 server **永不**填(core 对宿主
|
|
190
195
|
* 自铸的结算词是 pre-CAS 响亮拒)。
|
|
191
196
|
* · `answer` 原样透传([ref]:header 逐字匹配等语义校验全在 core,server 不预判)。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** 铸出一个 ephemeral 工作区路径时登记(persistent / 用户自有目录**不**登记 —— 那不是我们的)。 */
|
|
2
|
+
export declare function trackEphemeralHostWorkspace(dir: string): void;
|
|
3
|
+
/**
|
|
4
|
+
* `destroy()` **删成功之后**注销 —— 注销过的路径兜底腿一个字节都不碰。
|
|
5
|
+
*
|
|
6
|
+
* 🔴 「删成功之后」不是措辞:`destroy()` 的 rm 是 best-effort(teardown 契约不许抛),EBUSY / EACCES 被吞掉
|
|
7
|
+
* 之后目录还在,而 `destroyed` 已置位 ⇒ 第二次 destroy 直接早退。此时若照样注销,这条路径就**永久**失去了
|
|
8
|
+
* 被兜底回收的机会 —— 登记簿的语义是「我还欠一次清理」,删没删掉才是它该跟着走的事实。
|
|
9
|
+
*/
|
|
10
|
+
export declare function untrackEphemeralHostWorkspace(dir: string): void;
|
|
11
|
+
/** 当前在册数(判据用:钉"登记了"与"注销了",不必去摸文件系统)。 */
|
|
12
|
+
export declare function liveEphemeralHostWorkspaceCount(): number;
|
|
13
|
+
/** 兜底回收的读数。`failed` 非空 ⇒ 消费方必须**响亮**说出来,不许静默吞。 */
|
|
14
|
+
export interface HostWorkspaceReaping {
|
|
15
|
+
readonly removed: readonly string[];
|
|
16
|
+
readonly failed: readonly {
|
|
17
|
+
readonly dir: string;
|
|
18
|
+
readonly error: string;
|
|
19
|
+
}[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 同步删掉所有仍在册的工作区;删成功的注销,**删失败的留在册上等下一次机会**。
|
|
23
|
+
* **同步**是硬要求:`process.on("exit")` 里只有同步 I/O 会被执行。幂等(删过的路径 `force:true` 直接 no-op),
|
|
24
|
+
* 可重复调用 —— 重复调用正是失败项的重试路径。
|
|
25
|
+
*/
|
|
26
|
+
export declare function reapEphemeralHostWorkspaces(): HostWorkspaceReaping;
|
|
27
|
+
//# sourceMappingURL=host-workspace-registry.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
const LIVE = new Set();
|
|
3
|
+
export function trackEphemeralHostWorkspace(dir) {
|
|
4
|
+
LIVE.add(dir);
|
|
5
|
+
}
|
|
6
|
+
export function untrackEphemeralHostWorkspace(dir) {
|
|
7
|
+
LIVE.delete(dir);
|
|
8
|
+
}
|
|
9
|
+
export function liveEphemeralHostWorkspaceCount() {
|
|
10
|
+
return LIVE.size;
|
|
11
|
+
}
|
|
12
|
+
export function reapEphemeralHostWorkspaces() {
|
|
13
|
+
const removed = [];
|
|
14
|
+
const failed = [];
|
|
15
|
+
for (const dir of [...LIVE]) {
|
|
16
|
+
try {
|
|
17
|
+
rmSync(dir, { recursive: true, force: true });
|
|
18
|
+
removed.push(dir);
|
|
19
|
+
LIVE.delete(dir);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
failed.push({ dir, error: e instanceof Error ? e.message : String(e) });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { removed, failed };
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=host-workspace-registry.js.map
|
|
@@ -8,6 +8,9 @@ export interface OneTimeMigrationReport {
|
|
|
8
8
|
readonly retiredSnapshotRows: number;
|
|
9
9
|
/** M2:退役单座列 —— `dropped` = 本次真 DROP COLUMN,`absent` = 早已不在。 */
|
|
10
10
|
readonly retiredSteerSeatColumn: "dropped" | "absent";
|
|
11
|
+
/** M2:DROP COLUMN 随手丢掉的**陈旧**格数(行已不是 `pending` ⇒ 那条指令再也送不出去;`absent` 时恒 0)。
|
|
12
|
+
* 非零 = 这次迁移真丢了存量字节,必须让运维看见 —— 与 M1 的 `retiredSnapshotRows` 逐字同一条纪律。 */
|
|
13
|
+
readonly retiredSteerSeatStaleRows: number;
|
|
11
14
|
/** M3:改名的行数(账本 `tool_end` 行 + 已决 checkpoint 的执行判词列,两者相加)。 */
|
|
12
15
|
readonly askOriginRowsMigrated: number;
|
|
13
16
|
}
|
|
@@ -4,25 +4,28 @@ export const NO_SQL_MIGRATIONS = {
|
|
|
4
4
|
retiredSnapshotTable: "absent",
|
|
5
5
|
retiredSnapshotRows: 0,
|
|
6
6
|
retiredSteerSeatColumn: "absent",
|
|
7
|
+
retiredSteerSeatStaleRows: 0,
|
|
7
8
|
};
|
|
8
9
|
const RETIRED_SNAPSHOT_TABLE = "snapshot_manifest";
|
|
9
10
|
const RETIRED_STEER_SEAT_COLUMN = "pending_steer";
|
|
11
|
+
const DELIVERABLE_STEER_SEAT_WHERE = "(status = 'pending' OR (status = 'resolved' AND execution_outcome IS NULL))";
|
|
10
12
|
const RETIRED_ASK_ORIGIN = { from: "unresolvable", to: "ancestor_marked" };
|
|
11
13
|
const GATE_BEARING_LEDGER_EVENT_TYPE = "tool_end";
|
|
12
14
|
export class RetiredSteerSeatError extends Error {
|
|
13
15
|
rows;
|
|
14
16
|
code = "checkpoint.pending_steer_seat_retired";
|
|
15
17
|
constructor(rows) {
|
|
16
|
-
super(`checkpoint.pending_steer_seat_retired: ${rows} checkpoint row(s)
|
|
17
|
-
`steering column \`${RETIRED_STEER_SEAT_COLUMN}\`. That seat is gone from the engine (a resume of such a row
|
|
18
|
-
`refused pre-CAS), so the value is an operator instruction nothing will ever deliver, and this service refuses ` +
|
|
18
|
+
super(`checkpoint.pending_steer_seat_retired: ${rows} STILL-DELIVERABLE checkpoint row(s) carry a value in the RETIRED ` +
|
|
19
|
+
`single-seat steering column \`${RETIRED_STEER_SEAT_COLUMN}\`. That seat is gone from the engine (a resume of such a row ` +
|
|
20
|
+
`is refused pre-CAS), so the value is an operator instruction nothing will ever deliver, and this service refuses ` +
|
|
19
21
|
`to guess what it should become (folding a bare seat into the queue would mint a queue entry the engine never ` +
|
|
20
|
-
`wrote).
|
|
21
|
-
`
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`
|
|
25
|
-
`
|
|
22
|
+
`wrote). Only rows a resume can still reach are counted here — \`${DELIVERABLE_STEER_SEAT_WHERE}\`, i.e. pending now, or ` +
|
|
23
|
+
`reopenable back to pending (the store's only reopen CAS). A cell on any other row sits on a checkpoint that can never ` +
|
|
24
|
+
`be resumed again, so its instruction was already undeliverable — those are dropped with the column and reported as ` +
|
|
25
|
+
`\`retiredSteerSeatStaleRows\` in the migration log line, not held against the boot. ` +
|
|
26
|
+
`Two ways out for the rows above: decide or cancel those runs on server 7.73.x first (the resume delivers the ` +
|
|
27
|
+
`instruction and the row settles for good), or accept dropping them ` +
|
|
28
|
+
`(\`UPDATE checkpoint SET ${RETIRED_STEER_SEAT_COLUMN} = NULL WHERE ${RETIRED_STEER_SEAT_COLUMN} IS NOT NULL AND ${DELIVERABLE_STEER_SEAT_WHERE}\`) ` +
|
|
26
29
|
`and start this version again.`);
|
|
27
30
|
this.rows = rows;
|
|
28
31
|
this.name = "RetiredSteerSeatError";
|
|
@@ -112,11 +115,14 @@ async function withPgLock(pool, run) {
|
|
|
112
115
|
}
|
|
113
116
|
async function migrateOn(db, dialect) {
|
|
114
117
|
const seatColumnPresent = await columnExists(db, dialect, "checkpoint", RETIRED_STEER_SEAT_COLUMN);
|
|
118
|
+
let retiredSteerSeatStaleRows = 0;
|
|
115
119
|
if (seatColumnPresent) {
|
|
116
|
-
const seatRows = await db.rows(`SELECT COUNT(*) AS n FROM checkpoint WHERE ${RETIRED_STEER_SEAT_COLUMN} IS NOT NULL`);
|
|
120
|
+
const seatRows = await db.rows(`SELECT COUNT(*) AS n FROM checkpoint WHERE ${RETIRED_STEER_SEAT_COLUMN} IS NOT NULL AND ${DELIVERABLE_STEER_SEAT_WHERE}`);
|
|
117
121
|
const seats = Number(seatRows[0]?.n ?? 0);
|
|
118
122
|
if (seats > 0)
|
|
119
123
|
throw new RetiredSteerSeatError(seats);
|
|
124
|
+
const staleRows = await db.rows(`SELECT COUNT(*) AS n FROM checkpoint WHERE ${RETIRED_STEER_SEAT_COLUMN} IS NOT NULL`);
|
|
125
|
+
retiredSteerSeatStaleRows = Number(staleRows[0]?.n ?? 0);
|
|
120
126
|
}
|
|
121
127
|
const snapshotTablePresent = await tableExists(db, dialect, RETIRED_SNAPSHOT_TABLE);
|
|
122
128
|
const retiredSchemaPresent = seatColumnPresent || snapshotTablePresent;
|
|
@@ -161,7 +167,7 @@ async function migrateOn(db, dialect) {
|
|
|
161
167
|
throw new Error(`one-time migration failed AFTER completing destructive steps — record these before retrying, a retry cannot ` +
|
|
162
168
|
`report them again: ${destroyed.join("; ")}. Underlying failure: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
163
169
|
}
|
|
164
|
-
return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, askOriginRowsMigrated };
|
|
170
|
+
return { retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated };
|
|
165
171
|
}
|
|
166
172
|
export function migrateRetiredAskOrigin(type, data) {
|
|
167
173
|
if (type !== GATE_BEARING_LEDGER_EVENT_TYPE)
|
|
@@ -184,6 +190,7 @@ export function logOneTimeMigrations(log, backend, report) {
|
|
|
184
190
|
retiredSnapshotTable: report.retiredSnapshotTable,
|
|
185
191
|
retiredSnapshotRows: report.retiredSnapshotRows,
|
|
186
192
|
retiredSteerSeatColumn: report.retiredSteerSeatColumn,
|
|
193
|
+
retiredSteerSeatStaleRows: report.retiredSteerSeatStaleRows,
|
|
187
194
|
askOriginRowsMigrated: report.askOriginRowsMigrated,
|
|
188
195
|
note: "one-time persisted-data migrations for this version's retirements (retired whole-tree snapshot index table " +
|
|
189
196
|
"dropped; retired single-seat steering column dropped; retired ask-origin spelling rewritten in place). This " +
|
|
@@ -235,6 +235,21 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
|
|
|
235
235
|
}): Promise<Result<string, FileError>>;
|
|
236
236
|
/** Resolve a (possibly relative) path against {@link cwd} like the other adapters. Host paths are platform-native. */
|
|
237
237
|
private resolve;
|
|
238
|
+
/**
|
|
239
|
+
* S-260 ②:**建之前**先判「我这个用户能不能在那儿建」。
|
|
240
|
+
*
|
|
241
|
+
* 不判的话,唯一的读数是 `failed to create host workspace …: EACCES: permission denied, mkdir '<随机子目录>'`
|
|
242
|
+
* —— 它说的是那个**随机**子目录建不出来,而真正的事实是**父目录**(`HOST_WORKSPACE_BASE`,缺省
|
|
243
|
+
* `os.tmpdir()`)对本进程的运行用户不可写。运维拿着那条 errno 会去查那个随机路径(它当然不存在),
|
|
244
|
+
* 查不到根因;更糟的是每一个任务都复发一次同一条。⇒ 前判把它换成一句能直接照做的拒绝:哪个目录、
|
|
245
|
+
* 哪个用户、改哪个旋钮。
|
|
246
|
+
*
|
|
247
|
+
* 只对 **ephemeral** 形做(persistent 形是用户自己的目录,`mkdir -p` 一个已存在的只读 checkout 本来
|
|
248
|
+
* 就该成功 —— 在那儿加可写判据会把"只读仓库上跑只读任务"这个正当用法拒掉)。
|
|
249
|
+
* root 与非 POSIX 平台跳过:root 的可写判据恒真(root-squash 的网络盘上反而会假拒),
|
|
250
|
+
* 而 `process.getuid` 不存在时这条 POSIX 权限判据无从谈起。
|
|
251
|
+
*/
|
|
252
|
+
private assertWorkspaceParentWritable;
|
|
238
253
|
/** Lazily create the per-task workspace exactly once (memoized; concurrent first-use shares it). */
|
|
239
254
|
private ensurePrepared;
|
|
240
255
|
private shellReady;
|
|
@@ -3,7 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
-
import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdirSync, rmdirSync, mkdtempSync, existsSync, realpathSync } from "node:fs";
|
|
6
|
+
import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdirSync, rmdirSync, mkdtempSync, existsSync, realpathSync, accessSync, constants as FS_CONST } from "node:fs";
|
|
7
7
|
import { StringDecoder } from "node:string_decoder";
|
|
8
8
|
import { armPipeDestroyGrace, numEnvOr } from "./remote-shell.js";
|
|
9
9
|
import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
|
|
@@ -11,6 +11,7 @@ import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeS
|
|
|
11
11
|
import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
|
|
12
12
|
import { FileError, ExecutionError, RemoteExecutionError, scrubSecretEnv, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
|
|
13
13
|
import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
|
|
14
|
+
import { trackEphemeralHostWorkspace, untrackEphemeralHostWorkspace } from "./host-workspace-registry.js";
|
|
14
15
|
const PROVIDER = "host";
|
|
15
16
|
const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
|
16
17
|
const HOST_BG_MAX_CONCURRENT = numEnvOr("HOST_BG_MAX_CONCURRENT", 8, 1);
|
|
@@ -143,6 +144,7 @@ export class RemoteHostExecutionEnv {
|
|
|
143
144
|
return;
|
|
144
145
|
try {
|
|
145
146
|
await fs.rm(this.workspaceDir, { recursive: true, force: true });
|
|
147
|
+
untrackEphemeralHostWorkspace(this.workspaceDir);
|
|
146
148
|
}
|
|
147
149
|
catch {
|
|
148
150
|
}
|
|
@@ -1084,14 +1086,52 @@ export class RemoteHostExecutionEnv {
|
|
|
1084
1086
|
resolve(p) {
|
|
1085
1087
|
return path.isAbsolute(p) ? path.normalize(p) : path.normalize(path.resolve(this.cwd, p));
|
|
1086
1088
|
}
|
|
1089
|
+
assertWorkspaceParentWritable() {
|
|
1090
|
+
const uid = process.getuid?.();
|
|
1091
|
+
if (uid === undefined || uid === 0)
|
|
1092
|
+
return;
|
|
1093
|
+
let anc = path.dirname(this.workspaceDir);
|
|
1094
|
+
for (;;) {
|
|
1095
|
+
if (existsSync(anc))
|
|
1096
|
+
break;
|
|
1097
|
+
const up = path.dirname(anc);
|
|
1098
|
+
if (up === anc)
|
|
1099
|
+
break;
|
|
1100
|
+
anc = up;
|
|
1101
|
+
}
|
|
1102
|
+
try {
|
|
1103
|
+
accessSync(anc, FS_CONST.W_OK | FS_CONST.X_OK);
|
|
1104
|
+
}
|
|
1105
|
+
catch (cause) {
|
|
1106
|
+
let who = `uid=${uid} gid=${process.getgid?.() ?? "?"}`;
|
|
1107
|
+
try {
|
|
1108
|
+
who = `${os.userInfo().username} (${who})`;
|
|
1109
|
+
}
|
|
1110
|
+
catch {
|
|
1111
|
+
who = `<no passwd entry> (${who})`;
|
|
1112
|
+
}
|
|
1113
|
+
throw new Error(`host workspace base ${anc} is not writable by ${who} — the per-task workspace ${this.workspaceDir} cannot be created. ` +
|
|
1114
|
+
`Point HOST_WORKSPACE_BASE at a directory this user owns, or run the service as the user that owns ${anc}.`, { cause });
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1087
1117
|
ensurePrepared() {
|
|
1088
1118
|
if (this.prepared)
|
|
1089
1119
|
return Promise.resolve();
|
|
1120
|
+
if (!this.persistent) {
|
|
1121
|
+
try {
|
|
1122
|
+
this.assertWorkspaceParentWritable();
|
|
1123
|
+
}
|
|
1124
|
+
catch (e) {
|
|
1125
|
+
return Promise.reject(e instanceof Error ? e : new Error(String(e)));
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1090
1128
|
if (!this.preparing) {
|
|
1091
1129
|
this.preparing = fs
|
|
1092
1130
|
.mkdir(this.workspaceDir, { recursive: true })
|
|
1093
1131
|
.then(() => {
|
|
1094
1132
|
this.prepared = true;
|
|
1133
|
+
if (!this.persistent)
|
|
1134
|
+
trackEphemeralHostWorkspace(this.workspaceDir);
|
|
1095
1135
|
})
|
|
1096
1136
|
.catch((e) => {
|
|
1097
1137
|
this.preparing = undefined;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** 拒体里逐字回显的键名条数上限。键名原样回显(不猜语义),但**条数有界** —— 一个塞满键的体不许把 400 响应
|
|
2
|
+
* 放大成流量面。超出部分只报数。 */
|
|
3
|
+
export declare const MAX_KEY_ECHO = 20;
|
|
4
|
+
/** 键名回显的清洗:剥控制/双向字符(键名进的是 JSON 错误体与运维日志,一个 RLO 能把两行字读反),
|
|
5
|
+
* 再按 {@link MAX_KEY_ECHO_CHARS} 截断。清洗之外**逐字**,不做大小写/近似猜测。 */
|
|
6
|
+
export declare function echoKeyName(prefix: string, key: string): string;
|
|
7
|
+
/** 一次键闭集裁决的产物:一句 wire 文案 + 两张分类键名表(消费端可机读,不必解析文案)。 */
|
|
8
|
+
export interface KeyClosureIssue {
|
|
9
|
+
message: string;
|
|
10
|
+
/** 词表外的键(拼错/未来键),全路径形,已清洗+截断+排序+封顶。 */
|
|
11
|
+
unknownKeys: string[];
|
|
12
|
+
/** 上游合约(CC / SDK / 引擎)里合法、但本 service 不在这一面接线的键,同形。 */
|
|
13
|
+
unsupportedKeys: string[];
|
|
14
|
+
}
|
|
15
|
+
/** 逐键分拣:受理集内跳过,`tolerated` 命中进 `unsupported`,其余进 `unknown`。两桶都拒,标签只影响文案。 */
|
|
16
|
+
export declare function scanKeyClosure(bag: object, prefix: string, accepted: ReadonlySet<string>, tolerated: ReadonlySet<string>, into: {
|
|
17
|
+
unknown: string[];
|
|
18
|
+
unsupported: string[];
|
|
19
|
+
}): void;
|
|
20
|
+
/** 一张分类表的文案片段(封顶后只报数)。 */
|
|
21
|
+
export declare function keyClosureClause(names: string[], label: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* FRESH 提交面的**顶层**键闭集裁决:受理集之外的键 ⇒ 返回拒体(调用方 400 `request.body_shape`);
|
|
24
|
+
* 全部受理 ⇒ `null`。
|
|
25
|
+
*
|
|
26
|
+
* 判据边界(与 settings 那只同源,有意窄):只判**键名**;体本身非对象 ⇒ 本门不判(留给既有的
|
|
27
|
+
* 「missing 'objective' string」那道门,免得同一个体被两句话抢答)。
|
|
28
|
+
*/
|
|
29
|
+
export declare function taskBodyKeyIssue(raw: unknown): KeyClosureIssue | null;
|
|
30
|
+
//# sourceMappingURL=request-key-closure.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
|
|
2
|
+
export const MAX_KEY_ECHO = 20;
|
|
3
|
+
const MAX_KEY_ECHO_CHARS = 64;
|
|
4
|
+
export function echoKeyName(prefix, key) {
|
|
5
|
+
const clean = key.replace(CONTROL_AND_BIDI_STRIP_RE, "");
|
|
6
|
+
return `${prefix}${clean.length > MAX_KEY_ECHO_CHARS ? `${clean.slice(0, MAX_KEY_ECHO_CHARS)}…` : clean}`;
|
|
7
|
+
}
|
|
8
|
+
export function scanKeyClosure(bag, prefix, accepted, tolerated, into) {
|
|
9
|
+
for (const k of Object.keys(bag)) {
|
|
10
|
+
if (accepted.has(k))
|
|
11
|
+
continue;
|
|
12
|
+
(tolerated.has(k) ? into.unsupported : into.unknown).push(echoKeyName(prefix, k));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function keyClosureClause(names, label) {
|
|
16
|
+
const shown = names.slice(0, MAX_KEY_ECHO);
|
|
17
|
+
const more = names.length - shown.length;
|
|
18
|
+
return `${label}: ${shown.join(", ")}${more > 0 ? ` (+${more} more)` : ""}`;
|
|
19
|
+
}
|
|
20
|
+
const TASK_BODY_DECLARED_KEYS = [
|
|
21
|
+
"a2aPeers", "acceptPartialRestore", "additionalDirectories", "additionalReadDirectories", "agents",
|
|
22
|
+
"appendSystemPrompt", "attachmentIds", "attachments", "capabilitiesNeeded", "cascade", "clientContext",
|
|
23
|
+
"compaction", "compactionModel", "council", "cwd", "debate", "deferTools", "enableFork", "excludeTools",
|
|
24
|
+
"finalVerification", "forwardSubagentEvents", "images", "interactiveTools", "jobId", "limits", "maxCostUsd",
|
|
25
|
+
"maxTokens", "mcpServers", "memoryCapture", "memoryWrite", "model", "objective", "oneShot", "outputRetries",
|
|
26
|
+
"outputSchema", "permissionMode", "projectId", "promptProfile", "reasoningEffort", "requireExistingSession",
|
|
27
|
+
"resilience", "restoreFiles", "resumeAt", "resumeAtMode", "retainBackgroundProcesses", "retainSubagentSessions",
|
|
28
|
+
"rewindFiles", "rewindFilesTo", "sandboxImageProfile", "scenario", "scratchpadDir", "selfOrchestration",
|
|
29
|
+
"sessionId", "settings", "skills", "suggestNextPrompts", "systemPrompt", "taskId", "toolMaterializeStrategy",
|
|
30
|
+
"verify", "verifyRounds",
|
|
31
|
+
];
|
|
32
|
+
const TASK_BODY_SCENARIO_KEYS = ["businessContext", "lenses", "repo", "rounds", "team"];
|
|
33
|
+
const ACCEPTED_BODY = new Set([...TASK_BODY_DECLARED_KEYS, ...TASK_BODY_SCENARIO_KEYS]);
|
|
34
|
+
const UNSUPPORTED_BODY = new Set(["handsReadOnly", "readDenyPatterns", "readFace", "shellGate", "writeFace"]);
|
|
35
|
+
export function taskBodyKeyIssue(raw) {
|
|
36
|
+
if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw))
|
|
37
|
+
return null;
|
|
38
|
+
const buckets = { unknown: [], unsupported: [] };
|
|
39
|
+
scanKeyClosure(raw, "", ACCEPTED_BODY, UNSUPPORTED_BODY, buckets);
|
|
40
|
+
if (buckets.unknown.length === 0 && buckets.unsupported.length === 0)
|
|
41
|
+
return null;
|
|
42
|
+
buckets.unknown.sort();
|
|
43
|
+
buckets.unsupported.sort();
|
|
44
|
+
const parts = [
|
|
45
|
+
...(buckets.unknown.length > 0 ? [keyClosureClause(buckets.unknown, "unknown (not a task-request key — check the spelling)")] : []),
|
|
46
|
+
...(buckets.unsupported.length > 0 ? [keyClosureClause(buckets.unsupported, "unsupported (an engine seat this service derives, never reads from the body)")] : []),
|
|
47
|
+
];
|
|
48
|
+
return {
|
|
49
|
+
message: `body: unrecognized top-level key(s) — refused rather than dropped, because a dropped key reads as "in effect" to the caller. ` +
|
|
50
|
+
`${parts.join("; ")}. ` +
|
|
51
|
+
`Accepted: ${[...ACCEPTED_BODY].sort().join(",")}`,
|
|
52
|
+
unknownKeys: buckets.unknown.slice(0, MAX_KEY_ECHO),
|
|
53
|
+
unsupportedKeys: buckets.unsupported.slice(0, MAX_KEY_ECHO),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=request-key-closure.js.map
|
package/dist/runs.d.ts
CHANGED
|
@@ -97,6 +97,57 @@ export declare function markChildrenStoppedByUserOnAbort(signal: AbortSignal, ta
|
|
|
97
97
|
export declare function isSessionConflictResult(result: {
|
|
98
98
|
terminal: TerminalCause;
|
|
99
99
|
}): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* E18 resume-at CALLER error → an HTTP 4xx. A bad/stale resume-at target (the resolved entryId is gone or is not a
|
|
102
|
+
* settled message boundary) surfaces as a FAILED TaskResult carrying a `resume_at.*` errorCode (core swallows the
|
|
103
|
+
* prepare-throw into the result, exactly like a session conflict — runtask catch → status:"failed"+errorCode). The
|
|
104
|
+
* sync path otherwise 200s a failed result; map these to 4xx so a caller mistake reads as one. `not_found` (the
|
|
105
|
+
* entry is genuinely absent) → 404; everything else (not_a_message / conflicts_resume / no_session) → 422.
|
|
106
|
+
*
|
|
107
|
+
* [ref] Phase3: `requireExistingSession` on a genuinely-missing/purged session ALSO surfaces as a failed result
|
|
108
|
+
* (core prepare-task maps the store's `not_found` → `resume.session_not_found`); map it to 404 (the session is gone),
|
|
109
|
+
* the same "caller mistake reads as a 4xx, not a silent 200 fresh run" discipline.
|
|
110
|
+
*
|
|
111
|
+
* [ref] rewind exclusive mode (core 1.292 resumeAtMode:"before"): its two edge rejections ride the same `resume_at.*`
|
|
112
|
+
* prefix (before_target_not_user / before_root_unsupported → 422 via the existing branch, errorCode passed through
|
|
113
|
+
* UNCHANGED for the shell). `rewind_snapshot.unresolvable` (rewindFiles + "before": no snapshot at/above the branch
|
|
114
|
+
* point) is core's third rejection with a DIFFERENT prefix — same caller-mistake shape (prepare-throw, nothing
|
|
115
|
+
* billed), map it to 422 explicitly so it doesn't fall through to a 200-with-failed body.
|
|
116
|
+
*
|
|
117
|
+
* [ref] 件A([ref] 件4): core 5.13.0's org-memory admission rides the SAME prepare-throw pipe
|
|
118
|
+
* (GOVERNANCE_CODES). `memory.admission_denied` is TERMINAL — the principal has no grant on that tenant
|
|
119
|
+
* plane → 403 (a retry cannot change the verdict). `memory.admission_required` is TRANSIENT fail-closed —
|
|
120
|
+
* the directory is unreachable / no resolver is wired → 503 (retry later; the sync leg forwards the
|
|
121
|
+
* result's `retryAfterMs` as body `retryAfterSec`, same family shape as usage.window_exhausted). Exact
|
|
122
|
+
* codes only, no memory.* family grab — an unknown sibling stays a 200-with-failed-body until cataloged.
|
|
123
|
+
*
|
|
124
|
+
* [ref] (core 7.0.0 片2/3, [ref] S-3): the session capture opt-out's four prepare-throw refusals ride the
|
|
125
|
+
* same pipe. `memory.capture_optout_denied` is TERMINAL (the principal's `allowMemoryOptOut` verdict is an
|
|
126
|
+
* explicit `false`, or the posture is `capture-required` / `governed`-with-absent-verdict — a retry cannot
|
|
127
|
+
* change it) → 403, the `memory.admission_denied` twin. `memory.capture_optout_unpersisted` is the
|
|
128
|
+
* declaration arm's "control-plane record could not be written" refusal — a store outage, retry may succeed
|
|
129
|
+
* → 503 (transient fail-closed, the `memory.admission_required` twin; no retryAfterMs on this one — core
|
|
130
|
+
* mints none, so the sync leg forwards no hint rather than inventing one). `config.memory_capture_spelling`
|
|
131
|
+
* is a TaskSpec authoring mistake (the only legal spelling is the string "off") → 400, the
|
|
132
|
+
* `config.memory_erasure_request` family shape. `config.memory_capture_unsupported` is the DEPLOYMENT SHAPE
|
|
133
|
+
* refusing the declaration (remote/per-run execution with no durable capture-record carrier: the record
|
|
134
|
+
* would not survive to a resume replica) → 409 — the request is well-formed, the deployment's state
|
|
135
|
+
* conflicts with it, the `config.refresh_unavailable` family shape ("change the deployment, not the request").
|
|
136
|
+
* Status choices are pinned by the `config.`/`memory.` family status sets in error-code-catalog-live.test.ts.
|
|
137
|
+
*
|
|
138
|
+
* S-125 件⑥(core 7.4.0 [ref]):`rewind.child_scope_unsupported` —— 这条腿是一个**委派子代**,它的文件编辑
|
|
139
|
+
* 记在**根 session** 的文件历史里,所以「把文件还原到某个 entry」是根 session 的动作、不是子代的;core 在
|
|
140
|
+
* prepare 期响亮拒(零计费,文件一个字节没动)。映射 **422**:请求形式合法、语义上找错了执行者 —— 与
|
|
141
|
+
* `resume_at.*` 的「调用方指错了锚」同一族同一档,而不是 404(目标在,只是不归你)也不是 409(部署状态没冲突)。
|
|
142
|
+
* ⚠️ **前缀不吞**:本码前缀是 `rewind.`,与下面那条 `resume_at.` **开集前缀臂**不同族 —— 它不会被那一臂顺手
|
|
143
|
+
* 兜走(兜走会把一条「找错执行者」的拒答成「锚解析不了」),也不是 `rewind_snapshot.`(那是「快照找不到」)。
|
|
144
|
+
* 三个前缀各自成族,逐码对表见 `test/resume-anchor.test.ts` 的 S-125 `rewind.` 族格。
|
|
145
|
+
* 🔴 **可达性如实**:本仓的 HTTP 面**今天铸不出**这条腿 —— 子代由 core 内部的 Agent 工具派生,
|
|
146
|
+
* `fileHistoryLineage` 是 core prepare 的 internals(不在 `TaskSpec` 上),server 从不直接 prepare 一条带
|
|
147
|
+
* lineage 的子代腿。本臂是**分诊面的完备性**(core 说 X ⇒ wire 形是 Y),不是一条今天在跑的路;将来的
|
|
148
|
+
* 委派入口接线那天零改动即生效。同族其余五码今天**仍落 200-with-failed-body**,那是本批**之前**就存在的
|
|
149
|
+
* 缺口(不是本批回归),已在上述对表格里逐码显式登记为债务,并由那道门看住不许陈腐。
|
|
150
|
+
*/
|
|
100
151
|
export declare function resumeAtHttpStatus(result: {
|
|
101
152
|
terminal: TerminalCause;
|
|
102
153
|
}): number | undefined;
|
package/dist/runs.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runWithVerification, runCascade, defaultTaskRegistry, terminalProjection, DURABLE_AGENT_HANDLE_RE } from "@sema-agent/core";
|
|
2
2
|
import { failureCodeOf, stripResumeToken } from "./terminal.js";
|
|
3
|
+
import { GOVERNANCE_HTTP_STATUS } from "./governance-http-status.js";
|
|
3
4
|
import { redactErrorMessage } from "./observability/run-terminal-log.js";
|
|
4
5
|
import { withPrincipal } from "./observability/principal-context.js";
|
|
5
6
|
import { redactSecrets } from "./trace/redact.js";
|
|
@@ -66,16 +67,6 @@ export function isSessionConflictResult(result) {
|
|
|
66
67
|
return result.terminal.code === "conflict";
|
|
67
68
|
return /stale write|concurrent write|conflict/i.test(result.terminal.message ?? "");
|
|
68
69
|
}
|
|
69
|
-
const GOVERNANCE_HTTP_STATUS = {
|
|
70
|
-
"memory.admission_denied": 403,
|
|
71
|
-
"memory.admission_required": 503,
|
|
72
|
-
"memory.capture_optout_denied": 403,
|
|
73
|
-
"config.tool_mount_denied": 400,
|
|
74
|
-
"config.locked_key": 400,
|
|
75
|
-
"usage.window_exhausted": null,
|
|
76
|
-
"config.compliance_required": null,
|
|
77
|
-
"config.compliance_denied": null,
|
|
78
|
-
};
|
|
79
70
|
export function resumeAtHttpStatus(result) {
|
|
80
71
|
const code = failureCodeOf(result.terminal);
|
|
81
72
|
if (code === undefined)
|
|
@@ -95,6 +86,8 @@ export function resumeAtHttpStatus(result) {
|
|
|
95
86
|
return 400;
|
|
96
87
|
if (code === "config.invalid_checkpoint_store")
|
|
97
88
|
return 400;
|
|
89
|
+
if (code === "config.skill_listing_budget_invalid")
|
|
90
|
+
return 400;
|
|
98
91
|
if (code === "config.memory_capture_unsupported")
|
|
99
92
|
return 409;
|
|
100
93
|
if (!code.startsWith("resume_at."))
|
|
@@ -196,7 +189,7 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
|
|
|
196
189
|
if (approval) {
|
|
197
190
|
const coordinator = approval.coordinator;
|
|
198
191
|
spec.onAsk = approvalLeg.windowZero
|
|
199
|
-
? () => Promise.resolve(coordinator.unattendedAskOutcome())
|
|
192
|
+
? (askReq) => Promise.resolve(coordinator.unattendedAskOutcome({ taskId, owner: elicitOwner ?? owner, toolName: askReq.toolName }))
|
|
200
193
|
: coordinator.boundAsk({
|
|
201
194
|
owner: elicitOwner ?? owner,
|
|
202
195
|
taskId,
|
|
@@ -84,13 +84,27 @@ export declare function validateCommandRules(rules: CommandRule[]): string[];
|
|
|
84
84
|
*/
|
|
85
85
|
export declare function compileCommandPolicy(rules: CommandRule[] | undefined): ToolPolicy | undefined;
|
|
86
86
|
/**
|
|
87
|
-
* Expand an autonomy mode into TIGHTEN-ONLY `TaskSpec` safety overrides (the
|
|
88
|
-
* - `read-only` / `plan` → `handsReadOnly: true` (read + propose, never mutate; the
|
|
89
|
-
* boundary). `plan` shares the SAME safety boundary as `read-only`; the "present a plan
|
|
90
|
-
* is higher up (UX), not a distinct TaskSpec safety field, so both map to read-only hands.
|
|
87
|
+
* Expand an autonomy mode into TIGHTEN-ONLY `TaskSpec` safety overrides (the primitives — TRAP #2):
|
|
88
|
+
* - `read-only` / `plan` → `handsReadOnly: true` **+ `writeFace: "roots"`** (read + propose, never mutate; the
|
|
89
|
+
* §6 verifier read-only boundary). `plan` shares the SAME safety boundary as `read-only`; the "present a plan
|
|
90
|
+
* for approval" intent is higher up (UX), not a distinct TaskSpec safety field, so both map to read-only hands.
|
|
91
91
|
* - `ask` → `shellGate: "always"` (every `Bash` command tightens to an `irreversible_ask` durable suspend —
|
|
92
92
|
* the fail-closed default for an unattended deployment with no parsed classifier).
|
|
93
93
|
* - `auto` / `undefined` → `{}` (no extra tightening; still subject to the deployment baseline + commandPolicy).
|
|
94
|
+
*
|
|
95
|
+
* 🔴 **为什么只读那两档同时说 `writeFace:"roots"`**([ref] 随批;不是补丁,是把这一档的表态说全):
|
|
96
|
+
* core 7.18.0 的 `resolveContainmentFace` 第 1 行把「只读(verifier)挂载 × 任务层 `writeFace:"open"`」
|
|
97
|
+
* 判成**真实矛盾**并**抛** `config.write_face_readonly_conflict`(理由逐字:那种挂载根本不带写面,
|
|
98
|
+
* 声明一个开放写面 = 为一次调用没有的工具表态)。而 `permissionMode:"bypassPermissions"` 自 [ref] 起
|
|
99
|
+
* 会在阶段④铸出 `writeFace:"open"` —— 于是「`AUTONOMY=read-only` 的部署 × 一个 bypass 请求」这一格
|
|
100
|
+
* 不修就是**整条 run 当场配置错误失败**。
|
|
101
|
+
*
|
|
102
|
+
* 修在这里而不是在写点加一条「若 handsReadOnly 则别写」的判据:两个字段**在这一档里是同一件事**
|
|
103
|
+
* (「这条 run 没有写面」),而 `tightenTaskSpec` 本来就把 `writeFace` 当 tighten-only 安全字段收
|
|
104
|
+
* (`base:"open"` × `override:"roots"` ⇒ `"roots"`,反向 throw)—— 用既有的合并表表达它,规则数不变;
|
|
105
|
+
* 在写点加判据则是让阶段④去读一个它不拥有的治理事实,那才是第二个判官。
|
|
106
|
+
* 与 `ask → shellGate:"always"` 同姿势:这一档**自己就是**一次「围栏在」的表态,所以无表态的请求上
|
|
107
|
+
* 它照样把键写出来(不是「只在有 open 时才压」——那又是一条判据)。
|
|
94
108
|
*/
|
|
95
109
|
export declare function autonomyOverrides(autonomy: Autonomy | undefined): Partial<TaskSpec>;
|
|
96
110
|
/**
|
|
@@ -85,7 +85,7 @@ export function autonomyOverrides(autonomy) {
|
|
|
85
85
|
switch (autonomy) {
|
|
86
86
|
case "read-only":
|
|
87
87
|
case "plan":
|
|
88
|
-
return { handsReadOnly: true };
|
|
88
|
+
return { handsReadOnly: true, writeFace: "roots" };
|
|
89
89
|
case "ask":
|
|
90
90
|
return { shellGate: "always" };
|
|
91
91
|
case "auto":
|
|
@@ -163,7 +163,7 @@ export function applyRuntimeGovernance(base, governance) {
|
|
|
163
163
|
const shellObserver = createGovernanceShellGateMarkPolicy(marksOf);
|
|
164
164
|
overrides.toolPolicy = governancePolicy === undefined ? shellObserver : combinePolicies(governancePolicy, shellObserver);
|
|
165
165
|
}
|
|
166
|
-
if (
|
|
166
|
+
if (Object.keys(overrides).length === 0) {
|
|
167
167
|
return base;
|
|
168
168
|
}
|
|
169
169
|
return tightenTaskSpec(base, overrides);
|
package/dist/spec-fields.d.ts
CHANGED
|
@@ -37,8 +37,10 @@ export declare function normalizeAttachments(v: unknown): TaskSpec["attachments"
|
|
|
37
37
|
*/
|
|
38
38
|
export declare function normalizeResumeAtMode(v: unknown, resumeAtResolved: boolean): "at" | "before" | undefined;
|
|
39
39
|
/** [ref] 防御上限:一份请求最多带多少个 agent 定义。core 侧无上限;这里封一个防御值 —— 每个定义都进
|
|
40
|
-
* 系统提示(whenToUse/skills/systemPrompt 全是 prompt 载荷),不设界就是 per-turn token
|
|
41
|
-
*
|
|
40
|
+
* 系统提示(whenToUse/skills/systemPrompt 全是 prompt 载荷),不设界就是 per-turn token 成本洞。
|
|
41
|
+
* 32 足够覆盖 CC 式 `.claude/agents/` 全家桶还有余量。⚠️ 这条帽守的是 **agent 定义**的条数
|
|
42
|
+
* (每个定义的 whenToUse/systemPrompt 都是无条件进系统提示的 prompt 载荷),与 [ref] 退役的
|
|
43
|
+
* **技能**条数帽不是同一轴:技能清单由引擎按预算渲染(超了裁描述),agent 定义没有那个预算面。 */
|
|
42
44
|
export declare const MAX_TASK_AGENTS = 32;
|
|
43
45
|
/** [ref] 单条 agent 定义的形状裁决:null=合法;否则一句可 400 的英文错误(不带索引,调用方自己加)。
|
|
44
46
|
* 白名单外键 fail-loud —— 与 resilience/limits 的「容忍未知键」相反:agents 是能力定义,静默丢字段 =
|
package/dist/spec-fields.js
CHANGED
|
@@ -58,7 +58,6 @@ const MAX_AGENT_TEXT_CHARS = 4_096;
|
|
|
58
58
|
const MAX_AGENT_SYSTEM_PROMPT_CHARS = 65_536;
|
|
59
59
|
const MAX_AGENT_TOOLS = 64;
|
|
60
60
|
const MAX_AGENT_TOOL_NAME_CHARS = 128;
|
|
61
|
-
const MAX_AGENT_SKILLS = 10;
|
|
62
61
|
const MAX_AGENT_SKILL_CONTENT_CHARS = 1_048_576;
|
|
63
62
|
const MAX_AGENT_MAX_TURNS = 1_000;
|
|
64
63
|
const boundedString = (max) => (v) => typeof v === "string" && v.length <= max;
|
|
@@ -73,9 +72,9 @@ const skillOk = (s) => {
|
|
|
73
72
|
if (k !== "name" && k !== "description" && k !== "content" && k !== "manifest" && k !== "files")
|
|
74
73
|
return false;
|
|
75
74
|
}
|
|
76
|
-
if (typeof s.name !== "string" || s.name.length === 0
|
|
75
|
+
if (typeof s.name !== "string" || s.name.length === 0)
|
|
77
76
|
return false;
|
|
78
|
-
if (typeof s.description !== "string"
|
|
77
|
+
if (typeof s.description !== "string")
|
|
79
78
|
return false;
|
|
80
79
|
if (typeof s.content !== "string" || s.content.length === 0 || s.content.length > MAX_AGENT_SKILL_CONTENT_CHARS)
|
|
81
80
|
return false;
|
|
@@ -135,8 +134,8 @@ const TASK_AGENT_FIELD_SHAPES = {
|
|
|
135
134
|
allowTools: { desc: `an array of at most ${MAX_AGENT_TOOLS} non-empty strings of at most ${MAX_AGENT_TOOL_NAME_CHARS} characters each`, ok: toolList },
|
|
136
135
|
denyTools: { desc: `an array of at most ${MAX_AGENT_TOOLS} non-empty strings of at most ${MAX_AGENT_TOOL_NAME_CHARS} characters each`, ok: toolList },
|
|
137
136
|
skills: {
|
|
138
|
-
desc:
|
|
139
|
-
ok: (v) => Array.isArray(v) && v.
|
|
137
|
+
desc: "an array of { name, description, content } skill objects (optional manifest/files; files entries need { path, content }) — no count cap; the engine renders the listing within its own byte budget",
|
|
138
|
+
ok: (v) => Array.isArray(v) && v.every(skillOk),
|
|
140
139
|
},
|
|
141
140
|
background: { desc: "a boolean", ok: (v) => typeof v === "boolean" },
|
|
142
141
|
isolation: { desc: '"worktree"', ok: (v) => v === "worktree" },
|