@sema-agent/server 7.9.0 → 7.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/USAGE.md +6 -0
  2. package/dist/adoption/plan.d.ts +152 -0
  3. package/dist/adoption/plan.js +513 -0
  4. package/dist/adoption/runner.d.ts +54 -0
  5. package/dist/adoption/runner.js +505 -0
  6. package/dist/adoption/sql.d.ts +76 -0
  7. package/dist/adoption/sql.js +106 -0
  8. package/dist/adoption/wire.d.ts +250 -0
  9. package/dist/adoption/wire.js +153 -0
  10. package/dist/approval-card.d.ts +24 -0
  11. package/dist/approval-card.js +32 -0
  12. package/dist/approval-reconciler.d.ts +1 -1
  13. package/dist/approval-reconciler.js +1 -1
  14. package/dist/boot/adoption.d.ts +30 -0
  15. package/dist/boot/adoption.js +57 -0
  16. package/dist/boot/coordinators.d.ts +4 -0
  17. package/dist/boot/coordinators.js +3 -1
  18. package/dist/boot/parked-revive-gate.d.ts +38 -5
  19. package/dist/boot/parked-revive-gate.js +53 -6
  20. package/dist/boot/reapers.js +3 -3
  21. package/dist/boot/runner-deps.d.ts +10 -2
  22. package/dist/boot/runner-deps.js +12 -1
  23. package/dist/capabilities/repo-tools.d.ts +36 -2
  24. package/dist/capabilities/repo-tools.js +125 -11
  25. package/dist/capabilities/scenarios.d.ts +2 -2
  26. package/dist/capabilities/scenarios.js +1 -1
  27. package/dist/config-types.d.ts +22 -2
  28. package/dist/config.js +44 -2
  29. package/dist/fleet/fleet-terminal-window.d.ts +1 -1
  30. package/dist/fleet/fleet-terminal-window.js +1 -1
  31. package/dist/git-api-kind.d.ts +7 -0
  32. package/dist/git-api-kind.js +8 -0
  33. package/dist/http/routes/adoption.d.ts +26 -0
  34. package/dist/http/routes/adoption.js +120 -0
  35. package/dist/http/routes/approvals-assistant.js +2 -2
  36. package/dist/http/routes/capabilities.js +12 -0
  37. package/dist/http/routes/rules.d.ts +23 -0
  38. package/dist/http/routes/rules.js +117 -0
  39. package/dist/http/routes/shared-memory.d.ts +31 -0
  40. package/dist/http/routes/shared-memory.js +181 -0
  41. package/dist/http/routes/trace-usage.js +139 -3
  42. package/dist/http/server.d.ts +19 -1
  43. package/dist/http/server.js +47 -5
  44. package/dist/index.d.ts +1 -1
  45. package/dist/index.js +1 -1
  46. package/dist/main.js +54 -5
  47. package/dist/observability/fail-open.d.ts +8 -0
  48. package/dist/observability/fail-open.js +8 -0
  49. package/dist/plugins/adoption-log-sql.d.ts +191 -0
  50. package/dist/plugins/adoption-log-sql.js +273 -0
  51. package/dist/plugins/checkpoint-store-sql.d.ts +22 -9
  52. package/dist/plugins/checkpoint-store-sql.js +40 -29
  53. package/dist/plugins/local-checkpoint-store.d.ts +11 -1
  54. package/dist/plugins/local-checkpoint-store.js +10 -2
  55. package/dist/plugins/memory-engine-pg.js +3 -3
  56. package/dist/plugins/permission-rule-store-sql.d.ts +242 -0
  57. package/dist/plugins/permission-rule-store-sql.js +817 -0
  58. package/dist/plugins/pg-pool.js +44 -7
  59. package/dist/plugins/run-store-sql.d.ts +3 -3
  60. package/dist/plugins/run-store-sql.js +3 -3
  61. package/dist/plugins/session-policy-store-sql.d.ts +6 -0
  62. package/dist/plugins/session-policy-store-sql.js +7 -1
  63. package/dist/plugins/shared-memory-store-sql.d.ts +223 -0
  64. package/dist/plugins/shared-memory-store-sql.js +516 -0
  65. package/dist/plugins/store-backend.d.ts +30 -0
  66. package/dist/plugins/store-backend.js +14 -0
  67. package/dist/plugins/tidb-pool.js +55 -8
  68. package/dist/plugins/workflow-journal-store-sql.d.ts +3 -3
  69. package/dist/plugins/workflow-journal-store-sql.js +6 -6
  70. package/dist/plugins/workflow-run-store-sql.d.ts +1 -1
  71. package/dist/plugins/workflow-run-store-sql.js +12 -12
  72. package/dist/rules-consent.d.ts +126 -0
  73. package/dist/rules-consent.js +198 -0
  74. package/dist/run-local.js +2 -2
  75. package/dist/shared-memory-scope-authorizer.d.ts +29 -0
  76. package/dist/shared-memory-scope-authorizer.js +17 -0
  77. package/dist/tool-approval.d.ts +55 -0
  78. package/dist/tool-approval.js +124 -5
  79. package/dist/trace/core-keyset-guard.d.ts +2 -2
  80. package/dist/trace/project.d.ts +1 -0
  81. package/dist/trace/project.js +1 -0
  82. package/package.json +3 -3
@@ -0,0 +1,57 @@
1
+ /**
2
+ * design/183 I6(server 同族)—— **每副本 boot 必查收编日志**。
3
+ *
4
+ * 🔴 为什么这道门必须存在:phase 真源住 SQL 正是为了「发起 host 在两条腿之间被摧毁 ⇒ 接棒副本按日志
5
+ * 续跑」。可是**没有人会替它按下重试** —— 运维那次 POST 的响应早就丢了。所以每个副本起来时都扫一遍
6
+ * 在飞行:能推就推到终态,推不动就**响亮**留痕(禁静默跳过 —— 静默跳过等于把半迁移状态带进服务期,
7
+ * 那正是 I6 存在的理由)。
8
+ *
9
+ * 🔴 为什么是「续跑或响亮拒」而不是「拒启」:半迁移的收编**是可恢复的**(每条腿幂等,phase 单调),
10
+ * 拒启只会让一个能自愈的状态变成一次停机。拒启留给不可恢复的装配谎言(boot/org-memory.ts 那类)。
11
+ * 与之对称:续跑失败**不吞异常** —— 它落一条 error 级留痕并把 outcome 记进返回值,调用方(main)
12
+ * 把读数打进启动日志,运维在启动行里就能看见「这台副本上还有 N 条收编没走完」。
13
+ *
14
+ * 阻塞性:扫描在**监听之前**跑完(main 的调用点)。一条弧的推进是几条 UPDATE,毫秒级;库里正常状态下
15
+ * 在飞行数为 0,一次 `WHERE state='in_flight'` 的索引点查而已。
16
+ */
17
+ import { createAdoptionRunner } from "../adoption/runner.js";
18
+ /**
19
+ * boot 期的在飞收编扫描。无 SQL 后端 ⇒ 零动作(收编面在那种部署上本来就 501)。
20
+ * 绝不抛:一次库抖动不该拒掉整台 worker 的启动,但**必须**留下响亮读数(`error` 位 + error 级日志)。
21
+ */
22
+ export async function runAdoptionBootScan(ctx) {
23
+ const store = ctx.backend?.adoptionLog?.();
24
+ if (!store)
25
+ return { scanned: 0, resumed: [], stalled: [], rejected: [], failed: [] };
26
+ const runner = createAdoptionRunner({
27
+ store,
28
+ dialect: ctx.backend?.kind === "pg" ? "pg" : "tidb",
29
+ now: ctx.now ?? Date.now,
30
+ logger: ctx.logger,
31
+ });
32
+ try {
33
+ const inFlight = await store.listInFlight();
34
+ if (inFlight.length === 0)
35
+ return { scanned: 0, resumed: [], stalled: [], rejected: [], failed: [] };
36
+ ctx.logger.warn?.("adoption_in_flight_on_boot", {
37
+ count: inFlight.length,
38
+ ids: inFlight.map((r) => r.adoptionId),
39
+ note: "design/183 I6: resuming in-flight identity adoptions before this replica starts serving",
40
+ });
41
+ const out = await runner.resumeInFlight();
42
+ const reading = { scanned: inFlight.length, ...out };
43
+ if (reading.stalled.length > 0 || reading.rejected.length > 0 || reading.failed.length > 0) {
44
+ ctx.logger.error?.("adoption_boot_scan_incomplete", reading);
45
+ }
46
+ else {
47
+ ctx.logger.info?.("adoption_boot_scan_resumed", reading);
48
+ }
49
+ return reading;
50
+ }
51
+ catch (err) {
52
+ const error = err instanceof Error ? err.message : String(err);
53
+ ctx.logger.error?.("adoption_boot_scan_failed", { error });
54
+ return { scanned: 0, resumed: [], stalled: [], rejected: [], failed: [], error };
55
+ }
56
+ }
57
+ //# sourceMappingURL=adoption.js.map
@@ -15,11 +15,15 @@ import { ToolApprovalCoordinator } from "../tool-approval.js";
15
15
  import { SendUserFileEmitter } from "../capabilities/send-user-file-tool.js";
16
16
  import { TaskEnvRegistry } from "../capabilities/sandbox-file-send.js";
17
17
  import type { StoreBackend } from "../plugins/store-backend.js";
18
+ import type { RuleConsentLane } from "../rules-consent.js";
18
19
  export interface LiveCoordinatorsCtx {
19
20
  config: ServiceConfig;
20
21
  logger: Logger;
21
22
  backend: StoreBackend | undefined;
22
23
  sendUserFileTaskEnvs: TaskEnvRegistry | undefined;
24
+ /** #154 车二:持久化权限规则的同意车道(main.ts 与 `RunnerDeps.permissionRuleStore` **同源**于
25
+ * `backend.permissionRule()` 的同一个返回值)。在场 ⇒ ask 帧投 `ruleSuggestions` + 回决可兑付。 */
26
+ ruleConsent: RuleConsentLane | undefined;
23
27
  }
24
28
  export declare function createLiveCoordinators(ctx: LiveCoordinatorsCtx): {
25
29
  elicitation: ElicitationCoordinator | undefined;
@@ -8,7 +8,7 @@ import { withLedgerRecording } from "../plugins/send-file-ledger.js";
8
8
  import { basename, resolve } from "node:path";
9
9
  import { stat as fsStat, readFile as fsReadFile } from "node:fs/promises";
10
10
  export function createLiveCoordinators(ctx) {
11
- const { config, logger, backend, sendUserFileTaskEnvs } = ctx;
11
+ const { config, logger, backend, sendUserFileTaskEnvs, ruleConsent } = ctx;
12
12
  // E23 (shell-host contract): inbound MCP elicitation coordinator (live-only HITL). Present ONLY when MCP_ELICITATION_ENABLED
13
13
  // — absent ⇒ onElicit is not wired ⇒ core advertises no elicitation capability to any server (fail-closed). Shared
14
14
  // by the runner (the onElicit seam) and the HTTP layer (the respond route + the per-run ALS context wraps).
@@ -71,6 +71,8 @@ export function createLiveCoordinators(ctx) {
71
71
  // X-2 写侧准入门两帽(协议未上场时门本身不参与,见 ToolApprovalCoordinator.admit)。
72
72
  admitMaxPerTask: config.streamApproval.admitMaxPerTask,
73
73
  admitMaxPerOwner: config.streamApproval.admitMaxPerOwner,
74
+ // #154 车二:同意车道(缺席 = 规则店没装配 ⇒ 帧上零候选、回决的 persistRule 如实拒)。
75
+ ...(ruleConsent !== undefined ? { ruleConsent } : {}),
74
76
  })
75
77
  : undefined;
76
78
  // SendUserFile(真 CC 契约,clay 2026-07-14)两种 lane 形态,其余缺席=诚实(工具不进 roster):
@@ -1,5 +1,6 @@
1
1
  import { type BackgroundAgentRecord, type ToolExecuteContext } from "@sema-agent/core";
2
2
  import type { ServiceConfig } from "../config.js";
3
+ import type { RunnerDepsOnAsk } from "./runner-deps.js";
3
4
  import { type ApprovalBaselineConfigView, type DeploymentGovernanceConfigView, type LiveQuestionFace } from "../deployment-governance.js";
4
5
  import { type SandboxPathEnvSlots } from "./deferred-sandbox-path-env.js";
5
6
  /** 本腿读的配置切面(`ServiceConfig` 结构满足)。两个构造口各自的切面 + lane 判别式读的 `remoteExec`。 */
@@ -15,8 +16,35 @@ export interface ApprovalExemptionProbe {
15
16
  export interface ParkedReviveGateDeps {
16
17
  /** ⚠️ **活引用**:热改字段(autonomy/commandPolicy/守卫集/审批四旋钮)每次赎回现读,见下方「禁 memoize」。 */
17
18
  readonly config: ParkedReviveGateConfigView;
18
- /** #152:活体 AskUserQuestion 面。赎回腿今天无 ALS ctx ⇒ 判 ask,与原形同判。 */
19
+ /** #152:活体 AskUserQuestion 面。赎回腿今天无 ALS ctx ⇒ 判 ask,与原形同判。
20
+ * ⚠️ 它同时是**决议链元数据** `contentMandate` 的唯一判据(见 {@link mandatePostureOf})。 */
19
21
  readonly question: LiveQuestionFace | undefined;
22
+ /**
23
+ * 部署级活体审批席 —— `RunnerDeps.onAsk` 的**同源铸法**(main.ts 递 `createRunnerDepsOnAsk(toolApproval)`,
24
+ * 单一属主同 boot/runner-deps.ts)。
25
+ *
26
+ * ⚠️ **本仓确有 per-task `spec.onAsk` 装配点**(codex 对抗复审 R1-中1 验真纠正 —— 别照抄
27
+ * `createRunnerDepsOnAsk` 头注那句「bg/resume 两条腿没有 per-task 装配点」,那句话的射程只是那两条腿):
28
+ * `http/routes/tasks.ts` 的宿主 sync/stream 腿在装配点写 `spec.onAsk = toolApproval.boundAsk({owner,
29
+ * taskId, sessionId, legKey…})`(windowZero 形则写一个恒返 `"unavailable"` 的闭包)。core 冻的是
30
+ * `spec.onAsk ?? deps.onAsk`,所以那条腿冻下去的是**带身份的** bound 席,不是这一格。
31
+ *
32
+ * 这对本腿的两个用途分别意味着什么:
33
+ * · **`durableMandate` 判据(承重,见 {@link mandatePostureOf})—— 不受影响**:两个装配点是**同一个
34
+ * 条件**的两种表达(`spec.onAsk` 的两支都在 `deps.toolApproval` 在场时才写,windowZero 支写的也是
35
+ * 函数),所以「席位在不在场」这个**布尔**在 spec 位与 deps 位上恒等。core 的
36
+ * `isLiveApproverSeat` 只判 `typeof === "function"` ⇒ 本腿算出的位与 park 时冻的位逐字相同,摘要对得上。
37
+ * · **重建条目的 `onAsk` 席位本身 —— 是一个诚实的降级,不是等价替换**:bound 席携的身份
38
+ * (owner/taskId/sessionId/legKey/腿时限)是**宿主那条腿**的,park 时未持久化 ⇒ 跨副本重建不出来。
39
+ * 交这一格的效果:赎回腿没有 ALS 上下文,`ToolApprovalCoordinator.ask` 第一行 `if (!ctx) return
40
+ * "unavailable"`(tool-approval.ts)⇒ 继承 ask 恒走 core 的 `approverUnavailable` 回路 = **再 park
41
+ * 一次给人**。它**到不了**宿主原来那张卡(那是残余,与「客户端折叠层重建不出」同族);它也**不会**
42
+ * 错投到别处(无 ctx 就直接返回,`askBroadcast` 根本不执行 —— codex 提出的「按赎回腿归因/投递」
43
+ * 这条机制经亲读证伪)。
44
+ * 为什么仍然要交:席位缺席 + `durableMandate` 为 false(装了活体席的部署就是这一形)⇒ 继承 ask 只剩
45
+ * headless auto-deny,比「再问人一次」严且哑。交了之后最坏也是 park,方向 fail-safe。
46
+ */
47
+ readonly approverSeat: RunnerDepsOnAsk | undefined;
20
48
  readonly approvalExemptionStore: ApprovalExemptionProbe | undefined;
21
49
  readonly logger: {
22
50
  info(event: string, fields?: Record<string, unknown>): void;
@@ -36,6 +64,9 @@ export interface ParkedReviveGateDeps {
36
64
  * 事件(载体键集钉见 test/parked-revive-gate-wiring.test.ts)。
37
65
  */
38
66
  export type RebuiltInheritedGate = NonNullable<NonNullable<NonNullable<ToolExecuteContext["reviveClaim"]>["parkedResume"]>["inheritedGate"]>;
67
+ /** 重建链的**单层条目**形 —— 同样从载体推导(理由同 {@link RebuiltInheritedGate}:core 又加一个
68
+ * 能收紧的槽时,本仓的编译面要看得见)。 */
69
+ export type RebuiltParentConstraint = NonNullable<RebuiltInheritedGate["parentConstraints"]>[number];
39
70
  /**
40
71
  * 赎回腿的父约束链重建(design/181 件二)。
41
72
  *
@@ -66,10 +97,12 @@ export type RebuiltInheritedGate = NonNullable<NonNullable<NonNullable<ToolExecu
66
97
  * 时点语义(成文进 docs/ASSISTANT-WIRE-CONTRACT.md,免运维当 bug 报):
67
98
  * · toolPolicy 轴 = **当前** config(与 resume 重折一致,收紧与放松都被兑现——放松是一次显式运维动作);
68
99
  * · shellGate 轴 = core 取 max(live, park 时 seed)—— 收紧兑现、放松**不**兑现(fail-safe)。
69
- * · `onAsk` 不供,但**这不等于 auto-deny**(codex R3-中1 验真纠正):带 `durableMandate` 的继承层
70
- * `ask` 时,core 只要还看得见 checkpoint 店 + durable 审批配置就把它**重新浮回 durable 门**
71
- * ( park 一次,人再批一次);deny 只是「durable 设施不在场」的兜底臂。⛔ 但**不是无条件**
72
- * (codex R4-中1):core 对挂起链计 `suspendCount`,到 `maxSuspends`(默认 5)即终止任务而不再 park。
100
+ * · `onAsk` = 部署的活体审批席(`deps.approverSeat`),**在场即供**:park 时那一层冻的就是它。
101
+ * 席位缺席的部署上不供,而**这不等于 auto-deny**(codex R3-中1 验真纠正):此形恒带
102
+ * `durableMandate`( {@link mandatePostureOf}),继承层判 `ask` core 只要还看得见 checkpoint 店
103
+ * + durable 审批配置就把它**重新浮回 durable 门**( park 一次,人再批一次);deny 只是「durable
104
+ * 设施不在场」的兜底臂。⛔ 但**不是无条件**(codex R4-中1):core 对挂起链计 `suspendCount`,到
105
+ * `maxSuspends`(默认 5)即终止任务而不再 park。
73
106
  * · 客户端每请求 settings 折出来的层(`settings.permissions` 的 allow/deny/ask、模式派生的 fs-write
74
107
  * ask 门、scratchpad 豁免……整类)park 时未持久化 ⇒ 重建不出。§2.3 已披露的残余,机制面有特征化钉
75
108
  * (test/parked-revive-e2e.test.ts);真 settings→park→赎回的端到端钉未建(候件,随收口件一起)。
@@ -57,17 +57,20 @@ function hostGateCwd(config, localRoot) {
57
57
  * 时点语义(成文进 docs/ASSISTANT-WIRE-CONTRACT.md,免运维当 bug 报):
58
58
  * · toolPolicy 轴 = **当前** config(与 resume 重折一致,收紧与放松都被兑现——放松是一次显式运维动作);
59
59
  * · shellGate 轴 = core 取 max(live, park 时 seed)—— 收紧兑现、放松**不**兑现(fail-safe)。
60
- * · `onAsk` 不供,但**这不等于 auto-deny**(codex R3-中1 验真纠正):带 `durableMandate` 的继承层
61
- * `ask` 时,core 只要还看得见 checkpoint 店 + durable 审批配置就把它**重新浮回 durable 门**
62
- * ( park 一次,人再批一次);deny 只是「durable 设施不在场」的兜底臂。⛔ 但**不是无条件**
63
- * (codex R4-中1):core 对挂起链计 `suspendCount`,到 `maxSuspends`(默认 5)即终止任务而不再 park。
60
+ * · `onAsk` = 部署的活体审批席(`deps.approverSeat`),**在场即供**:park 时那一层冻的就是它。
61
+ * 席位缺席的部署上不供,而**这不等于 auto-deny**(codex R3-中1 验真纠正):此形恒带
62
+ * `durableMandate`( {@link mandatePostureOf}),继承层判 `ask` core 只要还看得见 checkpoint 店
63
+ * + durable 审批配置就把它**重新浮回 durable 门**( park 一次,人再批一次);deny 只是「durable
64
+ * 设施不在场」的兜底臂。⛔ 但**不是无条件**(codex R4-中1):core 对挂起链计 `suspendCount`,到
65
+ * `maxSuspends`(默认 5)即终止任务而不再 park。
64
66
  * · 客户端每请求 settings 折出来的层(`settings.permissions` 的 allow/deny/ask、模式派生的 fs-write
65
67
  * ask 门、scratchpad 豁免……整类)park 时未持久化 ⇒ 重建不出。§2.3 已披露的残余,机制面有特征化钉
66
68
  * (test/parked-revive-e2e.test.ts);真 settings→park→赎回的端到端钉未建(候件,随收口件一起)。
67
69
  */
68
70
  export function createParkedReviveInheritedGate(deps) {
69
- const { config, question, approvalExemptionStore, logger, localRoot } = deps;
71
+ const { config, question, approvalExemptionStore, logger, localRoot, approverSeat } = deps;
70
72
  const slots = deps.slots ?? sandboxPathEnvSlots;
73
+ const mandate = mandatePostureOf(deps);
71
74
  return (row) => {
72
75
  // lane 分形与 resolve-spec 同一判别式(单一属主,取值处只此一个)。
73
76
  // 🔑 沙箱腿的 slot 键 = **row.sessionId**(子代自己的会话,design/181 §2.2 裁定):revive 后 core 以
@@ -101,11 +104,55 @@ export function createParkedReviveInheritedGate(deps) {
101
104
  // 治理段无可施加时 applyRuntimeGovernance 原样返回 base ⇒ 基线本身即为链(恒非 undefined,无需断言)。
102
105
  // shellGate 只在治理段真产出档位时出现在载体上(缺席 = 本次没有 live 值可交,core 用 seed 兜底)。
103
106
  return {
104
- parentConstraints: [{ policy: governed.toolPolicy ?? baseline, durableMandate: true }],
107
+ parentConstraints: [
108
+ {
109
+ policy: governed.toolPolicy ?? baseline,
110
+ ...(approverSeat !== undefined ? { onAsk: approverSeat } : {}),
111
+ ...mandate,
112
+ },
113
+ ],
105
114
  ...(governed.shellGate !== undefined ? { shellGate: governed.shellGate } : {}),
106
115
  };
107
116
  };
108
117
  }
118
+ /**
119
+ * 重建条目的**决议链元数据**(core 5.22.0 F-012)—— 两位 mandate 的逐字重算。
120
+ *
121
+ * 为什么它承重:5.22.0 把 re-supply 契约从 count 换成 `cpv1:` 内容摘要
122
+ * (`constraintChainDigest`),而摘要 **把这两位卷了进去**。park 时那一层记的是什么,赎回腿就必须供
123
+ * 什么——供多了、供少了都是 `resume.parent_constraint_mismatch` 响亮拒(pre-CAS,checkpoint 留
124
+ * pending)。**不许**为了让摘要对上而回读 checkpoint 里记的那两位再原样回声:那等于把「重算出同一条链」
125
+ * 换成「抄一份自证」,摘要的防篡改语义当场归零。
126
+ *
127
+ * core 的原式(`prepare-task.js` 的 `inheritedGateForChildren`):
128
+ * ```
129
+ * durableMandate = runtimeCaps.forceDurableGate || (spec.durableApproval !== undefined && !isLiveApproverSeat(spec.onAsk ?? deps.onAsk))
130
+ * contentMandate = runtimeCaps.forceDurableGate || (spec.durableApproval !== undefined && !isLiveQuestionFace(spec.onQuestion ?? deps.onQuestion))
131
+ * ```
132
+ * 本仓三项对位:
133
+ * · `spec.durableApproval !== undefined` —— 本工厂**只在** `config.durableApproval` 为真时构造
134
+ * (main.ts 的构造条件逐字),故这一项在本函数射程内恒成立;
135
+ * · 审批席 = `spec.onAsk ?? deps.onAsk` 的**在场性**。本仓两个装配点(deps 级
136
+ * `createRunnerDepsOnAsk`、per-task 级 `routes/tasks.ts` 的 `boundAsk`)受**同一个**条件闸
137
+ * ——`toolApproval` 协调器在不在场——所以这个布尔在两位上恒等,`approverSeat` 在不在场即判据。
138
+ * ⚠️ 判据是**在场性**不是同一只闭包:身份不同不影响摘要(全文见 {@link ParkedReviveGateDeps.approverSeat});
139
+ * · 问答席 = resolve-spec 的 `liveQuestionFace`:在场 ⇒ spec 不 stamp sentinel、`deps.onQuestion` 的
140
+ * 协调器生效(`isLiveQuestionFace` 判 true);缺席 ⇒ spec stamp `QUESTION_AWAITS_RESUME`
141
+ * (该 sentinel 被 `isLiveQuestionFace` 判 false)。两支都归结为 `question` 在不在场。
142
+ *
143
+ * ⛔ **已披露残余(fail-closed 且响亮,不是静默放行)**:`runtimeCaps.forceDurableGate`(center 逐
144
+ * principal 的授权位)在本装配点解不出来 —— core 的 `runtimeCapsResolver` 是 async 且按 principal 现解,
145
+ * 而重供席位的形是同步 `(row) => gate`。该位为真、且部署又装了活体席的组合上,park 记 true 而本腿供
146
+ * false ⇒ core pre-CAS 响亮拒、checkpoint 留 pending(带 `parked_resume.startup_failed` 码可观测),
147
+ * 绝不会让赎回腿在比挂起时更松的链下跑起来。收口件二选一:core 把该位一起持久进链条目,或重供席位改
148
+ * async 让 resolver 进得来。
149
+ */
150
+ function mandatePostureOf(deps) {
151
+ return {
152
+ ...(deps.approverSeat === undefined ? { durableMandate: true } : {}),
153
+ ...(deps.question === undefined ? { contentMandate: true } : {}),
154
+ };
155
+ }
109
156
  /** host 腿的裁决 env(真 fs;构造是纯字段赋值,不 spawn)。**每次赎回现铸**——与「禁 memoize」同因:
110
157
  * cwd 占位读的是活 config 的 `localDataRoot`。 */
111
158
  function createHostAdjudicationEnv(config, localRoot) {
@@ -244,12 +244,12 @@ export function startReapers(ctx) {
244
244
  .then(reapCount("runs_reaped_total", { kind: "stale" }))
245
245
  .then(() => runsReapStaleGuard.onSuccess(), runsReapStaleGuard.onError);
246
246
  // Durable F4 (design/45) + design/80 §3 inv#3 crash-safe backstop: CAS-expire checkpoints past their
247
- // deadline OR their absolute terminal_at backstop (≈ deny), then fail the suspended run rows whose
247
+ // deadline OR their absolute terminal_at_ms backstop (≈ deny), then fail the suspended run rows whose
248
248
  // checkpoint was thereby expired (release task_active = unlock the session). These run EVERY tick,
249
- // REGARDLESS of APPROVAL_TIMEOUT_SEC — terminal_at (stamped at put, never before an explicit deadline)
249
+ // REGARDLESS of APPROVAL_TIMEOUT_SEC — terminal_at_ms (stamped at put, never before an explicit deadline)
250
250
  // bounds even a NULL-deadline pending checkpoint, so the backstop is the always-on safety net (it was
251
251
  // inert when nested under the approvalTimeoutSec>0 guard — adversarial finding). The run-row half is
252
- // checkpoint-STATE-driven (not a uniform timer) so it aligns with the per-row terminal_at. Global +
252
+ // checkpoint-STATE-driven (not a uniform timer) so it aligns with the per-row terminal_at_ms. Global +
253
253
  // idempotent across replicas, no election.
254
254
  if (checkpointStore) {
255
255
  // 轴A #5 注释落档(1.254):core CheckpointStore.reap 契约把弃置臂的 unpin 义务派给部署 reaper——
@@ -75,10 +75,18 @@ export interface RunnerDepsCtx {
75
75
  * 基座**——主/sub 两 Runner 必须同源:委托子代的 prepare 同样跑准入(委托冻结的重判腿),漏挂
76
76
  * subRunner=子代平面绕过目录判决。 */
77
77
  orgMemoryAdmission: import("./org-memory.js").OrgMemoryAdmissionWiring;
78
+ /** design/177 —— org 共享记忆库的供给面(core `RunnerDeps.sharedMemoryStores`)。在场即挂载
79
+ * `memory_list`/`memory_read` 两只工具(core 没有 TaskSpec 伴生旋钮:dep 的在场**就是**部署意图);
80
+ * 缺席 ⇒ 两只都不挂,连占位都没有。装配点按「SQL 供给面 ∧ org 折叠面」双在场才铸它,与 HTTP 面
81
+ * 同一个合取式(main.ts)。 */
82
+ sharedMemoryStores: RunnerDeps["sharedMemoryStores"];
78
83
  toolResultStore: ToolResultStoreFull | undefined;
79
84
  sessionPolicyStore: ReturnType<StoreBackend["sessionPolicy"]> | undefined;
80
85
  runtimeCapsResolver: RunnerDeps["runtimeCapsResolver"];
81
86
  fileSnapshotStore: ReturnType<StoreBackend["fileSnapshot"]> | undefined;
87
+ /** #154 车二:持久化权限规则店 provider(core `RunnerDeps.permissionRuleStore`)。缺席 ⇒ 引擎的
88
+ * `permissionRules.storeWired` 如实报 false、`AskRequest.ruleSuggestions` 不铸(诚实缺席)。 */
89
+ permissionRuleStore: RunnerDeps["permissionRuleStore"];
82
90
  executionEnvFactory: RunnerDeps["executionEnvFactory"];
83
91
  lspManager: RunnerDeps["lspManager"];
84
92
  fleetBus: FleetEventBus;
@@ -94,7 +102,7 @@ export interface RunnerDepsCtx {
94
102
  }
95
103
  /** design/158 A10 留档发现②:main runner `RunnerDeps` 与 main.ts subRunner 字面量之间此前手工重复
96
104
  * 的 ~15 个键,类型标注见 {@link createSharedRunnerDeps} 头注。 */
97
- export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes">;
105
+ export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores">;
98
106
  /**
99
107
  * design/158 A10 留档发现②(review 2026-07-29,[1543]§三族A 同源修补的延续):main runner 的
100
108
  * `RunnerDeps` 字面量(下方 `createRunnerDeps`)与 `main.ts` 里 subRunner 的 `new Runner({...})`
@@ -123,7 +131,7 @@ export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "
123
131
  * 属性,比再抽一层共享基座更强的同源保证),不重复收纳进这里。
124
132
  */
125
133
  /** 基座真实消费的窄面(Pick)——subRunner 调用点(main.ts)只需凑这 13 个字段,不必造全量 ctx。 */
126
- export type SharedRunnerDepsCtx = Pick<RunnerDepsCtx, "config" | "brain" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "deploymentHooks" | "toolResultStore" | "sessionPolicyStore" | "usageWindowStore" | "orgMemoryAdmission">;
134
+ export type SharedRunnerDepsCtx = Pick<RunnerDepsCtx, "config" | "brain" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "deploymentHooks" | "toolResultStore" | "sessionPolicyStore" | "usageWindowStore" | "orgMemoryAdmission" | "sharedMemoryStores">;
127
135
  export declare function createSharedRunnerDeps(ctx: SharedRunnerDepsCtx): SharedRunnerDeps;
128
136
  export declare function createRunnerDeps(ctx: RunnerDepsCtx): RunnerDeps;
129
137
  //# sourceMappingURL=runner-deps.d.ts.map
@@ -66,11 +66,16 @@ export function createSharedRunnerDeps(ctx) {
66
66
  // 构造(boot/stores.ts),两键同真同假。
67
67
  usageWindows: ctx.config.usageWindows ? ctx.config.usageWindows : undefined,
68
68
  usageWindowStore: ctx.usageWindowStore ? ctx.usageWindowStore : undefined,
69
+ // design/177 —— 必须进**共享基座**(codex 复审轮3 真缺口):它原先只挂在主 runnerDeps 上,于是
70
+ // 委托子代静默没有 memory_list/memory_read,而能力面与 HTTP 面照样宣布共享库可用 —— 正是 [1543]§三族A
71
+ // 「主 runner 挂了、subRunner 漏挂」那个病族。授权按 per-request principal 折叠(子代同宿主身份),
72
+ // 所以共享同一个 provider 实例既正确又是唯一能让两只 Runner 看到同一份库集合的做法。
73
+ sharedMemoryStores: ctx.sharedMemoryStores ? ctx.sharedMemoryStores : undefined,
69
74
  };
70
75
  return shared;
71
76
  }
72
77
  export function createRunnerDeps(ctx) {
73
- const { config, logger, metrics, localRoot, outcomeSink, elicitation, question, toolApproval, sessionStore, memoryEngine, memorySyncRunner, runtimeCapsResolver, fileSnapshotStore, fleetBus, workflowRunStore, workflowJournalStore, workflowAgentRegistry, workflowNotifyGate, workflowCompletionInbox, deliverWorkflowCompletion, getRunStore, } = ctx;
78
+ const { config, logger, metrics, localRoot, outcomeSink, elicitation, question, toolApproval, sessionStore, memoryEngine, memorySyncRunner, runtimeCapsResolver, fileSnapshotStore, permissionRuleStore, fleetBus, workflowRunStore, workflowJournalStore, workflowAgentRegistry, workflowNotifyGate, workflowCompletionInbox, deliverWorkflowCompletion, getRunStore, } = ctx;
74
79
  // design/158 A10 留档发现②:共享基座——见 createSharedRunnerDeps 头注(main.ts 的第二处展开是
75
80
  // 并行车道待办,未在本次改动内接线)。
76
81
  const sharedRunnerDeps = createSharedRunnerDeps(ctx);
@@ -242,6 +247,9 @@ export function createRunnerDeps(ctx) {
242
247
  // memory dir). Absent (multi-tenant / MEMORY_ENGINE=off) ⇒ no deps.memoryBackend ⇒ memory dark.
243
248
  memoryBackend: memoryEngine ? memoryEngine.backend : undefined,
244
249
  memoryEngineDir: memoryEngine ? memoryEngine.root : undefined,
250
+ // (sharedMemoryStores 由共享基座展开携带 —— 主/sub 两只 Runner 同源,见 createSharedRunnerDeps。
251
+ // 它与 `memoryBackend` **正交且完全不桥接**:那条是本地记忆链(注入索引 + 文件投影 + harvest,
252
+ // 有写路径),这条是纯文档只读面,共享内容永不进注入块/索引、永不落模型可写盘、永不被 harvest。)
245
253
  // S3-TOB 复审 F-9(operator 可观测底座):harvest 报告 → metrics(拒收/incident/patch 计数从此可见;
246
254
  // core swallow-guard 保证 throwing consumer 不伤边界)。
247
255
  onMemoryHarvestReport: memoryEngine
@@ -264,6 +272,9 @@ export function createRunnerDeps(ctx) {
264
272
  // E19: working-tree snapshot/restore for rewind (+ the 2c artifact store). core snapshots each completed turn +
265
273
  // restores on resumeAt when spec.rewindFiles is set, for ANY env when this store is wired (gate-split 1.134.0).
266
274
  fileSnapshotStore: fileSnapshotStore ? fileSnapshotStore : undefined,
275
+ // #154 车二(core 5.18.0 design/179):持久化权限规则店 —— 引擎据它铸 `AskRequest.ruleSuggestions`
276
+ // 并把 `permissionRules.storeWired` 报成真。缺席=诚实缺席(零候选、storeWired:false),绝不占位。
277
+ permissionRuleStore: permissionRuleStore ? permissionRuleStore : undefined,
267
278
  // (executionEnvFactory/lspManager already carried by the shared-base spread above.)
268
279
  // design/129-B (core 1.240.0): the PROCESS-LEVEL background-child observer — spawn/tick/terminal
269
280
  // for every bg delegation child, never dying with a leg. Feeds the fleet rows (launch 即有行, session-scoped
@@ -13,8 +13,42 @@ export interface RepoCoords {
13
13
  }
14
14
  /** Parse `owner/name` or a repo URL (e.g. https://git.example.com/some-org/some-repo.git). */
15
15
  export declare function parseRepo(input: string): RepoCoords;
16
+ import type { GitApiKind } from "../git-api-kind.js";
17
+ export { GIT_API_KINDS, isGitApiKind, type GitApiKind } from "../git-api-kind.js";
18
+ /** The read-only surface the repo tools are built over — one implementation per Git-host dialect. */
19
+ export interface RepoReadClient {
20
+ defaultBranch(coords: RepoCoords): Promise<string>;
21
+ tree(coords: RepoCoords, ref: string): Promise<string[]>;
22
+ readFile(coords: RepoCoords, path: string, ref: string): Promise<string>;
23
+ pullDiff(coords: RepoCoords, index: number): Promise<string>;
24
+ }
25
+ export declare function createRepoClient(kind: GitApiKind, baseUrl: string, token?: string, fetchImpl?: typeof fetch): RepoReadClient;
16
26
  /** Thin read-only Gitea API client. Built once at startup; bound to a repo per task. */
17
- export declare class GiteaClient {
27
+ export declare class GiteaClient implements RepoReadClient {
28
+ private readonly baseUrl;
29
+ private readonly token?;
30
+ private readonly fetchImpl;
31
+ constructor(baseUrl: string, token?: string | undefined, fetchImpl?: typeof fetch);
32
+ private api;
33
+ defaultBranch({ owner, repo }: RepoCoords): Promise<string>;
34
+ tree(coords: RepoCoords, ref: string): Promise<string[]>;
35
+ readFile(coords: RepoCoords, path: string, ref: string): Promise<string>;
36
+ pullDiff(coords: RepoCoords, index: number): Promise<string>;
37
+ }
38
+ /**
39
+ * Thin read-only GitHub (github.com / GHE) API client — same four-call surface as `GiteaClient`.
40
+ *
41
+ * Dialect differences (each one is why `GiteaClient` alone 404s against GitHub):
42
+ * - base URL is the API root itself (`https://api.github.com`, GHE `https://ghe.example/api/v3`) —
43
+ * no `/api/v1` prefix is appended;
44
+ * - auth is `Authorization: Bearer <token>` (Gitea uses `token <token>`);
45
+ * - a PR's unified diff comes from `/pulls/{n}` via `Accept: application/vnd.github.v3.diff`
46
+ * (Gitea uses a `.diff` URL suffix);
47
+ * - the tree API has no `per_page` cap — GitHub returns up to its own limit and reports overflow via
48
+ * `truncated`; we additionally cap client-side at MAX_TREE_ENTRIES so the model-facing bound is the
49
+ * same for both dialects.
50
+ */
51
+ export declare class GitHubClient implements RepoReadClient {
18
52
  private readonly baseUrl;
19
53
  private readonly token?;
20
54
  private readonly fetchImpl;
@@ -26,5 +60,5 @@ export declare class GiteaClient {
26
60
  pullDiff(coords: RepoCoords, index: number): Promise<string>;
27
61
  }
28
62
  /** Build the read-only repo tool set bound to one repo (cheap per-task closure; the client is shared). */
29
- export declare function repoToolsFor(client: GiteaClient, coords: RepoCoords): ToolSpec[];
63
+ export declare function repoToolsFor(client: RepoReadClient, coords: RepoCoords): ToolSpec[];
30
64
  //# sourceMappingURL=repo-tools.d.ts.map
@@ -1,4 +1,5 @@
1
1
  import { Type } from "typebox";
2
+ import { z } from "zod";
2
3
  /** Parse `owner/name` or a repo URL (e.g. https://git.example.com/some-org/some-repo.git). */
3
4
  export function parseRepo(input) {
4
5
  const s = input.trim().replace(/\.git$/, "");
@@ -13,6 +14,43 @@ const MAX_FILE_BYTES = 96 * 1024;
13
14
  const MAX_TREE_ENTRIES = 2000;
14
15
  /** Bound each Git-host call so a slow/hung host can't pin a review lens to its whole deadline. */
15
16
  const REQUEST_TIMEOUT_MS = 15_000;
17
+ export { GIT_API_KINDS, isGitApiKind } from "../git-api-kind.js";
18
+ /** Wire schemas for the Git-host responses we consume (both dialects share these shapes — Gitea copied
19
+ * GitHub's). Boundary discipline(宪法 [2704] §2):no bare `as` on `.json()`. */
20
+ const RepoInfoWire = z.looseObject({ default_branch: z.string().optional() });
21
+ const TreeWire = z.looseObject({
22
+ tree: z.array(z.looseObject({ path: z.string(), type: z.string() })).optional(),
23
+ truncated: z.boolean().optional(),
24
+ });
25
+ const ContentsWire = z.looseObject({
26
+ content: z.string().optional().nullable(),
27
+ encoding: z.string().optional(),
28
+ type: z.string().optional(),
29
+ });
30
+ const PullMetaWire = z.looseObject({ changed_files: z.number().optional() });
31
+ /** Shared guard: `encodeURIComponent` does NOT escape "." — a `..` segment survives the per-segment
32
+ * encode and the WHATWG URL parser collapses it when `fetch` builds the request, letting a crafted
33
+ * `path` climb out of `/repos/<owner>/<repo>/contents/` to any endpoint the read token can reach.
34
+ * The tools are contractually bound to exactly one repo, so reject traversal before it hits fetch. */
35
+ function pathSegmentsRejectingTraversal(path) {
36
+ const segments = path.split("/").filter(Boolean);
37
+ if (segments.some((s) => s === "..")) {
38
+ throw new Error(`invalid path "${path}" (".." segments are not allowed)`);
39
+ }
40
+ return segments;
41
+ }
42
+ export function createRepoClient(kind, baseUrl, token, fetchImpl = fetch) {
43
+ switch (kind) {
44
+ case "gitea":
45
+ return new GiteaClient(baseUrl, token, fetchImpl);
46
+ case "github":
47
+ return new GitHubClient(baseUrl, token, fetchImpl);
48
+ default: {
49
+ const never = kind;
50
+ throw new Error(`unknown gitApiKind ${String(never)}`);
51
+ }
52
+ }
53
+ }
16
54
  /** Thin read-only Gitea API client. Built once at startup; bound to a repo per task. */
17
55
  export class GiteaClient {
18
56
  baseUrl;
@@ -36,12 +74,12 @@ export class GiteaClient {
36
74
  return res;
37
75
  }
38
76
  async defaultBranch({ owner, repo }) {
39
- const info = (await (await this.api(`/repos/${owner}/${repo}`)).json());
77
+ const info = RepoInfoWire.parse(await (await this.api(`/repos/${owner}/${repo}`)).json());
40
78
  return info.default_branch || "main";
41
79
  }
42
80
  async tree(coords, ref) {
43
81
  const { owner, repo } = coords;
44
- const body = (await (await this.api(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=${MAX_TREE_ENTRIES}`)).json());
82
+ const body = TreeWire.parse(await (await this.api(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=${MAX_TREE_ENTRIES}`)).json());
45
83
  const paths = (body.tree ?? []).filter((e) => e.type === "blob").map((e) => e.path);
46
84
  // The upstream API caps the listing at `per_page` and reports the overflow via `truncated` — it does
47
85
  // not report how many entries were left out. Surface that (same disclosure-marker family as
@@ -52,15 +90,8 @@ export class GiteaClient {
52
90
  }
53
91
  async readFile(coords, path, ref) {
54
92
  const { owner, repo } = coords;
55
- // `encodeURIComponent` does NOT escape "." — so a `..` segment survives the per-segment encode and the
56
- // WHATWG URL parser collapses it when `fetch` builds the request, letting a crafted `path` climb out of
57
- // `/repos/<owner>/<repo>/contents/` to any endpoint the read token can reach (cross-repo / admin). The
58
- // tools are contractually bound to exactly one repo (see header), so reject traversal before it hits fetch.
59
- const segments = path.split("/").filter(Boolean);
60
- if (segments.some((s) => s === "..")) {
61
- throw new Error(`invalid path "${path}" (".." segments are not allowed)`);
62
- }
63
- const body = (await (await this.api(`/repos/${owner}/${repo}/contents/${segments.map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`)).json());
93
+ const segments = pathSegmentsRejectingTraversal(path);
94
+ const body = ContentsWire.parse(await (await this.api(`/repos/${owner}/${repo}/contents/${segments.map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`)).json());
64
95
  if (body.type !== "file" || body.content == null)
65
96
  throw new Error(`not a file: ${path}`);
66
97
  const text = body.encoding === "base64" ? Buffer.from(body.content, "base64").toString("utf8") : body.content;
@@ -75,6 +106,89 @@ export class GiteaClient {
75
106
  return text.length > MAX_FILE_BYTES * 4 ? text.slice(0, MAX_FILE_BYTES * 4) + "\n…[diff truncated]" : text;
76
107
  }
77
108
  }
109
+ /**
110
+ * Thin read-only GitHub (github.com / GHE) API client — same four-call surface as `GiteaClient`.
111
+ *
112
+ * Dialect differences (each one is why `GiteaClient` alone 404s against GitHub):
113
+ * - base URL is the API root itself (`https://api.github.com`, GHE `https://ghe.example/api/v3`) —
114
+ * no `/api/v1` prefix is appended;
115
+ * - auth is `Authorization: Bearer <token>` (Gitea uses `token <token>`);
116
+ * - a PR's unified diff comes from `/pulls/{n}` via `Accept: application/vnd.github.v3.diff`
117
+ * (Gitea uses a `.diff` URL suffix);
118
+ * - the tree API has no `per_page` cap — GitHub returns up to its own limit and reports overflow via
119
+ * `truncated`; we additionally cap client-side at MAX_TREE_ENTRIES so the model-facing bound is the
120
+ * same for both dialects.
121
+ */
122
+ export class GitHubClient {
123
+ baseUrl;
124
+ token;
125
+ fetchImpl;
126
+ constructor(baseUrl, token, fetchImpl = fetch) {
127
+ this.baseUrl = baseUrl;
128
+ this.token = token;
129
+ this.fetchImpl = fetchImpl;
130
+ }
131
+ async api(path, accept = "application/vnd.github+json") {
132
+ const headers = { accept, "x-github-api-version": "2022-11-28" };
133
+ if (this.token)
134
+ headers.authorization = `Bearer ${this.token}`;
135
+ const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}${path}`, {
136
+ headers,
137
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
138
+ });
139
+ if (!res.ok)
140
+ throw new Error(`git api ${path} → HTTP ${res.status}`);
141
+ return res;
142
+ }
143
+ async defaultBranch({ owner, repo }) {
144
+ const info = RepoInfoWire.parse(await (await this.api(`/repos/${owner}/${repo}`)).json());
145
+ return info.default_branch || "main";
146
+ }
147
+ async tree(coords, ref) {
148
+ const { owner, repo } = coords;
149
+ const body = TreeWire.parse(await (await this.api(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`)).json());
150
+ const paths = (body.tree ?? []).filter((e) => e.type === "blob").map((e) => e.path);
151
+ // Same disclosure-marker family as GiteaClient.tree: a capped listing must never read as the whole
152
+ // repo. Two cap sources here — upstream `truncated`, and our own client-side MAX_TREE_ENTRIES bound.
153
+ if (paths.length > MAX_TREE_ENTRIES) {
154
+ return [...paths.slice(0, MAX_TREE_ENTRIES), `…[truncated, tree has more than ${MAX_TREE_ENTRIES} entries]`];
155
+ }
156
+ return body.truncated
157
+ ? [...paths, `…[truncated, tree has more than ${MAX_TREE_ENTRIES} entries]`]
158
+ : paths;
159
+ }
160
+ async readFile(coords, path, ref) {
161
+ const { owner, repo } = coords;
162
+ const segments = pathSegmentsRejectingTraversal(path);
163
+ const body = ContentsWire.parse(await (await this.api(`/repos/${owner}/${repo}/contents/${segments.map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`)).json());
164
+ if (body.type !== "file" || body.content == null)
165
+ throw new Error(`not a file: ${path}`);
166
+ // GitHub 文档行为(codex F1 定谳):1–100 MB 文件走对象形返回 `content:"", encoding:"none"` ——
167
+ // content 非 null 过上面的门,非 base64 臂又会把空串当正文交出 = 「大文件被当空文件评审」的静默
168
+ // 完整性失败。显式臂响亮拒(本工具本地帽 96 KiB,>1 MB 内容也装不下,拒绝即诚实)。
169
+ if (body.encoding === "none") {
170
+ throw new Error(`file too large for the contents API: ${path} (GitHub returns encoding:"none" for files over 1 MB — this read tool cannot fetch it; review it via the PR diff or skip it)`);
171
+ }
172
+ const text = body.encoding === "base64" ? Buffer.from(body.content, "base64").toString("utf8") : body.content;
173
+ return text.length > MAX_FILE_BYTES
174
+ ? text.slice(0, MAX_FILE_BYTES) + `\n…[truncated, ${text.length - MAX_FILE_BYTES} more bytes]`
175
+ : text;
176
+ }
177
+ async pullDiff(coords, index) {
178
+ const { owner, repo } = coords;
179
+ // GitHub 在服务端静默截断 PR diff(300 文件 / 1 MB / 20k 行,文档行为;codex F2 定谳)——被截的
180
+ // 响应可能远小于本地帽,靠字节数判不出来。先取 PR 元数据拿 changed_files,与 diff 里实际出现的
181
+ // 文件数对账,少了就显式披露(上游截断与本地帽是两种截断,分开标)。
182
+ const meta = PullMetaWire.parse(await (await this.api(`/repos/${owner}/${repo}/pulls/${index}`)).json());
183
+ const res = await this.api(`/repos/${owner}/${repo}/pulls/${index}`, "application/vnd.github.v3.diff");
184
+ let text = await res.text();
185
+ const filesInDiff = (text.match(/^diff --git /gm) ?? []).length;
186
+ if (typeof meta.changed_files === "number" && filesInDiff < meta.changed_files) {
187
+ text += `\n…[upstream truncated: this diff covers ${filesInDiff} of ${meta.changed_files} changed files — GitHub caps PR diffs (300 files / 1 MB); review the remaining files individually]`;
188
+ }
189
+ return text.length > MAX_FILE_BYTES * 4 ? text.slice(0, MAX_FILE_BYTES * 4) + "\n…[diff truncated]" : text;
190
+ }
191
+ }
78
192
  /** Build the read-only repo tool set bound to one repo (cheap per-task closure; the client is shared). */
79
193
  export function repoToolsFor(client, coords) {
80
194
  const ref = (r) => (r ? Promise.resolve(r) : client.defaultBranch(coords));
@@ -1,7 +1,7 @@
1
1
  import { type WebFetchConfig, type BackgroundAgentStore, type CheckpointStore, type PromptProvider, type Runner, type SkillSpec, type SubagentSpawnContext, type ToolSpec, type WebSearchConfig } from "@sema-agent/core";
2
2
  import type { Metrics } from "../observability/metrics.js";
3
3
  import type { Logger } from "../observability/logger.js";
4
- import { GiteaClient } from "./repo-tools.js";
4
+ import { type RepoReadClient } from "./repo-tools.js";
5
5
  import { type LoadedSkill } from "./skills.js";
6
6
  import { type ScenarioHands } from "./hands-lane.js";
7
7
  /**
@@ -46,7 +46,7 @@ export interface ScenarioDeps {
46
46
  handslessSubRunner: Runner;
47
47
  model: string;
48
48
  skills: LoadedSkill[];
49
- repoClient?: GiteaClient;
49
+ repoClient?: RepoReadClient;
50
50
  /** Multi-tenant deployment (REQUIRE_PRINCIPAL). Gates the full-body `WebFetch` tool out of the
51
51
  * default roster — core says a multi-tenant deployment MUST allowlist hosts (an injected/malicious tenant model
52
52
  * could exfil via an arbitrary public URL; the SSRF floor only blocks internal). Single-user TOC keeps WebFetch. */
@@ -1,7 +1,7 @@
1
1
  import { CODE_AGENT_PROMPT, CODE_SYSTEM_PROMPT, DEFAULT_SYSTEM_PROMPT, assembleCodeTools, defaultTaskRegistry } from "@sema-agent/core";
2
2
  import { HttpError } from "../security.js";
3
3
  import { nowTool } from "./builtin-tools.js";
4
- import { GiteaClient, parseRepo, repoToolsFor } from "./repo-tools.js";
4
+ import { parseRepo, repoToolsFor } from "./repo-tools.js";
5
5
  import { createCouncilTool, clampRounds } from "./code-review-council.js";
6
6
  import { stablePrompt } from "./prompt.js";
7
7
  import { createTeamTool, teamCoordinatorPrompt, getTeam, teamNames } from "./team.js";