@lqc123qwe/car-runtime 1.0.0-rc.1

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 (54) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/.github/workflows/release.yml +21 -0
  3. package/README.md +122 -0
  4. package/adr/ADR-001.md +4 -0
  5. package/adr/ADR-002.md +5 -0
  6. package/adr/ADR-003.md +35 -0
  7. package/adr/host-onboarding.md +68 -0
  8. package/adr/mlps-audit-checklist.md +33 -0
  9. package/adr/win32-spike.md +45 -0
  10. package/package.json +25 -0
  11. package/scripts/ptc-baseline/run-baseline.ts +118 -0
  12. package/scripts/release-pipeline.ts +189 -0
  13. package/scripts/win32-koffi-spike.ts +34 -0
  14. package/src/authz/authz.ts +96 -0
  15. package/src/cli.ts +122 -0
  16. package/src/governance/dump.ts +41 -0
  17. package/src/host/facade.ts +99 -0
  18. package/src/host/hostGateway.ts +98 -0
  19. package/src/host/mappings.ts +74 -0
  20. package/src/host/stdio.ts +74 -0
  21. package/src/kernel/context.ts +208 -0
  22. package/src/kernel/events.ts +99 -0
  23. package/src/load/loader.ts +165 -0
  24. package/src/load/registry.ts +88 -0
  25. package/src/load/verifier.ts +67 -0
  26. package/src/loop/goal.ts +53 -0
  27. package/src/loop/recover.ts +47 -0
  28. package/src/loop/stop.ts +200 -0
  29. package/src/mcp/gateway.ts +118 -0
  30. package/src/ptc/budget.ts +37 -0
  31. package/src/ptc/erasable.ts +28 -0
  32. package/src/ptc/runCode.ts +127 -0
  33. package/src/ptc/sdk.ts +28 -0
  34. package/src/ptc/worker-entry.ts +67 -0
  35. package/src/sandbox/sandbox.ts +199 -0
  36. package/src/security/secrets.ts +111 -0
  37. package/src/session/export.ts +110 -0
  38. package/src/session/log.ts +120 -0
  39. package/src/telemetry/metrics.ts +38 -0
  40. package/test/kernel.spec.ts +136 -0
  41. package/test/s11.spec.ts +129 -0
  42. package/test/s12.spec.ts +135 -0
  43. package/test/s13.spec.ts +117 -0
  44. package/test/s14.spec.ts +117 -0
  45. package/test/s15.spec.ts +80 -0
  46. package/test/s17.spec.ts +102 -0
  47. package/test/s18.spec.ts +72 -0
  48. package/test/s2.spec.ts +245 -0
  49. package/test/s3.spec.ts +179 -0
  50. package/test/s4.spec.ts +169 -0
  51. package/test/s5.spec.ts +95 -0
  52. package/test/s6.spec.ts +169 -0
  53. package/test/s7.spec.ts +135 -0
  54. package/test/s9.spec.ts +89 -0
@@ -0,0 +1,245 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { declareEvent, EventBus } from '../src/kernel/events.ts'
4
+ import { runTurn, type ModelStep, type ToolDef, type ToolCall } from '../src/loop/stop.ts'
5
+ import { SessionLog } from '../src/session/log.ts'
6
+ import { mountPlugin, parseManifest } from '../src/load/loader.ts'
7
+ import { writeFileSync, rmSync } from 'node:fs'
8
+
9
+ // ==================== F7 事件分发契约 ====================
10
+
11
+ test('F7: 未声明事件的订阅 = 启动期静态报错(US-7 AC2)', () => {
12
+ const bus = new EventBus()
13
+ assert.throws(() => bus.on('undeclared/event', () => {}), /undeclared event/)
14
+ })
15
+
16
+ test('F7: bail 模式——首断即止(短路是设计意图)', async () => {
17
+ declareEvent('guard/check', 'bail')
18
+ const bus = new EventBus()
19
+ const calls: string[] = []
20
+ bus.on('guard/check', () => { calls.push('h1'); return 'BLOCK' })
21
+ bus.on('guard/check', () => { calls.push('h2'); return 'BLOCK2' })
22
+ const r = await bus.dispatch('guard/check', {})
23
+ assert.equal(r, 'BLOCK')
24
+ assert.deepEqual(calls, ['h1'])
25
+ })
26
+
27
+ test('F7: serial 模式——有序检查点链式传值', async () => {
28
+ declareEvent('ctx/enrich', 'serial')
29
+ const bus = new EventBus()
30
+ bus.on('ctx/enrich', (_p: unknown, next: () => string) => next() + '!A')
31
+ bus.on('ctx/enrich', (_p: unknown, next: () => string) => next() + '!B')
32
+ assert.equal(await bus.dispatch<string>('ctx/enrich', 'x', () => 'base'), 'base!A!B')
33
+ })
34
+
35
+ test('F7: waterfall 模式——环绕中间件(next() 前后均可介入)', async () => {
36
+ declareEvent('tools/around', 'waterfall')
37
+ const bus = new EventBus()
38
+ const trace: string[] = []
39
+ bus.on('tools/around', async (_p: unknown, next: () => Promise<string>) => {
40
+ trace.push('before')
41
+ const r = await next()
42
+ trace.push('after')
43
+ return r.toUpperCase()
44
+ })
45
+ bus.on('tools/around', () => Promise.resolve('raw'))
46
+ const r = await bus.dispatch<string>('tools/around', {})
47
+ assert.equal(r, 'RAW')
48
+ assert.deepEqual(trace, ['before', 'after'])
49
+ })
50
+
51
+ test('F7: parallel 模式扇出聚合;emit 模式广播无返回', async () => {
52
+ declareEvent('audit/fanout', 'parallel')
53
+ declareEvent('lifecycle/notify', 'emit')
54
+ const bus = new EventBus()
55
+ bus.on('audit/fanout', (p: number) => p * 2)
56
+ bus.on('audit/fanout', (p: number) => p * 3)
57
+ assert.deepEqual(await bus.dispatch<number, number>('audit/fanout', 5), [10, 15])
58
+ const seen: number[] = []
59
+ bus.on('lifecycle/notify', () => { seen.push(1) })
60
+ assert.equal(await bus.dispatch('lifecycle/notify', {}), undefined)
61
+ assert.deepEqual(seen, [1])
62
+ })
63
+
64
+ test('F7: 契约重复声明且模式不一致 = 显式报错', () => {
65
+ declareEvent('dup/e', 'serial')
66
+ assert.throws(() => declareEvent('dup/e', 'bail'), /re-declared/)
67
+ })
68
+
69
+ // ==================== F5 停止语义(ADR-001) ====================
70
+
71
+ test('F5 AC1: 工具批次 → step null 续走,无异常', async () => {
72
+ const log = new SessionLog()
73
+ let n = 0
74
+ const tools = new Map([['t', { declaredSideEffect: 'readonly', run: async () => 'ok' } as ToolDef]])
75
+ const r = await runTurn({
76
+ log, turnId: 'T', tools,
77
+ model: async (): Promise<ModelStep> => n++ === 0
78
+ ? { stopReason: 'toolUse', toolCalls: [{ id: 'c1', tool: 't', args: {} }] }
79
+ : { stopReason: 'stop', text: 'done' },
80
+ })
81
+ assert.equal(r.reason, 'completed')
82
+ const kinds = log.events.map(e => e.kind)
83
+ assert.ok(kinds.includes('toolCall') && kinds.includes('toolResult'))
84
+ })
85
+
86
+ test('F5 AC2: step1 max-tokens + steering 续跑 → turn/end 保留 max-tokens(不被冲销)', async () => {
87
+ const log = new SessionLog()
88
+ let n = 0
89
+ const r = await runTurn({
90
+ log, turnId: 'T',
91
+ model: async (): Promise<ModelStep> => n++ === 0 ? { stopReason: 'length' } : { stopReason: 'stop', text: 'recovered' },
92
+ })
93
+ assert.equal(r.reason, 'max-tokens')
94
+ const end = log.events.find(e => e.kind === 'turnEnd') as any
95
+ assert.equal(end.meta.reason, 'max-tokens')
96
+ })
97
+
98
+ test('F5 AC3: 截断响应含写工具 → 解析前收口(无副作用落地)', async () => {
99
+ const log = new SessionLog()
100
+ let executed = false
101
+ const tools = new Map([['writeFile', { declaredSideEffect: 'write', run: async () => { executed = true } } as ToolDef]])
102
+ const r = await runTurn({
103
+ log, turnId: 'T', tools,
104
+ model: async (): Promise<ModelStep> => ({ stopReason: 'length', truncatedTools: [{ id: 'c1', tool: 'writeFile' }] }),
105
+ })
106
+ assert.equal(r.reason, 'max-tokens')
107
+ assert.equal(executed, false)
108
+ })
109
+
110
+ test('F5 AC4: 只读白名单 + retryReadOnly → 补错重试(Pi 式)', async () => {
111
+ const log = new SessionLog()
112
+ let n = 0
113
+ const tools = new Map([['search', { declaredSideEffect: 'readonly', run: async () => 'results' } as ToolDef]])
114
+ const r = await runTurn({
115
+ log, turnId: 'T', tools,
116
+ preset: { maxTokensRetryReadOnly: true },
117
+ model: async (): Promise<ModelStep> => {
118
+ if (n++ === 0) return { stopReason: 'length', truncatedTools: [{ id: 'c1', tool: 'search' }] }
119
+ return { stopReason: 'stop', text: 'done' }
120
+ },
121
+ })
122
+ // I4 一致性:即便只读重试续走,turn/end 仍保留 max-tokens(与 AC2 同语义)
123
+ assert.equal(r.reason, 'max-tokens')
124
+ assert.equal(r.steps, 2, '重试后续走了一个 step')
125
+ const err = log.events.find(e => e.kind === 'toolResult' && JSON.stringify((e.payload as any)?.error || '').includes('truncated'))
126
+ assert.ok(err, '只读工具收到截断错误结果')
127
+ })
128
+
129
+ test('F5: 取消 → 未派发调用补记成对事件,reason=aborted(日志无缺口)', async () => {
130
+ const log = new SessionLog()
131
+ const signal = { aborted: false }
132
+ let i = 0
133
+ const tools = new Map([['t1', { declaredSideEffect: 'readonly', run: async () => { if (++i === 1) signal.aborted = true; return 'x' } } as ToolDef]])
134
+ const r = await runTurn({
135
+ log, turnId: 'T', tools, signal,
136
+ model: async (): Promise<ModelStep> => ({ stopReason: 'toolUse', toolCalls: [
137
+ { id: 'c1', tool: 't1', args: {} }, { id: 'c2', tool: 't1', args: {} },
138
+ ] }),
139
+ })
140
+ assert.equal(r.reason, 'aborted')
141
+ // 日志无缺口:c2 的 toolCall 与合成错误 toolResult 成对存在
142
+ const c2call = log.events.some(e => e.kind === 'toolCall' && (e.payload as any).id === 'c2')
143
+ const c2result = log.events.some(e => e.kind === 'toolResult' && (e.payload as any).id === 'c2' && (e.payload as any).error === 'aborted-before-dispatch')
144
+ assert.ok(c2call && c2result, '未派发调用补记成对事件')
145
+ })
146
+
147
+ test('F5: concludesTurn=OR——任一工具收口即 completed', async () => {
148
+ const log = new SessionLog()
149
+ const tools = new Map([
150
+ ['commit', { declaredSideEffect: 'write', concludesTurn: true, run: async () => 'committed' } as ToolDef],
151
+ ['probe', { declaredSideEffect: 'readonly', run: async () => 'p' } as ToolDef],
152
+ ])
153
+ const r = await runTurn({
154
+ log, turnId: 'T', tools, preset: { mode: 'full' },
155
+ model: async (): Promise<ModelStep> => ({ stopReason: 'toolUse', toolCalls: [
156
+ { id: 'c1', tool: 'probe', args: {} }, { id: 'c2', tool: 'commit', args: {} },
157
+ ] }),
158
+ })
159
+ assert.equal(r.reason, 'completed')
160
+ })
161
+
162
+ test('F5: terminate=AND——整批全部 terminate 才 aborted;权限门拒绝写操作', async () => {
163
+ const log = new SessionLog()
164
+ const tools = new Map([
165
+ ['danger', { declaredSideEffect: 'write', terminate: true, run: async () => 'never' } as ToolDef],
166
+ ])
167
+ let authorizeCalled = false
168
+ let n = 0
169
+ const r = await runTurn({
170
+ log, turnId: 'T', tools,
171
+ preset: { mode: 'confirm', authorize: async () => { authorizeCalled = true; return false } },
172
+ model: async (): Promise<ModelStep> => n++ === 0
173
+ ? { stopReason: 'toolUse', toolCalls: [{ id: 'c1', tool: 'danger', args: {} }] }
174
+ : { stopReason: 'stop', text: 'acknowledged' },
175
+ })
176
+ assert.equal(r.reason, 'completed') // 单工具被拒后无收口信号 → 模型继续 → stop
177
+ assert.equal(authorizeCalled, true)
178
+ const denied = log.events.some(e => e.kind === 'toolResult' && (e.payload as any)?.error === 'authorization-denied')
179
+ assert.ok(denied, '拒绝记录落日志')
180
+ })
181
+
182
+ // ==================== F6 装配层(Stub/invalidate/manifest) ====================
183
+
184
+ test('F6: manifest 校验——version 非精确 semver = 冲突链报错', () => {
185
+ assert.throws(() => parseManifest({ name: 'p', version: '^1.0.0' }, 'plugin.ts'), /exact semver.*plugin\.ts/)
186
+ assert.throws(() => parseManifest({ version: '1.0.0' }, 'plugin.ts'), /"name" is required/)
187
+ assert.throws(() => parseManifest({ name: 'p', version: '1.0.0', peerPolicyOverride: { relaxed: true } as any }, 'm.ts'), /reason.*required/)
188
+ const ok = parseManifest({ name: 'p', version: '1.0.0' }, 'm.ts')
189
+ assert.equal(ok.name, 'p')
190
+ })
191
+
192
+ test('F6: Stub 占位——bindCore 前调用显式抛错,bind 后可用;invalidate 后再访问报错', async () => {
193
+ const file = new URL('./fixture-plugin.ts', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
194
+ writeFileSync(file, `export default function apply(api) {\n api.registerTool({ name: 'tool-v1', run: async () => 'v1' })\n}\n`)
195
+ try {
196
+ const p = await mountPlugin({ file, manifest: { name: 'hot', version: '1.0.0' } })
197
+ // 注册类动作 Stub 期可用(收集待注册项);能力查询 bind 前显式抛错
198
+ assert.throws(() => p.api.getRegisteredTools(), /CAR-STUB/)
199
+ const hostTools: Array<{ name: string }> = []
200
+ p.bindCore({
201
+ registerTool: (t) => { hostTools.push(t) },
202
+ getRegisteredTools: () => hostTools,
203
+ })
204
+ // bind 后冲刷 pending 注册
205
+ assert.deepEqual(p.api.getRegisteredTools().map((t) => t.name), ['tool-v1'])
206
+ assert.deepEqual(hostTools.map((t) => t.name), ['tool-v1'])
207
+ p.invalidate()
208
+ assert.throws(() => p.api.getRegisteredTools(), /CAR-INVALIDATED/)
209
+ } finally { try { rmSync(file, { force: true }) } catch {} }
210
+ })
211
+
212
+ test('F6: 热重载——同文件 epoch 击穿缓存,新代码生效 + 旧句柄失效', async () => {
213
+ const file = new URL('./fixture-reload.ts', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
214
+ let p1: Awaited<ReturnType<typeof mountPlugin>> | undefined
215
+ try {
216
+ writeFileSync(file, `export default function apply(api) { api.registerTool({ name: 'tool-v1', run: async () => 1 }) }\n`)
217
+ p1 = await mountPlugin({ file, manifest: { name: 'r', version: '1.0.0' } })
218
+ p1.bindCore({
219
+ registerTool(t: { name: string }) {},
220
+ getRegisteredTools: () => [{ name: 'tool-v1' }],
221
+ })
222
+ assert.deepEqual(p1.api.getRegisteredTools(), [{ name: 'tool-v1' }])
223
+ // 重写源文件 → epoch+1 重载
224
+ writeFileSync(file, `export default function apply(api) { api.registerTool({ name: 'tool-v2', run: async () => 2 }) }\n`)
225
+ const p2 = await mountPlugin({ file, manifest: { name: 'r', version: '1.0.1' }, reloadEpoch: 1 })
226
+ p2.bindCore({
227
+ registerTool(t: { name: string }) {},
228
+ getRegisteredTools: () => [{ name: 'tool-v2' }],
229
+ })
230
+ assert.deepEqual(p2.api.getRegisteredTools(), [{ name: 'tool-v2' }])
231
+ p1.invalidate()
232
+ assert.throws(() => p1!.api.getRegisteredTools(), /CAR-INVALIDATED/)
233
+ } finally {
234
+ try { rmSync(file, { force: true }) } catch {}
235
+ void p1
236
+ }
237
+ })
238
+
239
+ test('F6: 非工厂默认导出 = 冲突链报错', async () => {
240
+ const file = new URL('./fixture-bad.ts', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
241
+ writeFileSync(file, `export default 42\n`)
242
+ try {
243
+ await assert.rejects(() => mountPlugin({ file, manifest: { name: 'b', version: '1.0.0' } }), /default-export a factory/)
244
+ } finally { try { rmSync(file, { force: true }) } catch {} }
245
+ })
@@ -0,0 +1,179 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { probeCapabilities, SandboxExecutor } from '../src/sandbox/sandbox.ts'
4
+ import { AuthzService, checkCapability } from '../src/authz/authz.ts'
5
+ import { McpGateway, containsPlaintextCredential, type McpTransport, type JsonRpcRequest, type JsonRpcResponse } from '../src/mcp/gateway.ts'
6
+ import { SessionLog } from '../src/session/log.ts'
7
+
8
+ // ==================== F3 沙箱:探测/降级/Q-04 约束 ====================
9
+
10
+ test('F3: Windows 平台探测 → 显式降级(BD-01,经中间确认② Linux 先行)', async () => {
11
+ const probe = await probeCapabilities({ platform: 'win32' })
12
+ assert.equal(probe.degraded, true)
13
+ assert.ok(probe.reason)
14
+ })
15
+
16
+ test('F3: Linux 无 landlock-run 二进制 → 探测失败降级(容器环境同路径)', async () => {
17
+ const probe = await probeCapabilities({ platform: 'linux' })
18
+ assert.equal(probe.degraded, true)
19
+ assert.match(probe.reason!, /landlock-run/)
20
+ })
21
+
22
+ test('F3 Q-04: 降级态 env-read 一律拒绝(DENIED,不进审批)', async () => {
23
+ const events: any[] = []
24
+ const exec = new SandboxExecutor({
25
+ probe: { platform: 'win32', landlock: false, seccomp: false, degraded: true, reason: 'test' },
26
+ audit: (e) => events.push(e),
27
+ workspace: process.cwd(),
28
+ })
29
+ const r = await exec.exec({ argv: ['node', '-e', '1'], capabilities: ['env-read'], mode: 'workspace-write', permissionMode: 'full' })
30
+ assert.equal(r.ok, false)
31
+ assert.match(r.denied!, /env-read denied in degraded/)
32
+ assert.ok(events.some(e => e.kind === 'sandbox-denied'))
33
+ })
34
+
35
+ test('F3 Q-04: 降级态写操作强制确认模式(full 亦受限;拒绝即留痕)', async () => {
36
+ const events: any[] = []
37
+ const exec = new SandboxExecutor({
38
+ probe: { platform: 'win32', landlock: false, seccomp: false, degraded: true, reason: 'test' },
39
+ audit: (e) => events.push(e),
40
+ workspace: process.cwd(),
41
+ })
42
+ const r = await exec.exec({
43
+ argv: ['node', '-e', 'console.log(1)'], capabilities: ['exec'], mode: 'workspace-write',
44
+ permissionMode: 'full', // full 在降级态受限为 confirm,且无审批回调 → 默认拒绝
45
+ })
46
+ assert.equal(r.ok, false)
47
+ assert.match(r.denied!, /authorization-denied/)
48
+ assert.ok(events.some(e => e.kind === 'sandbox-denied' && e.detail.degraded === true))
49
+ })
50
+
51
+ test('F3 Q-04: 降级态 confirm + 审批放行 → 可执行且留痕 degraded=true', async () => {
52
+ const events: any[] = []
53
+ const exec = new SandboxExecutor({
54
+ probe: { platform: 'win32', landlock: false, seccomp: false, degraded: true, reason: 'test' },
55
+ audit: (e) => events.push(e),
56
+ workspace: process.cwd(),
57
+ })
58
+ const r = await exec.exec({
59
+ argv: ['node', '-e', 'console.log("hi")'], capabilities: ['exec'], mode: 'workspace-write',
60
+ permissionMode: 'full', authorize: async () => true,
61
+ })
62
+ assert.equal(r.ok, true)
63
+ assert.equal(r.degraded, true)
64
+ assert.ok(events.some(e => e.kind === 'sandbox-exec' && e.detail.degraded === true))
65
+ })
66
+
67
+ // ==================== F4 授权服务 ====================
68
+
69
+ test('F4: 能力标签 deny-by-default(未声明 = 无能力,T-22)', () => {
70
+ assert.equal(checkCapability({}, 'fs-write'), false)
71
+ assert.equal(checkCapability({ capabilities: ['net'] }, 'fs-write'), false)
72
+ assert.equal(checkCapability({ capabilities: ['net'] }, 'net'), true)
73
+ })
74
+
75
+ test('F4: 幂等——同 authorizationId 返回同一决策(T-10 防重放不一致)', async () => {
76
+ const log = new SessionLog()
77
+ const authz = new AuthzService({ log, timeoutMs: 200 })
78
+ const req = { authorizationId: 'A-1', actor: 'plugin-x', capability: 'exec' as const, resource: '/ws' }
79
+ const d1 = await authz.decideByUser(req, async () => true)
80
+ const d2 = await authz.decideByUser(req, async () => false) // 重放:返回缓存决策
81
+ assert.equal(d1.decision, 'APPROVED')
82
+ assert.equal(d2.decision, 'APPROVED')
83
+ assert.equal(d2, d1)
84
+ })
85
+
86
+ test('F4: 超时默认拒绝(timeout-default-deny,EXPIRED)', async () => {
87
+ const log = new SessionLog()
88
+ const authz = new AuthzService({ log, timeoutMs: 50 })
89
+ const d = await authz.decideByUser(
90
+ { authorizationId: 'A-2', actor: 'p', capability: 'fs-write', resource: '/ws' },
91
+ () => new Promise<boolean>(() => {}), // 永不回应
92
+ )
93
+ assert.equal(d.decision, 'EXPIRED')
94
+ assert.equal(d.decidedBy, 'timeout')
95
+ })
96
+
97
+ test('F4: 授权决策 100% 落审计日志(US6 凭据链)', async () => {
98
+ const log = new SessionLog()
99
+ const authz = new AuthzService({ log, timeoutMs: 100 })
100
+ await authz.decideByPolicy({ authorizationId: 'A-3', actor: 'p', capability: 'net', resource: 'https://x' }, 'readonly')
101
+ const audit = log.events.find(e => e.turnId === 'authz:A-3')
102
+ assert.ok(audit)
103
+ assert.equal((audit!.meta as any)?.reason.includes('denied in readonly mode'), true)
104
+ })
105
+
106
+ // ==================== F8 MCP 桥接网关 ====================
107
+
108
+ function mockTransport(script: Map<string, unknown>, failAfter?: number): McpTransport & { calls: number } {
109
+ let calls = 0
110
+ return {
111
+ calls: 0,
112
+ async send(req: JsonRpcRequest): Promise<JsonRpcResponse> {
113
+ calls++
114
+ if (failAfter !== undefined && calls > failAfter) throw new Error('EPIPE: process crashed')
115
+ const method = req.method
116
+ if (method === 'tools/list') {
117
+ return { jsonrpc: '2.0', id: req.id, result: { tools: [
118
+ { name: 'py_search', description: 'python tool', sideEffect: 'readonly' },
119
+ { name: 'py_write', description: 'python writer' }, // 未声明 → write 最高约束
120
+ ] } }
121
+ }
122
+ if (method === 'tools/call') return { jsonrpc: '2.0', id: req.id, result: { content: 'py-result' } }
123
+ throw new Error('unknown method ' + method)
124
+ },
125
+ alive() { return true },
126
+ close() {},
127
+ }
128
+ }
129
+
130
+ test('F8: 登记后工具进入能力矩阵;未声明 sideEffect 按 write 最高约束(T-22)', async () => {
131
+ const gw = new McpGateway()
132
+ const tools = await gw.register({ serverId: 'py1', transport: mockTransport(new Map()) })
133
+ assert.deepEqual(tools.map(t => t.name).sort(), ['py_search', 'py_write'])
134
+ const matrix = gw.listTools()
135
+ assert.equal(matrix.find(t => t.name === 'py_search')!.declaredSideEffect, 'readonly')
136
+ assert.equal(matrix.find(t => t.name === 'py_write')!.declaredSideEffect, 'write')
137
+ })
138
+
139
+ test('F8: 未登记连接拒绝(A050001)+ 重复登记显式报错', async () => {
140
+ const gw = new McpGateway()
141
+ // 未登记 serverId 调用 = 显式拒绝(登记制)
142
+ await assert.rejects(() => gw.callTool('ghost', 'any_tool', {}), /A050001/)
143
+ const t = mockTransport(new Map())
144
+ await gw.register({ serverId: 'py1', transport: t })
145
+ await assert.rejects(() => gw.register({ serverId: 'py1', transport: t }), /already registered/)
146
+ })
147
+
148
+ test('F8: 调用走网关 + 崩溃 → BD-02 标记不可用(错误结果不抛异常,主链路不崩)', async () => {
149
+ const gw = new McpGateway()
150
+ const t = mockTransport(new Map(), 2) // tools/list 占 1 次,tools/call 起崩溃 // 第 2 次调用起崩溃
151
+ await gw.register({ serverId: 'py1', transport: t })
152
+ const ok1 = await gw.callTool('py1', 'py_search', {})
153
+ assert.equal(ok1.ok, true)
154
+ const fail = await gw.callTool('py1', 'py_search', {})
155
+ assert.equal(fail.ok, false)
156
+ assert.match(fail.error!, /BD-02/)
157
+ // 后续调用:直接不可用(不再触碰崩溃通道),返回显式错误结果
158
+ const fail2 = await gw.callTool('py1', 'py_search', {})
159
+ assert.match(fail2.error!, /unavailable/)
160
+ })
161
+
162
+ test('F8: 明文凭据 env = 拒绝登记(§3.2.5 传参禁忌)', () => {
163
+ assert.match(containsPlaintextCredential({ OPENAI_KEY: 'sk-abc123' })!, /OPENAI_KEY/)
164
+ assert.equal(containsPlaintextCredential({ LOG_LEVEL: 'info' }), null)
165
+ })
166
+
167
+ test('F8: mcp-call 权限一致性——写类 MCP 工具在 confirm 模式触发审批(无旁路,US-6 AC3)', async () => {
168
+ const gw = new McpGateway()
169
+ const log = new SessionLog()
170
+ const authz = new AuthzService({ log, timeoutMs: 100 })
171
+ await gw.register({ serverId: 'py1', transport: mockTransport(new Map()) })
172
+ const def = gw.listTools().find(t => t.name === 'py_write')!
173
+ // 未声明 → write → readonly 模式下 policy 直接 DENIED
174
+ const d = authz.decideByPolicy(
175
+ { authorizationId: 'A-M1', actor: 'model', capability: 'mcp', resource: `py1:${def.name}` },
176
+ 'readonly',
177
+ )
178
+ assert.equal(d.decision, 'DENIED')
179
+ })
@@ -0,0 +1,169 @@
1
+ /**
2
+ * S4 · 端到端串联(E2E)+ N2 基线实测 + Q-06 性能定标
3
+ *
4
+ * E2E 链路(对齐高层架构 §5.3 业务闭环五环节):
5
+ * 触发 → 装配加载(F9+F6)→ 沙箱内事件运行(F7+F4+F3 降级约束)→ 停止收口(F5/ADR-001)
6
+ * → 日志落盘(F2 哈希链)→ 审计回放(deriveMessages + verifyChain + 基础导出)
7
+ * Q-06 定标:装配时延 / 分发时延 / 日志 1 万事件追加+回放+校验耗时
8
+ */
9
+ import { test } from 'node:test'
10
+ import assert from 'node:assert/strict'
11
+ import { writeFileSync, mkdtempSync, rmSync, readFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { Context } from '../src/kernel/context.ts'
15
+ import { declareEvent, EventBus } from '../src/kernel/events.ts'
16
+ import { SessionLog, loadSessionLog } from '../src/session/log.ts'
17
+ import { runTurn } from '../src/loop/stop.ts'
18
+ import { mountPlugin } from '../src/load/loader.ts'
19
+ import { probeCapabilities, SandboxExecutor } from '../src/sandbox/sandbox.ts'
20
+ import { McpGateway, type McpTransport, type JsonRpcRequest, type JsonRpcResponse } from '../src/mcp/gateway.ts'
21
+ import { AuthzService } from '../src/authz/authz.ts'
22
+
23
+ const cleanup = (p: string) => { try { rmSync(p, { force: true, recursive: true }) } catch {} }
24
+
25
+ test('E2E:五环节全链路(装配→运行→收口→落盘→回放+MCP 桥接+授权留痕)', async () => {
26
+ const dir = mkdtempSync(join(tmpdir(), 'car-e2e-'))
27
+ try {
28
+ // —— 触发:插件文件(免编译 TS)——
29
+ const pluginFile = join(dir, 'demo-plugin.ts')
30
+ writeFileSync(pluginFile, `export default function apply(api) {\n api.registerTool({ name: 'e2e_tool', run: async (args) => 'e2e-' + args.tag })\n}\n`)
31
+ const manifest = { name: 'demo', version: '1.0.0' }
32
+
33
+ // —— 环节 1:装配加载(F9 两阶段 + F6 免编译 + F1 依赖推导)——
34
+ const t0 = Date.now()
35
+ const plugin = await mountPlugin({ file: pluginFile, manifest })
36
+ const ctx = new Context()
37
+ const log = new SessionLog('S-E2E')
38
+ const authz = new AuthzService({ log, timeoutMs: 100 })
39
+ declareEvent('tools/around', 'waterfall')
40
+ const bus = new EventBus()
41
+ let auditTrail = ''
42
+ bus.on('tools/around', async (_p, next) => { const r = await next(); auditTrail = 'wrapped:' + r; return r })
43
+ bus.on('tools/around', () => Promise.resolve('e2e-ok')) // 下游结果提供者
44
+ const hostTools: any[] = []
45
+ ctx.plugin({
46
+ name: 'demo',
47
+ apply: (c) => {
48
+ // 插件文件工厂已在 mount 时经 Stub 收集注册;bindCore 冲刷进宿主(勿重复注册)
49
+ plugin.bindCore({ registerTool: (t) => { hostTools.push(t); c.provide('tool:e2e_tool', t) } })
50
+ },
51
+ })
52
+ const mountMs = Date.now() - t0
53
+ assert.ok(hostTools.some(t => t.name === 'e2e_tool'), '装配:插件工具注册进能力矩阵')
54
+
55
+ // —— 环节 2:事件运行(MCP 桥接同审批门 + waterfall 环绕 + 权限门)——
56
+ const gw = new McpGateway()
57
+ const rpc: McpTransport = {
58
+ async send(req: JsonRpcRequest): Promise<JsonRpcResponse> {
59
+ if (req.method === 'tools/list') return { jsonrpc: '2.0', id: req.id, result: { tools: [{ name: 'py_tool', sideEffect: 'readonly' }] } }
60
+ if (req.method === 'tools/call') return { jsonrpc: '2.0', id: req.id, result: { content: 'py-ok' } }
61
+ throw new Error('unreachable')
62
+ },
63
+ alive: () => true, close() {},
64
+ }
65
+ await gw.register({ serverId: 'py1', transport: rpc })
66
+ const mcpResult = await gw.callTool('py1', 'py_tool', {})
67
+ assert.equal(mcpResult.ok, true)
68
+ log.append('user', 'user', 'T0', 'run e2e')
69
+ log.snapshotModelRequest()
70
+ log.append('model', 'toolCall', 'T0', { id: 'm1', tool: 'py_tool', args: {} })
71
+ log.append('plugin', 'toolResult', 'T0', { id: 'm1', result: mcpResult.result })
72
+ // 权限门:write 类走 confirm(授权留痕)
73
+ const decision = await authz.decideByUser(
74
+ { authorizationId: 'A-E2E-1', actor: 'demo', capability: 'fs-write', resource: join(dir, 'out.txt') },
75
+ async () => true,
76
+ )
77
+ assert.equal(decision.decision, 'APPROVED')
78
+
79
+ // —— 环节 3:停止收口(F5/ADR-001)——
80
+ const probe = await probeCapabilities()
81
+ const sandbox = new SandboxExecutor({ probe, audit: () => {}, workspace: dir })
82
+ assert.equal(sandbox.degraded, process.platform !== 'linux') // Windows 开发态必然降级
83
+ const exec = await sandbox.exec({ argv: ['node', '-e', 'console.log("s")'], capabilities: ['exec'], mode: 'workspace-write', permissionMode: 'confirm', authorize: async () => true })
84
+ assert.equal(exec.ok, true)
85
+ const tools = new Map([['e2e_tool', { declaredSideEffect: 'write', run: hostTools.find(t => t.name === 'e2e_tool')!.run } as any]])
86
+ let n = 0
87
+ const turn = await runTurn({
88
+ log, turnId: 'T0', tools, preset: { mode: 'full' },
89
+ model: async (): Promise<any> => n++ === 0
90
+ ? { stopReason: 'toolUse', toolCalls: [{ id: 'c1', tool: 'e2e_tool', args: { tag: 'ok' } }] }
91
+ : { stopReason: 'stop', text: 'final' },
92
+ })
93
+ assert.equal(turn.reason, 'completed')
94
+ // waterfall 环绕实证:分发 → before → next() → after → 结果可改写
95
+ const wrapped = await bus.dispatch<string>('tools/around', {}, () => 'e2e-ok')
96
+ assert.equal(wrapped, 'e2e-ok') // 下游结果透传;环绕 trail 已记录
97
+ assert.deepEqual(auditTrail, 'wrapped:e2e-ok') // handler 侧记录 before/after 顺序
98
+
99
+ // —— 环节 4:日志落盘(F2 哈希链 + N1 断言)——
100
+ const jsonl = join(dir, 'session.jsonl')
101
+ writeFileSync(jsonl, log.exportJSONL())
102
+ assert.equal(log.assertModelVisibleLogged().ok, true)
103
+
104
+ // —— 环节 5:审计回放(只读加载 + 断链校验 + 投影)——
105
+ const { log: reloaded, brokenAt } = loadSessionLog(jsonl)
106
+ assert.equal(brokenAt, null)
107
+ const replayed = reloaded.deriveMessages()
108
+ assert.ok(replayed.length >= 4)
109
+ assert.deepEqual(replayed[0], { role: 'user', content: 'run e2e' })
110
+ // 授权凭据链可回放(US6:谁在何时调用了什么、凭什么)
111
+ assert.ok(reloaded.events.some(e => e.turnId === 'authz:A-E2E-1'))
112
+ void mountMs
113
+ } finally { cleanup(dir) }
114
+ })
115
+
116
+ test('N2: 首插件跑通耗时实测(目标 ≤300s,实际秒级)', async () => {
117
+ const dir = mkdtempSync(join(tmpdir(), 'car-n2-'))
118
+ try {
119
+ const t0 = Date.now()
120
+ const f = join(dir, 'first.ts')
121
+ writeFileSync(f, `export default function apply(api) { api.registerTool({ name: 'first', run: async () => 42 }) }\n`)
122
+ const p = await mountPlugin({ file: f, manifest: { name: 'first', version: '1.0.0' } })
123
+ let got: unknown
124
+ p.bindCore({ registerTool: async (t) => { got = await t.run({}) } })
125
+ p.api.getRegisteredTools // 能力查询(bind 后可用)
126
+ const ctx = new Context()
127
+ ctx.plugin({ name: 'first', apply: (c) => c.effect(() => async () => {}, 'noop') })
128
+ await ctx.disposeRuntime()
129
+ const wall = Date.now() - t0
130
+ assert.equal(got, 42)
131
+ assert.ok(wall < 300_000, `实测 ${wall}ms`)
132
+ console.log(` [N2 实测] 首插件跑通 = ${wall}ms(含免编译加载+装配+执行+卸载)`)
133
+ } finally { cleanup(dir) }
134
+ })
135
+
136
+ test('Q-06: 性能定标——装配 / 分发 / 日志 1 万事件', async () => {
137
+ // 装配时延:20 插件依赖图
138
+ const ctx = new Context()
139
+ const t0 = Date.now()
140
+ for (let i = 0; i < 20; i++) {
141
+ ctx.plugin({ name: 'p' + i, apply: (c) => c.provide('svc' + i, { i }) })
142
+ }
143
+ for (let i = 0; i < 20; i++) {
144
+ ctx.plugin({ name: 'c' + i, inject: ['svc' + i], apply: () => {} })
145
+ }
146
+ const mountMs = Date.now() - t0
147
+ // 分发时延:serial 1000 次
148
+ declareEvent('bench/e', 'serial')
149
+ const bus = new EventBus()
150
+ bus.on('bench/e', (_p: unknown, next: () => number) => next() + 1)
151
+ const t1 = Date.now()
152
+ for (let i = 0; i < 1000; i++) await bus.dispatch<number>('bench/e', i, () => i)
153
+ const dispatchMs = Date.now() - t1
154
+ // 日志:1 万事件 追加+哈希链+回放+校验
155
+ const log = new SessionLog('S-BENCH')
156
+ const t2 = Date.now()
157
+ for (let i = 0; i < 10_000; i++) log.append('model', 'assistant', 'TB', 'x' + i)
158
+ const appendMs = Date.now() - t2
159
+ const t3 = Date.now()
160
+ const replayed = log.deriveMessages()
161
+ const chainOk = log.verifyChain() === null
162
+ const replayMs = Date.now() - t3
163
+ assert.equal(replayed.length, 10_000)
164
+ assert.equal(chainOk, true)
165
+ const row = { mountMs20Plugins: mountMs, dispatch1000SerialMs: dispatchMs, append10kMs: appendMs, replayAndVerify10kMs: replayMs }
166
+ console.log(' [Q-06 定标]', JSON.stringify(row))
167
+ // 保守上限断言(CI 波动容差):装配 <2s、千次分发 <5s、万事件追加 <10s、回放校验 <10s
168
+ assert.ok(mountMs < 2000 && dispatchMs < 5000 && appendMs < 10_000 && replayMs < 10_000)
169
+ })