@lqc123qwe/car-runtime 1.4.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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 1.5-S3 · 配置完整形态(S35):car.config.ts + zod(D-22)
3
+ *
4
+ * 覆盖:
5
+ * - zod 校验层 JSON 键位兼容(存量锚点):未知键 fail-visible(llm.temperature)/ 类型错
6
+ * (llm.maxTokens 必须为正整数)/ sandbox 扁平渲染 / 坏 JSON / 顶层非对象
7
+ * - car.config.ts 载体:发现序 ts > json(并存 = ts 胜出)/ 显式 --config *.ts /
8
+ * 未预热显式报错(禁静默回退 json)/ epoch 缓存击穿(热改重预热取新内容)
9
+ * - TS 形态校验:缺 default export / 非 object default / 坏 TS(Node 不支持时引导文案)
10
+ * - mcp url/headers 键位经 zod(s34 锚点:恰好其一 / http(s) URL / 通道专属键)
11
+ */
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { loadCarConfig, warmCarConfig } from '../src/load/config.ts'
18
+ import { mergeSignatureGate, mergeLlmConfig } from '../src/load/config.ts'
19
+
20
+ function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
21
+ const dir = mkdtempSync(join(tmpdir(), `car-s35-${name}-`))
22
+ try {
23
+ const r = fn(dir)
24
+ const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
25
+ if (r instanceof Promise) return r.finally(cleanup)
26
+ cleanup()
27
+ return Promise.resolve()
28
+ } catch (e) {
29
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
30
+ throw e
31
+ }
32
+ }
33
+
34
+ // ==================== zod 校验层(JSON 键位兼容) ====================
35
+
36
+ test('S35: zod 校验层——存量锚点全过(未知键/类型错/坏 JSON/顶层非对象/扁平渲染)', () => {
37
+ withTempDir('zod-compat', dir => {
38
+ // s31 锚点:未知键 llm.temperature / maxTokens 类型错
39
+ writeFileSync(join(dir, 'a.json'), JSON.stringify({ llm: { temperature: 0.7 } }))
40
+ assert.match(loadCarConfig({ explicitPath: join(dir, 'a.json') }).error!, /未知键 llm\.temperature/)
41
+ writeFileSync(join(dir, 'b.json'), JSON.stringify({ llm: { maxTokens: 1.5 } }))
42
+ assert.match(loadCarConfig({ explicitPath: join(dir, 'b.json') }).error!, /llm\.maxTokens 必须为正整数/)
43
+ // 坏 JSON / 顶层非对象(CAR-E-CONFIG 前缀)
44
+ writeFileSync(join(dir, 'c.json'), '{ sandbox: {')
45
+ assert.match(loadCarConfig({ explicitPath: join(dir, 'c.json') }).error!, /CAR-E-CONFIG/)
46
+ writeFileSync(join(dir, 'd.json'), '[]')
47
+ assert.match(loadCarConfig({ explicitPath: join(dir, 'd.json') }).error!, /CAR-E-CONFIG/)
48
+ // 未知顶层键 / 未知嵌套键(unrecognized_keys 渲染)
49
+ writeFileSync(join(dir, 'e.json'), JSON.stringify({ plugins: [] }))
50
+ assert.match(loadCarConfig({ explicitPath: join(dir, 'e.json') }).error!, /未知顶层键 "plugins"/)
51
+ writeFileSync(join(dir, 'f.json'), JSON.stringify({ sandbox: { unsigned: { allow: true, extra: 1 } } }))
52
+ assert.match(loadCarConfig({ explicitPath: join(dir, 'f.json') }).error!, /未知键 sandbox\.unsigned\.extra/)
53
+ // 有效配置:扁平渲染(mergeSignatureGate 消费形态不变,s26 锚点)
54
+ writeFileSync(join(dir, 'g.json'), JSON.stringify({ sandbox: { unsigned: { allow: true }, sig: { enforce: true, trustRoot: 'PUBKEY' } }, llm: { baseUrl: 'https://x/v1', model: 'm' } }))
55
+ const ok = loadCarConfig({ explicitPath: join(dir, 'g.json') })
56
+ assert.equal(ok.error, undefined)
57
+ assert.deepEqual(ok.config.sandbox, { unsignedAllow: true, sigEnforce: true, sigTrustRoot: 'PUBKEY' })
58
+ assert.deepEqual(ok.config.llm, { baseUrl: 'https://x/v1', model: 'm' })
59
+ // mcp url/headers 键位经 zod(s34 锚点)
60
+ writeFileSync(join(dir, 'h.json'), JSON.stringify({ mcp: { servers: { a: { command: 'node', url: 'https://x' } } } }))
61
+ assert.match(loadCarConfig({ explicitPath: join(dir, 'h.json') }).error!, /恰好其一/)
62
+ writeFileSync(join(dir, 'i.json'), JSON.stringify({ mcp: { servers: { a: { command: 'node', headers: { h: 'v' } } } } }))
63
+ assert.match(loadCarConfig({ explicitPath: join(dir, 'i.json') }).error!, /headers 仅远程通道/)
64
+ writeFileSync(join(dir, 'j.json'), JSON.stringify({ mcp: { servers: { a: { url: 'https://x/mcp', headers: { authorization: 'Bearer ${T}' } } } } }))
65
+ const ok2 = loadCarConfig({ explicitPath: join(dir, 'j.json') })
66
+ assert.equal(ok2.error, undefined)
67
+ assert.deepEqual(ok2.config.mcp?.servers?.a, { url: 'https://x/mcp', headers: { authorization: 'Bearer ${T}' } })
68
+ })
69
+ })
70
+
71
+ // ==================== car.config.ts 载体 ====================
72
+
73
+ test('S35: car.config.ts 载体——发现序 ts > json(并存 ts 胜出)+ 显式 --config *.ts + merge 全链', async () => {
74
+ await withTempDir('ts-carrier', async dir => {
75
+ writeFileSync(join(dir, 'car.config.ts'), `export default {\n llm: { baseUrl: 'https://ts.example/v1', model: 'ts-model', maxTokens: 512 },\n mcp: { servers: { remote: { url: 'https://mcp.example/mcp' } } },\n}\n`)
76
+ writeFileSync(join(dir, 'car.config.json'), JSON.stringify({ llm: { baseUrl: 'https://json.example/v1', model: 'json-model' } }))
77
+ await warmCarConfig({ cwd: dir })
78
+ const r = loadCarConfig({ cwd: dir })
79
+ assert.equal(r.error, undefined)
80
+ assert.match(r.path!, /car\.config\.ts$/, '发现序 ts > json:并存时 ts 胜出')
81
+ assert.equal(r.config.llm?.baseUrl, 'https://ts.example/v1')
82
+ assert.deepEqual(r.config.mcp?.servers?.remote, { url: 'https://mcp.example/mcp' }, 'ts 载体携带 1.5 新键位')
83
+ // merge 全链(D-16 优先级)不变
84
+ const merged = mergeLlmConfig({}, r.config)
85
+ assert.equal(merged.model, 'ts-model')
86
+ assert.equal(merged.maxTokens, 512)
87
+ // 显式 --config *.ts
88
+ await warmCarConfig({ explicitPath: join(dir, 'car.config.ts'), cwd: dir })
89
+ const r2 = loadCarConfig({ explicitPath: join(dir, 'car.config.ts'), cwd: dir })
90
+ assert.equal(r2.error, undefined)
91
+ assert.equal(r2.config.llm?.model, 'ts-model')
92
+ // 纯 json 目录不受影响(回归口径:无 ts 载体 = 原行为)
93
+ await withTempDir('json-only', async dir2 => {
94
+ writeFileSync(join(dir2, 'car.config.json'), JSON.stringify({ sandbox: { sig: { enforce: true } } }))
95
+ const r3 = loadCarConfig({ cwd: dir2 })
96
+ assert.equal(r3.error, undefined)
97
+ assert.equal(mergeSignatureGate({}, r3.config).mode, 'enforce')
98
+ })
99
+ })
100
+ })
101
+
102
+ test('S35: car.config.ts 未预热显式报错(禁静默回退 json)+ epoch 缓存击穿(热改重预热取新内容)', async () => {
103
+ await withTempDir('warm-protocol', async dir => {
104
+ writeFileSync(join(dir, 'car.config.ts'), `export default { llm: { model: 'v1' } }\n`)
105
+ writeFileSync(join(dir, 'car.config.json'), JSON.stringify({ llm: { model: 'json-fallback' } }))
106
+ // 未预热:显式报错(不静默回退 json——「配置写了但不生效」漂移面显式化)
107
+ const cold = loadCarConfig({ cwd: dir })
108
+ assert.match(cold.error!, /car\.config\.ts 未预热/)
109
+ assert.match(cold.error!, /warmCarConfig/)
110
+ // 预热后取 ts 内容
111
+ await warmCarConfig({ cwd: dir })
112
+ assert.equal(loadCarConfig({ cwd: dir }).config.llm?.model, 'v1')
113
+ // 热改:同路径重写 → 重预热(epoch 查询串击穿模块缓存)→ 新内容
114
+ writeFileSync(join(dir, 'car.config.ts'), `export default { llm: { model: 'v2' } }\n`)
115
+ await warmCarConfig({ cwd: dir })
116
+ assert.equal(loadCarConfig({ cwd: dir }).config.llm?.model, 'v2', 'epoch 击穿缓存')
117
+ })
118
+ })
119
+
120
+ test('S35: TS 形态校验——缺 default / 非 object default / 坏 TS fail-visible', async () => {
121
+ await withTempDir('ts-shape', async dir => {
122
+ writeFileSync(join(dir, 'car.config.ts'), `export const config = { llm: { model: 'x' } }\n`)
123
+ await warmCarConfig({ cwd: dir })
124
+ assert.match(loadCarConfig({ cwd: dir }).error!, /缺 default export/)
125
+
126
+ await withTempDir('ts-shape2', async dir2 => {
127
+ writeFileSync(join(dir2, 'car.config.ts'), `export default ['not', 'an', 'object']\n`)
128
+ await warmCarConfig({ cwd: dir2 })
129
+ assert.match(loadCarConfig({ cwd: dir2 }).error!, /default export 必须为对象/)
130
+ })
131
+
132
+ await withTempDir('ts-broken', async dir3 => {
133
+ writeFileSync(join(dir3, 'car.config.ts'), `export default { llm: ((( }\n`)
134
+ await warmCarConfig({ cwd: dir3 })
135
+ const r = loadCarConfig({ cwd: dir3 })
136
+ assert.match(r.error!, /CAR-E-CONFIG: car\.config\.ts 载入失败/)
137
+ })
138
+
139
+ await withTempDir('ts-zod-invalid', async dir4 => {
140
+ writeFileSync(join(dir4, 'car.config.ts'), `export default { llm: { temperature: 0.9 } }\n`)
141
+ await warmCarConfig({ cwd: dir4 })
142
+ assert.match(loadCarConfig({ cwd: dir4 }).error!, /未知键 llm\.temperature/, 'ts 载体与 json 同一 zod 校验层')
143
+ })
144
+ })
145
+ })
@@ -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
+ })