@lqc123qwe/car-runtime 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +12 -0
- package/.github/workflows/release.yml +21 -0
- package/README.md +122 -0
- package/adr/ADR-001.md +4 -0
- package/adr/ADR-002.md +5 -0
- package/adr/ADR-003.md +35 -0
- package/adr/host-onboarding.md +68 -0
- package/adr/mlps-audit-checklist.md +33 -0
- package/adr/win32-spike.md +45 -0
- package/package.json +25 -0
- package/scripts/ptc-baseline/run-baseline.ts +118 -0
- package/scripts/release-pipeline.ts +189 -0
- package/scripts/win32-koffi-spike.ts +34 -0
- package/src/authz/authz.ts +96 -0
- package/src/cli.ts +122 -0
- package/src/governance/dump.ts +41 -0
- package/src/host/facade.ts +99 -0
- package/src/host/hostGateway.ts +98 -0
- package/src/host/mappings.ts +74 -0
- package/src/host/stdio.ts +74 -0
- package/src/kernel/context.ts +208 -0
- package/src/kernel/events.ts +99 -0
- package/src/load/loader.ts +165 -0
- package/src/load/registry.ts +88 -0
- package/src/load/verifier.ts +67 -0
- package/src/loop/goal.ts +53 -0
- package/src/loop/recover.ts +47 -0
- package/src/loop/stop.ts +200 -0
- package/src/mcp/gateway.ts +118 -0
- package/src/ptc/budget.ts +37 -0
- package/src/ptc/erasable.ts +28 -0
- package/src/ptc/runCode.ts +127 -0
- package/src/ptc/sdk.ts +28 -0
- package/src/ptc/worker-entry.ts +67 -0
- package/src/sandbox/sandbox.ts +199 -0
- package/src/security/secrets.ts +111 -0
- package/src/session/export.ts +110 -0
- package/src/session/log.ts +120 -0
- package/src/telemetry/metrics.ts +38 -0
- package/test/kernel.spec.ts +136 -0
- package/test/s11.spec.ts +129 -0
- package/test/s12.spec.ts +135 -0
- package/test/s13.spec.ts +117 -0
- package/test/s14.spec.ts +117 -0
- package/test/s15.spec.ts +80 -0
- package/test/s17.spec.ts +102 -0
- package/test/s18.spec.ts +72 -0
- package/test/s2.spec.ts +245 -0
- package/test/s3.spec.ts +179 -0
- package/test/s4.spec.ts +169 -0
- package/test/s5.spec.ts +95 -0
- package/test/s6.spec.ts +169 -0
- package/test/s7.spec.ts +135 -0
- package/test/s9.spec.ts +89 -0
package/test/s12.spec.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { PassThrough } from 'node:stream'
|
|
4
|
+
import { HOST_MAPPINGS } from '../src/host/mappings.ts'
|
|
5
|
+
import { HostGateway } from '../src/host/hostGateway.ts'
|
|
6
|
+
import { createRuntimeFacade } from '../src/host/facade.ts'
|
|
7
|
+
import { createStdioServer } from '../src/host/stdio.ts'
|
|
8
|
+
import { loadSessionLog } from '../src/session/log.ts'
|
|
9
|
+
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
function setup() {
|
|
14
|
+
const profiles = new Map(HOST_MAPPINGS.map(h => [h.hostId, h]))
|
|
15
|
+
const facade = createRuntimeFacade({ profiles })
|
|
16
|
+
const audits: Array<{ kind: string }> = []
|
|
17
|
+
const gw = new HostGateway({ facade, audit: e => audits.push({ kind: e.kind }) })
|
|
18
|
+
for (const h of HOST_MAPPINGS) gw.registerHost({ hostId: h.hostId, profile: h, transport: {} as never })
|
|
19
|
+
return { gw, audits, facade }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ==================== stdio ServerTransport(JSON-RPC over 换行分隔 JSON) ====================
|
|
23
|
+
|
|
24
|
+
test('S12: stdio——tools/list 返回 9 tool 面;tools/call 分发到 HostGateway', async () => {
|
|
25
|
+
const { gw } = setup()
|
|
26
|
+
const server = createStdioServer((tool, args) => gw.handle('claude-code', tool, args))
|
|
27
|
+
const input = new PassThrough()
|
|
28
|
+
const output = new PassThrough()
|
|
29
|
+
const lines: string[] = []
|
|
30
|
+
output.on('data', d => lines.push(...String(d).split('\n').filter(Boolean)))
|
|
31
|
+
const done = server.serve(input, output)
|
|
32
|
+
input.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) + '\n')
|
|
33
|
+
input.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 'sess-9' } } }) + '\n')
|
|
34
|
+
input.end()
|
|
35
|
+
await done
|
|
36
|
+
await new Promise(r => setImmediate(r))
|
|
37
|
+
assert.equal(lines.length, 2)
|
|
38
|
+
const list = JSON.parse(lines[0])
|
|
39
|
+
assert.equal(list.result.tools.length, 9)
|
|
40
|
+
const call = JSON.parse(lines[1])
|
|
41
|
+
assert.match(call.result.content[0].text, /"sessionId":"SH-/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('S12: stdio——未知 method 显式报错;非法 JSON 不崩溃', async () => {
|
|
45
|
+
const { gw } = setup()
|
|
46
|
+
const server = createStdioServer((tool, args) => gw.handle('claude-code', tool, args))
|
|
47
|
+
const input = new PassThrough()
|
|
48
|
+
const output = new PassThrough()
|
|
49
|
+
const lines: string[] = []
|
|
50
|
+
output.on('data', d => lines.push(...String(d).split('\n').filter(Boolean)))
|
|
51
|
+
const done = server.serve(input, output)
|
|
52
|
+
input.write('not-json\n')
|
|
53
|
+
input.write(JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'host/secret' }) + '\n')
|
|
54
|
+
input.end()
|
|
55
|
+
await done
|
|
56
|
+
await new Promise(r => setImmediate(r))
|
|
57
|
+
const parseErr = JSON.parse(lines[0])
|
|
58
|
+
assert.equal(parseErr.error.code, -32700)
|
|
59
|
+
const unknown = JSON.parse(lines[1])
|
|
60
|
+
assert.equal(unknown.error.code, -32601)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ==================== 归一化数据流:宿主事件 → SessionLog 全链路 ====================
|
|
64
|
+
|
|
65
|
+
test('S12: 端到端——宿主事件批归一化落哈希链,verify/replay/export 全通', async () => {
|
|
66
|
+
const { gw } = setup()
|
|
67
|
+
const start = await gw.handle('claude-code', 'session_start', { hostSessionId: 'sess-42' })
|
|
68
|
+
const sessionId = (start.result as { sessionId: string }).sessionId
|
|
69
|
+
// 宿主报一批事件(含一条未知事件——降级通道)
|
|
70
|
+
const turn = await gw.handle('claude-code', 'session_turn', {
|
|
71
|
+
sessionId,
|
|
72
|
+
input: { events: [
|
|
73
|
+
{ hostEvent: 'user_message', payload: { content: '帮我查下部署状态' } },
|
|
74
|
+
{ hostEvent: 'tool_use', payload: { tool_name: 'doctor', tool_call_id: 'c1' } },
|
|
75
|
+
{ hostEvent: 'tool_result', payload: { tool_call_id: 'c1', status: 'ok' } },
|
|
76
|
+
{ hostEvent: 'assistant_message', payload: { content: '部署正常' } },
|
|
77
|
+
{ hostEvent: 'turn_complete', payload: { reason: 'completed' } },
|
|
78
|
+
{ hostEvent: 'brand_new_host_event', payload: { x: 1 } }, // 未命中 → hostRaw
|
|
79
|
+
] },
|
|
80
|
+
})
|
|
81
|
+
assert.equal((turn.result as { reason: string }).reason, 'completed')
|
|
82
|
+
const verify = await gw.handle('claude-code', 'session_verify', { sessionId })
|
|
83
|
+
assert.equal((verify.result as { ok: boolean }).ok, true)
|
|
84
|
+
const replay = await gw.handle('claude-code', 'session_replay', { sessionId })
|
|
85
|
+
const msgs = (replay.result as { messages: Array<{ role: string; content?: { text?: string } }> }).messages
|
|
86
|
+
assert.ok(msgs.some(m => m.role === 'user' && m.content?.text === '帮我查下部署状态'), 'user 归一化入链')
|
|
87
|
+
assert.ok(msgs.some(m => m.role === 'assistant' && m.content?.text === '部署正常'))
|
|
88
|
+
// hostRaw 事件进日志但不进消息投影(原始通道)
|
|
89
|
+
const exportR = await gw.handle('claude-code', 'session_export', { sessionId })
|
|
90
|
+
const bundle = (exportR.result as { bundle: { files: { name: string; content: string }[] } }).bundle
|
|
91
|
+
const jsonl = bundle.files[0].content
|
|
92
|
+
assert.ok(jsonl.includes('brand_new_host_event'), '未命中事件 hostRaw 留痕')
|
|
93
|
+
assert.ok(jsonl.includes('session_registered'), '登记事件落哈希链')
|
|
94
|
+
// 导出包可离线重放(落盘 → loadSessionLog 断链校验)
|
|
95
|
+
const dir = mkdtempSync(join(tmpdir(), 'car-s12-'))
|
|
96
|
+
try {
|
|
97
|
+
const file = join(dir, 'events.jsonl')
|
|
98
|
+
writeFileSync(file, jsonl)
|
|
99
|
+
const { log, brokenAt } = loadSessionLog(file)
|
|
100
|
+
assert.equal(brokenAt, null)
|
|
101
|
+
assert.ok(log.deriveMessages().length >= 2)
|
|
102
|
+
} finally {
|
|
103
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 沙箱 trash 容错 */ }
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('S12: 跨宿主等价——codex 同语义事件批产出等价日志投影', async () => {
|
|
108
|
+
const { gw } = setup()
|
|
109
|
+
const cc = await gw.handle('claude-code', 'session_start', { hostSessionId: 's1' })
|
|
110
|
+
const cx = await gw.handle('codex', 'session_start', { hostSessionId: 's1' })
|
|
111
|
+
const ccId = (cc.result as { sessionId: string }).sessionId
|
|
112
|
+
const cxId = (cx.result as { sessionId: string }).sessionId
|
|
113
|
+
assert.notEqual(ccId, cxId, '跨宿主会话隔离')
|
|
114
|
+
await gw.handle('claude-code', 'session_turn', { sessionId: ccId, input: { events: [
|
|
115
|
+
{ hostEvent: 'user_message', payload: { content: 'hi' } },
|
|
116
|
+
{ hostEvent: 'tool_use', payload: { tool_name: 'fs', tool_call_id: 'c1' } },
|
|
117
|
+
] } })
|
|
118
|
+
await gw.handle('codex', 'session_turn', { sessionId: cxId, input: { events: [
|
|
119
|
+
{ hostEvent: 'input_item', payload: { text: 'hi' } },
|
|
120
|
+
{ hostEvent: 'function_call', payload: { name: 'fs', call_id: 'c1' } },
|
|
121
|
+
] } })
|
|
122
|
+
const r1 = await gw.handle('claude-code', 'session_replay', { sessionId: ccId })
|
|
123
|
+
const r2 = await gw.handle('codex', 'session_replay', { sessionId: cxId })
|
|
124
|
+
const m1 = (r1.result as { messages: unknown[] }).messages
|
|
125
|
+
const m2 = (r2.result as { messages: unknown[] }).messages
|
|
126
|
+
assert.deepEqual(m1, m2, '双宿主等价核心断言:投影逐字节一致')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('S12: 审计全留痕——host-call 与拒绝均落审计(无旁路)', async () => {
|
|
130
|
+
const { gw, audits } = setup()
|
|
131
|
+
await gw.handle('ghost', 'session_start', {}) // 未登记
|
|
132
|
+
await gw.handle('claude-code', 'session_start', { hostSessionId: 'x' })
|
|
133
|
+
assert.ok(audits.some(a => a.kind === 'host-rejected'))
|
|
134
|
+
assert.ok(audits.some(a => a.kind === 'host-call'))
|
|
135
|
+
})
|
package/test/s13.spec.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { resolvePackage, isWhitelisted, type RegistryConfig } from '../src/load/registry.ts'
|
|
4
|
+
import { HOST_MAPPINGS } from '../src/host/mappings.ts'
|
|
5
|
+
import { HostGateway } from '../src/host/hostGateway.ts'
|
|
6
|
+
import { createRuntimeFacade } from '../src/host/facade.ts'
|
|
7
|
+
import { deriveSessionId } from '../src/host/mappings.ts'
|
|
8
|
+
|
|
9
|
+
// ==================== registry resolution(T-3/T-4 fail-closed 组合) ====================
|
|
10
|
+
|
|
11
|
+
const enterprise: RegistryConfig = {
|
|
12
|
+
registries: [
|
|
13
|
+
{ url: 'https://verdaccio.corp.local', priority: 1, signed: false },
|
|
14
|
+
{ url: 'https://artifactory.corp.local/npm', priority: 2, signed: true },
|
|
15
|
+
],
|
|
16
|
+
allowNpmFallback: false,
|
|
17
|
+
offline: false,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('registry: resolution 四级顺序——② 白名单 priority 升序', () => {
|
|
21
|
+
const r = resolvePackage({ name: 'pkg', version: '1.0.0' }, enterprise)
|
|
22
|
+
assert.equal(r.source, 'registry')
|
|
23
|
+
assert.equal(r.url, 'https://verdaccio.corp.local')
|
|
24
|
+
assert.match(r.decisionId, /^RD-[0-9a-f]{16}$/)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('registry: ① 显式源绑定命中 → 直连;未命中 → 硬失败不降级', () => {
|
|
28
|
+
const hit = resolvePackage({ name: 'pkg', version: '1.0.0', pinnedRegistry: 'https://artifactory.corp.local/npm' }, enterprise)
|
|
29
|
+
assert.equal(hit.source, 'registry')
|
|
30
|
+
assert.equal(hit.url, 'https://artifactory.corp.local/npm')
|
|
31
|
+
const miss = resolvePackage({ name: 'pkg', version: '1.0.0', pinnedRegistry: 'https://evil.example.com' }, enterprise)
|
|
32
|
+
assert.equal(miss.source, 'rejected')
|
|
33
|
+
assert.match(miss.detail, /硬失败不换源/)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('registry: ③ 兜底可关——企业默认拒绝;④ 离线最高优先级', () => {
|
|
37
|
+
const empty: RegistryConfig = { registries: [], allowNpmFallback: false, offline: false }
|
|
38
|
+
const r = resolvePackage({ name: 'pkg', version: '1.0.0' }, empty)
|
|
39
|
+
assert.equal(r.source, 'rejected')
|
|
40
|
+
assert.match(r.detail, /白名单为空且兜底关闭/)
|
|
41
|
+
const open: RegistryConfig = { registries: [], allowNpmFallback: true, offline: false }
|
|
42
|
+
assert.equal(resolvePackage({ name: 'pkg', version: '1.0.0' }, open).source, 'npm-fallback')
|
|
43
|
+
const offline = resolvePackage({ name: 'pkg', version: '1.0.0', pinnedRegistry: 'https://artifactory.corp.local/npm' }, { ...enterprise, offline: true })
|
|
44
|
+
assert.equal(offline.source, 'rejected', '离线覆盖一切(含显式绑定)')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('registry: 审计回调全量落盘(含拒绝决策)+ isWhitelisted', () => {
|
|
48
|
+
const audited: string[] = []
|
|
49
|
+
resolvePackage({ name: 'a', version: '1.0.0' }, enterprise, d => audited.push(d.kind))
|
|
50
|
+
resolvePackage({ name: 'b', version: '1.0.0', pinnedRegistry: 'https://evil' }, enterprise, d => audited.push(d.kind))
|
|
51
|
+
assert.equal(audited.length, 2)
|
|
52
|
+
assert.ok(auditsAllRegistry(audited))
|
|
53
|
+
function auditsAllRegistry(kinds: string[]) { return kinds.every(k => k === 'registry-resolution') }
|
|
54
|
+
assert.equal(isWhitelisted('https://verdaccio.corp.local', enterprise), true)
|
|
55
|
+
assert.equal(isWhitelisted('https://evil.example.com', enterprise), false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// ==================== 双宿主契约测试:五类语义扩全(S11 v0 → S13 v1) ====================
|
|
59
|
+
|
|
60
|
+
function setup() {
|
|
61
|
+
const profiles = new Map(HOST_MAPPINGS.map(h => [h.hostId, h]))
|
|
62
|
+
const facade = createRuntimeFacade({ profiles })
|
|
63
|
+
const gw = new HostGateway({ facade, audit: () => {} })
|
|
64
|
+
for (const h of HOST_MAPPINGS) gw.registerHost({ hostId: h.hostId, profile: h, transport: {} as never })
|
|
65
|
+
return gw
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 五类语义 × 双宿主 fixture(契约测试数据源——host-mappings-v1 的行为化规约) */
|
|
69
|
+
const FIVE_CLASSES = [
|
|
70
|
+
{ semantic: 'user-input', cc: ['user_message', { content: 'hi' }], cx: ['input_item', { text: 'hi' }] },
|
|
71
|
+
{ semantic: 'assistant-output', cc: ['assistant_message', { content: 'done' }], cx: ['agent_message', { message: 'done' }] },
|
|
72
|
+
{ semantic: 'tool-call', cc: ['tool_use', { tool_name: 'fs', tool_call_id: 'c1' }], cx: ['function_call', { name: 'fs', call_id: 'c1' }] },
|
|
73
|
+
{ semantic: 'tool-result', cc: ['tool_result', { tool_call_id: 'c1', status: 'ok' }], cx: ['function_call_output', { call_id: 'c1', status: 'ok' }] },
|
|
74
|
+
{ semantic: 'turn-end', cc: ['turn_complete', { reason: 'completed' }], cx: ['task_complete', { reason: 'completed' }] },
|
|
75
|
+
] as const
|
|
76
|
+
|
|
77
|
+
test('契约测试 v1: 五类语义双宿主全链路投影等价(经 facade 落哈希链)', async () => {
|
|
78
|
+
const gw = setup()
|
|
79
|
+
const ccId = ((await gw.handle('claude-code', 'session_start', { hostSessionId: 'fx' })).result as { sessionId: string }).sessionId
|
|
80
|
+
const cxId = ((await gw.handle('codex', 'session_start', { hostSessionId: 'fx' })).result as { sessionId: string }).sessionId
|
|
81
|
+
await gw.handle('claude-code', 'session_turn', { sessionId: ccId, input: { events: FIVE_CLASSES.map(f => ({ hostEvent: f.cc[0], payload: f.cc[1] })) } })
|
|
82
|
+
await gw.handle('codex', 'session_turn', { sessionId: cxId, input: { events: FIVE_CLASSES.map(f => ({ hostEvent: f.cx[0], payload: f.cx[1] })) } })
|
|
83
|
+
const r1 = await gw.handle('claude-code', 'session_replay', { sessionId: ccId })
|
|
84
|
+
const r2 = await gw.handle('codex', 'session_replay', { sessionId: cxId })
|
|
85
|
+
const m1 = (r1.result as { messages: unknown[] }).messages
|
|
86
|
+
const m2 = (r2.result as { messages: unknown[] }).messages
|
|
87
|
+
assert.deepEqual(m1, m2, '五类语义投影逐字节一致')
|
|
88
|
+
assert.equal(m1.length, 4, 'user/assistant/toolCall(assistant)/toolResult 入投影;turnEnd 不入消息流')
|
|
89
|
+
// 两侧哈希链独立完整
|
|
90
|
+
const v1 = await gw.handle('claude-code', 'session_verify', { sessionId: ccId })
|
|
91
|
+
const v2 = await gw.handle('codex', 'session_verify', { sessionId: cxId })
|
|
92
|
+
assert.equal((v1.result as { ok: boolean }).ok, true)
|
|
93
|
+
assert.equal((v2.result as { ok: boolean }).ok, true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('契约测试 v1: 降级同步——未知宿主事件在两侧同构落 hostRaw', async () => {
|
|
97
|
+
const gw = setup()
|
|
98
|
+
const ccId = ((await gw.handle('claude-code', 'session_start', { hostSessionId: 'dg' })).result as { sessionId: string }).sessionId
|
|
99
|
+
const cxId = ((await gw.handle('codex', 'session_start', { hostSessionId: 'dg' })).result as { sessionId: string }).sessionId
|
|
100
|
+
await gw.handle('claude-code', 'session_turn', { sessionId: ccId, input: { events: [{ hostEvent: 'future_thing', payload: { p: 1 } }] } })
|
|
101
|
+
await gw.handle('codex', 'session_turn', { sessionId: cxId, input: { events: [{ hostEvent: 'future_thing', payload: { p: 1 } }] } })
|
|
102
|
+
const e1 = await gw.handle('claude-code', 'session_export', { sessionId: ccId })
|
|
103
|
+
const e2 = await gw.handle('codex', 'session_export', { sessionId: cxId })
|
|
104
|
+
for (const e of [e1, e2]) {
|
|
105
|
+
const jsonl = (e.result as { bundle: { files: { name: string; content: string }[] } }).bundle.files[0].content
|
|
106
|
+
assert.ok(jsonl.includes('"hostRaw"'), 'hostRaw 载体')
|
|
107
|
+
assert.ok(jsonl.includes('future_thing'), '原事件名留痕')
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('契约测试 v1: 会话隔离与确定性派生(同 hostSessionId 跨宿主独立)', async () => {
|
|
112
|
+
assert.notEqual(deriveSessionId('claude-code', 's'), deriveSessionId('codex', 's'))
|
|
113
|
+
const gw = setup()
|
|
114
|
+
const a = await gw.handle('claude-code', 'session_start', { hostSessionId: 'dup' })
|
|
115
|
+
const b = await gw.handle('claude-code', 'session_start', { hostSessionId: 'dup' })
|
|
116
|
+
assert.equal((a.result as { sessionId: string }).sessionId, (b.result as { sessionId: string }).sessionId, '同宿主幂等')
|
|
117
|
+
})
|
package/test/s14.spec.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { convergeBudget, PTC_BUDGET_BASELINE } from '../src/ptc/budget.ts'
|
|
4
|
+
import { checkErasableOnly } from '../src/ptc/erasable.ts'
|
|
5
|
+
import { runCode, makePtcToolDefinition, type ToolBridge } from '../src/ptc/runCode.ts'
|
|
6
|
+
import { runTurn, type ModelStep, type ToolDef } from '../src/loop/stop.ts'
|
|
7
|
+
import { SessionLog } from '../src/session/log.ts'
|
|
8
|
+
|
|
9
|
+
// ==================== 预算收敛(只许下调) ====================
|
|
10
|
+
|
|
11
|
+
test('PTC 预算: 基线对齐 dsh 一手口径(60s/600s/64MB)', () => {
|
|
12
|
+
assert.deepEqual(PTC_BUDGET_BASELINE, { computeMs: 60_000, maxWallMs: 600_000, maxOutputBytes: 64 * 1024 * 1024 })
|
|
13
|
+
const b = convergeBudget()
|
|
14
|
+
assert.deepEqual(b, { computeMs: 60_000, maxWallMs: 600_000, maxOutputBytes: 64 * 1024 * 1024 })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('PTC 预算: 下调通过;上调 = CAR-E-BUDGET 显式报错(fail-closed)', () => {
|
|
18
|
+
const b = convergeBudget({ computeMs: 30_000, maxWallMs: 1000, maxOutputBytes: 1024 })
|
|
19
|
+
assert.deepEqual(b, { computeMs: 30_000, maxWallMs: 1000, maxOutputBytes: 1024 })
|
|
20
|
+
assert.throws(() => convergeBudget({ maxWallMs: 700_000 }), /只许下调/)
|
|
21
|
+
assert.throws(() => convergeBudget({ maxOutputBytes: 65 * 1024 * 1024 }), /CAR-E-BUDGET/)
|
|
22
|
+
assert.throws(() => convergeBudget({ computeMs: -1 }), /positive/)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// ==================== erasable-only 检查 ====================
|
|
26
|
+
|
|
27
|
+
test('erasable: enum/namespace/参数属性/装饰器/import=require 全拒绝', () => {
|
|
28
|
+
const cases: Array<[string, string]> = [
|
|
29
|
+
['enum 声明', 'enum Color { Red }\nreturn 1'],
|
|
30
|
+
['namespace 声明', 'namespace Util {\n export const x = 1\n}\nreturn 1'],
|
|
31
|
+
['构造器参数属性', 'class A { constructor(private x: number) {} }\nreturn 1'],
|
|
32
|
+
['import=require', "import fs = require('node:fs')\nreturn 1"],
|
|
33
|
+
['装饰器', 'const y = Object()\n@Component()\nclass B {}\nreturn 1'],
|
|
34
|
+
]
|
|
35
|
+
for (const [name, code] of cases) {
|
|
36
|
+
const r = checkErasableOnly(code)
|
|
37
|
+
assert.equal(r.ok, false, `${name} 应被拒绝`)
|
|
38
|
+
assert.match(r.violation!, /non-erasable/)
|
|
39
|
+
}
|
|
40
|
+
// 普通 erasable async 函数体通过
|
|
41
|
+
assert.equal(checkErasableOnly('const x: number = 1\nawait Promise.resolve()\nreturn x + 1').ok, true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// ==================== runCode:worker 隔离 + 工具桥 + 预算 ====================
|
|
45
|
+
|
|
46
|
+
function tools(): Map<string, ToolBridge> {
|
|
47
|
+
return new Map([
|
|
48
|
+
['add', { run: async (a: unknown) => (a as { x: number; y: number }).x + (a as { x: number; y: number }).y }],
|
|
49
|
+
])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test('runCode: code+description 双必填(description = 授权门人审凭据)', async () => {
|
|
53
|
+
const t = tools()
|
|
54
|
+
await assert.rejects(() => runCode({ code: ' ', description: 'd', toolCallId: 'p1', budget: { maxWallMs: 1000 } }, { tools: t }), /code is required/)
|
|
55
|
+
await assert.rejects(() => runCode({ code: 'return 1', description: '', toolCallId: 'p1', budget: { maxWallMs: 5000 } }, { tools: t }), /description is required/)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('runCode: worker 执行程序体 return 值回传(每次新 worker)', async () => {
|
|
59
|
+
const r = await runCode({ code: 'const a = await tools.add({ x: 2, y: 3 })\nreturn { sum: a }', description: '两数相加验证工具桥', toolCallId: 'p2', budget: { maxWallMs: 10_000 } }, { tools: tools() })
|
|
60
|
+
assert.equal(r.ok, true, r.error)
|
|
61
|
+
assert.deepEqual(r.result, { sum: 5 })
|
|
62
|
+
assert.ok(r.wallMs < 10_000)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('runCode: 未知工具调用 = 显式错误(不静默)', async () => {
|
|
66
|
+
const r = await runCode({ code: 'await tools.nope({})\nreturn 1', description: '未知工具调用路径验证', toolCallId: 'p3', budget: { maxWallMs: 5000 } }, { tools: tools() })
|
|
67
|
+
assert.equal(r.ok, false)
|
|
68
|
+
assert.match(r.error!, /unknown tool/)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('runCode: maxWallMs 超限 = budget-exceeded 工具错误结果(不掐 turn 不抛异常)', async () => {
|
|
72
|
+
const r = await runCode({ code: 'while (true) { await new Promise(r => setTimeout(r, 10)) }\nreturn 1', description: '死循环预算护栏验证', toolCallId: 'p4', budget: { maxWallMs: 300 } }, { tools: tools() })
|
|
73
|
+
assert.equal(r.ok, false)
|
|
74
|
+
assert.equal(r.budgetExceeded, true)
|
|
75
|
+
assert.match(r.error!, /budget-exceeded \(maxWallMs=300\)/)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('runCode: erasable 违规在入口拒绝(不产生 worker)', async () => {
|
|
79
|
+
await assert.rejects(() => runCode({ code: 'enum E { A }\nreturn 1', description: 'erasable 挂点验证', toolCallId: 'p5', budget: { maxWallMs: 1000 } }, { tools: tools() }), /CAR-E-PTC.*non-erasable/)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// ==================== 停止语义衔接(ADR-001:预算到期不掐 turn) ====================
|
|
83
|
+
|
|
84
|
+
test('PTC × runTurn: 一等 ToolDefinition 强制 write;程序错误结果交回模型后 turn 正常收口', async () => {
|
|
85
|
+
const log = new SessionLog()
|
|
86
|
+
const ptc = makePtcToolDefinition({ tools: tools() })
|
|
87
|
+
assert.equal(ptc.declaredSideEffect, 'write') // containment 非安全边界 → 最高约束
|
|
88
|
+
const all = new Map<string, ToolDef>([['run_code', ptc as unknown as ToolDef]])
|
|
89
|
+
let n = 0
|
|
90
|
+
const r = await runTurn({
|
|
91
|
+
log, turnId: 'PTC', tools: all, preset: { mode: 'full' },
|
|
92
|
+
model: async (): Promise<ModelStep> => {
|
|
93
|
+
if (n++ === 0) return { stopReason: 'toolUse', toolCalls: [{ id: 'pc1', tool: 'run_code', args: { code: 'return await tools.add({ x: 20, y: 22 })', description: '求和演示' } }] }
|
|
94
|
+
return { stopReason: 'stop', text: 'sum is 42' }
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
assert.equal(r.reason, 'completed') // 预算语义外:正常收口
|
|
98
|
+
const call = log.events.find(e => e.kind === 'toolCall')
|
|
99
|
+
assert.ok(call, 'PTC 调用落哈希链(与其他工具同链路,无旁路)')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('PTC × runTurn: 程序体抛错 = 工具错误结果交回模型(turn 不 interrupted/aborted)', async () => {
|
|
103
|
+
const log = new SessionLog()
|
|
104
|
+
const ptc = makePtcToolDefinition({ tools: tools() })
|
|
105
|
+
const all = new Map<string, ToolDef>([['run_code', ptc as unknown as ToolDef]])
|
|
106
|
+
let n = 0
|
|
107
|
+
const r = await runTurn({
|
|
108
|
+
log, turnId: 'PTC2', tools: all, preset: { mode: 'full' },
|
|
109
|
+
model: async (): Promise<ModelStep> => {
|
|
110
|
+
if (n++ === 0) return { stopReason: 'toolUse', toolCalls: [{ id: 'pc2', tool: 'run_code', args: { code: 'throw new Error("boom")', description: '错误路径验证' } }] }
|
|
111
|
+
return { stopReason: 'stop', text: 'handled' }
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
assert.equal(r.reason, 'completed') // 错误结果交回模型继续,六值枚举不被 PTC 扩展
|
|
115
|
+
const res = log.events.find(e => e.kind === 'toolResult') as { payload: { error?: string } } | undefined
|
|
116
|
+
assert.ok(res, 'toolResult 落链')
|
|
117
|
+
})
|
package/test/s15.spec.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { renderSdk, renderSdkFromRegistry } from '../src/ptc/sdk.ts'
|
|
4
|
+
import { runCode, makePtcToolDefinition, type ToolBridge } from '../src/ptc/runCode.ts'
|
|
5
|
+
|
|
6
|
+
// ==================== TS SDK 渲染(声明面 = 授权面) ====================
|
|
7
|
+
|
|
8
|
+
test('SDK 渲染: 注册表快照 → system prompt TS 声明(非 .d.ts 文件)', () => {
|
|
9
|
+
const reg = new Map<string, ToolBridge & { description?: string; paramHint?: string }>([
|
|
10
|
+
['add', { run: async () => {}, description: '两数相加', paramHint: 'args: { x: number; y: number }' }],
|
|
11
|
+
['fs_read', { run: async () => {} }],
|
|
12
|
+
])
|
|
13
|
+
const sdk = renderSdkFromRegistry(reg)
|
|
14
|
+
assert.ok(sdk.includes('declare const tools: {'))
|
|
15
|
+
assert.ok(sdk.includes('"add": (args: { x: number; y: number }) => Promise<unknown> // 两数相加'))
|
|
16
|
+
assert.ok(sdk.includes('"fs_read": (args: unknown) => Promise<unknown>'), '无 paramHint 落 unknown 形状')
|
|
17
|
+
// 空注册表 = 空声明面(授权面同步为空)
|
|
18
|
+
assert.ok(renderSdkFromRegistry(new Map()).includes('declare const tools: {\n};'))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('SDK 渲染: 声明面/授权面一致性——同一 Map 快照两处消费', () => {
|
|
22
|
+
// 红线的可执行化:渲染函数只接受注册表自身(无独立声明通道),结构上不可能漂移
|
|
23
|
+
const reg = new Map<string, ToolBridge>([['only', { run: async () => {} }]])
|
|
24
|
+
const sdk = renderSdkFromRegistry(reg as never)
|
|
25
|
+
assert.ok(sdk.includes('"only"'))
|
|
26
|
+
assert.ok(!sdk.includes('"hidden"'), '未注册工具不可能出现在声明面')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// ==================== 授权门集成(authorizationId 幂等) ====================
|
|
30
|
+
|
|
31
|
+
test('PTC 授权门: 拒绝 = 无 worker 启动 + ptc-denied 审计留痕', async () => {
|
|
32
|
+
const audits: Array<{ kind: string; authorizationId?: string }> = []
|
|
33
|
+
const r = await runCode(
|
|
34
|
+
{ code: 'return 1', description: '拒绝路径验证', toolCallId: 'tc-42', budget: { maxWallMs: 1000 } },
|
|
35
|
+
{ tools: new Map(), audit: d => audits.push(d as never), authorize: async () => false },
|
|
36
|
+
)
|
|
37
|
+
assert.equal(r.ok, false)
|
|
38
|
+
assert.match(r.error!, /authorization-denied/)
|
|
39
|
+
const denied = audits.find(a => a.kind === 'ptc-denied')
|
|
40
|
+
assert.ok(denied, '拒绝落审计')
|
|
41
|
+
assert.equal(denied!.authorizationId, 'ptc-tc-42', '幂等键 = ptc-+toolCallId')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('PTC 授权门: 放行正常执行(authorize 回调看到 description 人审凭据)', async () => {
|
|
45
|
+
let seenDescription = ''
|
|
46
|
+
const r = await runCode(
|
|
47
|
+
{ code: 'return 7', description: '求和放行', toolCallId: 'tc-43', budget: { maxWallMs: 5000 } },
|
|
48
|
+
{ tools: new Map(), authorize: async req => { seenDescription = req.description; return true } },
|
|
49
|
+
)
|
|
50
|
+
assert.equal(r.ok, true)
|
|
51
|
+
assert.equal(r.result, 7)
|
|
52
|
+
assert.equal(seenDescription, '求和放行')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// ==================== secrets 出站覆盖(F12 × worker 输出) ====================
|
|
56
|
+
|
|
57
|
+
test('F12 × PTC: worker 输出含 API Key → 回填上下文前强制脱敏', async () => {
|
|
58
|
+
const audits: Array<{ kind: string; hits?: number }> = []
|
|
59
|
+
const r = await runCode(
|
|
60
|
+
{ code: 'return { note: "key is sk-proj-abcdefghij0123456789abcd keep it safe" }', description: '出站脱敏验证', toolCallId: 'tc-44', budget: { maxWallMs: 5000 } },
|
|
61
|
+
{ tools: new Map(), audit: d => audits.push(d as never) },
|
|
62
|
+
)
|
|
63
|
+
assert.equal(r.ok, true)
|
|
64
|
+
assert.equal(r.secretsRedacted, 1)
|
|
65
|
+
assert.ok(!JSON.stringify(r.result).includes('sk-proj-abcdefghij0123456789abcd'), '明文不可见于返回结果')
|
|
66
|
+
assert.ok(JSON.stringify(r.result).includes('****'), '遮蔽格式生效')
|
|
67
|
+
assert.ok(audits.some(a => a.kind === 'ptc-secrets-redacted'), '脱敏事件落审计')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// ==================== registry × HostGateway tool 面(S12 留白接线) ====================
|
|
71
|
+
|
|
72
|
+
test('registry × facade: tool_list/tool_call 接线(S12 留白补齐)', async () => {
|
|
73
|
+
// 直接验证 registry 判定与 facade tool 语义的分界已由 s13 覆盖;此处验证 makePtcToolDefinition 的
|
|
74
|
+
// audit 流与 registry decisionId 一样具备可落链结构(均为 {kind, ...payload} 纯数据)
|
|
75
|
+
const audits: Array<Record<string, unknown>> = []
|
|
76
|
+
const ptc = makePtcToolDefinition({ tools: new Map([['add', { run: async (a: unknown) => (a as { x: number }).x + 1 }]]), audit: d => audits.push(d) })
|
|
77
|
+
await ptc.run({ code: 'return await tools.add({ x: 41 })', description: '注册表快照执行' })
|
|
78
|
+
assert.ok(audits.every(a => typeof a.kind === 'string'), '审计事件结构化(可落哈希链)')
|
|
79
|
+
assert.equal(audits[0].kind, 'ptc-start')
|
|
80
|
+
})
|
package/test/s17.spec.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { resolvePackage, resolveCandidateChain, type RegistryConfig, type ExcludedSource } from '../src/load/registry.ts'
|
|
4
|
+
import { enforceSignature } from '../src/load/verifier.ts'
|
|
5
|
+
import { createCounters, assertZeroContent } from '../src/telemetry/metrics.ts'
|
|
6
|
+
|
|
7
|
+
const corp: RegistryConfig = {
|
|
8
|
+
registries: [
|
|
9
|
+
{ url: 'https://a.corp.local', priority: 1, signed: true },
|
|
10
|
+
{ url: 'https://b.corp.local', priority: 2, signed: false },
|
|
11
|
+
{ url: 'https://c.corp.local', priority: 3, signed: false },
|
|
12
|
+
],
|
|
13
|
+
allowNpmFallback: true,
|
|
14
|
+
offline: false,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ==================== T-5 定稿②:exclude 通道(类型上禁校验失败换源) ====================
|
|
18
|
+
|
|
19
|
+
test('registry exclude: 网络失败源被跳过,按 priority 继续下一源', () => {
|
|
20
|
+
const exclude: ExcludedSource[] = [{ url: 'https://a.corp.local', reason: 'network' }]
|
|
21
|
+
const r = resolvePackage({ name: 'pkg', version: '1.0.0' }, corp, undefined, { exclude })
|
|
22
|
+
assert.equal(r.source, 'registry')
|
|
23
|
+
assert.equal(r.url, 'https://b.corp.local')
|
|
24
|
+
assert.match(r.detail, /excluded=1/)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('registry exclude: 候选耗尽 → 兜底(T-5 定稿②修正语义:非「白名单为空」)', () => {
|
|
28
|
+
const exclude: ExcludedSource[] = corp.registries.map(r => ({ url: r.url, reason: 'network' as const }))
|
|
29
|
+
const r = resolvePackage({ name: 'pkg', version: '1.0.0' }, corp, undefined, { exclude })
|
|
30
|
+
assert.equal(r.source, 'npm-fallback')
|
|
31
|
+
assert.match(r.detail, /candidates exhausted/)
|
|
32
|
+
// 兜底关闭 → 候选耗尽 = fail-closed 拒绝
|
|
33
|
+
const closed = resolvePackage({ name: 'pkg', version: '1.0.0' }, { ...corp, allowNpmFallback: false }, undefined, { exclude })
|
|
34
|
+
assert.equal(closed.source, 'rejected')
|
|
35
|
+
assert.match(closed.detail, /候选耗尽且兜底关闭/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('registry exclude: 显式绑定源网络失败 → 落审计后按 priority 继续(T-5 ②网络路径)', () => {
|
|
39
|
+
const audits: string[] = []
|
|
40
|
+
const r = resolvePackage(
|
|
41
|
+
{ name: 'pkg', version: '1.0.0', pinnedRegistry: 'https://a.corp.local' }, corp,
|
|
42
|
+
d => audits.push(String((d as { detail: string }).detail)),
|
|
43
|
+
{ exclude: [{ url: 'https://a.corp.local', reason: 'network' }] },
|
|
44
|
+
)
|
|
45
|
+
assert.equal(r.source, 'registry')
|
|
46
|
+
assert.equal(r.url, 'https://b.corp.local', 'pinned 网络失败 ≠ 信任否定,继续剩余白名单')
|
|
47
|
+
assert.equal(audits.length, 1)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('registry exclude: 显式绑定不在白名单 = 硬失败不换源(既有语义保持)', () => {
|
|
51
|
+
const r = resolvePackage({ name: 'pkg', version: '1.0.0', pinnedRegistry: 'https://evil.example.com' }, corp)
|
|
52
|
+
assert.equal(r.source, 'rejected')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('registry: resolveCandidateChain 只读预览(零审计副作用)', () => {
|
|
56
|
+
const audits: unknown[] = []
|
|
57
|
+
const c = resolveCandidateChain(corp)
|
|
58
|
+
assert.deepEqual(c.chain, ['https://a.corp.local', 'https://b.corp.local', 'https://c.corp.local'])
|
|
59
|
+
assert.equal(c.fallback, true)
|
|
60
|
+
assert.equal(audits.length, 0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ==================== S17 采集面(零内容计数器) ====================
|
|
64
|
+
|
|
65
|
+
test('telemetry: 3 Counter 零内容——snapshot 仅枚举 labels,无内容字段', () => {
|
|
66
|
+
const c = createCounters()
|
|
67
|
+
c.onCount('car_load_total', { result: 'ok' })
|
|
68
|
+
c.onCount('car_load_total', { result: 'ok' })
|
|
69
|
+
c.onCount('car_load_total', { result: 'failed' })
|
|
70
|
+
c.onCount('car_unsigned_confirmed', { confirmed: 'no' })
|
|
71
|
+
c.onCount('car_registry_decision', { source: 'registry' })
|
|
72
|
+
const snap = c.snapshot()
|
|
73
|
+
assert.deepEqual(snap, {
|
|
74
|
+
'car_load_total|result=ok': 2,
|
|
75
|
+
'car_load_total|result=failed': 1,
|
|
76
|
+
'car_unsigned_confirmed|confirmed=no': 1,
|
|
77
|
+
'car_registry_decision|source=registry': 1,
|
|
78
|
+
})
|
|
79
|
+
assert.equal(assertZeroContent(snap), true, '零内容红线(无 sk-/路径/长串)')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('telemetry: Q-08 兼容——未接遥测时仅进程内累计(无出站面)', () => {
|
|
83
|
+
const c = createCounters()
|
|
84
|
+
c.onCount('car_registry_decision', { source: 'npm-fallback' })
|
|
85
|
+
// snapshot 即登记表人工填报兜底通道数据源;本层无任何出站调用面
|
|
86
|
+
assert.ok(Object.keys(c.snapshot()).length === 1)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// ==================== verifier × 采集面(unsigned warn 计数) ====================
|
|
90
|
+
|
|
91
|
+
test('verifier onCount: unsigned warn 路径计数(confirmed=no);enforce 路径不产生 unsigned 计数', () => {
|
|
92
|
+
const c = createCounters()
|
|
93
|
+
const deps = { mode: 'warn' as const, trustRootPublicKey: 'x' }
|
|
94
|
+
const r = enforceSignature('hash-abc', undefined, deps, (n, l) => c.onCount(n, l))
|
|
95
|
+
assert.equal(r.allowed, true)
|
|
96
|
+
assert.ok(r.warning)
|
|
97
|
+
assert.equal(c.snapshot()['car_unsigned_confirmed|confirmed=no'], 1)
|
|
98
|
+
// enforce 模式:缺失 = 拒绝(无 unsigned 计数——不是放行路径)
|
|
99
|
+
const r2 = enforceSignature('hash-abc', undefined, { ...deps, mode: 'enforce' }, (n, l) => c.onCount(n, l))
|
|
100
|
+
assert.equal(r2.allowed, false)
|
|
101
|
+
assert.equal(c.snapshot()['car_unsigned_confirmed|confirmed=no'], 1, '计数不增(拒绝路径非放行路径)')
|
|
102
|
+
})
|
package/test/s18.spec.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { join, dirname } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
|
|
8
|
+
|
|
9
|
+
/** 真实子进程 stdio 对话(E-6 预演:非 PassThrough 模拟,是 spawn 进程级验证) */
|
|
10
|
+
function talk(host: string, requests: string[]): Promise<{ outs: any[]; stderr: string }> {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve', '--host', host], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
13
|
+
let out = ''
|
|
14
|
+
let err = ''
|
|
15
|
+
child.stdout.on('data', d => { out += d })
|
|
16
|
+
child.stderr.on('data', d => { err += d })
|
|
17
|
+
child.on('error', reject)
|
|
18
|
+
child.on('exit', () => {
|
|
19
|
+
try { resolve({ outs: out.split('\n').filter(Boolean).map(l => JSON.parse(l)), stderr: err }) } catch (e) { reject(e) }
|
|
20
|
+
})
|
|
21
|
+
for (const r of requests) child.stdin.write(r + '\n')
|
|
22
|
+
child.stdin.end()
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('S18: mcp-serve 真实进程——tools/list + session_start + turn + verify 全链路', async () => {
|
|
27
|
+
const { outs } = await talk('claude-code', [
|
|
28
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
|
|
29
|
+
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 'cc-e2e' } } }),
|
|
30
|
+
])
|
|
31
|
+
assert.equal(outs[0].result.tools.length, 9)
|
|
32
|
+
const sid = JSON.parse(outs[1].result.content[0].text).sessionId
|
|
33
|
+
assert.match(sid, /^SH-[0-9a-f]{24}$/)
|
|
34
|
+
|
|
35
|
+
// 同一进程第二轮(另起进程验证幂等派生一致性)
|
|
36
|
+
const { outs: outs2 } = await talk('claude-code', [
|
|
37
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 'cc-e2e' } } }),
|
|
38
|
+
])
|
|
39
|
+
assert.equal(JSON.parse(outs2[0].result.content[0].text).sessionId, sid, '跨进程确定性派生(幂等)')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('S18: 采集窗口——会话结束 stderr 输出零内容快照(登记表兜底通道实化)', async () => {
|
|
43
|
+
const { stderr } = await talk('claude-code', [
|
|
44
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's' } } }),
|
|
45
|
+
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'session_turn', arguments: { sessionId: 'SH-nonexistent', input: { events: [] } } } }),
|
|
46
|
+
])
|
|
47
|
+
assert.match(stderr, /\[car mcp-serve\].*snapshot=/)
|
|
48
|
+
assert.match(stderr, /zeroContent=true/, '零内容红线自检通过')
|
|
49
|
+
const snap = JSON.parse(/snapshot=(\{.*?\}) zeroContent/.exec(stderr)![1])
|
|
50
|
+
assert.ok('car_registry_decision|source=registry' in snap)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('S18: codex 宿主接入(同一入口不同 hostId)', async () => {
|
|
54
|
+
const { outs } = await talk('codex', [
|
|
55
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 'cx-e2e' } } }),
|
|
56
|
+
])
|
|
57
|
+
const sid = JSON.parse(outs[0].result.content[0].text).sessionId
|
|
58
|
+
assert.match(sid, /^SH-[0-9a-f]{24}$/)
|
|
59
|
+
const cc = await talk('claude-code', [
|
|
60
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 'cx-e2e' } } }),
|
|
61
|
+
])
|
|
62
|
+
assert.notEqual(JSON.parse(cc.outs[0].result.content[0].text).sessionId, sid, '跨宿主隔离经真实进程验证')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('S18: 未知 hostId 显式拒绝(exit 2)', async () => {
|
|
66
|
+
const code = await new Promise<number>(resolve => {
|
|
67
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve', '--host', 'unknown-host'])
|
|
68
|
+
child.on('exit', c => resolve(c ?? -1))
|
|
69
|
+
child.stdin.end()
|
|
70
|
+
})
|
|
71
|
+
assert.equal(code, 2)
|
|
72
|
+
})
|