@lqc123qwe/car-runtime 1.5.0 → 1.7.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.
- package/README.md +29 -2
- package/package.json +2 -1
- package/scripts/release-pipeline.ts +19 -2
- package/src/cli.ts +36 -7
- package/src/host/facade.ts +44 -7
- package/src/host/hostGateway.ts +2 -1
- package/src/host/stdio.ts +11 -4
- package/src/load/config.ts +18 -13
- package/src/runtime-core/telemetry.ts +45 -4
- package/src/runtime-core/trace.ts +13 -2
- package/src/session/log.ts +22 -4
- package/test/s36-host-tool-surface.spec.ts +250 -0
- package/test/s37-projection-dedupe.spec.ts +161 -0
- package/test/s38-config-jiti.spec.ts +116 -0
- package/test/s39-otlp-span-semantics.spec.ts +100 -0
- package/test/s40-metrics-cumulative.spec.ts +67 -0
- package/test/s41-mcp-serve-turn-span.spec.ts +148 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.6-S2 · 宿主工具面透传(S36):RuntimeFacade toolList/toolCall 实装(D-25)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - toolList:声明面出站(name/description/parameters)+ **declaredSideEffect 不出站**(权限面
|
|
6
|
+
* 只进权限门红线);未装配 = 空面(占位形态兼容)
|
|
7
|
+
* - toolCall 权限门:readonly 执行 / write(显式声明与未声明 T-22 收口)一律 policy 拒绝
|
|
8
|
+
* (CAR-E-HOST-AUTHZ——mcp-serve 无人审回路,deny-by-default)/ 未知工具显式报错 /
|
|
9
|
+
* 工具异常=成对错误结果(US-5 同款)
|
|
10
|
+
* - 成对落链:actor='user'(宿主=用户代理)+ turnId='T0'(会话级能力面,sessionTurn 从 T1
|
|
11
|
+
* 起不冲突)+ id 序列 host-tc-N;verifyChain 全程完整;跨源同名 CAR-E-DUP 由 cli 装配层
|
|
12
|
+
* fail-closed(spawn E2E 断言)
|
|
13
|
+
* - spawn E2E:mcp-serve 真进程 stdio——tools/list 十面不变 + tool_list 真实出插件工具 +
|
|
14
|
+
* tool_call readonly 执行 / write 拒绝 / verify 链完整(1.6-GO-5 生产调用点核查)
|
|
15
|
+
*/
|
|
16
|
+
import { test } from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { spawn } from 'node:child_process'
|
|
19
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
import { join, dirname } from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
import { createRuntimeFacade } from '../src/host/facade.ts'
|
|
24
|
+
import { HostGateway } from '../src/host/hostGateway.ts'
|
|
25
|
+
import { HOST_MAPPINGS, deriveSessionId } from '../src/host/mappings.ts'
|
|
26
|
+
import type { ToolReg } from '../src/load/loader.ts'
|
|
27
|
+
|
|
28
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
|
|
29
|
+
|
|
30
|
+
function makeTools(): Map<string, ToolReg> {
|
|
31
|
+
return new Map([
|
|
32
|
+
['echo', {
|
|
33
|
+
name: 'echo', description: '回声工具', parameters: { type: 'object', properties: { text: { type: 'string' } } },
|
|
34
|
+
declaredSideEffect: 'readonly', run: async (args: Record<string, unknown>) => 'pong:' + String((args as { text?: string }).text ?? ''),
|
|
35
|
+
}],
|
|
36
|
+
['write_file', { name: 'write_file', declaredSideEffect: 'write', run: async () => 'written' }],
|
|
37
|
+
['stealth', { name: 'stealth', run: async () => 'undeclared' }], // 未声明 sideEffect——T-22 收口 write
|
|
38
|
+
])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function gatewayWith(tools?: Map<string, ToolReg>) {
|
|
42
|
+
const facade = createRuntimeFacade({ profiles: new Map(HOST_MAPPINGS.map(h => [h.hostId, h])), ...(tools ? { tools } : {}) })
|
|
43
|
+
const gw = new HostGateway({ facade, audit: () => {} })
|
|
44
|
+
gw.registerHost({ hostId: 'claude-code', profile: HOST_MAPPINGS[0]!, transport: {} as never })
|
|
45
|
+
return { facade, gw }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('S36: toolList 声明面出站——name/description/parameters 齐,declaredSideEffect 权限面不出站', async () => {
|
|
49
|
+
const { gw } = gatewayWith(makeTools())
|
|
50
|
+
await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-list' })
|
|
51
|
+
const r = await gw.handle('claude-code', 'tool_list', {})
|
|
52
|
+
assert.ok(r.ok)
|
|
53
|
+
const tools = (r.result as { tools: Array<Record<string, unknown>> }).tools
|
|
54
|
+
assert.equal(tools.length, 3)
|
|
55
|
+
const echo = tools.find(t => t.name === 'echo')!
|
|
56
|
+
assert.equal(echo.description, '回声工具')
|
|
57
|
+
assert.deepEqual(echo.parameters, { type: 'object', properties: { text: { type: 'string' } } })
|
|
58
|
+
assert.ok(tools.every(t => !('declaredSideEffect' in t)), '权限面不出站红线:sideEffect 信息不经宿主面外泄')
|
|
59
|
+
assert.ok(tools.every(t => !('run' in t)), '执行面不出站')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('S36: toolList 未装配 = 空面(S12 占位形态兼容)', async () => {
|
|
63
|
+
const { gw } = gatewayWith()
|
|
64
|
+
await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-empty' })
|
|
65
|
+
const r = await gw.handle('claude-code', 'tool_list', {})
|
|
66
|
+
assert.ok(r.ok)
|
|
67
|
+
assert.deepEqual((r.result as { tools: unknown[] }).tools, [])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('S36: toolCall readonly 执行——ok + 成对落链(actor=user/turnId=T0/id 序列)+ 链完整', async () => {
|
|
71
|
+
const { gw } = gatewayWith(makeTools())
|
|
72
|
+
const { sessionId } = await (await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-exec' })).result as { sessionId: string }
|
|
73
|
+
// gateway.handle 把 facade 返回值包进 {ok:true, result:<facade envelope>}——双层 ok 语义
|
|
74
|
+
const r1 = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'echo', arguments: { text: 'hi' } })
|
|
75
|
+
assert.ok(r1.ok)
|
|
76
|
+
assert.deepEqual(r1.result, { ok: true, result: 'pong:hi' })
|
|
77
|
+
const r2 = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'echo', arguments: { text: 'again' } })
|
|
78
|
+
assert.deepEqual(r2.result, { ok: true, result: 'pong:again' })
|
|
79
|
+
// 落链核查经 sessionExport(生产消费路径):toolCall actor=user + turnId=T0 + id 序列 host-tc-N
|
|
80
|
+
const exp = await gw.handle('claude-code', 'session_export', { sessionId })
|
|
81
|
+
const bundle = (exp.result as { bundle: { files: Array<{ name: string; content: string }> } }).bundle
|
|
82
|
+
const eventsFile = bundle.files.find(f => f.name.endsWith('events.jsonl'))!
|
|
83
|
+
const events = eventsFile.content.trim().split('\n').map(l => JSON.parse(l)) as Array<{ actor: string; kind: string; turnId: string; payload: { id?: string; tool?: string } }>
|
|
84
|
+
const calls = events.filter(e => e.kind === 'toolCall')
|
|
85
|
+
assert.equal(calls.length, 2)
|
|
86
|
+
assert.ok(calls.every(e => e.actor === 'user' && e.turnId === 'T0'), 'D-25:宿主=用户代理,会话级能力面落 T0(sessionTurn 从 T1 起不冲突)')
|
|
87
|
+
assert.deepEqual(calls.map(e => e.payload.id), ['host-tc-1', 'host-tc-2'])
|
|
88
|
+
const verify = await gw.handle('claude-code', 'session_verify', { sessionId })
|
|
89
|
+
assert.deepEqual((verify.result as { ok: boolean }).ok, true, '工具调用成对落链后哈希链完整')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('S36: toolCall write 类 policy 拒绝(显式声明 + 未声明 T-22 收口双路径)+ 拒绝落链', async () => {
|
|
93
|
+
const { gw } = gatewayWith(makeTools())
|
|
94
|
+
const { sessionId } = await (await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-deny' })).result as { sessionId: string }
|
|
95
|
+
const w = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'write_file' })
|
|
96
|
+
assert.ok(w.ok, 'handle 层 ok(facade 正常返回 envelope)')
|
|
97
|
+
assert.equal((w.result as { ok: boolean }).ok, false, 'facade 层 ok=false')
|
|
98
|
+
assert.match((w.result as { error?: string }).error!, /CAR-E-HOST-AUTHZ/)
|
|
99
|
+
const s = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'stealth' })
|
|
100
|
+
assert.equal((s.result as { ok: boolean }).ok, false, 'T-22:未声明 sideEffect 收口 write → 同样拒绝')
|
|
101
|
+
assert.match((s.result as { error?: string }).error!, /CAR-E-HOST-AUTHZ/)
|
|
102
|
+
// 拒绝也落链(runtime/toolResult authorization-denied)——审计全量留痕
|
|
103
|
+
const replay = await gw.handle('claude-code', 'session_replay', { sessionId })
|
|
104
|
+
const results = ((replay.result as { messages: Array<{ role: string; content?: unknown }> }).messages).filter(m => m.role === 'toolResult')
|
|
105
|
+
const denied = results.filter(m => (m.content as { error?: string }).error === 'authorization-denied')
|
|
106
|
+
assert.equal(denied.length, 2, '两次拒绝各落一条授权拒绝记录(单条即终态——D-23 不收敛)')
|
|
107
|
+
const verify = await gw.handle('claude-code', 'session_verify', { sessionId })
|
|
108
|
+
assert.deepEqual((verify.result as { ok: boolean }).ok, true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('S36: toolCall 未知工具显式报错 + 成对落链;工具异常=成对错误结果(US-5 同款)', async () => {
|
|
112
|
+
const tools = new Map<string, ToolReg>([['boom', { name: 'boom', declaredSideEffect: 'readonly', run: async () => { throw new Error('炸了') } }]])
|
|
113
|
+
const { gw } = gatewayWith(tools)
|
|
114
|
+
const { sessionId } = await (await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-err' })).result as { sessionId: string }
|
|
115
|
+
const unk = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'nope' })
|
|
116
|
+
assert.ok(unk.ok, 'handle 层正常返回')
|
|
117
|
+
assert.equal((unk.result as { ok: boolean }).ok, false, 'facade 层显式拒绝')
|
|
118
|
+
assert.match((unk.result as { error?: string }).error!, /unknown tool "nope"/)
|
|
119
|
+
const err = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'boom', arguments: {} })
|
|
120
|
+
assert.equal((err.result as { ok: boolean }).ok, false)
|
|
121
|
+
assert.match((err.result as { error?: string }).error!, /炸了/)
|
|
122
|
+
const replay = await gw.handle('claude-code', 'session_replay', { sessionId })
|
|
123
|
+
const results = ((replay.result as { messages: Array<{ role: string; content?: unknown }> }).messages).filter(m => m.role === 'toolResult')
|
|
124
|
+
assert.equal((results[0]!.content as { error?: string }).error, 'unknown tool "nope"')
|
|
125
|
+
assert.match((results[1]!.content as { error?: string }).error!, /炸了/, '工具异常=结果非中断(US-5/D5 语义同款)')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('S36: 未装配 tool 面时 toolCall 显式报错(S13 静默占位退役——fail-visible)', async () => {
|
|
129
|
+
const { gw } = gatewayWith()
|
|
130
|
+
const { sessionId } = await (await gw.handle('claude-code', 'session_start', { hostSessionId: 's36-nobridge' })).result as { sessionId: string }
|
|
131
|
+
const r = await gw.handle('claude-code', 'tool_call', { sessionId, tool: 'echo' })
|
|
132
|
+
assert.equal(r.ok, false)
|
|
133
|
+
assert.match(r.error!, /tool 面未装配/)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// ==================== spawn E2E(生产调用点——1.6-GO-5) ====================
|
|
137
|
+
|
|
138
|
+
function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
|
|
139
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s36-${name}-`))
|
|
140
|
+
try {
|
|
141
|
+
const r = fn(dir)
|
|
142
|
+
const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
|
|
143
|
+
if (r instanceof Promise) return r.finally(cleanup)
|
|
144
|
+
cleanup()
|
|
145
|
+
return Promise.resolve()
|
|
146
|
+
} catch (e) {
|
|
147
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
|
|
148
|
+
throw e
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function talk(requests: string[], plugin: string): Promise<{ outs: any[]; stderr: string }> {
|
|
153
|
+
return new Promise((resolve, reject) => {
|
|
154
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve', '--plugin', plugin], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
155
|
+
let out = ''
|
|
156
|
+
let err = ''
|
|
157
|
+
child.stdout.on('data', d => { out += d })
|
|
158
|
+
child.stderr.on('data', d => { err += d })
|
|
159
|
+
child.on('error', reject)
|
|
160
|
+
child.on('exit', () => {
|
|
161
|
+
try { resolve({ outs: out.split('\n').filter(Boolean).map(l => JSON.parse(l)), stderr: err }) } catch (e) { reject(e) }
|
|
162
|
+
})
|
|
163
|
+
for (const r of requests) child.stdin.write(r + '\n')
|
|
164
|
+
child.stdin.end()
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
test('S36: spawn E2E——mcp-serve 真进程 tool_list 透传插件工具面 + tool_call 执行/拒绝 + verify 链完整', async () => {
|
|
169
|
+
await withTempDir('e2e', async dir => {
|
|
170
|
+
const plugin = join(dir, 'p.ts')
|
|
171
|
+
writeFileSync(plugin, [
|
|
172
|
+
'export const manifest = { name: "s36p", version: "1.0.0" }',
|
|
173
|
+
'export default function apply(api) {',
|
|
174
|
+
' api.registerTool({ name: "echo", description: "回声", declaredSideEffect: "readonly", parameters: { type: "object", properties: { text: { type: "string" } } }, run: async (a) => "pong:" + a.text })',
|
|
175
|
+
' api.registerTool({ name: "write_file", declaredSideEffect: "write", run: async () => "written" })',
|
|
176
|
+
'}',
|
|
177
|
+
].join('\n'))
|
|
178
|
+
// 派生 id 进程内预计算(确定性派生——session_start 返回必须一致)
|
|
179
|
+
const sid = deriveSessionId('claude-code', 's36-e2e')
|
|
180
|
+
const { outs } = await talk([
|
|
181
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
|
|
182
|
+
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's36-e2e' } } }),
|
|
183
|
+
JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'tool_list', arguments: {} } }),
|
|
184
|
+
JSON.stringify({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'tool_call', arguments: { sessionId: sid, tool: 'echo', arguments: { text: 'hi' } } } }),
|
|
185
|
+
JSON.stringify({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'tool_call', arguments: { sessionId: sid, tool: 'write_file', arguments: {} } } }),
|
|
186
|
+
JSON.stringify({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'session_verify', arguments: { sessionId: sid } } }),
|
|
187
|
+
JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'session_replay', arguments: { sessionId: sid } } }),
|
|
188
|
+
], plugin)
|
|
189
|
+
assert.equal(outs[0].result.tools.length, 10, 'MCP tools/list 十面不变(宿主能力面与插件工具面分层)')
|
|
190
|
+
assert.equal(JSON.parse(outs[1].result.content[0].text).sessionId, sid, '确定性派生经真进程复核')
|
|
191
|
+
const listed = JSON.parse(outs[2].result.content[0].text) as { tools: Array<Record<string, unknown>> }
|
|
192
|
+
assert.deepEqual(listed.tools.map(t => t.name), ['echo', 'write_file'], 'tool_list 透传插件声明面')
|
|
193
|
+
assert.equal(listed.tools.find(t => t.name === 'echo')!.description, '回声')
|
|
194
|
+
assert.ok(listed.tools.every(t => !('declaredSideEffect' in t)), '权限面不出站经真进程协议面复核')
|
|
195
|
+
assert.deepEqual(JSON.parse(outs[3].result.content[0].text), { ok: true, result: 'pong:hi' }, 'readonly 工具经宿主面真实执行(facade envelope 经 stdio content 透出)')
|
|
196
|
+
const denied = JSON.parse(outs[4].result.content[0].text) as { ok: boolean; error?: string }
|
|
197
|
+
assert.equal(denied.ok, false, 'write 类工具 policy 拒绝(facade envelope——handle 层未抛错)')
|
|
198
|
+
assert.match(denied.error!, /CAR-E-HOST-AUTHZ/)
|
|
199
|
+
assert.equal(JSON.parse(outs[5].result.content[0].text).ok, true, '工具调用成对落链后哈希链完整')
|
|
200
|
+
const msgs = JSON.parse(outs[6].result.content[0].text).messages as Array<{ role: string; content?: unknown; toolCall?: unknown }>
|
|
201
|
+
assert.ok(msgs.some(m => m.role === 'assistant' && (m.toolCall as { tool?: string })?.tool === 'echo'), 'toolCall 落链经 replay 可见')
|
|
202
|
+
assert.deepEqual(msgs.filter(m => m.role === 'toolResult').map(m => m.content), [
|
|
203
|
+
{ id: 'host-tc-1', result: 'pong:hi' },
|
|
204
|
+
{ id: 'host-tc-2', error: 'authorization-denied', granted: false, mode: 'host-policy' },
|
|
205
|
+
])
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('S36: spawn E2E——tool_call 带 sessionId 全链(readonly 执行 / write 拒绝 / verify 完整)', async () => {
|
|
210
|
+
await withTempDir('e2e2', async dir => {
|
|
211
|
+
const plugin = join(dir, 'p.ts')
|
|
212
|
+
writeFileSync(plugin, [
|
|
213
|
+
'export const manifest = { name: "s36p2", version: "1.0.0" }',
|
|
214
|
+
'export default function apply(api) {',
|
|
215
|
+
' api.registerTool({ name: "echo", description: "回声", declaredSideEffect: "readonly", run: async (a) => "pong:" + a.text })',
|
|
216
|
+
' api.registerTool({ name: "write_file", declaredSideEffect: "write", run: async () => "written" })',
|
|
217
|
+
'}',
|
|
218
|
+
].join('\n'))
|
|
219
|
+
const start = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's36-e2e2' } } })
|
|
220
|
+
const first = await talk([start], plugin)
|
|
221
|
+
const sid = JSON.parse(first.outs[0].result.content[0].text).sessionId as string
|
|
222
|
+
const { outs } = await talk([
|
|
223
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's36-e2e2' } } }),
|
|
224
|
+
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'tool_call', arguments: { sessionId: sid, tool: 'echo', arguments: { text: 'hi' } } } }),
|
|
225
|
+
JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'tool_call', arguments: { sessionId: sid, tool: 'write_file', arguments: {} } } }),
|
|
226
|
+
], plugin)
|
|
227
|
+
assert.deepEqual(JSON.parse(outs[1].result.content[0].text), { ok: true, result: 'pong:hi' }, 'readonly 工具经宿主面真实执行(跨进程幂等派生会话)')
|
|
228
|
+
const denied2 = JSON.parse(outs[2].result.content[0].text) as { ok: boolean; error?: string }
|
|
229
|
+
assert.equal(denied2.ok, false, 'write 类工具 policy 拒绝经真进程协议面可达')
|
|
230
|
+
assert.match(denied2.error!, /CAR-E-HOST-AUTHZ/)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('S36: spawn E2E——跨源同名工具 CAR-E-DUP fail-closed 启动中止(exit 1)', async () => {
|
|
235
|
+
await withTempDir('e2e3', async dir => {
|
|
236
|
+
const pA = join(dir, 'a.ts')
|
|
237
|
+
const pB = join(dir, 'b.ts')
|
|
238
|
+
for (const [p, name] of [[pA, 'a'], [pB, 'b']] as const) {
|
|
239
|
+
writeFileSync(p, `export const manifest = { name: "${name}", version: "1.0.0" }\nexport default function apply(api) { api.registerTool({ name: "dup_tool", declaredSideEffect: "readonly", run: async () => "${name}" }) }\n`)
|
|
240
|
+
}
|
|
241
|
+
const code = await new Promise<number>(resolve => {
|
|
242
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve', '--plugin', pA, '--plugin', pB], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
243
|
+
let err = ''
|
|
244
|
+
child.stderr.on('data', d => { err += d })
|
|
245
|
+
child.on('exit', c => { resolve(c ?? -1) })
|
|
246
|
+
child.stdin.end()
|
|
247
|
+
})
|
|
248
|
+
assert.equal(code, 1, '禁静默覆盖:fail-closed 启动中止(1.4-S1 跨源同名纪律同款)')
|
|
249
|
+
})
|
|
250
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.6-S1 · 投影收敛(S37):deriveMessages 同 id toolResult last-wins(D-23)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - runTurn confirm 全链:写类工具 granted 流——事件面两条 toolResult(runtime 授权 + plugin 执行,
|
|
6
|
+
* M4 既有语义不动 = 审计全量留痕),投影单条且内容 = 执行结果;N1 快照一致性由构造保持
|
|
7
|
+
* - denied 流:授权拒绝单条保留(唯一结果,原样投影)
|
|
8
|
+
* - readonly 工具:单 plugin toolResult(无收敛对象——回归)
|
|
9
|
+
* - id 缺席不收敛:无 id 载荷原样投影(golden ④ 投影语义兼容);id 与无 id 混存互不影响
|
|
10
|
+
* - 适配器出站体(1.6-GO-5 生产调用点核查):收敛投影经 openai-compat / anthropic 双适配器
|
|
11
|
+
* 出站体无重复 tool 消息 / tool_result 块(真实 provider 重复 id 拒绝面出清)
|
|
12
|
+
*/
|
|
13
|
+
import { test } from 'node:test'
|
|
14
|
+
import assert from 'node:assert/strict'
|
|
15
|
+
import { SessionLog } from '../src/session/log.ts'
|
|
16
|
+
import { runTurn, type ModelStep } from '../src/loop/stop.ts'
|
|
17
|
+
import { createOpenAICompatAdapter, createAnthropicAdapter } from '../src/runtime-core/llm.ts'
|
|
18
|
+
|
|
19
|
+
const dataLine = (obj: unknown) => `data: ${JSON.stringify(obj)}\n\n`
|
|
20
|
+
const openaiSse = (finish: string) =>
|
|
21
|
+
dataLine({ choices: [{ delta: {}, finish_reason: finish }] }) + 'data: [DONE]\n\n'
|
|
22
|
+
const sseResponse = (text: string) => {
|
|
23
|
+
const enc = new TextEncoder()
|
|
24
|
+
return new Response(new ReadableStream<Uint8Array>({ start(c) { c.enqueue(enc.encode(text)); c.close() } }),
|
|
25
|
+
{ status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
26
|
+
}
|
|
27
|
+
const anthropicSse = (events: object[]) => events.map(dataLine).join('')
|
|
28
|
+
const ANTHROPIC_STOP = [
|
|
29
|
+
{ type: 'message_start', message: { role: 'assistant' } },
|
|
30
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' } },
|
|
31
|
+
{ type: 'message_stop' },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
/** chatStep 消息映射同款复刻(deriveMessages → LlmRequest.messages 的唯一路径) */
|
|
35
|
+
function toLlmMessages(log: SessionLog) {
|
|
36
|
+
return log.deriveMessages().map(m => ({
|
|
37
|
+
role: m.role as 'system' | 'user' | 'assistant' | 'toolResult',
|
|
38
|
+
content: (m as unknown as { toolCall?: unknown }).toolCall !== undefined
|
|
39
|
+
? { toolCall: (m as unknown as { toolCall: unknown }).toolCall }
|
|
40
|
+
: m.content,
|
|
41
|
+
}))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('S37: 写类工具 granted 流——事件面两条 toolResult(M4 语义不动),投影单条且为执行结果 + N1 一致', async () => {
|
|
45
|
+
const log = new SessionLog('s37-granted')
|
|
46
|
+
const steps: ModelStep[] = [
|
|
47
|
+
{ stopReason: 'toolUse', toolCalls: [{ id: 'tc1', tool: 'save_note', args: { text: 'hi' } }] },
|
|
48
|
+
{ stopReason: 'stop' },
|
|
49
|
+
]
|
|
50
|
+
const r = await runTurn({
|
|
51
|
+
log, turnId: 'T1',
|
|
52
|
+
model: async () => steps.shift()!,
|
|
53
|
+
tools: new Map([['save_note', { declaredSideEffect: 'write', run: async () => '已保存' }]]),
|
|
54
|
+
preset: { mode: 'confirm', authorize: async () => true },
|
|
55
|
+
})
|
|
56
|
+
assert.equal(r.reason, 'completed')
|
|
57
|
+
// 事件面不动:授权决策(runtime)+ 执行结果(plugin)两条都落链(审计全量留痕)
|
|
58
|
+
const resultEvents = log.events.filter(e => e.kind === 'toolResult')
|
|
59
|
+
assert.equal(resultEvents.length, 2, 'M4 既有语义冻结:runtime 授权记录 + plugin 执行结果均落链')
|
|
60
|
+
assert.equal(resultEvents[0]!.actor, 'runtime')
|
|
61
|
+
assert.equal(resultEvents[1]!.actor, 'plugin')
|
|
62
|
+
// 投影收敛:同 id last-wins,模型可见流单条且为执行结果
|
|
63
|
+
const proj = log.deriveMessages().filter(m => m.role === 'toolResult')
|
|
64
|
+
assert.equal(proj.length, 1, 'D-23:授权中间记录不再进模型可见流')
|
|
65
|
+
assert.deepEqual(proj[0]!.content, { id: 'tc1', result: '已保存' })
|
|
66
|
+
// N1 快照一致性(runTurn 每步 snapshotModelRequest;收敛激活下重算同函数构造一致)
|
|
67
|
+
assert.deepEqual(log.assertModelVisibleLogged(), { ok: true })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('S37: denied 流——授权拒绝单条保留(唯一结果原样投影)', async () => {
|
|
71
|
+
const log = new SessionLog('s37-denied')
|
|
72
|
+
const steps: ModelStep[] = [
|
|
73
|
+
{ stopReason: 'toolUse', toolCalls: [{ id: 'tc2', tool: 'save_note', args: {} } ] },
|
|
74
|
+
{ stopReason: 'stop' },
|
|
75
|
+
]
|
|
76
|
+
await runTurn({
|
|
77
|
+
log, turnId: 'T1',
|
|
78
|
+
model: async () => steps.shift()!,
|
|
79
|
+
tools: new Map([['save_note', { declaredSideEffect: 'write', run: async () => '不应执行' }]]),
|
|
80
|
+
preset: { mode: 'confirm', authorize: async () => false },
|
|
81
|
+
})
|
|
82
|
+
assert.equal(log.events.filter(e => e.kind === 'toolResult').length, 1, '拒绝即终态:仅 runtime 授权拒绝事件')
|
|
83
|
+
const proj = log.deriveMessages().filter(m => m.role === 'toolResult')
|
|
84
|
+
assert.equal(proj.length, 1, '唯一结果原样保留(不误收敛)')
|
|
85
|
+
assert.equal((proj[0]!.content as { error?: string }).error, 'authorization-denied')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('S37: readonly 工具单条 toolResult 无收敛对象(回归)', async () => {
|
|
89
|
+
const log = new SessionLog('s37-readonly')
|
|
90
|
+
const steps: ModelStep[] = [
|
|
91
|
+
{ stopReason: 'toolUse', toolCalls: [{ id: 'tc3', tool: 'echo', args: {} }] },
|
|
92
|
+
{ stopReason: 'stop' },
|
|
93
|
+
]
|
|
94
|
+
await runTurn({
|
|
95
|
+
log, turnId: 'T1',
|
|
96
|
+
model: async () => steps.shift()!,
|
|
97
|
+
tools: new Map([['echo', { declaredSideEffect: 'readonly', run: async () => 'pong' }]]),
|
|
98
|
+
preset: { mode: 'readonly' },
|
|
99
|
+
})
|
|
100
|
+
assert.equal(log.events.filter(e => e.kind === 'toolResult').length, 1)
|
|
101
|
+
const proj = log.deriveMessages().filter(m => m.role === 'toolResult')
|
|
102
|
+
assert.equal(proj.length, 1)
|
|
103
|
+
assert.deepEqual(proj[0]!.content, { id: 'tc3', result: 'pong' })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('S37: id 缺席不收敛——无 id 载荷原样投影(golden ④ 兼容),id 与无 id 混存互不影响', () => {
|
|
107
|
+
const log = new SessionLog('s37-idless')
|
|
108
|
+
log.append('runtime', 'toolResult', 'T1', { ok: 1 })
|
|
109
|
+
log.append('runtime', 'toolResult', 'T1', { ok: 2 })
|
|
110
|
+
log.append('plugin', 'toolResult', 'T1', { id: 'tc9', error: 'authorization-denied', granted: true })
|
|
111
|
+
log.append('plugin', 'toolResult', 'T1', { id: 'tc9', result: 'final' })
|
|
112
|
+
const proj = log.deriveMessages().filter(m => m.role === 'toolResult')
|
|
113
|
+
assert.equal(proj.length, 3, '无 id 两条原样保留 + 同 id 收敛为最后一条')
|
|
114
|
+
assert.deepEqual(proj[0]!.content, { ok: 1 })
|
|
115
|
+
assert.deepEqual(proj[1]!.content, { ok: 2 })
|
|
116
|
+
assert.deepEqual(proj[2]!.content, { id: 'tc9', result: 'final' })
|
|
117
|
+
// 前缀投影(upTo)语义:前缀内 last-wins 独立成立
|
|
118
|
+
const half = log.deriveMessages(3).filter(m => m.role === 'toolResult')
|
|
119
|
+
assert.equal(half.length, 3, '前缀 [0,3):无 id 两条 + tc9 授权记录(前缀内 tc9 授权记录即终态)')
|
|
120
|
+
assert.deepEqual(half[2]!.content, { id: 'tc9', error: 'authorization-denied', granted: true })
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('S37: openai-compat 出站体——收敛后单 tool 消息(重复 tool_call_id 出清)', async () => {
|
|
124
|
+
const log = new SessionLog('s37-openai')
|
|
125
|
+
log.append('user', 'user', 'T1', '查天气')
|
|
126
|
+
log.append('model', 'toolCall', 'T1', { id: 'tc1', tool: 'weather', args: { city: '北京' } })
|
|
127
|
+
log.append('runtime', 'toolResult', 'T1', { id: 'tc1', error: 'authorization-denied', granted: true, mode: 'confirm' })
|
|
128
|
+
log.append('plugin', 'toolResult', 'T1', { id: 'tc1', result: '晴 25 度' })
|
|
129
|
+
const seen: RequestInit[] = []
|
|
130
|
+
const adapter = createOpenAICompatAdapter({
|
|
131
|
+
baseUrl: 'https://api.test',
|
|
132
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { seen.push(init!); return sseResponse(openaiSse('stop')) }) as typeof fetch,
|
|
133
|
+
})
|
|
134
|
+
for await (const _ of adapter.chat({ model: 'm', messages: toLlmMessages(log), tools: [] })) void _
|
|
135
|
+
const body = JSON.parse(seen[0]!.body as string)
|
|
136
|
+
const toolMsgs = body.messages.filter((m: { role: string }) => m.role === 'tool')
|
|
137
|
+
assert.equal(toolMsgs.length, 1, '1.6-GO-5:出站体单 tool 消息(收敛前为两条)')
|
|
138
|
+
assert.equal(toolMsgs[0].tool_call_id, 'tc1')
|
|
139
|
+
assert.equal(toolMsgs[0].content, JSON.stringify('晴 25 度'), '内容 = 执行结果(非授权中间记录)')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('S37: anthropic 出站体——收敛后单 tool_result 块(重复 tool_use_id 400 面出清)', async () => {
|
|
143
|
+
const log = new SessionLog('s37-anthropic')
|
|
144
|
+
log.append('user', 'user', 'T1', '查天气')
|
|
145
|
+
log.append('model', 'toolCall', 'T1', { id: 'toolu_1', tool: 'weather', args: { city: '北京' } })
|
|
146
|
+
log.append('runtime', 'toolResult', 'T1', { id: 'toolu_1', error: 'authorization-denied', granted: true, mode: 'confirm' })
|
|
147
|
+
log.append('plugin', 'toolResult', 'T1', { id: 'toolu_1', result: '晴 25 度' })
|
|
148
|
+
const seen: RequestInit[] = []
|
|
149
|
+
const adapter = createAnthropicAdapter({
|
|
150
|
+
baseUrl: 'https://api.test',
|
|
151
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { seen.push(init!); return sseResponse(anthropicSse(ANTHROPIC_STOP)) }) as typeof fetch,
|
|
152
|
+
})
|
|
153
|
+
for await (const _ of adapter.chat({ model: 'm', messages: toLlmMessages(log), tools: [] })) void _
|
|
154
|
+
const body = JSON.parse(seen[0]!.body as string)
|
|
155
|
+
const blocks = body.messages.flatMap((m: { content: unknown }) =>
|
|
156
|
+
Array.isArray(m.content) ? m.content : []).filter((b: { type: string }) => b.type === 'tool_result')
|
|
157
|
+
assert.equal(blocks.length, 1, '1.6-GO-5:出站体单 tool_result 块(重复 tool_use_id 会被 provider 400)')
|
|
158
|
+
assert.equal(blocks[0].tool_use_id, 'toolu_1')
|
|
159
|
+
assert.equal(blocks[0].content, JSON.stringify('晴 25 度'))
|
|
160
|
+
assert.ok(!('is_error' in blocks[0]), '执行结果非错误形态')
|
|
161
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.6-S3 · jiti 载体(S38):car.config.ts 经 jiti 自包含转译(D-24)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - 非可擦除语法(enum)真载——1.5 D-22 原生类型剥离不可表达的 transform-only 形态,
|
|
6
|
+
* erasable-only 预检登记项随之出清(被取代)
|
|
7
|
+
* - strip-only spawn 差分(1.6-GO-5 生产调用点):`--experimental-strip-types`(无 transform)
|
|
8
|
+
* 下 enum 配置经 jiti 真载成功 + 原生 import 同文件失败(负控——证明语法确属非可擦除、
|
|
9
|
+
* jiti 是载体);同进程实测首载时延(登记口径,无硬门禁,CI 宽上界防抖)
|
|
10
|
+
* - CLI 真路径:car run 于 enum 配置目录——配置经 jiti 装载生效(CAR-E-LLM-CONFIG 引导 =
|
|
11
|
+
* 配置解析通过、模型未配;若配置载体失败应为 CAR-E-CONFIG)
|
|
12
|
+
* - s35 存量零改动全量回归 = 载体迁移验收线(interoptDefault 显式关:jiti 缺省 true 会把
|
|
13
|
+
* 无 default 模块的 exports 整体当 default,「缺 default export」校验即失效)
|
|
14
|
+
*/
|
|
15
|
+
import { test } from 'node:test'
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { join, dirname } from 'node:path'
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
22
|
+
import { warmCarConfig, loadCarConfig } from '../src/load/config.ts'
|
|
23
|
+
|
|
24
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
|
|
25
|
+
const SRC_CONFIG = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'load', 'config.ts')
|
|
26
|
+
|
|
27
|
+
function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s38-${name}-`))
|
|
29
|
+
try {
|
|
30
|
+
const r = fn(dir)
|
|
31
|
+
const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
|
|
32
|
+
if (r instanceof Promise) return r.finally(cleanup)
|
|
33
|
+
cleanup()
|
|
34
|
+
return Promise.resolve()
|
|
35
|
+
} catch (e) {
|
|
36
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
|
|
37
|
+
throw e
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('S38: 非可擦除语法(enum)经 jiti 真载——D-22 原生类型剥离不可表达形态', async () => {
|
|
42
|
+
await withTempDir('enum', async dir => {
|
|
43
|
+
writeFileSync(join(dir, 'car.config.ts'), [
|
|
44
|
+
`export enum LlmMode { full = 'full-model' }`,
|
|
45
|
+
`export default { llm: { model: LlmMode.full } }`,
|
|
46
|
+
].join('\n'))
|
|
47
|
+
await warmCarConfig({ cwd: dir })
|
|
48
|
+
const r = loadCarConfig({ cwd: dir })
|
|
49
|
+
assert.equal(r.error, undefined)
|
|
50
|
+
assert.equal(r.config.llm?.model, 'full-model', 'enum 值经 jiti 转译进配置')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('S38: strip-only spawn 差分——原生 import enum 载体失败(负控)+ jiti 真载成功 + 首载时延实测', async () => {
|
|
55
|
+
await withTempDir('strip', async dir => {
|
|
56
|
+
const configPath = join(dir, 'car.config.ts')
|
|
57
|
+
writeFileSync(configPath, [
|
|
58
|
+
`export enum LlmMode { full = 'strip-model' }`,
|
|
59
|
+
`export default { llm: { model: LlmMode.full } }`,
|
|
60
|
+
].join('\n'))
|
|
61
|
+
// 夹具自身仅可擦除语法(strip-only 可载);非可擦除只出现在被测配置里
|
|
62
|
+
const fixture = join(dir, 'fixture.ts')
|
|
63
|
+
writeFileSync(fixture, [
|
|
64
|
+
`import { warmCarConfig, loadCarConfig } from ${JSON.stringify(pathToFileURL(SRC_CONFIG).href)}`,
|
|
65
|
+
`import { pathToFileURL as toURL } from 'node:url'`,
|
|
66
|
+
`const configPath = process.argv[2]!`,
|
|
67
|
+
`let nativeError = ''`,
|
|
68
|
+
`try { await import(toURL(configPath).href) } catch (e) { nativeError = String((e as Error).message) }`,
|
|
69
|
+
`if (!nativeError) { console.log('NATIVE_UNEXPECTEDLY_OK'); process.exit(1) }`,
|
|
70
|
+
`const t0 = performance.now()`,
|
|
71
|
+
`await warmCarConfig({ explicitPath: configPath })`,
|
|
72
|
+
`const elapsed = Math.round(performance.now() - t0)`,
|
|
73
|
+
`const r = loadCarConfig({ explicitPath: configPath })`,
|
|
74
|
+
`if (r.error) { console.log('JITI_FAIL ' + r.error); process.exit(1) }`,
|
|
75
|
+
`console.log('OK model=' + r.config.llm?.model + ' firstLoadMs=' + elapsed + ' nativeRejected=' + nativeError.slice(0, 60).replace(/\\n/g, ' '))`,
|
|
76
|
+
].join('\n'))
|
|
77
|
+
// --experimental-strip-types:仅类型剥离无转译(22.19+ 可用;enum 属 transform-only 形态)
|
|
78
|
+
const { code, stdout } = await new Promise<{ code: number | null; stdout: string }>(resolve => {
|
|
79
|
+
const child = spawn(process.execPath, ['--experimental-strip-types', fixture, configPath], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
80
|
+
let out = ''
|
|
81
|
+
child.stdout.on('data', d => { out += d })
|
|
82
|
+
child.on('exit', c => resolve({ code: c, stdout: out }))
|
|
83
|
+
})
|
|
84
|
+
assert.equal(code, 0, 'strip-only 环境下 jiti 真载 enum 配置')
|
|
85
|
+
const m = /OK model=(\S+) firstLoadMs=(\d+) nativeRejected=(.+)/.exec(stdout.trim())!
|
|
86
|
+
assert.equal(m[1], 'strip-model')
|
|
87
|
+
assert.ok(m[2] !== undefined, '首载时延实测出值')
|
|
88
|
+
console.log(` [登记] car.config.ts jiti 首载时延(spawn 冷启,本机实跑):${m[2]} ms`)
|
|
89
|
+
assert.ok(Number(m[2]) < 15_000, '首载时延宽上界(CI 防抖;真实数值进收口报告)')
|
|
90
|
+
assert.ok(m[3]!.length > 0, '负控:原生 import 对非可擦除语法显式失败——jiti 是载体而非原生剥离')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('S38: CLI 真路径——car run 于 enum 配置目录:配置经 jiti 装载生效(模型未配引导而非配置报错)', async () => {
|
|
95
|
+
await withTempDir('cli', async dir => {
|
|
96
|
+
writeFileSync(join(dir, 'car.config.ts'), [
|
|
97
|
+
`export enum LlmMode { full = 'full-model' }`,
|
|
98
|
+
`export default { llm: { model: LlmMode.full } }`,
|
|
99
|
+
].join('\n'))
|
|
100
|
+
const plugin = join(dir, 'noop.ts')
|
|
101
|
+
writeFileSync(plugin, [
|
|
102
|
+
`export const manifest = { name: "s38p", version: "1.0.0" }`,
|
|
103
|
+
`export default function apply(api) { api.registerTool({ name: "noop", declaredSideEffect: "readonly", run: async () => "ok" }) }`,
|
|
104
|
+
].join('\n'))
|
|
105
|
+
const { code, output } = await new Promise<{ code: number | null; output: string }>(resolve => {
|
|
106
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', plugin], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
107
|
+
let out = ''
|
|
108
|
+
child.stdout.on('data', d => { out += d })
|
|
109
|
+
child.stderr.on('data', d => { out += d })
|
|
110
|
+
child.on('exit', c => resolve({ code: c, output: out }))
|
|
111
|
+
})
|
|
112
|
+
assert.equal(code, 2, 'D-13:无凭据/端点 → exit 2 显式引导')
|
|
113
|
+
assert.match(output, /CAR-E-LLM-CONFIG/, '配置经 jiti 解析通过(enum 值进 llm.model)走到模型就绪检查')
|
|
114
|
+
assert.doesNotMatch(output, /CAR-E-CONFIG/, '配置载体本身零错误')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.7-S1 · OTLP span 语义标准化(D-26):exception 标准事件 + status / addEvent 标准 events 编码
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.7-迭代规划 TELE-1;1.4 遗留「遥测 OTLP 增强」出清主体):
|
|
5
|
+
* - recordException → OTel 规范形态:span event(name='exception',exception.type/exception.message
|
|
6
|
+
* 事件属性 + timeUnixNano)+ span status → STATUS_CODE_ERROR——替换 1.1 起「仅 exception.message
|
|
7
|
+
* 属性 + 恒 UNSET」非标准形态;stacktrace 缺省不出站(D-26 口径:防路径/源码面外泄)
|
|
8
|
+
* - addEvent → 标准 events 数组编码(name + 原形态属性 + 时刻)——替换 `event.<name>` JSON 属性串
|
|
9
|
+
* - 出站体加法字段:无事件 span 不出 events 键(空形态合法);正常 span 恒 STATUS_CODE_UNSET(行为不变面)
|
|
10
|
+
* - noop 门面零出站不变(默认关三原则;s29/s32 回归由全量保证)
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { createTelemetryFacade } from '../src/runtime-core/telemetry.ts'
|
|
15
|
+
|
|
16
|
+
const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
|
|
17
|
+
|
|
18
|
+
function capture() {
|
|
19
|
+
const bodies: any[] = []
|
|
20
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
21
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
22
|
+
})
|
|
23
|
+
return { f, bodies }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const spansOf = (bodies: any[]) => bodies.filter(b => b.scopeSpans).flatMap(b => b.scopeSpans[0].spans)
|
|
27
|
+
|
|
28
|
+
test('S39: recordException → 标准 exception 事件 + STATUS_CODE_ERROR(stacktrace 不出站)', async () => {
|
|
29
|
+
const { f, bodies } = capture()
|
|
30
|
+
const s = f.getTracer().startSpan('car.step', { attributes: { 'car.step': 1 } })
|
|
31
|
+
s.recordException(new TypeError('boom'))
|
|
32
|
+
s.end('error')
|
|
33
|
+
await f.flush()
|
|
34
|
+
const span = spansOf(bodies)[0]
|
|
35
|
+
assert.equal(span.status.code, 'STATUS_CODE_ERROR', 'recordException → status ERROR(1.7 前恒 UNSET)')
|
|
36
|
+
assert.ok(Array.isArray(span.events) && span.events.length === 1, 'exception 为 span event')
|
|
37
|
+
const ev = span.events[0]
|
|
38
|
+
assert.equal(ev.name, 'exception')
|
|
39
|
+
assert.ok(ev.timeUnixNano, '事件时刻出站')
|
|
40
|
+
const attrs = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
41
|
+
assert.equal(attrs['exception.type'], 'TypeError', 'err.name → exception.type')
|
|
42
|
+
assert.equal(attrs['exception.message'], 'boom', 'err.message → exception.message')
|
|
43
|
+
const allKeys = [...span.attributes, ...span.events.flatMap((e: any) => e.attributes)].map((a: any) => a.key)
|
|
44
|
+
assert.equal(allKeys.some((k: string) => /stack/i.test(k)), false, 'stacktrace 缺省不出站(D-26)')
|
|
45
|
+
assert.equal(allKeys.includes('exception.message') && span.attributes.some((a: any) => a.key === 'exception.message'), false,
|
|
46
|
+
'exception.message 不再落于 span attributes(1.1 非标准形态退役)')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('S39: recordException 非 Error 值兜底——type=Error / message=String(err)', async () => {
|
|
50
|
+
const { f, bodies } = capture()
|
|
51
|
+
const s = f.getTracer().startSpan('car.step')
|
|
52
|
+
s.recordException('plain failure')
|
|
53
|
+
s.end('error')
|
|
54
|
+
await f.flush()
|
|
55
|
+
const ev = spansOf(bodies)[0].events[0]
|
|
56
|
+
const attrs = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
57
|
+
assert.deepEqual(attrs, { 'exception.type': 'Error', 'exception.message': 'plain failure' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('S39: addEvent → 标准 events 编码(原形态属性 + 时刻;attributes 不再承载事件)', async () => {
|
|
61
|
+
const { f, bodies } = capture()
|
|
62
|
+
const s = f.getTracer().startSpan('car.turn')
|
|
63
|
+
s.addEvent('cache-hit', { key: 'x', n: 1, flag: true })
|
|
64
|
+
s.end('completed')
|
|
65
|
+
await f.flush()
|
|
66
|
+
const span = spansOf(bodies)[0]
|
|
67
|
+
assert.equal(span.status.code, 'STATUS_CODE_UNSET', '正常 span 恒 UNSET(行为不变面)')
|
|
68
|
+
assert.equal(span.events.length, 1)
|
|
69
|
+
const ev = span.events[0]
|
|
70
|
+
assert.equal(ev.name, 'cache-hit')
|
|
71
|
+
assert.ok(ev.timeUnixNano)
|
|
72
|
+
const map = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value]))
|
|
73
|
+
assert.deepEqual(map, { key: { stringValue: 'x' }, n: { doubleValue: 1 }, flag: { boolValue: true } },
|
|
74
|
+
'原形态属性出站(1.7 前为 event.<name>=JSON 串)')
|
|
75
|
+
const attrKeys = span.attributes.map((a: any) => a.key)
|
|
76
|
+
assert.equal(attrKeys.includes('event.cache-hit'), false, 'attributes 不再承载事件(非标准形态退役)')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('S39: 无事件 span 不出 events 键(出站体加法字段——空形态合法)', async () => {
|
|
80
|
+
const { f, bodies } = capture()
|
|
81
|
+
f.getTracer().startSpan('plain').end()
|
|
82
|
+
await f.flush()
|
|
83
|
+
const span = spansOf(bodies)[0]
|
|
84
|
+
assert.equal('events' in span, false, '无事件不出 events 键')
|
|
85
|
+
assert.equal(span.status.code, 'STATUS_CODE_UNSET')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('S39: 多事件按序出站(exception 与 addEvent 混排——事件数组保序)', async () => {
|
|
89
|
+
const { f, bodies } = capture()
|
|
90
|
+
const s = f.getTracer().startSpan('car.step')
|
|
91
|
+
s.addEvent('retry')
|
|
92
|
+
s.recordException(new Error('net'))
|
|
93
|
+
s.addEvent('fallback')
|
|
94
|
+
s.end('completed')
|
|
95
|
+
await f.flush()
|
|
96
|
+
const names = spansOf(bodies)[0].events.map((e: any) => e.name)
|
|
97
|
+
assert.deepEqual(names, ['retry', 'exception', 'fallback'])
|
|
98
|
+
// exception 事件混排下 status 仍 ERROR
|
|
99
|
+
assert.equal(spansOf(bodies)[0].status.code, 'STATUS_CODE_ERROR')
|
|
100
|
+
})
|