@lqc123qwe/car-runtime 1.5.0 → 1.6.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/README.md CHANGED
@@ -5,7 +5,8 @@ Composable Agent Runtime — S1 迭代产物:微内核骨架(F1 可逆注册
5
5
  ## 运行
6
6
 
7
7
  ```bash
8
- # 测试(零依赖,Node 22.19+ 内置能力)
8
+ npm install # 1.5 起 zod 为常规运行时依赖(D-22)——CLI/doctor 传递加载
9
+ # 测试(Node 22.19+;sqlite 需 --experimental-sqlite flag)
9
10
  node --experimental-transform-types --test test/*.spec.ts
10
11
  ```
11
12
 
@@ -199,9 +200,22 @@ node --experimental-transform-types --test test/*.spec.ts
199
200
  - **1.4 工程收口(2026-09-27)**:W1 完整四块兑现——**car run 首次真实调模型**(多步工具循环 E2E 全链),模型配置通道(llm.* + env + flags)、system prompt 落链(§3.5.5 红线兑现)、TurnAborted 首次实抛、CarM8Error 保形(A080001 引导文案可达)、TLS loopback 豁免(D-12a 兑现)、MCP/PTC 进真实 turn、遥测 turn/step span 出站。全量 **282(279 PASS + 3 能力门控 skip / 0 fail)** + tsc strict 0 + secrets-scan 105 文件 0 命中。**1.4-BUG-1(fetch 缺 POST)/BUG-2(win32 退出崩溃)/BUG-3(IPv6 方括号)均为生产接线暴露的真缺陷**。
200
201
  - **1.4.0 正式发布(2026-09-27,S9 收官,全自动第二次实战)**:npm `@lqc123qwe/car-runtime@1.4.0` 在架——**dist-tags latest → 1.4.0、rc 保留指向 1.0.0-rc.1** + provenance。Release `1.4.0`(agent 经 GCM 凭据 API 创建)→ **j16 run 36328295548 全步 success**(构建/keyless 签名/自动上传五件套/npm publish)→ **post-publish 12 PASS / 1 DRY-RUN / 0 FAIL(G-09 实查)**;外部等价检查 **SHA256 MATCH(`55ce08fd…`)+ cosign keyless Verified OK**。
201
202
  - **遗留登记**:anthropic 适配器(冻结);远程 MCP 传输(SSE/HTTP);写类工具双 toolResult 投影观察项;car.config.ts+zod;jiti。规划:`car-docs/10-内核v1/1.4-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.4-收口终验报告.md`。
203
+
204
+ ## 1.5 状态(provider 扩展 + 远程 MCP + 配置完整形态)
205
+
206
+ | `src/runtime-core/llm.ts` | 1.5-S1(D-20)anthropic 适配器:Messages API 流式 SSE(data-only 解析;`{baseUrl}/v1/messages`;x-api-key + anthropic-version 2023-06-01;system 顶层化〔多条 \n\n 连接〕;toolResult 收敛单 user 多 tool_result 块〔is_error〕;tools → input_schema;**max_tokens 必填缺省 4096**;stop_reason 映射未映射 fail-visible;凭据 provider='anthropic')+ **streamSsePost 引擎抽取**(§3.5.4 重试/超时/取消语义单点共用——openai-compat/anthropic 不漂移)+ assertTlsOrLoopback 抽取(D-12a 口径不变) | 1.4 冻结表 anthropic 出清 |
207
+ | `src/runtime-core/chatStep.ts` | **1.5-BUG-1 修复:toolCall 投影丢弃**——deriveMessages 的 assistant 工具调用形态为 `{role:'assistant', toolCall}`(log.ts 口径),chatStep 仅透传 content → 工具调用历史在后续请求静默消失(真实 provider 对无 tool_calls 前驱的 tool 消息 400);anthropic spawn E2E 生产调用点核查暴露,openai-compat 同受益 | 1.5-GO-5 双向核查产出 |
208
+ | `src/mcp/httpClient.ts` | 1.5-S2(D-21)远程 MCP 传输:Streamable HTTP(2025-06-18 单轨)+ **懒 initialize 握手**(恰好一次;Mcp-Session-Id 捕获回传)+ JSON/SSE 双形态响应(SSE 命中即收口流显式 destroy)+ **出站 node:http(s) 不走 undici fetch**(1.4-BUG-2 已验证口径;本地 spawn E2E 曾复现 win32 退出 0xC0000409,换通道后消除)+ TLS 门复用 + resolveMcpHeaders(`${ENV_VAR}` 值内插值 + 字面明文凭据拒绝 CR-05) | 1.4 冻结表远程 MCP 出清 |
209
+ | `src/load/config.ts` | 1.5-S3(D-22)配置完整形态:**car.config.ts 载体**(动态 import 与插件加载同纪律;发现序 ts > json〔并存 ts 胜出〕;warmCarConfig 预热保持 loadCarConfig 同步签名——未预热显式报错禁静默回退 json)+ **zod ^3 校验层**(首个常规运行时依赖,D-11a ① 修订;CAR-E-CONFIG fail-visible 保持;issue 渲染 `路径 原因`;car.config.json 键位兼容)+ mcp.servers url/headers 键位(command/url 互斥恰好其一;通道专属键 fail-visible) | 1.1-S3「届时迁移」预登记出清 |
210
+ | `src/dx/doctor.ts` + `src/runtime-core/credentials.ts` + `src/cli.ts` | 1.5-S4 配套:doctorModelReadiness provider 感知(adapterId anthropic → ANTHROPIC_API_KEY;providerEnvVars 导出复用)+ `--adapter-id anthropic` 装配分支 + HELP/错误文案双载体与 anthropic 引导 | 1.5 DX 配套 |
211
+ | `test/s33~s35` | s33 8 测(出站体/stop_reason 映射/流容错/chatStep 多块聚合/基线同款 TLS·凭据·重试·取消/doctor 感知/spawn E2E)+ s34 6 测(握手序/会话头/SSE 形态/closed 拒绝/headers 凭据规则/config 键位/spawn `--mcp` url 全链)+ s35 4 测(zod 键位兼容/发现序/预热协议+epoch 击穿/TS 形态校验) | 1.5-GO |
212
+
213
+ - **1.5 工程收口(2026-09-28,`ec5a4c4`)**:三大登记项全部兑现(1.4 冻结表「登记后续」清零)。全量 **300(297 PASS + 3 能力门控 skip / 0 fail)** + tsc strict 0 + secrets-scan commit 后复跑 114 文件 0 命中 + contract-check 4/4(golden 不动)。**1.5-BUG-1 为生产调用点核查直接产出**(1.4 的 E2E 假服务不校验 tool_calls 历史故未暴露)。**1.5-插曲-1(HELP 模板串 `${ENV_VAR}` 未转义 → CLI 全 spawn 挂)**——模板字面量内插值转义教训。**1.5-CI-1:test/dx-smoke jobs 补 npm install**(零依赖 CI 前提随 D-22 失效——j02/j03/j04/j10/j11/j12;改 workflow 后 PyYAML 复验执行)。
214
+ - **1.5.0 正式发布(2026-09-28,S6 收官)**:npm `@lqc123qwe/car-runtime@1.5.0` 在架——**dist-tags latest → 1.5.0、rc 保留 1.0.0-rc.1** + provenance(slsa.dev/provenance/v1)。Release `1.5.0`(agent GCM 凭据 API 创建)→ **j16 run 36394117668 全步 success**(构建/keyless 签名/自动上传五件套/npm publish)→ **post-publish 12 PASS / 1 DRY-RUN / 0 FAIL(G-09 实查)**;外部等价检查 **SHA256 MATCH(`2edae513…`)+ Rekor 透明日志交叉验证**(logIndex 2981593515,bundle↔API 双端一致;cosign 二进制本地缺席,以 Rekor API 对拍替代——登记口径)。**G-04 门禁口径随 D-22 更新**:零依赖断言 → npm audit(omit=dev high,0 findings)。
215
+ - **遗留登记**:jiti 首载;host 工具面;写类工具双 toolResult 投影观察项;MCP legacy HTTP+SSE(2024-11-05)不追溯。规划:`car-docs/10-内核v1/1.5-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.5-收口终验报告.md`。
202
216
  ## 状态
203
217
 
204
- - **版本主线**:S1-S4 48/48 | M2 89/89(v0.2.0)| M3 127/127(v0.3.0)| M4 139/139(**1.0.0-rc.1 已发布**)| M5 156/156(DEC-4 预埋项转正)| M6 179/179(2026-09-15 收口)| M7 全量 195(192 PASS + 3 能力门控 skip,2026-09-19 收口)| **M5-S28 装载签名门 全量 204(201 PASS + 3 skip,2026-09-26)** | **M8 运行时底座 全量 231(228 PASS + 3 skip,2026-09-26 收口,§3.2 八模块全部收口)** | **1.1 签名收尾最小集 全量 249(246 PASS + 3 skip,2026-09-27 收口,1.1.0 已发布,enforce 翻转准备就绪)** | **1.2 会话/遥测小项 全量 263(260 PASS + 3 skip,2026-09-27 收口,1.2.0 已发布)** | **1.3 发布资产自动化 全量 270(267 PASS + 3 skip,2026-09-27 收口,1.3.0 已发布,全链零人工拖拽兑现)** | **1.4 真实模型接入 全量 282(279 PASS + 3 skip,2026-09-27 收口,1.4.0 已发布)**
218
+ - **版本主线**:S1-S4 48/48 | M2 89/89(v0.2.0)| M3 127/127(v0.3.0)| M4 139/139(**1.0.0-rc.1 已发布**)| M5 156/156(DEC-4 预埋项转正)| M6 179/179(2026-09-15 收口)| M7 全量 195(192 PASS + 3 能力门控 skip,2026-09-19 收口)| **M5-S28 装载签名门 全量 204(201 PASS + 3 skip,2026-09-26)** | **M8 运行时底座 全量 231(228 PASS + 3 skip,2026-09-26 收口,§3.2 八模块全部收口)** | **1.1 签名收尾最小集 全量 249(246 PASS + 3 skip,2026-09-27 收口,1.1.0 已发布,enforce 翻转准备就绪)** | **1.2 会话/遥测小项 全量 263(260 PASS + 3 skip,2026-09-27 收口,1.2.0 已发布)** | **1.3 发布资产自动化 全量 270(267 PASS + 3 skip,2026-09-27 收口,1.3.0 已发布,全链零人工拖拽兑现)** | **1.4 真实模型接入 全量 282(279 PASS + 3 skip,2026-09-27 收口,1.4.0 已发布)** | **1.5 provider 扩展+远程 MCP+配置完整形态 全量 300(297 PASS + 3 skip,2026-09-28 收口,1.5.0 已发布)**
205
219
  - **1.0.0 正式发布(2026-09-26,S29/S30)**:npm `@lqc123qwe/car-runtime@1.0.0` 在架——**dist-tag latest → 1.0.0,rc 保留指向 1.0.0-rc.1**(§1.1 口径);SLSA provenance(Actions OIDC,透明日志在案);GitHub Release v1.0.0 六资产(tgz/bundle/SHA256SUMS/SBOM/审计/公钥);**post-publish 全链 13 PASS / 0 DRY-RUN / 0 FAIL** + 外部验证者等价检查(Release 实下载件 SHA256 MATCH + cosign Verified OK)。**宽限期时钟:2026-09-26 起算,2026-12-25 到期 verifier 默认值 warn→enforce 翻转(计划内变更)**。终验报告:`car-docs/10-内核v1/M5-S30-1.0收口终验报告.md`。发布工程事件:j16 首跑 E403(npm 2FA 策略 vs granular token 无 bypass——清单预设回退路径兑现)→ bypass token re-run 绿;G-09 期望 tag 动态化(`4c84b17`)在真实发布兑现价值。
206
220
  - **1.0.0-rc.1 已发布(2026-09-09)**:npm `@lqc123qwe/car-runtime@1.0.0-rc.1`(rc + latest 双 tag,SLSA provenance 在案)+ GitHub Release 6 制品;发布预演 **13 PASS / 0 DRY-RUN / 0 FAIL**(c762b72)。S22 GO 清单已全绿收口。
207
221
  - **轨道 A 三项全部完成**:E-1 WSL2 逃逸矩阵 **20/20 PASS 零逃逸**(2026-09-07,G-07 转 PASS)| E-3 远端发布通道 **13/13 全门禁 PASS**(2026-09-09,G-08/09/10 转 PASS)| E-2 Windows koffi FFI **四判据双环境全 PASS**(S24/S25/S26,2026-09-09~10;本机非提权 + windows-latest 提权 runner 双绿,CI 门禁已接入)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lqc123qwe/car-runtime",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Composable Agent Runtime (CAR) - microkernel plugin system with sandboxed PTC, append-only hashed session log, and MCP host bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  "@types/node": "^26.6.1"
27
27
  },
28
28
  "dependencies": {
29
+ "jiti": "^2.7.0",
29
30
  "zod": "^3.25.76"
30
31
  }
31
32
  }
@@ -69,9 +69,26 @@ const record = (stage: string, gate: string, ok: boolean, detail = '', dryRun =
69
69
  for (const p of secretPatterns) if (p.test(content)) hits++
70
70
  }
71
71
  record(stage, 'G-03 硬编码密钥扫描 = 0', hits === 0, `${hits} hits(扫描 ${scanned} 个 .ts,豁免:secrets.ts 规则库 + s7 标定基准,豁免清单见脚本注记)`)
72
- // 依赖审计:零运行时依赖 → 无漏洞面(pnpm audit 在引入依赖后启用)
72
+ // 依赖审计:1.5-S3(D-22)zod 为首个常规运行时依赖 → 门禁口径从「零依赖断言」切换为
73
+ // npm audit(omit=dev,high 阻塞)——与 ci.yml j01 依赖审计同款;audit 不可达(离线)= DRY-RUN
73
74
  const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'))
74
- record(stage, 'G-04 依赖审计', Object.keys(pkg.dependencies ?? {}).length === 0, '零运行时依赖(node:sqlite/node:crypto 内置),pnpm audit 在引入首依赖后启用')
75
+ const depCount = Object.keys(pkg.dependencies ?? {}).length
76
+ let auditOk = true
77
+ let auditDetail = '无运行时依赖(audit 免除)'
78
+ if (depCount > 0) {
79
+ if (process.env.REMOTE_CHECK === '1') {
80
+ try {
81
+ execSync('npm audit --omit=dev --audit-level=high', { cwd: ROOT, stdio: 'pipe' })
82
+ auditDetail = `运行时依赖 ${depCount} 项(D-22:zod ^3);npm audit --omit=dev --audit-level=high 0 findings`
83
+ } catch {
84
+ auditOk = false
85
+ auditDetail = `运行时依赖 ${depCount} 项;npm audit --audit-level=high 有 findings(阻塞——G-04 D-22 口径)`
86
+ }
87
+ } else {
88
+ auditDetail = `运行时依赖 ${depCount} 项(D-22:zod ^3);npm audit 在 REMOTE_CHECK=1 实跑(DRY-RUN)`
89
+ }
90
+ }
91
+ record(stage, 'G-04 依赖审计', auditOk, auditDetail)
75
92
  }
76
93
 
77
94
  // ── 阶段 2:构建与制品 ──
package/src/cli.ts CHANGED
@@ -461,9 +461,9 @@ async function main(): Promise<number> {
461
461
  // E-6 宿主实连入口(M4-S18):CAR-as-MCP-Server——宿主(Claude Code/Codex)经 stdio JSON-RPC 接入
462
462
  // 1.1-S4 装载接线:--plugin <file|dir>(可重复)/ CAR_PLUGINS env → 六阶段流水线(verify 门先于
463
463
  // import())挂载进 Context + car_load_total/car_unsigned_confirmed 全链计数。
464
- // 边界(1.1 规划 W2-4,防口径外推):此处是「装载接线 + 采集全链」——插件被真实装配
465
- // (factory 执行 + bindCore 冲刷注册项),但宿主会话执行插件工具的执行接线属 W1 登记后续
466
- // (sessionTurn 仍为事件批归一化)。
464
+ // 1.6-S2(D-25)宿主工具面透传接线:插件工具 Map 注入 RuntimeFacade——tool_list 出声明面
465
+ // (declaredSideEffect 不出站:权限面只进权限门红线)、tool_call 经权限门(write 类 policy
466
+ // 拒绝/readonly 执行)+ 成对落链;跨源同名 CAR-E-DUP fail-closed(1.4-S1 纪律同款)。
467
467
  const hostIdx = rest.indexOf('--host')
468
468
  const { HOST_MAPPINGS } = await import('./host/mappings.ts')
469
469
  // 默认宿主取映射表首项(数据驱动,静态架构断言红线:宿主标识只在 mappings 数据文件)
@@ -487,11 +487,12 @@ async function main(): Promise<number> {
487
487
  counters.onCount(name, labels)
488
488
  if (otel.enabled) otel.getMeter().createCounter(name).add(1, labels as Record<string, string> | undefined)
489
489
  }
490
+ // 1.6-S2:hostTools 提升到块外——跨源同名查重与 toolMap 装配在装载块之后统一收口
491
+ const hostTools: Array<{ name: string; description?: string; parameters?: unknown; declaredSideEffect?: 'readonly' | 'write'; run: (args: Record<string, unknown>) => Promise<unknown> }> = []
490
492
  if (pluginsArg.sources.length) {
491
493
  const { loadPlugins, formatLoadReport } = await import('./load/report.ts')
492
494
  const { Context } = await import('./kernel/context.ts')
493
495
  const ctx = new Context()
494
- const hostTools: any[] = []
495
496
  for (const src of pluginsArg.sources) {
496
497
  const result = await loadPlugins({ source: src, signature: { ...sig.options, onCount: (n, l) => onCount(n, l) } })
497
498
  if (result.report.stages.some(s => s.status === 'FAIL')) {
@@ -511,14 +512,18 @@ async function main(): Promise<number> {
511
512
  } })
512
513
  }
513
514
  const names = result.plugins.map(p => `${p.manifest.name}@${p.manifest.version}`).join(', ')
514
- console.error(`[car mcp-serve] plugins loaded: ${names}(tools: ${hostTools.map(t => t.name).join(', ') || '无'};装载已接线,会话内执行接线属 W1 登记后续)`)
515
+ console.error(`[car mcp-serve] plugins loaded: ${names}(tools: ${hostTools.map(t => t.name).join(', ') || '无'};装载+工具面透传已接线——1.6-S2)`)
515
516
  }
516
517
  }
517
518
  const { HostGateway } = await import('./host/hostGateway.ts')
518
519
  const { createRuntimeFacade } = await import('./host/facade.ts')
519
520
  const { createStdioServer } = await import('./host/stdio.ts')
520
521
  const profiles = new Map(HOST_MAPPINGS.map(h => [h.hostId, h]))
521
- const facade = createRuntimeFacade({ profiles })
522
+ // 1.6-S2:跨源同名显式报错(禁静默覆盖——last-wins Map 语义下同名即歧义,fail-closed 启动中止)
523
+ const dupTool = hostTools.find((t, i) => hostTools.findIndex(x => x.name === t.name) !== i)
524
+ if (dupTool) { console.error(`CAR-E-DUP: 工具 "${dupTool.name}" 跨源同名——禁静默覆盖(mcp-serve fail-closed 启动中止)`); return 1 }
525
+ const toolMap = new Map(hostTools.map(t => [t.name as string, t]))
526
+ const facade = createRuntimeFacade({ profiles, ...(toolMap.size ? { tools: toolMap } : {}) })
522
527
  const gw = new HostGateway({ facade, audit: e => onCount('car_registry_decision', { source: 'registry' }) })
523
528
  for (const h of HOST_MAPPINGS) gw.registerHost({ hostId: h.hostId, profile: h, transport: {} as never })
524
529
  const server = createStdioServer((tool, args) => gw.handle(hostId, tool, args))
@@ -7,14 +7,24 @@
7
7
  * 未命中事件走 hostRaw 降级(零静默);turnEnd reason 由最后一条归一化 turnEnd 决定(缺省 completed);
8
8
  * - 模型循环由宿主驱动(「统一边界而非统一内部」):CAR 承载日志/审计/工具面,step 循环不暴露;
9
9
  * - 会话内存态 Map 承载(v0.2.0 SessionLog 语义),持久化经 sessionExport(取证包)与后续 S13 文件落盘增强。
10
+ * - 1.6-S2(D-25)宿主工具面透传:opts.tools 注入插件工具 Map(mcp-serve 装配)——
11
+ * toolList 出 name/description/parameters(declaredSideEffect 不出站:权限面只进权限门红线);
12
+ * toolCall 经权限门(T-22 缺省 write 收口;write 类 policy 拒绝——mcp-serve 无人审回路,
13
+ * 人审 confirm 属 turn 循环 preset.authorize 通道不外推到宿主面)+ 成对落链
14
+ * (actor='user' 宿主=用户代理 / turnId='T0' 会话级能力面,sessionTurn 从 T1 起不冲突);
15
+ * 未装配 tools 时显式报错(S13 占位「静默空面」退役——fail-visible)。
10
16
  */
11
17
  import { SessionLog, type EventKind } from '../session/log.ts'
12
18
  import { forkCrossHost, loadForkedLog } from '../session/fork.ts'
13
19
  import { deriveSessionId, normalizeHostEvent } from './mappings.ts'
14
20
  import type { HostProfile } from './mappings.ts'
15
21
  import type { RuntimeFacade } from './hostGateway.ts'
22
+ import type { ToolReg } from '../load/loader.ts'
16
23
 
17
- interface SessionState { log: SessionLog; profile: HostProfile; hostSessionId: string; turnCount: number; lastReason: string }
24
+ interface SessionState { log: SessionLog; profile: HostProfile; hostSessionId: string; turnCount: number; lastReason: string; hostToolSeq: number }
25
+
26
+ /** 宿主工具面声明形态(toolList 出站——权限面字段不出站) */
27
+ export interface HostToolDescriptor { name: string; description?: string; parameters?: unknown }
18
28
 
19
29
  /** 迁移事件里已用过的最大 turn 序号(T{n})——import 侧续跑 turnId 不与迁移段冲突 */
20
30
  function maxTurnIndex(log: SessionLog): number {
@@ -26,7 +36,7 @@ function maxTurnIndex(log: SessionLog): number {
26
36
  return max
27
37
  }
28
38
 
29
- export function createRuntimeFacade(opts: { profiles: Map<string, HostProfile> }): RuntimeFacade {
39
+ export function createRuntimeFacade(opts: { profiles: Map<string, HostProfile>; tools?: Map<string, ToolReg> }): RuntimeFacade {
30
40
  const sessions = new Map<string, SessionState>()
31
41
 
32
42
  const bySession = (sessionId: string): SessionState => {
@@ -47,12 +57,12 @@ export function createRuntimeFacade(opts: { profiles: Map<string, HostProfile> }
47
57
  if (importJsonl !== undefined) {
48
58
  const { log } = loadForkedLog(importJsonl, { hostId, hostSessionId })
49
59
  const turnCount = maxTurnIndex(log)
50
- sessions.set(sessionId, { log, profile, hostSessionId, turnCount, lastReason: 'completed' })
60
+ sessions.set(sessionId, { log, profile, hostSessionId, turnCount, lastReason: 'completed', hostToolSeq: 0 })
51
61
  return { sessionId }
52
62
  }
53
63
  const log = new SessionLog(sessionId)
54
64
  log.append('runtime', 'hostRaw', 'T0', { hostEvent: 'session_registered', hostId, hostSessionId })
55
- sessions.set(sessionId, { log, profile, hostSessionId, turnCount: 0, lastReason: 'completed' })
65
+ sessions.set(sessionId, { log, profile, hostSessionId, turnCount: 0, lastReason: 'completed', hostToolSeq: 0 })
56
66
  return { sessionId }
57
67
  },
58
68
 
@@ -115,11 +125,38 @@ export function createRuntimeFacade(opts: { profiles: Map<string, HostProfile> }
115
125
  },
116
126
 
117
127
  async toolList() {
118
- return { tools: [] } // S13:宿主工具面透传(登记制接入宿主侧工具)
128
+ if (!opts.tools) return { tools: [] } // 未装配 = 空面(与占位形态兼容);装配后出声明面
129
+ const out: HostToolDescriptor[] = []
130
+ for (const t of opts.tools.values()) {
131
+ out.push({ name: t.name, ...(t.description !== undefined ? { description: t.description } : {}), ...(t.parameters !== undefined ? { parameters: t.parameters } : {}) })
132
+ }
133
+ return { tools: out }
119
134
  },
120
135
 
121
- async toolCall() {
122
- return { ok: false, error: 'tool 面透传在 S13 实装(tool_list/tool_call)' }
136
+ // 1.6-S2(D-25):权限门语义——未知工具显式报错;T-22 缺省 write;write 类 policy 拒绝
137
+ //(mcp-serve 无人审回路,deny-by-default);readonly 执行;工具异常=成对错误结果(US-5 同款)。
138
+ // 全路径成对落链(toolCall/toolResult)——宿主面调用 100% 留痕哈希链。
139
+ async toolCall(args) {
140
+ const { sessionId, tool, arguments: toolArgs } = args as { sessionId?: string; tool?: string; arguments?: Record<string, unknown> }
141
+ const s = bySession(String(sessionId))
142
+ if (!opts.tools) throw new Error('CAR-E-HOST: tool 面未装配(mcp-serve 未登记插件工具——tool_list/tool_call 不可用)')
143
+ const id = `host-tc-${++s.hostToolSeq}`
144
+ s.log.append('user', 'toolCall', 'T0', { id, tool: String(tool), args: toolArgs ?? {} })
145
+ const def = opts.tools.get(String(tool))
146
+ if (!def) {
147
+ s.log.append('plugin', 'toolResult', 'T0', { id, error: `unknown tool "${tool}"` })
148
+ return { ok: false, error: `unknown tool "${tool}"(tool_list 查看已装配工具面)` }
149
+ }
150
+ const sideEffect = def.declaredSideEffect ?? 'write' // T-22 收口:未声明按最高约束
151
+ if (sideEffect === 'write') {
152
+ s.log.append('runtime', 'toolResult', 'T0', { id, error: 'authorization-denied', granted: false, mode: 'host-policy' })
153
+ return { ok: false, error: 'CAR-E-HOST-AUTHZ: write 类工具经宿主面 policy 拒绝(mcp-serve 无人审回路——D-25 deny-by-default)' }
154
+ }
155
+ let result: unknown
156
+ let toolError: string | undefined
157
+ try { result = await def.run(toolArgs ?? {}) } catch (e) { toolError = String(e) }
158
+ s.log.append('plugin', 'toolResult', 'T0', { id, result, ...(toolError ? { error: toolError } : {}) })
159
+ return toolError !== undefined ? { ok: false, error: toolError } : { ok: true, result }
123
160
  },
124
161
  }
125
162
  }
@@ -29,7 +29,8 @@ export interface RuntimeFacade {
29
29
  sessionExport(args: { sessionId: string }): Promise<{ bundle: unknown }>
30
30
  /** M5-DEC4 转正:跨宿主 fork(宿主 A 侧调用,产出可迁移工件 + 宿主 B 侧确定性会话 id) */
31
31
  sessionFork(args: { sessionId: string; targetHostId: string; targetHostSessionId: string; upToSeq?: number }): Promise<{ sessionId: string; jsonl: string; migrated: number }>
32
- toolList(args: Record<string, never>): Promise<{ tools: string[] }>
32
+ /** 1.6-S2(D-25):声明面出站(name/description/parameters——declaredSideEffect 权限面不出站);未装配 = 空面 */
33
+ toolList(args: Record<string, never>): Promise<{ tools: Array<{ name: string; description?: string; parameters?: unknown }> }>
33
34
  toolCall(args: { sessionId: string; tool: string; arguments?: Record<string, unknown> }): Promise<{ ok: boolean; result?: unknown; error?: string }>
34
35
  }
35
36
 
package/src/host/stdio.ts CHANGED
@@ -21,6 +21,9 @@ export interface StdioServer {
21
21
 
22
22
  export function createStdioServer(dispatch: StdioDispatcher): StdioServer {
23
23
  let nextId = 0
24
+ // 1.6-S2:tools/call 按请求序串行派发(会话态依赖 + 响应序确定性——JSON-RPC 允许乱序,
25
+ // 但 stdio 宿主与测试按序解析;tools/list 等同步方法不受此链影响)
26
+ let callChain: Promise<void> = Promise.resolve()
24
27
  return {
25
28
  async serve(input, output) {
26
29
  let count = 0
@@ -59,15 +62,19 @@ export function createStdioServer(dispatch: StdioDispatcher): StdioServer {
59
62
  if (req.method === 'tools/call') {
60
63
  const name = req.params?.name ?? ''
61
64
  const args = req.params?.arguments ?? {}
62
- void dispatch(name, args).then(r => {
63
- if (r.ok) respond({ content: [{ type: 'text', text: JSON.stringify(r.result ?? {}) }], carOk: true })
64
- else respond(undefined, { code: -32000, message: r.error ?? 'host call failed' })
65
- }).catch(e => respond(undefined, { code: -32000, message: String(e) }))
65
+ callChain = callChain
66
+ .then(() => dispatch(name, args))
67
+ .then(r => {
68
+ if (r.ok) respond({ content: [{ type: 'text', text: JSON.stringify(r.result ?? {}) }], carOk: true })
69
+ else respond(undefined, { code: -32000, message: r.error ?? 'host call failed' })
70
+ })
71
+ .catch(e => respond(undefined, { code: -32000, message: String(e) }))
66
72
  return
67
73
  }
68
74
  respond(undefined, { code: -32601, message: `unknown method "${req.method}"(支持 initialize / tools/list / tools/call)` })
69
75
  })
70
76
  await done
77
+ await callChain // 串行链收口:进程退出前最后一批调用响应写完(win32 退出断言同源教训)
71
78
  return count
72
79
  },
73
80
  }
@@ -1,10 +1,13 @@
1
1
  /**
2
- * 1.1-S3 签名配置文件通道 → 1.5-S3 完整形态(car.config.ts + zod,D-22)
2
+ * 1.1-S3 签名配置文件通道 → 1.5-S3 完整形态(car.config.ts + zod,D-22)→ 1.6-S3 jiti 载体(D-24)
3
3
  *
4
4
  * 口径:
5
- * - 载体(D-22):发现序 **car.config.ts > car.config.json**(同目录两者并存 = ts 胜出,
5
+ * - 载体(D-22 + D-24 修订):发现序 **car.config.ts > car.config.json**(同目录两者并存 = ts 胜出,
6
6
  * json 忽略——禁静默合并);显式 --config 按扩展名分派(.ts 走 TS 载体,其余 JSON 解析);
7
- * TS 载体 = 动态 import()(Node 原生类型剥离,插件加载同纪律——jiti 继续登记不引入);
7
+ * TS 载体 = **jiti 自包含转译**(1.6-S3——零传递依赖 bundled 形态,第二个常规运行时依赖;
8
+ * D-22「jiti 继续登记不引入」到期出清):Node 22.19+ 加载 car.config.ts 不再依赖
9
+ * --experimental-transform-types,非可擦除语法(enum 等 transform-only 形态)可用——
10
+ * erasable-only 预检登记项随之出清(被取代);插件加载的原生类型剥离纪律不动。
8
11
  * 形态 = `export default {…}`(非对象/缺 default 显式报错)。
9
12
  * - **预热协议**:loadCarConfig 保持同步签名(doctor/resolveSigGate 等既有调用面不动);
10
13
  * car.config.ts 须先经 warmCarConfig 异步预热入缓存(CLI 入口统一预热),未预热显式报错
@@ -20,7 +23,6 @@
20
23
  */
21
24
  import { existsSync, readFileSync } from 'node:fs'
22
25
  import { isAbsolute, join } from 'node:path'
23
- import { pathToFileURL } from 'node:url'
24
26
  import { z } from 'zod'
25
27
  import type { SignatureGateOptions } from './sigGate.ts'
26
28
 
@@ -184,9 +186,10 @@ const tsCache = new Map<string, ConfigLoadResult>()
184
186
  let tsEpoch = 0
185
187
 
186
188
  /**
187
- * 1.5-S3(D-22):car.config.ts 载体预热(动态 import 与插件加载同纪律——Node 原生类型剥离,
188
- * Node 22 须 --experimental-transform-types / Node ≥23.6 原生支持;报错文案引导)。
189
- * CLI 入口统一预热;直接调用 loadCarConfig 的库方在存在 car.config.ts 时须先 await 本函数。
189
+ * 1.5-S3(D-22)→ 1.6-S3(D-24):car.config.ts 载体预热。
190
+ * jiti 自包含转译(每 epoch 新实例 + moduleCache 关 = 击穿语义与 1.5 等价)——
191
+ * Node 22.19+ 无需 --experimental-transform-types;直接调用 loadCarConfig 的库方
192
+ * 在存在 car.config.ts 时须先 await 本函数(CLI 入口统一预热,协议不变)。
190
193
  */
191
194
  export async function warmCarConfig(opts: { explicitPath?: string; cwd?: string } = {}): Promise<void> {
192
195
  let path: string | undefined
@@ -203,9 +206,14 @@ export async function warmCarConfig(opts: { explicitPath?: string; cwd?: string
203
206
  tsCache.set(path, result)
204
207
  }
205
208
 
206
- async function loadTsConfigFile(path: string, epoch: number): Promise<ConfigLoadResult> {
209
+ async function loadTsConfigFile(path: string, _epoch: number): Promise<ConfigLoadResult> {
207
210
  try {
208
- const mod = (await import(`${pathToFileURL(path).href}?cfg-epoch=${epoch}`)) as { default?: unknown }
211
+ // D-24:jiti 转译(零传递依赖 bundled);每 epoch 新实例 + moduleCache 关——击穿语义保持;
212
+ // interopDefault 显式关(jiti 缺省 true 会把无 default 模块的 exports 整体当 default 返回,
213
+ // 「缺 default export」fail-visible 校验即失效)
214
+ const { createJiti } = await import('jiti')
215
+ const jiti = createJiti(import.meta.url, { moduleCache: false, fsCache: false, interopDefault: false })
216
+ const mod = await jiti.import(path) as { default?: unknown }
209
217
  const raw = mod.default
210
218
  if (raw === undefined) {
211
219
  return { path, config: {}, error: `CAR-E-CONFIG: ${path} 缺 default export(car.config.ts 形态 = export default {…})` }
@@ -217,10 +225,7 @@ async function loadTsConfigFile(path: string, epoch: number): Promise<ConfigLoad
217
225
  return v.error ? { path, config: {}, error: v.error } : { path, config: v.config }
218
226
  } catch (e) {
219
227
  const err = e as { code?: string; message?: string }
220
- const hint = err.code === 'ERR_UNKNOWN_FILE_EXTENSION' || /Unknown file extension|loader/i.test(err.message ?? '')
221
- ? `——当前 Node 未启用 .ts 载体(须 --experimental-transform-types 或 Node ≥23.6 原生类型剥离)`
222
- : ''
223
- return { path, config: {}, error: `CAR-E-CONFIG: car.config.ts 载入失败(${err.message})${hint}` }
228
+ return { path, config: {}, error: `CAR-E-CONFIG: car.config.ts 载入失败(${err.message ?? String(e)})——jiti 转译失败显式拒绝,不静默忽略` }
224
229
  }
225
230
  }
226
231
 
@@ -4,6 +4,7 @@
4
4
  * 设计口径:
5
5
  * - 哈希链(SHA-256)为 CAR 自研增强(dsh 无此物,源码级核实)——合规卖点落地
6
6
  * - 「Model-visible means logged」:断言口径 = 请求时快照 === 日志前缀 [0, atSeq) 投影
7
+ * - 1.6-S1(D-23):投影对同 id toolResult 收敛 last-wins——事件面/审计面不动,模型可见流去冗余
7
8
  * - append-only:本层仅暴露 append/verifyChain/deriveMessages/assertModelVisibleLogged
8
9
  */
9
10
  import { createHash } from 'node:crypto'
@@ -83,16 +84,33 @@ export class SessionLog {
83
84
  return null
84
85
  }
85
86
 
86
- /** 投影:从日志前缀重建模型可见消息流(不依赖模型运行时状态) */
87
+ /**
88
+ * 投影:从日志前缀重建模型可见消息流(不依赖模型运行时状态)。
89
+ * 1.6-S1(D-23)同 id toolResult 收敛 last-wins:写类工具授权决策(runtime/toolResult)
90
+ * 与执行结果(plugin/toolResult)成对落链(M4 既有语义冻结——审计全量留痕不动),
91
+ * 模型可见流只保留最终结果(真实 provider 对重复 tool_call_id/tool_use_id 拒绝或未定义,
92
+ * 1.4 观察项经真实适配器落地升级为缺陷面)。id 缺席不收敛(hostRaw/无 id 载荷形态不变,
93
+ * golden ④ 投影语义兼容);授权拒绝单条即唯一结果原样保留。适配器侧零改动(消费已收敛上游)。
94
+ */
87
95
  deriveMessages(upTo?: number): Array<{ role: string; [k: string]: unknown }> {
96
+ const scoped = upTo !== undefined ? this.events.slice(0, upTo) : this.events
97
+ const lastSeqById = new Map<string, number>()
98
+ for (const e of scoped) {
99
+ if (e.kind !== 'toolResult') continue
100
+ const id = (e.payload as { id?: unknown } | null)?.id
101
+ if (typeof id === 'string') lastSeqById.set(id, e.seq)
102
+ }
88
103
  const out: Array<{ role: string; [k: string]: unknown }> = []
89
- for (const e of this.events) {
90
- if (upTo !== undefined && e.seq >= upTo) break
104
+ for (const e of scoped) {
91
105
  if (e.kind === 'system') out.push({ role: 'system', content: e.payload })
92
106
  else if (e.kind === 'user') out.push({ role: 'user', content: e.payload })
93
107
  else if (e.kind === 'assistant') out.push({ role: 'assistant', content: e.payload })
94
108
  else if (e.kind === 'toolCall') out.push({ role: 'assistant', toolCall: e.payload })
95
- else if (e.kind === 'toolResult') out.push({ role: 'toolResult', content: e.payload })
109
+ else if (e.kind === 'toolResult') {
110
+ const id = (e.payload as { id?: unknown } | null)?.id
111
+ if (typeof id === 'string' && lastSeqById.get(id) !== e.seq) continue // D-23:后续有同 id 最终结果,本条为授权中间记录
112
+ out.push({ role: 'toolResult', content: e.payload })
113
+ }
96
114
  }
97
115
  return out
98
116
  }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * 1.6-S2 · 宿主工具面透传(S36):RuntimeFacade toolList/toolCall 实装(D-25)
3
+ *
4
+ * 覆盖:
5
+ * - toolList:声明面出站(name/description/parameters)+ **declaredSideEffect 不出站**(权限面
6
+ * 只进权限门红线);未装配 = 空面(占位形态兼容)
7
+ * - toolCall 权限门:readonly 执行 / write(显式声明与未声明 T-22 收口)一律 policy 拒绝
8
+ * (CAR-E-HOST-AUTHZ——mcp-serve 无人审回路,deny-by-default)/ 未知工具显式报错 /
9
+ * 工具异常=成对错误结果(US-5 同款)
10
+ * - 成对落链:actor='user'(宿主=用户代理)+ turnId='T0'(会话级能力面,sessionTurn 从 T1
11
+ * 起不冲突)+ id 序列 host-tc-N;verifyChain 全程完整;跨源同名 CAR-E-DUP 由 cli 装配层
12
+ * fail-closed(spawn E2E 断言)
13
+ * - spawn E2E:mcp-serve 真进程 stdio——tools/list 十面不变 + tool_list 真实出插件工具 +
14
+ * tool_call readonly 执行 / write 拒绝 / verify 链完整(1.6-GO-5 生产调用点核查)
15
+ */
16
+ import { test } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { spawn } from 'node:child_process'
19
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
20
+ import { tmpdir } from 'node:os'
21
+ import { join, dirname } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+ import { createRuntimeFacade } from '../src/host/facade.ts'
24
+ import { HostGateway } from '../src/host/hostGateway.ts'
25
+ import { HOST_MAPPINGS, deriveSessionId } from '../src/host/mappings.ts'
26
+ import type { ToolReg } from '../src/load/loader.ts'
27
+
28
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
29
+
30
+ function makeTools(): Map<string, ToolReg> {
31
+ return new Map([
32
+ ['echo', {
33
+ name: 'echo', description: '回声工具', parameters: { type: 'object', properties: { text: { type: 'string' } } },
34
+ declaredSideEffect: 'readonly', run: async (args: Record<string, unknown>) => 'pong:' + String((args as { text?: string }).text ?? ''),
35
+ }],
36
+ ['write_file', { name: 'write_file', declaredSideEffect: 'write', run: async () => 'written' }],
37
+ ['stealth', { name: 'stealth', run: async () => 'undeclared' }], // 未声明 sideEffect——T-22 收口 write
38
+ ])
39
+ }
40
+
41
+ function gatewayWith(tools?: Map<string, ToolReg>) {
42
+ const facade = createRuntimeFacade({ profiles: new Map(HOST_MAPPINGS.map(h => [h.hostId, h])), ...(tools ? { tools } : {}) })
43
+ const gw = new HostGateway({ facade, audit: () => {} })
44
+ gw.registerHost({ hostId: 'claude-code', profile: HOST_MAPPINGS[0]!, transport: {} as never })
45
+ return { facade, gw }
46
+ }
47
+
48
+ test('S36: toolList 声明面出站——name/description/parameters 齐,declaredSideEffect 权限面不出站', async () => {
49
+ const { gw } = gatewayWith(makeTools())
50
+ await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-list' })
51
+ const r = await gw.handle('claude-code', 'tool_list', {})
52
+ assert.ok(r.ok)
53
+ const tools = (r.result as { tools: Array<Record<string, unknown>> }).tools
54
+ assert.equal(tools.length, 3)
55
+ const echo = tools.find(t => t.name === 'echo')!
56
+ assert.equal(echo.description, '回声工具')
57
+ assert.deepEqual(echo.parameters, { type: 'object', properties: { text: { type: 'string' } } })
58
+ assert.ok(tools.every(t => !('declaredSideEffect' in t)), '权限面不出站红线:sideEffect 信息不经宿主面外泄')
59
+ assert.ok(tools.every(t => !('run' in t)), '执行面不出站')
60
+ })
61
+
62
+ test('S36: toolList 未装配 = 空面(S12 占位形态兼容)', async () => {
63
+ const { gw } = gatewayWith()
64
+ await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-empty' })
65
+ const r = await gw.handle('claude-code', 'tool_list', {})
66
+ assert.ok(r.ok)
67
+ assert.deepEqual((r.result as { tools: unknown[] }).tools, [])
68
+ })
69
+
70
+ test('S36: toolCall readonly 执行——ok + 成对落链(actor=user/turnId=T0/id 序列)+ 链完整', async () => {
71
+ const { gw } = gatewayWith(makeTools())
72
+ const { sessionId } = await (await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-exec' })).result as { sessionId: string }
73
+ // gateway.handle 把 facade 返回值包进 {ok:true, result:<facade envelope>}——双层 ok 语义
74
+ const r1 = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'echo', arguments: { text: 'hi' } })
75
+ assert.ok(r1.ok)
76
+ assert.deepEqual(r1.result, { ok: true, result: 'pong:hi' })
77
+ const r2 = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'echo', arguments: { text: 'again' } })
78
+ assert.deepEqual(r2.result, { ok: true, result: 'pong:again' })
79
+ // 落链核查经 sessionExport(生产消费路径):toolCall actor=user + turnId=T0 + id 序列 host-tc-N
80
+ const exp = await gw.handle('claude-code', 'session_export', { sessionId })
81
+ const bundle = (exp.result as { bundle: { files: Array<{ name: string; content: string }> } }).bundle
82
+ const eventsFile = bundle.files.find(f => f.name.endsWith('events.jsonl'))!
83
+ const events = eventsFile.content.trim().split('\n').map(l => JSON.parse(l)) as Array<{ actor: string; kind: string; turnId: string; payload: { id?: string; tool?: string } }>
84
+ const calls = events.filter(e => e.kind === 'toolCall')
85
+ assert.equal(calls.length, 2)
86
+ assert.ok(calls.every(e => e.actor === 'user' && e.turnId === 'T0'), 'D-25:宿主=用户代理,会话级能力面落 T0(sessionTurn 从 T1 起不冲突)')
87
+ assert.deepEqual(calls.map(e => e.payload.id), ['host-tc-1', 'host-tc-2'])
88
+ const verify = await gw.handle('claude-code', 'session_verify', { sessionId })
89
+ assert.deepEqual((verify.result as { ok: boolean }).ok, true, '工具调用成对落链后哈希链完整')
90
+ })
91
+
92
+ test('S36: toolCall write 类 policy 拒绝(显式声明 + 未声明 T-22 收口双路径)+ 拒绝落链', async () => {
93
+ const { gw } = gatewayWith(makeTools())
94
+ const { sessionId } = await (await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-deny' })).result as { sessionId: string }
95
+ const w = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'write_file' })
96
+ assert.ok(w.ok, 'handle 层 ok(facade 正常返回 envelope)')
97
+ assert.equal((w.result as { ok: boolean }).ok, false, 'facade 层 ok=false')
98
+ assert.match((w.result as { error?: string }).error!, /CAR-E-HOST-AUTHZ/)
99
+ const s = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'stealth' })
100
+ assert.equal((s.result as { ok: boolean }).ok, false, 'T-22:未声明 sideEffect 收口 write → 同样拒绝')
101
+ assert.match((s.result as { error?: string }).error!, /CAR-E-HOST-AUTHZ/)
102
+ // 拒绝也落链(runtime/toolResult authorization-denied)——审计全量留痕
103
+ const replay = await gw.handle('claude-code', 'session_replay', { sessionId })
104
+ const results = ((replay.result as { messages: Array<{ role: string; content?: unknown }> }).messages).filter(m => m.role === 'toolResult')
105
+ const denied = results.filter(m => (m.content as { error?: string }).error === 'authorization-denied')
106
+ assert.equal(denied.length, 2, '两次拒绝各落一条授权拒绝记录(单条即终态——D-23 不收敛)')
107
+ const verify = await gw.handle('claude-code', 'session_verify', { sessionId })
108
+ assert.deepEqual((verify.result as { ok: boolean }).ok, true)
109
+ })
110
+
111
+ test('S36: toolCall 未知工具显式报错 + 成对落链;工具异常=成对错误结果(US-5 同款)', async () => {
112
+ const tools = new Map<string, ToolReg>([['boom', { name: 'boom', declaredSideEffect: 'readonly', run: async () => { throw new Error('炸了') } }]])
113
+ const { gw } = gatewayWith(tools)
114
+ const { sessionId } = await (await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-err' })).result as { sessionId: string }
115
+ const unk = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'nope' })
116
+ assert.ok(unk.ok, 'handle 层正常返回')
117
+ assert.equal((unk.result as { ok: boolean }).ok, false, 'facade 层显式拒绝')
118
+ assert.match((unk.result as { error?: string }).error!, /unknown tool "nope"/)
119
+ const err = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'boom', arguments: {} })
120
+ assert.equal((err.result as { ok: boolean }).ok, false)
121
+ assert.match((err.result as { error?: string }).error!, /炸了/)
122
+ const replay = await gw.handle('claude-code', 'session_replay', { sessionId })
123
+ const results = ((replay.result as { messages: Array<{ role: string; content?: unknown }> }).messages).filter(m => m.role === 'toolResult')
124
+ assert.equal((results[0]!.content as { error?: string }).error, 'unknown tool "nope"')
125
+ assert.match((results[1]!.content as { error?: string }).error!, /炸了/, '工具异常=结果非中断(US-5/D5 语义同款)')
126
+ })
127
+
128
+ test('S36: 未装配 tool 面时 toolCall 显式报错(S13 静默占位退役——fail-visible)', async () => {
129
+ const { gw } = gatewayWith()
130
+ const { sessionId } = await (await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-nobridge' })).result as { sessionId: string }
131
+ const r = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'echo' })
132
+ assert.equal(r.ok, false)
133
+ assert.match(r.error!, /tool 面未装配/)
134
+ })
135
+
136
+ // ==================== spawn E2E(生产调用点——1.6-GO-5) ====================
137
+
138
+ function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
139
+ const dir = mkdtempSync(join(tmpdir(), `car-s36-${name}-`))
140
+ try {
141
+ const r = fn(dir)
142
+ const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
143
+ if (r instanceof Promise) return r.finally(cleanup)
144
+ cleanup()
145
+ return Promise.resolve()
146
+ } catch (e) {
147
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
148
+ throw e
149
+ }
150
+ }
151
+
152
+ function talk(requests: string[], plugin: string): Promise<{ outs: any[]; stderr: string }> {
153
+ return new Promise((resolve, reject) => {
154
+ const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve', '--plugin', plugin], { stdio: ['pipe', 'pipe', 'pipe'] })
155
+ let out = ''
156
+ let err = ''
157
+ child.stdout.on('data', d => { out += d })
158
+ child.stderr.on('data', d => { err += d })
159
+ child.on('error', reject)
160
+ child.on('exit', () => {
161
+ try { resolve({ outs: out.split('\n').filter(Boolean).map(l => JSON.parse(l)), stderr: err }) } catch (e) { reject(e) }
162
+ })
163
+ for (const r of requests) child.stdin.write(r + '\n')
164
+ child.stdin.end()
165
+ })
166
+ }
167
+
168
+ test('S36: spawn E2E——mcp-serve 真进程 tool_list 透传插件工具面 + tool_call 执行/拒绝 + verify 链完整', async () => {
169
+ await withTempDir('e2e', async dir => {
170
+ const plugin = join(dir, 'p.ts')
171
+ writeFileSync(plugin, [
172
+ 'export const manifest = { name: "s36p", version: "1.0.0" }',
173
+ 'export default function apply(api) {',
174
+ ' api.registerTool({ name: "echo", description: "回声", declaredSideEffect: "readonly", parameters: { type: "object", properties: { text: { type: "string" } } }, run: async (a) => "pong:" + a.text })',
175
+ ' api.registerTool({ name: "write_file", declaredSideEffect: "write", run: async () => "written" })',
176
+ '}',
177
+ ].join('\n'))
178
+ // 派生 id 进程内预计算(确定性派生——session_start 返回必须一致)
179
+ const sid = deriveSessionId('claude-code', 's36-e2e')
180
+ const { outs } = await talk([
181
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
182
+ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's36-e2e' } } }),
183
+ JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'tool_list', arguments: {} } }),
184
+ JSON.stringify({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'tool_call', arguments: { sessionId: sid, tool: 'echo', arguments: { text: 'hi' } } } }),
185
+ JSON.stringify({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'tool_call', arguments: { sessionId: sid, tool: 'write_file', arguments: {} } } }),
186
+ JSON.stringify({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'session_verify', arguments: { sessionId: sid } } }),
187
+ JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'session_replay', arguments: { sessionId: sid } } }),
188
+ ], plugin)
189
+ assert.equal(outs[0].result.tools.length, 10, 'MCP tools/list 十面不变(宿主能力面与插件工具面分层)')
190
+ assert.equal(JSON.parse(outs[1].result.content[0].text).sessionId, sid, '确定性派生经真进程复核')
191
+ const listed = JSON.parse(outs[2].result.content[0].text) as { tools: Array<Record<string, unknown>> }
192
+ assert.deepEqual(listed.tools.map(t => t.name), ['echo', 'write_file'], 'tool_list 透传插件声明面')
193
+ assert.equal(listed.tools.find(t => t.name === 'echo')!.description, '回声')
194
+ assert.ok(listed.tools.every(t => !('declaredSideEffect' in t)), '权限面不出站经真进程协议面复核')
195
+ assert.deepEqual(JSON.parse(outs[3].result.content[0].text), { ok: true, result: 'pong:hi' }, 'readonly 工具经宿主面真实执行(facade envelope 经 stdio content 透出)')
196
+ const denied = JSON.parse(outs[4].result.content[0].text) as { ok: boolean; error?: string }
197
+ assert.equal(denied.ok, false, 'write 类工具 policy 拒绝(facade envelope——handle 层未抛错)')
198
+ assert.match(denied.error!, /CAR-E-HOST-AUTHZ/)
199
+ assert.equal(JSON.parse(outs[5].result.content[0].text).ok, true, '工具调用成对落链后哈希链完整')
200
+ const msgs = JSON.parse(outs[6].result.content[0].text).messages as Array<{ role: string; content?: unknown; toolCall?: unknown }>
201
+ assert.ok(msgs.some(m => m.role === 'assistant' && (m.toolCall as { tool?: string })?.tool === 'echo'), 'toolCall 落链经 replay 可见')
202
+ assert.deepEqual(msgs.filter(m => m.role === 'toolResult').map(m => m.content), [
203
+ { id: 'host-tc-1', result: 'pong:hi' },
204
+ { id: 'host-tc-2', error: 'authorization-denied', granted: false, mode: 'host-policy' },
205
+ ])
206
+ })
207
+ })
208
+
209
+ test('S36: spawn E2E——tool_call 带 sessionId 全链(readonly 执行 / write 拒绝 / verify 完整)', async () => {
210
+ await withTempDir('e2e2', async dir => {
211
+ const plugin = join(dir, 'p.ts')
212
+ writeFileSync(plugin, [
213
+ 'export const manifest = { name: "s36p2", version: "1.0.0" }',
214
+ 'export default function apply(api) {',
215
+ ' api.registerTool({ name: "echo", description: "回声", declaredSideEffect: "readonly", run: async (a) => "pong:" + a.text })',
216
+ ' api.registerTool({ name: "write_file", declaredSideEffect: "write", run: async () => "written" })',
217
+ '}',
218
+ ].join('\n'))
219
+ const start = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's36-e2e2' } } })
220
+ const first = await talk([start], plugin)
221
+ const sid = JSON.parse(first.outs[0].result.content[0].text).sessionId as string
222
+ const { outs } = await talk([
223
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's36-e2e2' } } }),
224
+ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'tool_call', arguments: { sessionId: sid, tool: 'echo', arguments: { text: 'hi' } } } }),
225
+ JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'tool_call', arguments: { sessionId: sid, tool: 'write_file', arguments: {} } } }),
226
+ ], plugin)
227
+ assert.deepEqual(JSON.parse(outs[1].result.content[0].text), { ok: true, result: 'pong:hi' }, 'readonly 工具经宿主面真实执行(跨进程幂等派生会话)')
228
+ const denied2 = JSON.parse(outs[2].result.content[0].text) as { ok: boolean; error?: string }
229
+ assert.equal(denied2.ok, false, 'write 类工具 policy 拒绝经真进程协议面可达')
230
+ assert.match(denied2.error!, /CAR-E-HOST-AUTHZ/)
231
+ })
232
+ })
233
+
234
+ test('S36: spawn E2E——跨源同名工具 CAR-E-DUP fail-closed 启动中止(exit 1)', async () => {
235
+ await withTempDir('e2e3', async dir => {
236
+ const pA = join(dir, 'a.ts')
237
+ const pB = join(dir, 'b.ts')
238
+ for (const [p, name] of [[pA, 'a'], [pB, 'b']] as const) {
239
+ writeFileSync(p, `export const manifest = { name: "${name}", version: "1.0.0" }\nexport default function apply(api) { api.registerTool({ name: "dup_tool", declaredSideEffect: "readonly", run: async () => "${name}" }) }\n`)
240
+ }
241
+ const code = await new Promise<number>(resolve => {
242
+ const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve', '--plugin', pA, '--plugin', pB], { stdio: ['pipe', 'pipe', 'pipe'] })
243
+ let err = ''
244
+ child.stderr.on('data', d => { err += d })
245
+ child.on('exit', c => { resolve(c ?? -1) })
246
+ child.stdin.end()
247
+ })
248
+ assert.equal(code, 1, '禁静默覆盖:fail-closed 启动中止(1.4-S1 跨源同名纪律同款)')
249
+ })
250
+ })
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 1.6-S1 · 投影收敛(S37):deriveMessages 同 id toolResult last-wins(D-23)
3
+ *
4
+ * 覆盖:
5
+ * - runTurn confirm 全链:写类工具 granted 流——事件面两条 toolResult(runtime 授权 + plugin 执行,
6
+ * M4 既有语义不动 = 审计全量留痕),投影单条且内容 = 执行结果;N1 快照一致性由构造保持
7
+ * - denied 流:授权拒绝单条保留(唯一结果,原样投影)
8
+ * - readonly 工具:单 plugin toolResult(无收敛对象——回归)
9
+ * - id 缺席不收敛:无 id 载荷原样投影(golden ④ 投影语义兼容);id 与无 id 混存互不影响
10
+ * - 适配器出站体(1.6-GO-5 生产调用点核查):收敛投影经 openai-compat / anthropic 双适配器
11
+ * 出站体无重复 tool 消息 / tool_result 块(真实 provider 重复 id 拒绝面出清)
12
+ */
13
+ import { test } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import { SessionLog } from '../src/session/log.ts'
16
+ import { runTurn, type ModelStep } from '../src/loop/stop.ts'
17
+ import { createOpenAICompatAdapter, createAnthropicAdapter } from '../src/runtime-core/llm.ts'
18
+
19
+ const dataLine = (obj: unknown) => `data: ${JSON.stringify(obj)}\n\n`
20
+ const openaiSse = (finish: string) =>
21
+ dataLine({ choices: [{ delta: {}, finish_reason: finish }] }) + 'data: [DONE]\n\n'
22
+ const sseResponse = (text: string) => {
23
+ const enc = new TextEncoder()
24
+ return new Response(new ReadableStream<Uint8Array>({ start(c) { c.enqueue(enc.encode(text)); c.close() } }),
25
+ { status: 200, headers: { 'content-type': 'text/event-stream' } })
26
+ }
27
+ const anthropicSse = (events: object[]) => events.map(dataLine).join('')
28
+ const ANTHROPIC_STOP = [
29
+ { type: 'message_start', message: { role: 'assistant' } },
30
+ { type: 'message_delta', delta: { stop_reason: 'end_turn' } },
31
+ { type: 'message_stop' },
32
+ ]
33
+
34
+ /** chatStep 消息映射同款复刻(deriveMessages → LlmRequest.messages 的唯一路径) */
35
+ function toLlmMessages(log: SessionLog) {
36
+ return log.deriveMessages().map(m => ({
37
+ role: m.role as 'system' | 'user' | 'assistant' | 'toolResult',
38
+ content: (m as unknown as { toolCall?: unknown }).toolCall !== undefined
39
+ ? { toolCall: (m as unknown as { toolCall: unknown }).toolCall }
40
+ : m.content,
41
+ }))
42
+ }
43
+
44
+ test('S37: 写类工具 granted 流——事件面两条 toolResult(M4 语义不动),投影单条且为执行结果 + N1 一致', async () => {
45
+ const log = new SessionLog('s37-granted')
46
+ const steps: ModelStep[] = [
47
+ { stopReason: 'toolUse', toolCalls: [{ id: 'tc1', tool: 'save_note', args: { text: 'hi' } }] },
48
+ { stopReason: 'stop' },
49
+ ]
50
+ const r = await runTurn({
51
+ log, turnId: 'T1',
52
+ model: async () => steps.shift()!,
53
+ tools: new Map([['save_note', { declaredSideEffect: 'write', run: async () => '已保存' }]]),
54
+ preset: { mode: 'confirm', authorize: async () => true },
55
+ })
56
+ assert.equal(r.reason, 'completed')
57
+ // 事件面不动:授权决策(runtime)+ 执行结果(plugin)两条都落链(审计全量留痕)
58
+ const resultEvents = log.events.filter(e => e.kind === 'toolResult')
59
+ assert.equal(resultEvents.length, 2, 'M4 既有语义冻结:runtime 授权记录 + plugin 执行结果均落链')
60
+ assert.equal(resultEvents[0]!.actor, 'runtime')
61
+ assert.equal(resultEvents[1]!.actor, 'plugin')
62
+ // 投影收敛:同 id last-wins,模型可见流单条且为执行结果
63
+ const proj = log.deriveMessages().filter(m => m.role === 'toolResult')
64
+ assert.equal(proj.length, 1, 'D-23:授权中间记录不再进模型可见流')
65
+ assert.deepEqual(proj[0]!.content, { id: 'tc1', result: '已保存' })
66
+ // N1 快照一致性(runTurn 每步 snapshotModelRequest;收敛激活下重算同函数构造一致)
67
+ assert.deepEqual(log.assertModelVisibleLogged(), { ok: true })
68
+ })
69
+
70
+ test('S37: denied 流——授权拒绝单条保留(唯一结果原样投影)', async () => {
71
+ const log = new SessionLog('s37-denied')
72
+ const steps: ModelStep[] = [
73
+ { stopReason: 'toolUse', toolCalls: [{ id: 'tc2', tool: 'save_note', args: {} } ] },
74
+ { stopReason: 'stop' },
75
+ ]
76
+ await runTurn({
77
+ log, turnId: 'T1',
78
+ model: async () => steps.shift()!,
79
+ tools: new Map([['save_note', { declaredSideEffect: 'write', run: async () => '不应执行' }]]),
80
+ preset: { mode: 'confirm', authorize: async () => false },
81
+ })
82
+ assert.equal(log.events.filter(e => e.kind === 'toolResult').length, 1, '拒绝即终态:仅 runtime 授权拒绝事件')
83
+ const proj = log.deriveMessages().filter(m => m.role === 'toolResult')
84
+ assert.equal(proj.length, 1, '唯一结果原样保留(不误收敛)')
85
+ assert.equal((proj[0]!.content as { error?: string }).error, 'authorization-denied')
86
+ })
87
+
88
+ test('S37: readonly 工具单条 toolResult 无收敛对象(回归)', async () => {
89
+ const log = new SessionLog('s37-readonly')
90
+ const steps: ModelStep[] = [
91
+ { stopReason: 'toolUse', toolCalls: [{ id: 'tc3', tool: 'echo', args: {} }] },
92
+ { stopReason: 'stop' },
93
+ ]
94
+ await runTurn({
95
+ log, turnId: 'T1',
96
+ model: async () => steps.shift()!,
97
+ tools: new Map([['echo', { declaredSideEffect: 'readonly', run: async () => 'pong' }]]),
98
+ preset: { mode: 'readonly' },
99
+ })
100
+ assert.equal(log.events.filter(e => e.kind === 'toolResult').length, 1)
101
+ const proj = log.deriveMessages().filter(m => m.role === 'toolResult')
102
+ assert.equal(proj.length, 1)
103
+ assert.deepEqual(proj[0]!.content, { id: 'tc3', result: 'pong' })
104
+ })
105
+
106
+ test('S37: id 缺席不收敛——无 id 载荷原样投影(golden ④ 兼容),id 与无 id 混存互不影响', () => {
107
+ const log = new SessionLog('s37-idless')
108
+ log.append('runtime', 'toolResult', 'T1', { ok: 1 })
109
+ log.append('runtime', 'toolResult', 'T1', { ok: 2 })
110
+ log.append('plugin', 'toolResult', 'T1', { id: 'tc9', error: 'authorization-denied', granted: true })
111
+ log.append('plugin', 'toolResult', 'T1', { id: 'tc9', result: 'final' })
112
+ const proj = log.deriveMessages().filter(m => m.role === 'toolResult')
113
+ assert.equal(proj.length, 3, '无 id 两条原样保留 + 同 id 收敛为最后一条')
114
+ assert.deepEqual(proj[0]!.content, { ok: 1 })
115
+ assert.deepEqual(proj[1]!.content, { ok: 2 })
116
+ assert.deepEqual(proj[2]!.content, { id: 'tc9', result: 'final' })
117
+ // 前缀投影(upTo)语义:前缀内 last-wins 独立成立
118
+ const half = log.deriveMessages(3).filter(m => m.role === 'toolResult')
119
+ assert.equal(half.length, 3, '前缀 [0,3):无 id 两条 + tc9 授权记录(前缀内 tc9 授权记录即终态)')
120
+ assert.deepEqual(half[2]!.content, { id: 'tc9', error: 'authorization-denied', granted: true })
121
+ })
122
+
123
+ test('S37: openai-compat 出站体——收敛后单 tool 消息(重复 tool_call_id 出清)', async () => {
124
+ const log = new SessionLog('s37-openai')
125
+ log.append('user', 'user', 'T1', '查天气')
126
+ log.append('model', 'toolCall', 'T1', { id: 'tc1', tool: 'weather', args: { city: '北京' } })
127
+ log.append('runtime', 'toolResult', 'T1', { id: 'tc1', error: 'authorization-denied', granted: true, mode: 'confirm' })
128
+ log.append('plugin', 'toolResult', 'T1', { id: 'tc1', result: '晴 25 度' })
129
+ const seen: RequestInit[] = []
130
+ const adapter = createOpenAICompatAdapter({
131
+ baseUrl: 'https://api.test',
132
+ fetchImpl: (async (_u: string | URL, init?: RequestInit) => { seen.push(init!); return sseResponse(openaiSse('stop')) }) as typeof fetch,
133
+ })
134
+ for await (const _ of adapter.chat({ model: 'm', messages: toLlmMessages(log), tools: [] })) void _
135
+ const body = JSON.parse(seen[0]!.body as string)
136
+ const toolMsgs = body.messages.filter((m: { role: string }) => m.role === 'tool')
137
+ assert.equal(toolMsgs.length, 1, '1.6-GO-5:出站体单 tool 消息(收敛前为两条)')
138
+ assert.equal(toolMsgs[0].tool_call_id, 'tc1')
139
+ assert.equal(toolMsgs[0].content, JSON.stringify('晴 25 度'), '内容 = 执行结果(非授权中间记录)')
140
+ })
141
+
142
+ test('S37: anthropic 出站体——收敛后单 tool_result 块(重复 tool_use_id 400 面出清)', async () => {
143
+ const log = new SessionLog('s37-anthropic')
144
+ log.append('user', 'user', 'T1', '查天气')
145
+ log.append('model', 'toolCall', 'T1', { id: 'toolu_1', tool: 'weather', args: { city: '北京' } })
146
+ log.append('runtime', 'toolResult', 'T1', { id: 'toolu_1', error: 'authorization-denied', granted: true, mode: 'confirm' })
147
+ log.append('plugin', 'toolResult', 'T1', { id: 'toolu_1', result: '晴 25 度' })
148
+ const seen: RequestInit[] = []
149
+ const adapter = createAnthropicAdapter({
150
+ baseUrl: 'https://api.test',
151
+ fetchImpl: (async (_u: string | URL, init?: RequestInit) => { seen.push(init!); return sseResponse(anthropicSse(ANTHROPIC_STOP)) }) as typeof fetch,
152
+ })
153
+ for await (const _ of adapter.chat({ model: 'm', messages: toLlmMessages(log), tools: [] })) void _
154
+ const body = JSON.parse(seen[0]!.body as string)
155
+ const blocks = body.messages.flatMap((m: { content: unknown }) =>
156
+ Array.isArray(m.content) ? m.content : []).filter((b: { type: string }) => b.type === 'tool_result')
157
+ assert.equal(blocks.length, 1, '1.6-GO-5:出站体单 tool_result 块(重复 tool_use_id 会被 provider 400)')
158
+ assert.equal(blocks[0].tool_use_id, 'toolu_1')
159
+ assert.equal(blocks[0].content, JSON.stringify('晴 25 度'))
160
+ assert.ok(!('is_error' in blocks[0]), '执行结果非错误形态')
161
+ })
@@ -0,0 +1,116 @@
1
+ /**
2
+ * 1.6-S3 · jiti 载体(S38):car.config.ts 经 jiti 自包含转译(D-24)
3
+ *
4
+ * 覆盖:
5
+ * - 非可擦除语法(enum)真载——1.5 D-22 原生类型剥离不可表达的 transform-only 形态,
6
+ * erasable-only 预检登记项随之出清(被取代)
7
+ * - strip-only spawn 差分(1.6-GO-5 生产调用点):`--experimental-strip-types`(无 transform)
8
+ * 下 enum 配置经 jiti 真载成功 + 原生 import 同文件失败(负控——证明语法确属非可擦除、
9
+ * jiti 是载体);同进程实测首载时延(登记口径,无硬门禁,CI 宽上界防抖)
10
+ * - CLI 真路径:car run 于 enum 配置目录——配置经 jiti 装载生效(CAR-E-LLM-CONFIG 引导 =
11
+ * 配置解析通过、模型未配;若配置载体失败应为 CAR-E-CONFIG)
12
+ * - s35 存量零改动全量回归 = 载体迁移验收线(interoptDefault 显式关:jiti 缺省 true 会把
13
+ * 无 default 模块的 exports 整体当 default,「缺 default export」校验即失效)
14
+ */
15
+ import { test } from 'node:test'
16
+ import assert from 'node:assert/strict'
17
+ import { spawn } from 'node:child_process'
18
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { join, dirname } from 'node:path'
21
+ import { fileURLToPath, pathToFileURL } from 'node:url'
22
+ import { warmCarConfig, loadCarConfig } from '../src/load/config.ts'
23
+
24
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
25
+ const SRC_CONFIG = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'load', 'config.ts')
26
+
27
+ function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
28
+ const dir = mkdtempSync(join(tmpdir(), `car-s38-${name}-`))
29
+ try {
30
+ const r = fn(dir)
31
+ const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
32
+ if (r instanceof Promise) return r.finally(cleanup)
33
+ cleanup()
34
+ return Promise.resolve()
35
+ } catch (e) {
36
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
37
+ throw e
38
+ }
39
+ }
40
+
41
+ test('S38: 非可擦除语法(enum)经 jiti 真载——D-22 原生类型剥离不可表达形态', async () => {
42
+ await withTempDir('enum', async dir => {
43
+ writeFileSync(join(dir, 'car.config.ts'), [
44
+ `export enum LlmMode { full = 'full-model' }`,
45
+ `export default { llm: { model: LlmMode.full } }`,
46
+ ].join('\n'))
47
+ await warmCarConfig({ cwd: dir })
48
+ const r = loadCarConfig({ cwd: dir })
49
+ assert.equal(r.error, undefined)
50
+ assert.equal(r.config.llm?.model, 'full-model', 'enum 值经 jiti 转译进配置')
51
+ })
52
+ })
53
+
54
+ test('S38: strip-only spawn 差分——原生 import enum 载体失败(负控)+ jiti 真载成功 + 首载时延实测', async () => {
55
+ await withTempDir('strip', async dir => {
56
+ const configPath = join(dir, 'car.config.ts')
57
+ writeFileSync(configPath, [
58
+ `export enum LlmMode { full = 'strip-model' }`,
59
+ `export default { llm: { model: LlmMode.full } }`,
60
+ ].join('\n'))
61
+ // 夹具自身仅可擦除语法(strip-only 可载);非可擦除只出现在被测配置里
62
+ const fixture = join(dir, 'fixture.ts')
63
+ writeFileSync(fixture, [
64
+ `import { warmCarConfig, loadCarConfig } from ${JSON.stringify(pathToFileURL(SRC_CONFIG).href)}`,
65
+ `import { pathToFileURL as toURL } from 'node:url'`,
66
+ `const configPath = process.argv[2]!`,
67
+ `let nativeError = ''`,
68
+ `try { await import(toURL(configPath).href) } catch (e) { nativeError = String((e as Error).message) }`,
69
+ `if (!nativeError) { console.log('NATIVE_UNEXPECTEDLY_OK'); process.exit(1) }`,
70
+ `const t0 = performance.now()`,
71
+ `await warmCarConfig({ explicitPath: configPath })`,
72
+ `const elapsed = Math.round(performance.now() - t0)`,
73
+ `const r = loadCarConfig({ explicitPath: configPath })`,
74
+ `if (r.error) { console.log('JITI_FAIL ' + r.error); process.exit(1) }`,
75
+ `console.log('OK model=' + r.config.llm?.model + ' firstLoadMs=' + elapsed + ' nativeRejected=' + nativeError.slice(0, 60).replace(/\\n/g, ' '))`,
76
+ ].join('\n'))
77
+ // --experimental-strip-types:仅类型剥离无转译(22.19+ 可用;enum 属 transform-only 形态)
78
+ const { code, stdout } = await new Promise<{ code: number | null; stdout: string }>(resolve => {
79
+ const child = spawn(process.execPath, ['--experimental-strip-types', fixture, configPath], { stdio: ['ignore', 'pipe', 'pipe'] })
80
+ let out = ''
81
+ child.stdout.on('data', d => { out += d })
82
+ child.on('exit', c => resolve({ code: c, stdout: out }))
83
+ })
84
+ assert.equal(code, 0, 'strip-only 环境下 jiti 真载 enum 配置')
85
+ const m = /OK model=(\S+) firstLoadMs=(\d+) nativeRejected=(.+)/.exec(stdout.trim())!
86
+ assert.equal(m[1], 'strip-model')
87
+ assert.ok(m[2] !== undefined, '首载时延实测出值')
88
+ console.log(` [登记] car.config.ts jiti 首载时延(spawn 冷启,本机实跑):${m[2]} ms`)
89
+ assert.ok(Number(m[2]) < 15_000, '首载时延宽上界(CI 防抖;真实数值进收口报告)')
90
+ assert.ok(m[3]!.length > 0, '负控:原生 import 对非可擦除语法显式失败——jiti 是载体而非原生剥离')
91
+ })
92
+ })
93
+
94
+ test('S38: CLI 真路径——car run 于 enum 配置目录:配置经 jiti 装载生效(模型未配引导而非配置报错)', async () => {
95
+ await withTempDir('cli', async dir => {
96
+ writeFileSync(join(dir, 'car.config.ts'), [
97
+ `export enum LlmMode { full = 'full-model' }`,
98
+ `export default { llm: { model: LlmMode.full } }`,
99
+ ].join('\n'))
100
+ const plugin = join(dir, 'noop.ts')
101
+ writeFileSync(plugin, [
102
+ `export const manifest = { name: "s38p", version: "1.0.0" }`,
103
+ `export default function apply(api) { api.registerTool({ name: "noop", declaredSideEffect: "readonly", run: async () => "ok" }) }`,
104
+ ].join('\n'))
105
+ const { code, output } = await new Promise<{ code: number | null; output: string }>(resolve => {
106
+ const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', plugin], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] })
107
+ let out = ''
108
+ child.stdout.on('data', d => { out += d })
109
+ child.stderr.on('data', d => { out += d })
110
+ child.on('exit', c => resolve({ code: c, output: out }))
111
+ })
112
+ assert.equal(code, 2, 'D-13:无凭据/端点 → exit 2 显式引导')
113
+ assert.match(output, /CAR-E-LLM-CONFIG/, '配置经 jiti 解析通过(enum 值进 llm.model)走到模型就绪检查')
114
+ assert.doesNotMatch(output, /CAR-E-CONFIG/, '配置载体本身零错误')
115
+ })
116
+ })