@lqc123qwe/car-runtime 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +17 -1
  2. package/dist/SHA256SUMS +1 -0
  3. package/dist/authz/authz.d.ts +46 -0
  4. package/dist/authz/authz.js +76 -0
  5. package/dist/car-runtime-1.11.0.tgz +0 -0
  6. package/dist/car-runtime-1.11.0.tgz.sig.bundle +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +825 -0
  9. package/dist/dx/doctor.d.ts +62 -0
  10. package/dist/dx/doctor.js +127 -0
  11. package/dist/governance/dump.d.ts +47 -0
  12. package/dist/governance/dump.js +17 -0
  13. package/dist/host/facade.d.ts +13 -0
  14. package/dist/host/facade.js +154 -0
  15. package/dist/host/hostGateway.d.ts +117 -0
  16. package/dist/host/hostGateway.js +65 -0
  17. package/dist/host/mappings.d.ts +27 -0
  18. package/dist/host/mappings.js +52 -0
  19. package/dist/host/stdio.d.ts +14 -0
  20. package/dist/host/stdio.js +83 -0
  21. package/dist/kernel/context.d.ts +76 -0
  22. package/dist/kernel/context.js +203 -0
  23. package/dist/kernel/events.d.ts +37 -0
  24. package/dist/kernel/events.js +82 -0
  25. package/dist/load/config.d.ts +79 -0
  26. package/dist/load/config.js +285 -0
  27. package/dist/load/loader.d.ts +72 -0
  28. package/dist/load/loader.js +162 -0
  29. package/dist/load/registry.d.ts +44 -0
  30. package/dist/load/registry.js +58 -0
  31. package/dist/load/report.d.ts +98 -0
  32. package/dist/load/report.js +301 -0
  33. package/dist/load/sigGate.d.ts +22 -0
  34. package/dist/load/sigGate.js +68 -0
  35. package/dist/load/sign.d.ts +25 -0
  36. package/dist/load/sign.js +47 -0
  37. package/dist/load/verifier.d.ts +35 -0
  38. package/dist/load/verifier.js +56 -0
  39. package/dist/loop/goal.d.ts +31 -0
  40. package/dist/loop/goal.js +34 -0
  41. package/dist/loop/recover.d.ts +17 -0
  42. package/dist/loop/recover.js +39 -0
  43. package/dist/loop/stop.d.ts +82 -0
  44. package/dist/loop/stop.js +184 -0
  45. package/dist/mcp/gateway.d.ts +96 -0
  46. package/dist/mcp/gateway.js +69 -0
  47. package/dist/mcp/httpClient.d.ts +27 -0
  48. package/dist/mcp/httpClient.js +196 -0
  49. package/dist/mcp/stdioClient.d.ts +8 -0
  50. package/dist/mcp/stdioClient.js +73 -0
  51. package/dist/ptc/budget.d.ts +21 -0
  52. package/dist/ptc/budget.js +36 -0
  53. package/dist/ptc/erasable.d.ts +14 -0
  54. package/dist/ptc/erasable.js +27 -0
  55. package/dist/ptc/runCode.d.ts +65 -0
  56. package/dist/ptc/runCode.js +140 -0
  57. package/dist/ptc/schemaRender.d.ts +15 -0
  58. package/dist/ptc/schemaRender.js +90 -0
  59. package/dist/ptc/sdk.d.ts +27 -0
  60. package/dist/ptc/sdk.js +23 -0
  61. package/dist/ptc/worker-entry.d.ts +1 -0
  62. package/dist/ptc/worker-entry.js +71 -0
  63. package/dist/release-audit.jsonl +4 -0
  64. package/dist/runtime-core/chatStep.d.ts +43 -0
  65. package/dist/runtime-core/chatStep.js +121 -0
  66. package/dist/runtime-core/credentials.d.ts +49 -0
  67. package/dist/runtime-core/credentials.js +118 -0
  68. package/dist/runtime-core/errors.d.ts +30 -0
  69. package/dist/runtime-core/errors.js +36 -0
  70. package/dist/runtime-core/llm.d.ts +76 -0
  71. package/dist/runtime-core/llm.js +638 -0
  72. package/dist/runtime-core/redaction.d.ts +39 -0
  73. package/dist/runtime-core/redaction.js +79 -0
  74. package/dist/runtime-core/telemetry.d.ts +88 -0
  75. package/dist/runtime-core/telemetry.js +332 -0
  76. package/dist/runtime-core/tools.d.ts +15 -0
  77. package/dist/runtime-core/tools.js +12 -0
  78. package/dist/runtime-core/trace.d.ts +55 -0
  79. package/dist/runtime-core/trace.js +137 -0
  80. package/dist/runtime-core/types.d.ts +104 -0
  81. package/dist/runtime-core/types.js +13 -0
  82. package/dist/sandbox/sandbox.d.ts +68 -0
  83. package/dist/sandbox/sandbox.js +153 -0
  84. package/dist/sbom.json +263 -0
  85. package/dist/seam.d.ts +24 -0
  86. package/dist/seam.js +23 -0
  87. package/dist/security/secrets-baseline.d.ts +43 -0
  88. package/dist/security/secrets-baseline.js +176 -0
  89. package/dist/security/secrets.d.ts +45 -0
  90. package/dist/security/secrets.js +138 -0
  91. package/dist/session/export.d.ts +55 -0
  92. package/dist/session/export.js +81 -0
  93. package/dist/session/fork.d.ts +51 -0
  94. package/dist/session/fork.js +63 -0
  95. package/dist/session/format.d.ts +21 -0
  96. package/dist/session/format.js +49 -0
  97. package/dist/session/indexStore.d.ts +44 -0
  98. package/dist/session/indexStore.js +265 -0
  99. package/dist/session/log.d.ts +79 -0
  100. package/dist/session/log.js +157 -0
  101. package/dist/session/store.d.ts +9 -0
  102. package/dist/session/store.js +37 -0
  103. package/dist/telemetry/metrics.d.ts +25 -0
  104. package/dist/telemetry/metrics.js +26 -0
  105. package/package.json +17 -1
  106. package/src/authz/authz.ts +12 -0
  107. package/src/loop/stop.ts +43 -10
  108. package/src/runtime-core/chatStep.ts +22 -2
  109. package/src/runtime-core/llm.ts +96 -13
  110. package/src/runtime-core/types.ts +22 -1
  111. package/src/seam.ts +24 -0
  112. package/src/session/log.ts +1 -1
  113. package/.gitattributes +0 -11
  114. package/.github/workflows/ci.yml +0 -293
  115. package/.github/workflows/release.yml +0 -54
  116. package/adr/ADR-001.md +0 -4
  117. package/adr/ADR-002.md +0 -5
  118. package/adr/ADR-003.md +0 -35
  119. package/adr/host-onboarding.md +0 -68
  120. package/adr/mlps-audit-checklist.md +0 -41
  121. package/adr/win32-spike.md +0 -45
  122. package/docs/plugin-authoring.md +0 -82
  123. package/native/landlock-run/Makefile +0 -19
  124. package/native/landlock-run/landlock-run.c +0 -162
  125. package/scripts/contract-check.ts +0 -56
  126. package/scripts/invariant-n1.ts +0 -96
  127. package/scripts/package-check.ts +0 -29
  128. package/scripts/ptc-baseline/run-baseline.ts +0 -118
  129. package/scripts/release-artifacts.ts +0 -111
  130. package/scripts/release-pipeline.ts +0 -245
  131. package/scripts/secrets-baseline.ts +0 -33
  132. package/scripts/secrets-scan.ts +0 -56
  133. package/scripts/win32-ffi-debug.ts +0 -143
  134. package/scripts/win32-koffi-spike.ts +0 -537
  135. package/test/contract/eventmap.golden.json +0 -7
  136. package/test/fixtures/hello-plugin.ts +0 -5
  137. package/test/kernel.spec.ts +0 -136
  138. package/test/registry-e2e/run-e2e.ts +0 -213
  139. package/test/s11.spec.ts +0 -129
  140. package/test/s12.spec.ts +0 -135
  141. package/test/s13.spec.ts +0 -117
  142. package/test/s14.spec.ts +0 -117
  143. package/test/s15.spec.ts +0 -80
  144. package/test/s16.spec.ts +0 -133
  145. package/test/s17.spec.ts +0 -102
  146. package/test/s18.spec.ts +0 -81
  147. package/test/s19.spec.ts +0 -138
  148. package/test/s2.spec.ts +0 -245
  149. package/test/s20.spec.ts +0 -241
  150. package/test/s21.spec.ts +0 -231
  151. package/test/s22-m7-store-format.spec.ts +0 -135
  152. package/test/s23-m7-export-index.spec.ts +0 -189
  153. package/test/s24-signature-gate.spec.ts +0 -185
  154. package/test/s25-m8-runtime-core.spec.ts +0 -600
  155. package/test/s26-signature-closeout.spec.ts +0 -349
  156. package/test/s27-host-load-wiring.spec.ts +0 -134
  157. package/test/s28-session-index-followup.spec.ts +0 -236
  158. package/test/s29-otel-producer.spec.ts +0 -252
  159. package/test/s3.spec.ts +0 -179
  160. package/test/s30-release-artifacts.spec.ts +0 -127
  161. package/test/s31-real-model-wiring.spec.ts +0 -329
  162. package/test/s32-telemetry-mcp-ptc.spec.ts +0 -218
  163. package/test/s33-anthropic-adapter.spec.ts +0 -354
  164. package/test/s34-mcp-remote-transport.spec.ts +0 -250
  165. package/test/s35-config-ts-zod.spec.ts +0 -145
  166. package/test/s36-host-tool-surface.spec.ts +0 -250
  167. package/test/s37-projection-dedupe.spec.ts +0 -161
  168. package/test/s38-config-jiti.spec.ts +0 -116
  169. package/test/s39-otlp-span-semantics.spec.ts +0 -100
  170. package/test/s4.spec.ts +0 -169
  171. package/test/s40-metrics-cumulative.spec.ts +0 -67
  172. package/test/s41-mcp-serve-turn-span.spec.ts +0 -148
  173. package/test/s42-traceparent-propagation.spec.ts +0 -415
  174. package/test/s43-remote-http-traceparent.spec.ts +0 -262
  175. package/test/s5.spec.ts +0 -95
  176. package/test/s6.spec.ts +0 -169
  177. package/test/s7.spec.ts +0 -136
  178. package/test/s7b-baseline.spec.ts +0 -20
  179. package/test/s9.spec.ts +0 -89
  180. package/test/sandbox-escape/j17-orchestrate.sh +0 -30
  181. package/test/sandbox-escape/run-matrix.sh +0 -98
  182. package/tsconfig.json +0 -18
@@ -1,250 +0,0 @@
1
- /**
2
- * 1.5-S2 · 远程 MCP 传输(S34):Streamable HTTP 客户端(D-21)
3
- *
4
- * 覆盖:
5
- * - 懒 initialize 握手:首次 send 前 initialize → notifications/initialized(恰好一次);
6
- * Mcp-Session-Id 捕获与回传;无会话头 server 容许
7
- * - 响应形态双兼容:application/json 单响应 / text/event-stream 逐事件取本请求 id
8
- * - McpGateway 注册回路:tools/list → 声明面(T-22 缺省 write 不变);tools/call 往返
9
- * - resolveMcpHeaders:${ENV} 插值(值内任意位置)/ env 缺席显式报错 / 字面明文凭据拒绝
10
- * - config 校验:command/url 互斥恰好其一 / url 形态 / 通道专属键(args/env 仅 command、headers 仅 url)
11
- * - spawn CLI 全链:car run --mcp <url server>(1.5-GO-5 生产调用点——真 initialize→tools/list→tools/call)
12
- */
13
- import { test } from 'node:test'
14
- import assert from 'node:assert/strict'
15
- import { spawn } from 'node:child_process'
16
- import { createServer, type Server, type IncomingMessage } from 'node:http'
17
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
18
- import { tmpdir } from 'node:os'
19
- import { join, dirname } from 'node:path'
20
- import { fileURLToPath } from 'node:url'
21
- import { McpGateway, type JsonRpcRequest } from '../src/mcp/gateway.ts'
22
- import { createHttpMcpTransport, resolveMcpHeaders, MCP_PROTOCOL_VERSION } from '../src/mcp/httpClient.ts'
23
- import { loadCarConfig } from '../src/load/config.ts'
24
-
25
- // 密钥扫描口径(generic-secret-assign):夹具凭据经变量注入;值非真实凭据
26
- const TEST_TOKEN = 'test-token-456'
27
- const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
28
-
29
- function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
30
- const dir = mkdtempSync(join(tmpdir(), `car-s34-${name}-`))
31
- try {
32
- const r = fn(dir)
33
- const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
34
- if (r instanceof Promise) return r.finally(cleanup)
35
- cleanup()
36
- return Promise.resolve()
37
- } catch (e) {
38
- try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
39
- throw e
40
- }
41
- }
42
-
43
- interface SeenReq { url: string; method: string; headers: IncomingMessage['headers']; body: any }
44
-
45
- /** 本地假远程 MCP server(Streamable HTTP):记录请求,按 method 分派;tools/list 可选 SSE 形态 */
46
- function fakeMcpHttp(opts: { sseToolsList?: boolean; sessionId?: string | null; rejectAll?: boolean } = {}): Promise<{
47
- server: Server; url: string; seen: SeenReq[]; close: () => Promise<void>
48
- }> {
49
- const seen: SeenReq[] = []
50
- const server = createServer((req, res) => {
51
- let raw = ''
52
- req.on('data', c => { raw += c })
53
- req.on('end', () => {
54
- const body = raw ? JSON.parse(raw) : {}
55
- seen.push({ url: req.url ?? '', method: body.method ?? '', headers: req.headers, body })
56
- if (opts.rejectAll) {
57
- res.writeHead(403, { 'content-type': 'application/json' })
58
- res.end(JSON.stringify({ error: 'forbidden' }))
59
- return
60
- }
61
- const respondJson = (payload: unknown, status = 200, headers: Record<string, string> = {}) => {
62
- res.writeHead(status, { 'content-type': 'application/json', ...headers })
63
- res.end(JSON.stringify(payload))
64
- }
65
- const respondSse = (payload: unknown) => {
66
- res.writeHead(200, { 'content-type': 'text/event-stream' })
67
- res.end(`data: ${JSON.stringify(payload)}\n\n`)
68
- }
69
- if (body.method === 'initialize') {
70
- respondJson({ jsonrpc: '2.0', id: body.id, result: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, serverInfo: { name: 'fake-remote', version: '0.0.0' } } }, 200, opts.sessionId === null ? {} : { 'mcp-session-id': opts.sessionId ?? 'sess-fake-1' })
71
- return
72
- }
73
- if (body.id === undefined) { res.writeHead(202).end(); return } // notification(notifications/initialized)
74
- if (body.method === 'tools/list') {
75
- const result = { tools: [{ name: 'remote_echo', description: '远程回声', inputSchema: { type: 'object', properties: { t: { type: 'string' } } } }] }
76
- opts.sseToolsList ? respondSse({ jsonrpc: '2.0', id: body.id, result }) : respondJson({ jsonrpc: '2.0', id: body.id, result })
77
- return
78
- }
79
- if (body.method === 'tools/call') {
80
- respondJson({ jsonrpc: '2.0', id: body.id, result: { content: [{ type: 'text', text: 'remote-ok:' + JSON.stringify(body.params.arguments) }] } })
81
- return
82
- }
83
- respondJson({ jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'method not found: ' + body.method } }, 404)
84
- })
85
- })
86
- const urlP = new Promise<string>(r => server.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(server.address() as { port: number }).port}`)))
87
- return urlP.then(url => ({ server, url, seen, close: () => new Promise<void>(r => server.close(() => r())) }))
88
- }
89
-
90
- // ==================== 传输客户端 ====================
91
-
92
- test('S34: 懒 initialize 握手——首次 send 前 initialize→initialized 恰好一次 + 会话头捕获回传 + tools/list 注册', async () => {
93
- const { server, url, seen, close } = await fakeMcpHttp()
94
- try {
95
- const transport = createHttpMcpTransport({ url })
96
- const gw = new McpGateway()
97
- const tools = await gw.register({ serverId: 'remote', transport })
98
- assert.equal(tools.length, 1)
99
- assert.equal(tools[0]!.name, 'remote_echo')
100
- assert.equal(tools[0]!.declaredSideEffect, 'write', 'T-22:未声明 sideEffect 注册时强制 write(远程与 stdio 同口径)')
101
- // 请求序:initialize → notifications/initialized → tools/list
102
- assert.deepEqual(seen.map(r => r.method), ['initialize', 'notifications/initialized', 'tools/list'])
103
- assert.equal(seen[0]!.body.params.protocolVersion, MCP_PROTOCOL_VERSION)
104
- assert.equal(seen[0]!.body.params.clientInfo.name, 'car-runtime')
105
- // 会话头:initialize 请求无(尚未捕获)→ 后续请求回传
106
- assert.equal(seen[0]!.headers['mcp-session-id'], undefined)
107
- assert.equal(seen[2]!.headers['mcp-session-id'], 'sess-fake-1')
108
- // accept 双形态声明 + 二次 send 不再握手
109
- assert.match(String(seen[0]!.headers.accept), /application\/json/)
110
- assert.match(String(seen[0]!.headers.accept), /text\/event-stream/)
111
- const r = await gw.callTool('remote', 'remote_echo', { t: 'hi' })
112
- assert.equal(r.ok, true)
113
- assert.deepEqual(seen.map(m => m.method), ['initialize', 'notifications/initialized', 'tools/list', 'tools/call'], 'initialize 恰好一次')
114
- // 声明面=授权面:gateway 声明面含远程工具
115
- assert.equal(gw.listTools().some(t => t.serverId === 'remote' && t.name === 'remote_echo'), true)
116
- transport.close()
117
- } finally {
118
- await close()
119
- }
120
- })
121
-
122
- test('S34: SSE 响应形态——tools/list 以 text/event-stream 回包逐事件取本请求 id;无会话头 server 容许', async () => {
123
- const { server, url, seen, close } = await fakeMcpHttp({ sseToolsList: true, sessionId: null })
124
- try {
125
- const transport = createHttpMcpTransport({ url })
126
- const gw = new McpGateway()
127
- const tools = await gw.register({ serverId: 'r2', transport })
128
- assert.equal(tools.length, 1, 'SSE 流内 tools/list 响应正确解析')
129
- assert.equal(seen[2]!.headers['mcp-session-id'], undefined, '无会话头 server 不回传(容许)')
130
- transport.close()
131
- } finally {
132
- await close()
133
- }
134
- })
135
-
136
- test('S34: closed 传输显式拒绝 + HTTP 错误 fail-visible(阶段名在错误信息)', async () => {
137
- const { server, url, close } = await fakeMcpHttp({ rejectAll: true })
138
- try {
139
- const transport = createHttpMcpTransport({ url })
140
- transport.close()
141
- await assert.rejects(() => transport.send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), /closed/)
142
- const bad = createHttpMcpTransport({ url })
143
- await assert.rejects(() => bad.send({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), /HTTP 403.*initialize/)
144
- bad.close()
145
- } finally {
146
- await close()
147
- }
148
- })
149
-
150
- // ==================== headers 凭据规则(D-21 / CR-05) ====================
151
-
152
- test('S34: resolveMcpHeaders——${ENV} 值内插值 / env 缺席显式报错 / 字面明文凭据拒绝', () => {
153
- const env = { CAR_MCP_TOKEN: TEST_TOKEN }
154
- assert.deepEqual(resolveMcpHeaders({ authorization: `Bearer \${CAR_MCP_TOKEN}` }, env), { authorization: `Bearer ${TEST_TOKEN}` })
155
- assert.deepEqual(resolveMcpHeaders({ 'x-flag': 'plain' }, env), { 'x-flag': 'plain' })
156
- assert.throws(() => resolveMcpHeaders({ authorization: 'Bearer ${CAR_MCP_MISSING}' }, env), /CAR_MCP_MISSING.*未设置/, 'env 缺席 fail-visible')
157
- assert.throws(() => resolveMcpHeaders({ 'x-api-key': `sk-literal-123456789` }, env), /plaintext credential/, '字面 sk- 值拒绝(CR-05 配置明文禁令)')
158
- })
159
-
160
- // ==================== config 校验(command/url 互斥 + 通道专属键) ====================
161
-
162
- test('S34: mcp.servers url/headers 键位——互斥恰好其一 / url 形态 / 通道专属键 fail-visible', () => {
163
- const load = (raw: unknown) => {
164
- const p = join(withTmp(), 'c.json')
165
- writeFileSync(p, JSON.stringify(raw))
166
- return loadCarConfig({ explicitPath: p })
167
- }
168
- const withTmp = () => mkdtempSync(join(tmpdir(), 'car-s34-cfg-'))
169
- // command + url 同给 = 显式报错
170
- assert.match(load({ mcp: { servers: { a: { command: 'node', url: 'https://x.test' } } } }).error!, /恰好其一/)
171
- // 都缺 = 显式报错
172
- assert.match(load({ mcp: { servers: { a: { args: ['x'] } } } }).error!, /恰好其一/)
173
- // url 形态
174
- assert.match(load({ mcp: { servers: { a: { url: 'ftp://x' } } } }).error!, /http\(s\) URL/)
175
- // 通道专属键
176
- assert.match(load({ mcp: { servers: { a: { url: 'https://x.test', args: ['x'] } } } }).error!, /args 仅 stdio 通道/)
177
- assert.match(load({ mcp: { servers: { a: { url: 'https://x.test', env: { K: 'v' } } } } }).error!, /env 仅 stdio 通道/)
178
- assert.match(load({ mcp: { servers: { a: { command: 'node', headers: { h: 'v' } } } } }).error!, /headers 仅远程通道/)
179
- // 有效:url + headers
180
- const ok = load({ mcp: { servers: { a: { url: 'https://x.test/mcp', headers: { authorization: 'Bearer ${T}' } } } } })
181
- assert.equal(ok.error, undefined)
182
- assert.deepEqual(ok.config.mcp?.servers?.a, { url: 'https://x.test/mcp', headers: { authorization: 'Bearer ${T}' } })
183
- // 有效:command(存量键位兼容)
184
- const ok2 = load({ mcp: { servers: { b: { command: 'node', args: ['s.js'] } } } })
185
- assert.equal(ok2.error, undefined)
186
- })
187
-
188
- // ==================== spawn CLI 全链(1.5-GO-5) ====================
189
-
190
- test('S34: spawn CLI 全链——car run --mcp 远程 server(initialize→tools/list→tools/call 进 turn)', async () => {
191
- await withTempDir('cli-remote', async dir => {
192
- const { server, url: mcpUrl, seen, close } = await fakeMcpHttp()
193
- let llmHits = 0
194
- const llm = createServer((req, res) => {
195
- let raw = ''
196
- req.on('data', c => { raw += c })
197
- req.on('end', () => {
198
- void req
199
- const sse = (events: object[]) => {
200
- res.writeHead(200, { 'content-type': 'text/event-stream' })
201
- res.end(events.map(e => `data: ${JSON.stringify(e)}\n\n`).join('') + 'data: [DONE]\n\n')
202
- }
203
- // 请求序驱动:首轮出 tool_calls(MCP 工具名在 tools[0].function.name——openai 出站体),
204
- // 次轮收尾文本;同时校验 MCP 工具确实进了声明面
205
- const n = llmHits++
206
- if (n === 0) {
207
- const body = JSON.parse(raw)
208
- assert.equal(body.tools?.[0]?.function?.name, 'mcp_remote_remote_echo', 'MCP 远程工具进声明面')
209
- sse([
210
- { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'mcp_remote_remote_echo', arguments: '{"t":"hi"}' } }] } }] },
211
- { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
212
- ])
213
- return
214
- }
215
- sse([
216
- { choices: [{ delta: { content: 'remote 调用完成' } }] },
217
- { choices: [{ delta: {}, finish_reason: 'stop' }] },
218
- ])
219
- })
220
- })
221
- await new Promise<void>(r => llm.listen(0, '127.0.0.1', r))
222
- const llmPort = (llm.address() as { port: number }).port
223
- writeFileSync(join(dir, 'empty.ts'), `export const manifest = { name: 'empty', version: '1.0.0' }\nexport default function apply() {}\n`)
224
- writeFileSync(join(dir, 'car.config.json'), JSON.stringify({
225
- llm: { baseUrl: `http://127.0.0.1:${llmPort}/v1`, model: 'm' },
226
- mcp: { servers: { remote: { url: mcpUrl, headers: { authorization: `Bearer \${CAR_MCP_TOKEN}` } } } },
227
- }))
228
- const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'empty.ts',
229
- '--prompt', 'call the remote echo tool with t=hi', '--mcp', 'remote'], {
230
- cwd: dir,
231
- env: { ...process.env, OPENAI_API_KEY: TEST_TOKEN, CAR_ALLOW_ENV_CREDENTIALS: '1', CAR_MCP_TOKEN: TEST_TOKEN },
232
- stdio: ['ignore', 'pipe', 'pipe'],
233
- })
234
- let out = ''
235
- let err = ''
236
- child.stdout.on('data', d => { out += d })
237
- child.stderr.on('data', d => { err += d })
238
- const code = await new Promise<number>(resolve => child.on('exit', c => resolve(c ?? -1)))
239
- server.close()
240
- llm.close()
241
- assert.equal(code, 0, err + out)
242
- assert.match(out, /\[MCP\] remote: 1 工具入声明面/)
243
- assert.match(out, /turnEnd=completed/)
244
- // 远程 server 侧:真握手 + 调用(Authorization 头 ${ENV} 展开)
245
- assert.deepEqual(seen.map(r => r.method), ['initialize', 'notifications/initialized', 'tools/list', 'tools/call'])
246
- assert.equal(seen[3]!.headers.authorization, `Bearer ${TEST_TOKEN}`)
247
- assert.deepEqual(seen[3]!.body.params, { name: 'remote_echo', arguments: { t: 'hi' } })
248
- await close()
249
- })
250
- })
@@ -1,145 +0,0 @@
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
- })
@@ -1,250 +0,0 @@
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
- })