@lqc123qwe/car-runtime 1.4.0 → 1.5.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,354 @@
1
+ /**
2
+ * 1.5-S1 · anthropic 适配器(S33):Messages API 流式 SSE(D-20)
3
+ *
4
+ * 覆盖:
5
+ * - 出站体形状:x-api-key / anthropic-version / system 顶层化(多条 \n\n 连接)/
6
+ * toolResult → user tool_result(连续合并 + is_error)/ assistant tool_use /
7
+ * tools input_schema / max_tokens 必填缺省 4096 + 显式覆盖
8
+ * - stop_reason 映射(D-20 表):end_turn/stop_sequence→stop、max_tokens→length、
9
+ * tool_use→toolUse、refusal→aborted;未映射 fail-visible(raw 附 message)
10
+ * - 流容错:ping/未知事件跳过;流内 error 事件 → error chunk;无 stop_reason → AL-05
11
+ * - chatStep 集成:多 tool_use 块按 index 聚合 → toolUse 两条 ToolCall
12
+ * - 基线同款:TLS 门(D-12a loopback)/ 凭据门 A080001 / 重试退避注入 / pre-aborted 取消零 chunk
13
+ * - spawn E2E:car run --adapter-id anthropic 全链(生产调用点核查——1.5-GO-5)
14
+ */
15
+ import { test } from 'node:test'
16
+ import assert from 'node:assert/strict'
17
+ import { spawn } from 'node:child_process'
18
+ import { createServer } from 'node:http'
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 { createAnthropicAdapter, RuntimeCore, assertTlsOrLoopback } from '../src/runtime-core/llm.ts'
24
+ import { chatStep } from '../src/runtime-core/chatStep.ts'
25
+ import { CredentialService } from '../src/runtime-core/credentials.ts'
26
+ import { CarM8Error } from '../src/runtime-core/errors.ts'
27
+ import type { LlmChunk } from '../src/runtime-core/types.ts'
28
+ import { SessionLog } from '../src/session/log.ts'
29
+ import { Context } from '../src/kernel/context.ts'
30
+ import { runTurn } from '../src/loop/stop.ts'
31
+ import { toToolDefinitions } from '../src/runtime-core/tools.ts'
32
+ import { doctorModelReadiness } from '../src/dx/doctor.ts'
33
+
34
+ // 密钥扫描口径(generic-secret-assign):夹具凭据经变量注入(赋值行无引号字面量);值非真实凭据
35
+ const TEST_KEY = 'test-key-123'
36
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
37
+
38
+ function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
39
+ const dir = mkdtempSync(join(tmpdir(), `car-s33-${name}-`))
40
+ try {
41
+ const r = fn(dir)
42
+ const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
43
+ if (r instanceof Promise) return r.finally(cleanup)
44
+ cleanup()
45
+ return Promise.resolve()
46
+ } catch (e) {
47
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
48
+ throw e
49
+ }
50
+ }
51
+
52
+ function sseResponse(text: string): Response {
53
+ const enc = new TextEncoder()
54
+ const stream = new ReadableStream<Uint8Array>({
55
+ start(c) { c.enqueue(enc.encode(text)); c.close() },
56
+ })
57
+ return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } })
58
+ }
59
+
60
+ const dataLine = (obj: unknown) => `data: ${JSON.stringify(obj)}\n\n`
61
+
62
+ /** anthropic 流脚本 → SSE 文本(无 [DONE] 终结——anthropic 以 message_stop 收口,解析器 data-only 不依赖) */
63
+ const anthropicSse = (events: object[]) => events.map(dataLine).join('')
64
+
65
+ const TEXT_EVENTS = (text: string): object[] => [
66
+ { type: 'message_start', message: { role: 'assistant' } },
67
+ { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
68
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } },
69
+ { type: 'content_block_stop', index: 0 },
70
+ { type: 'message_delta', delta: { stop_reason: 'end_turn' } },
71
+ { type: 'message_stop' },
72
+ ]
73
+
74
+ const TOOLUSE_EVENTS = (blocks: Array<{ id: string; name: string; args: string }>, text?: string): object[] => {
75
+ const out: object[] = [{ type: 'message_start', message: { role: 'assistant' } }]
76
+ if (text) {
77
+ out.push(
78
+ { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
79
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } },
80
+ { type: 'content_block_stop', index: 0 },
81
+ )
82
+ }
83
+ blocks.forEach((b, i) => {
84
+ const idx = text ? i + 1 : i
85
+ out.push(
86
+ { type: 'content_block_start', index: idx, content_block: { type: 'tool_use', id: b.id, name: b.name } },
87
+ { type: 'content_block_delta', index: idx, delta: { type: 'input_json_delta', partial_json: b.args } },
88
+ { type: 'content_block_stop', index: idx },
89
+ )
90
+ })
91
+ out.push({ type: 'message_delta', delta: { stop_reason: 'tool_use' } }, { type: 'message_stop' })
92
+ return out
93
+ }
94
+
95
+ async function drain(it: AsyncIterable<unknown>): Promise<unknown[]> {
96
+ const out: unknown[] = []
97
+ for await (const c of it) out.push(c)
98
+ return out
99
+ }
100
+
101
+ /** 无凭据服务(出站体形状单测用) */
102
+ function adapterOf(fetchImpl: typeof fetch, extra: Record<string, unknown> = {}) {
103
+ return createAnthropicAdapter({ baseUrl: 'https://api.test', fetchImpl, ...extra })
104
+ }
105
+
106
+ // ==================== 出站体形状(D-20) ====================
107
+
108
+ test('S33: anthropic 出站体——x-api-key/anthropic-version 头 + system 顶层化 + tools input_schema + max_tokens 缺省 4096', async () => {
109
+ const seen: Array<{ url: string; init: RequestInit }> = []
110
+ const adapter = adapterOf((async (url: string | URL, init?: RequestInit) => {
111
+ seen.push({ url: String(url), init: init! })
112
+ return sseResponse(anthropicSse(TEXT_EVENTS('ok')))
113
+ }) as typeof fetch)
114
+ await drain(adapter.chat({
115
+ model: 'claude-x',
116
+ messages: [
117
+ { role: 'system', content: '规则一' },
118
+ { role: 'system', content: '规则二' },
119
+ { role: 'user', content: 'hi' },
120
+ ],
121
+ tools: [{ name: 'echo', description: '回声', parameters: { type: 'object', properties: { t: { type: 'string' } } }, declaredSideEffect: 'readonly' }],
122
+ }))
123
+ assert.equal(seen.length, 1)
124
+ const { url, init } = seen[0]!
125
+ assert.ok(url.endsWith('/v1/messages'), '请求路径 {baseUrl}/v1/messages(官方 SDK base_url 同约定)')
126
+ const headers = init.headers as Record<string, string>
127
+ assert.equal(headers['x-api-key'], '', '无凭据服务时空 x-api-key(凭据门在适配器外单测覆盖)')
128
+ assert.equal(headers['anthropic-version'], '2023-06-01')
129
+ const body = JSON.parse(init.body as string)
130
+ assert.equal(body.model, 'claude-x')
131
+ assert.equal(body.stream, true)
132
+ assert.equal(body.max_tokens, 4096, 'D-20:Anthropic max_tokens 必填——req 缺省 4096')
133
+ assert.equal(body.system, '规则一\n\n规则二', 'system 消息提升顶层,多条 \\n\\n 连接')
134
+ assert.ok(!('system' in body.messages[0]), 'system 不再以消息形态出站')
135
+ assert.deepEqual(body.messages, [{ role: 'user', content: 'hi' }])
136
+ assert.deepEqual(body.tools, [{ name: 'echo', description: '回声', input_schema: { type: 'object', properties: { t: { type: 'string' } } } }], 'tools → input_schema;declaredSideEffect 不出站')
137
+ })
138
+
139
+ test('S33: anthropic 出站体——toolResult 合并单 user 多 tool_result + is_error + assistant tool_use + max_tokens 覆盖', async () => {
140
+ const seen: RequestInit[] = []
141
+ const adapter = adapterOf((async (_u: string | URL, init?: RequestInit) => {
142
+ seen.push(init!)
143
+ return sseResponse(anthropicSse(TEXT_EVENTS('done')))
144
+ }) as typeof fetch)
145
+ await drain(adapter.chat({
146
+ model: 'm',
147
+ maxTokens: 777,
148
+ messages: [
149
+ { role: 'user', content: '查天气' },
150
+ { role: 'assistant', content: { toolCall: { id: 'toolu_1', tool: 'weather', args: { city: '北京' } } } },
151
+ { role: 'toolResult', content: { id: 'toolu_1', result: '晴 25 度' } },
152
+ { role: 'toolResult', content: { id: 'toolu_2', error: 'boom' } },
153
+ ],
154
+ tools: [],
155
+ }))
156
+ const body = JSON.parse(seen[0]!.body as string)
157
+ assert.equal(body.max_tokens, 777, '显式 maxTokens 覆盖缺省')
158
+ assert.deepEqual(body.messages, [
159
+ { role: 'user', content: '查天气' },
160
+ { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'weather', input: { city: '北京' } }] },
161
+ { role: 'user', content: [
162
+ { type: 'tool_result', tool_use_id: 'toolu_1', content: '"晴 25 度"' },
163
+ { type: 'tool_result', tool_use_id: 'toolu_2', content: 'boom', is_error: true },
164
+ ] },
165
+ ], '连续 toolResult 合并单 user 多块;error → is_error')
166
+ })
167
+
168
+ // ==================== stop_reason 映射与流容错 ====================
169
+
170
+ test('S33: stop_reason 映射——end_turn→stop / tool_use→toolUse(args 聚合)/ max_tokens→length / refusal→aborted', async () => {
171
+ const mk = (events: object[]) => adapterOf((async () => sseResponse(anthropicSse(events))) as typeof fetch)
172
+ const run = (events: object[]) => drain(mk(events).chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [] }))
173
+
174
+ const stop = await run(TEXT_EVENTS('ok')) as LlmChunk[]
175
+ assert.equal(stop.at(-1)!.finishReason, 'stop')
176
+ assert.ok(stop.every(c => c.finishReason !== 'error'), '成功流零 error chunk')
177
+
178
+ const toolUse = await run(TOOLUSE_EVENTS([{ id: 'toolu_1', name: 'f', args: '{"a":1}' }])) as LlmChunk[]
179
+ assert.equal(toolUse.at(-1)!.finishReason, 'toolUse')
180
+ const startChunk = toolUse.find(c => c.toolCallDelta?.id)!
181
+ const deltaChunk = toolUse.find(c => c.toolCallDelta?.argumentsDelta)!
182
+ assert.equal(startChunk.toolCallDelta!.id, 'toolu_1', 'content_block_start 携带 id/name')
183
+ assert.equal(startChunk.toolCallDelta!.name, 'f')
184
+ assert.equal(deltaChunk.toolCallDelta!.argumentsDelta, '{"a":1}')
185
+
186
+ const len = await run([{ type: 'message_delta', delta: { stop_reason: 'max_tokens' } }]) as LlmChunk[]
187
+ assert.equal(len.at(-1)!.finishReason, 'length')
188
+
189
+ const abort = await run([{ type: 'message_delta', delta: { stop_reason: 'refusal' } }]) as LlmChunk[]
190
+ assert.equal(abort.at(-1)!.finishReason, 'aborted')
191
+
192
+ // stop_sequence 同映射 stop(登记口径)
193
+ const seq = await run([{ type: 'message_delta', delta: { stop_reason: 'stop_sequence' } }]) as LlmChunk[]
194
+ assert.equal(seq.at(-1)!.finishReason, 'stop')
195
+ })
196
+
197
+ test('S33: 未映射 stop_reason fail-visible(pause_turn 等)+ 流内 error 事件 + 无 stop_reason AL-05 兜底', async () => {
198
+ const mk = (events: object[]) => adapterOf((async () => sseResponse(anthropicSse(events))) as typeof fetch)
199
+ const run = (events: object[]) => drain(mk(events).chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [] })) as Promise<LlmChunk[]>
200
+
201
+ const weird = await run([{ type: 'message_delta', delta: { stop_reason: 'pause_turn' } }])
202
+ assert.equal(weird.length, 1, 'fail-visible 单 chunk(sawFinish 命中无二次兜底)')
203
+ assert.equal(weird[0]!.finishReason, 'error')
204
+ assert.match(weird[0]!.error!.message, /pause_turn/, '原始 stop_reason 附 message(不吞没)')
205
+
206
+ const streamErr = await run([{ type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } }])
207
+ assert.equal(streamErr[0]!.finishReason, 'error')
208
+ assert.match(streamErr[0]!.error!.message, /Overloaded/)
209
+
210
+ const noFinish = await run([
211
+ { type: 'content_block_start', index: 0, content_block: { type: 'text' } },
212
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'half' } },
213
+ { type: 'ping' }, // 容错跳过
214
+ { type: 'message_stop' },
215
+ ])
216
+ assert.equal(noFinish.at(-1)!.finishReason, 'error')
217
+ assert.match(noFinish.at(-1)!.error!.message, /AL-05/, '流结束无 stop_reason = AL-05 显式化')
218
+ })
219
+
220
+ // ==================== chatStep 集成 + 基线同款 ====================
221
+
222
+ test('S33: chatStep 集成——RuntimeCore 经 anthropic 适配器多 tool_use 块按 index 聚合为两条 ToolCall', async () => {
223
+ const adapter = adapterOf((async () => sseResponse(anthropicSse(TOOLUSE_EVENTS([
224
+ { id: 'toolu_1', name: 'get_weather', args: '{"city":"北京"}' },
225
+ { id: 'toolu_2', name: 'get_time', args: '{"tz":"UTC"}' },
226
+ ], '查两个')))) as typeof fetch)
227
+ const cred = new CredentialService({ keychainReader: () => null })
228
+ const core = new RuntimeCore(cred)
229
+ core.bindContext(new Context())
230
+ core.registerLlmAdapter(adapter, { default: true })
231
+ const log = new SessionLog('S-s33')
232
+ log.append('user', 'user', 'T0', 'x')
233
+ const r = await chatStep({ core, log, turnId: 'T0', model: 'm', tools: [] })
234
+ assert.equal(r.stopReason, 'toolUse')
235
+ assert.equal(r.text, '查两个')
236
+ assert.deepEqual(r.toolCalls, [
237
+ { id: 'toolu_1', tool: 'get_weather', args: { city: "北京" } },
238
+ { id: 'toolu_2', tool: 'get_time', args: { tz: 'UTC' } },
239
+ ], 'content_block_start id/name + input_json_delta 增量 → index 聚合')
240
+ assert.equal(log.assertModelVisibleLogged().ok, true)
241
+ })
242
+
243
+ test('S33: 基线同款——TLS 门(http 非 loopback 拒绝 / loopback 豁免)/ 凭据门 A080001 / 首块前 5xx 重试 / pre-aborted 零 chunk', async () => {
244
+ assertTlsOrLoopback('https://x.test')
245
+ assert.throws(() => createAnthropicAdapter({ baseUrl: 'http://api.test' }), /CAR-E-LLM-TLS/)
246
+ assert.doesNotThrow(() => createAnthropicAdapter({ baseUrl: 'http://127.0.0.1:9/v1' }), 'D-12a loopback 豁免同款')
247
+
248
+ // 凭据门:keychain 通道缺席 + env fallback 未开 → A080001(CarM8Error 保形)
249
+ const cred = new CredentialService({ keychainReader: () => null })
250
+ const adapter = adapterOf((async () => sseResponse(anthropicSse(TEXT_EVENTS('x')))) as typeof fetch, { credentials: cred })
251
+ await assert.rejects(
252
+ () => drain(adapter.chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [] })),
253
+ (e: unknown) => e instanceof CarM8Error && (e as CarM8Error).code === 'A080001',
254
+ )
255
+
256
+ // 重试:首块前 5xx ×2 → 退避注入可观测 → 第三次成功(streamSsePost 引擎与 openai-compat 共用)
257
+ let attempts = 0
258
+ const sleeps: number[] = []
259
+ const retryAdapter = createAnthropicAdapter({
260
+ baseUrl: 'https://api.test',
261
+ retries: 2, backoffMs: [11, 22],
262
+ sleep: async ms => { sleeps.push(ms) },
263
+ fetchImpl: (async () => {
264
+ attempts++
265
+ if (attempts < 3) return new Response('boom', { status: 500 })
266
+ return sseResponse(anthropicSse(TEXT_EVENTS('ok')))
267
+ }) as typeof fetch,
268
+ })
269
+ const out = await drain(retryAdapter.chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [] })) as LlmChunk[]
270
+ assert.equal(attempts, 3)
271
+ assert.deepEqual(sleeps, [11, 22], '指数退避注入可观测')
272
+ assert.equal(out.at(-1)!.finishReason, 'stop')
273
+
274
+ // 取消(D-19):pre-aborted → 零 fetch 调用零 chunk
275
+ let called = 0
276
+ const cancelAdapter = adapterOf((async () => { called++; return sseResponse(anthropicSse(TEXT_EVENTS('x'))) }) as typeof fetch)
277
+ const chunks = await drain(cancelAdapter.chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [], signal: { aborted: true } }))
278
+ assert.equal(chunks.length, 0)
279
+ assert.equal(called, 0, 'attempt 前取消——请求未发出')
280
+ })
281
+
282
+ test('S33: doctorModelReadiness provider 感知——adapterId anthropic → ANTHROPIC_API_KEY(值不打印)', () => {
283
+ withTempDir('doctor-anthropic', dir => {
284
+ writeFileSync(join(dir, 'car.config.json'), JSON.stringify({ llm: { baseUrl: 'https://api.anthropic.com', model: 'claude-x', adapterId: 'anthropic', allowEnvFallback: true } }))
285
+ const d1 = doctorModelReadiness({ env: { ANTHROPIC_API_KEY: TEST_KEY }, cwd: dir })
286
+ assert.equal(d1.configured, true)
287
+ assert.equal(d1.credential, 'env', 'anthropic provider 探测 ANTHROPIC_API_KEY')
288
+ assert.equal(d1.detail.includes(TEST_KEY), false, '凭据值永不打印')
289
+ // openai adapter 的凭据变量不算 anthropic 就绪
290
+ const d2 = doctorModelReadiness({ env: { OPENAI_API_KEY: TEST_KEY }, cwd: dir })
291
+ assert.equal(d2.credential, 'not-found', 'provider 变量面隔离')
292
+ // 显式 adapter 行在 detail
293
+ assert.match(d1.detail, /adapter=anthropic/)
294
+ })
295
+ })
296
+
297
+ // ==================== 多步真 E2E + spawn 生产调用点(1.5-GO-5) ====================
298
+
299
+ test('S33: spawn CLI 全链——car run --adapter-id anthropic(x-api-key / system 顶层 / tool_result 第二请求)', async () => {
300
+ await withTempDir('cli-anthropic', async dir => {
301
+ const plugin = `export const manifest = { name: 'p', version: '1.0.0' }\n` +
302
+ `export default function apply(api) {\n` +
303
+ ` api.registerTool({ name: 'echo_tool', description: '回声工具', parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, declaredSideEffect: 'readonly', run: async (a) => 'echo:' + a.text })\n` +
304
+ `}\n`
305
+ writeFileSync(join(dir, 'p.ts'), plugin)
306
+ const bodies: any[] = []
307
+ const headers: any[] = []
308
+ let n = 0
309
+ const server = createServer((req, res) => {
310
+ let raw = ''
311
+ req.on('data', c => { raw += c })
312
+ req.on('end', () => {
313
+ bodies.push(JSON.parse(raw))
314
+ headers.push(req.headers)
315
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
316
+ res.end(n++ === 0
317
+ ? anthropicSse(TOOLUSE_EVENTS([{ id: 'toolu_1', name: 'echo_tool', args: '{"text":"hi"}' }]))
318
+ : anthropicSse(TEXT_EVENTS('完成了')))
319
+ })
320
+ })
321
+ await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
322
+ const port = (server.address() as { port: number }).port
323
+ const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'p.ts',
324
+ '--adapter-id', 'anthropic',
325
+ '--prompt', 'call echo_tool with text hi', '--base-url', `http://127.0.0.1:${port}`, '--model', 'claude-cli',
326
+ '--system', '你是 CLI 集成测试助手'], {
327
+ cwd: dir,
328
+ env: { ...process.env, ANTHROPIC_API_KEY: TEST_KEY, CAR_ALLOW_ENV_CREDENTIALS: '1' },
329
+ stdio: ['ignore', 'pipe', 'pipe'],
330
+ })
331
+ let out = ''
332
+ let err = ''
333
+ child.stdout.on('data', d => { out += d })
334
+ child.stderr.on('data', d => { err += d })
335
+ const code = await new Promise<number>(resolve => child.on('exit', c => resolve(c ?? -1)))
336
+ server.close()
337
+ assert.equal(code, 0, err)
338
+ assert.match(out, /turnEnd=completed/)
339
+ // 请求 1:anthropic 出站体(system 顶层 / input_schema / x-api-key 凭据 / 版本头)
340
+ assert.equal(headers[0]['x-api-key'], TEST_KEY, '凭据经 CredentialService→x-api-key')
341
+ assert.equal(headers[0]['anthropic-version'], '2023-06-01')
342
+ assert.equal(bodies[0].system, '你是 CLI 集成测试助手', '--system 落链(system 事件)→ 顶层 system')
343
+ assert.equal(JSON.stringify(bodies[0]).includes(TEST_KEY), false, '凭据值不出站到消息体')
344
+ assert.equal(bodies[0].tools[0].name, 'echo_tool')
345
+ assert.equal(bodies[0].tools[0].input_schema.properties.text.type, 'string')
346
+ // 请求 2:assistant tool_use + user tool_result(D-20 映射回路)
347
+ const asst = bodies[1].messages.find((m: any) => m.role === 'assistant')
348
+ assert.deepEqual(asst.content, [{ type: 'tool_use', id: 'toolu_1', name: 'echo_tool', input: { text: 'hi' } }])
349
+ const tr = bodies[1].messages.find((m: any) => m.role === 'user' && Array.isArray(m.content))
350
+ assert.equal(tr.content[0].type, 'tool_result')
351
+ assert.equal(tr.content[0].tool_use_id, 'toolu_1')
352
+ assert.equal(tr.content[0].content, '"echo:hi"')
353
+ })
354
+ })
@@ -0,0 +1,250 @@
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
+ })