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