@lqc123qwe/car-runtime 1.7.0 → 1.9.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.
@@ -12,6 +12,28 @@
12
12
  * 无 model() 循环,step span 面外推登记为不适用)。facade 内部自增派生 turnId(T{n}),装配层
13
13
  * 调用前不可知——TurnSpanHandle 加法 `setTurnId` 回填面(car run 直传 turnId 路径不受影响);
14
14
  * startTurn('') 空值不设 car.turn_id 属性(回填前出站形态无占位内容)。
15
+ *
16
+ * 1.8(D-29/D-30/D-31 · W3C traceparent 跨进程传播):自研 traceId(16B hex)与 spanId(8B hex)
17
+ * 格式天然满足 W3C Trace Context(trace-id 32hex / span-id 16hex),1.8 补齐编解码与三边界载体:
18
+ * - formatTraceparent/parseTraceparent(D-29):纯字符串编解码,零依赖;parse 严格校验
19
+ * (version 00 / 32hex / 16hex / 全零拒绝),畸形返回 null——**协议面 fail-open**(挂链放弃、
20
+ * 新 trace,遥测面禁 fail-hard——BD-05 同源);flags 出站恒 '01'(本端记录中——采样为端点
21
+ * 本地决策,不跨端传播语义);
22
+ * - mcpToolSpan(D-29):car run 外呼 MCP 工具的 client span(parent = turn span——step span 已于
23
+ * model() 返回时收口,工具执行在 step 外);attributes car.mcp_server/car.tool 为**登记标识符面**
24
+ * (零内容口径扩展:注册表枚举标识,非会话内容);traceparent 供 gateway.callTool 注入
25
+ * params._meta(otel 关 = '' 不注入,线上 JSON-RPC 字节面不变);
26
+ * - startTurn 加法 opts.trace(D-30):mcp-serve 服务端挂链远端宿主——traceId 复用远端 +
27
+ * parentSpanId = 远端 spanId,car.turn span 加入宿主 trace(fail-open:畸形 traceparent 由
28
+ * 调用方 parse 为 null 后不传,新 trace);
29
+ * - ptcRelay(D-31):PTC worker 桥挂链——workerTrace 载荷(traceId/parentSpanId)经 workerData
30
+ * 进隔离体,worker 计时经 done 消息回传后 complete() 主线程单点收口 car.ptc span(facade
31
+ * time.startMs/end(atMs) 时刻覆盖;单 exporter 纪律——worker 零独立出站通道)。
32
+ *
33
+ * 1.9(D-32 · 远程 MCP Streamable HTTP 头通道):mcpToolSpan 产出的 traceparent 除 1.8 `params._meta`
34
+ * 载体外,经 gateway.callTool → transport.send 第二参透传,httpClient 注入 `traceparent` 请求头
35
+ * (W3C Trace Context HTTP 载体惯例——远程 server 主消费面)。本模块仅复用 parseTraceparent 作
36
+ * 传输层 fail-open 校验(畸形不注入不抛)——编解码与传播语义 1.8 口径零触碰。
15
37
  */
16
38
  import { randomBytes } from 'node:crypto'
17
39
  import type { TelemetryFacade } from './telemetry.ts'
@@ -21,6 +43,20 @@ export interface StepSpanHandle {
21
43
  recordException(e: unknown): void
22
44
  }
23
45
 
46
+ /** 1.8-D29:MCP 外呼 client span 句柄——traceparent = '' 表示无遥测(不注入 _meta) */
47
+ export interface McpClientSpanHandle {
48
+ readonly traceparent: string
49
+ end(outcome: string): void
50
+ recordException(e: unknown): void
51
+ }
52
+
53
+ /** 1.8-D31:PTC worker 桥挂链句柄——workerTrace=null 表示无遥测(worker 零感知零改动) */
54
+ export interface PtcRelayHandle {
55
+ readonly workerTrace: { traceId: string; parentSpanId: string } | null
56
+ /** worker 计时回传收口(主线程单点出站;error 路径 recordException D-26 形态) */
57
+ complete(startMs: number, endMs: number, outcome: string, error?: unknown): void
58
+ }
59
+
24
60
  export interface TurnSpanHandle {
25
61
  traceId: string
26
62
  spanId: string
@@ -30,6 +66,30 @@ export interface TurnSpanHandle {
30
66
  recordException(e: unknown): void
31
67
  end(outcome: string): void
32
68
  stepSpan(step: number): StepSpanHandle
69
+ /** 1.8-D29:MCP 外呼 client span(parent = turn span;noop 门面 traceparent='' 零注入) */
70
+ mcpToolSpan(serverId: string, tool: string): McpClientSpanHandle
71
+ /** 1.8-D31:PTC worker 桥挂链面(noop 门面 workerTrace=null 零载荷) */
72
+ ptcRelay(): PtcRelayHandle
73
+ }
74
+
75
+ /** 1.8-D29:W3C traceparent 出站形态(flags '01' = 本端记录中;采样为端点本地决策不跨端传播) */
76
+ export function formatTraceparent(traceId: string, spanId: string): string {
77
+ return `00-${traceId}-${spanId}-01`
78
+ }
79
+
80
+ const W3C_TP_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/
81
+
82
+ /**
83
+ * 1.8-D29:W3C traceparent 解析——严格校验(version 00 / trace-id 32hex 非 / span-id 16hex 非 / 全零拒绝)。
84
+ * 畸形返回 null = 挂链放弃(调用方走新 trace——协议面 fail-open,禁 fail-hard)。
85
+ */
86
+ export function parseTraceparent(tp: string): { traceId: string; parentSpanId: string } | null {
87
+ const m = W3C_TP_RE.exec(tp.trim())
88
+ if (!m) return null
89
+ const traceId = m[1]!
90
+ const parentSpanId = m[2]!
91
+ if (/^0+$/.test(traceId) || /^0+$/.test(parentSpanId)) return null
92
+ return { traceId, parentSpanId }
33
93
  }
34
94
 
35
95
  export class TurnTracer {
@@ -40,13 +100,22 @@ export class TurnTracer {
40
100
  this.#facade = facade?.enabled ? facade : null
41
101
  }
42
102
 
43
- startTurn(turnId: string): TurnSpanHandle {
103
+ /** 1.8-D30:opts.trace = 远端挂链(mcp-serve session_turn `_meta.traceparent` 提取后传入;缺省新 trace 不变) */
104
+ startTurn(turnId: string, opts?: { trace?: { traceId: string; parentSpanId: string } }): TurnSpanHandle {
44
105
  if (!this.#facade) {
45
- return { traceId: '', spanId: '', setTurnId() {}, recordException() {}, end() {}, stepSpan: () => ({ end() {}, recordException() {} }) }
106
+ return {
107
+ traceId: '', spanId: '', setTurnId() {}, recordException() {}, end() {},
108
+ stepSpan: () => ({ end() {}, recordException() {} }),
109
+ mcpToolSpan: () => ({ traceparent: '', end() {}, recordException() {} }),
110
+ ptcRelay: () => ({ workerTrace: null, complete() {} }),
111
+ }
46
112
  }
47
113
  const tracer = this.#facade.getTracer()
48
- const traceId = randomBytes(16).toString('hex')
49
- const turn = tracer.startSpan('car.turn', { attributes: turnId ? { 'car.turn_id': turnId } : {}, trace: { traceId } })
114
+ const traceId = opts?.trace?.traceId ?? randomBytes(16).toString('hex')
115
+ const turn = tracer.startSpan('car.turn', {
116
+ attributes: turnId ? { 'car.turn_id': turnId } : {},
117
+ trace: { traceId, ...(opts?.trace?.parentSpanId ? { parentSpanId: opts.trace.parentSpanId } : {}) },
118
+ })
50
119
  return {
51
120
  traceId,
52
121
  spanId: turn.spanId,
@@ -69,6 +138,38 @@ export class TurnTracer {
69
138
  recordException(e) { s.recordException(e) },
70
139
  }
71
140
  },
141
+ // 1.8-D29:client span 挂 turn(工具执行在 step span 收口后——turn 为最近存活父)
142
+ mcpToolSpan(serverId, tool) {
143
+ const s = tracer.startSpan('car.mcp.tool', {
144
+ attributes: { 'car.mcp_server': serverId, 'car.tool': tool },
145
+ trace: { traceId, parentSpanId: turn.spanId },
146
+ })
147
+ return {
148
+ traceparent: formatTraceparent(traceId, s.spanId),
149
+ end(outcome) {
150
+ s.setAttribute('car.outcome', outcome)
151
+ s.end()
152
+ },
153
+ recordException(e) { s.recordException(e) },
154
+ }
155
+ },
156
+ // 1.8-D31:worker 侧计时 relay——span 生成收口在主线程(单 exporter 纪律)
157
+ ptcRelay(): PtcRelayHandle {
158
+ return {
159
+ workerTrace: { traceId, parentSpanId: turn.spanId },
160
+ complete(startMs, endMs, outcome, error) {
161
+ // 负时长防线(同时钟域不应出现——防御下游转换器语义错乱)
162
+ const s = tracer.startSpan('car.ptc', {
163
+ attributes: { 'car.turn_id': turnId },
164
+ trace: { traceId, parentSpanId: turn.spanId },
165
+ time: { startMs },
166
+ })
167
+ if (error !== undefined) s.recordException(error instanceof Error ? error : new Error(String(error)))
168
+ s.setAttribute('car.outcome', outcome)
169
+ s.end(Math.max(endMs, startMs))
170
+ },
171
+ }
172
+ },
72
173
  }
73
174
  }
74
175
  }
@@ -0,0 +1,415 @@
1
+ /**
2
+ * 1.8 · W3C traceparent 跨进程传播(D-29/D-30/D-31 · S42)
3
+ *
4
+ * 覆盖(1.8-迭代规划 TRACE-1/2/3):
5
+ * - 单元:W3C 编解码(合法/畸形/全零 fail-open);mcpToolSpan client span(挂 turn + traceparent
6
+ * 形态 + noop 零注入);startTurn opts.trace 远端挂链;ptcRelay relay span(worker 时刻覆盖 +
7
+ * error 路径);gateway.callTool `_meta` 注入与缺省不注入(字节面兼容);
8
+ * - E2E-A:car run --mcp --ptc 全链——假 MCP server 捕获 `_meta.traceparent` 回显进工具结果,
9
+ * 与 OTLP 出站 car.mcp.tool span 三点对拍闭合(traceId + spanId)+ car.ptc relay span 挂链
10
+ * turn + worker 计时窗口;零内容断言(结果文本/提示词不出站);
11
+ * - E2E-B:mcp-serve 收 `_meta.traceparent` → car.turn span 加入宿主 trace(traceId 复用 +
12
+ * parentSpanId = 远端 spanId);畸形 traceparent fail-open 新 trace。
13
+ */
14
+ import { test } from 'node:test'
15
+ import assert from 'node:assert/strict'
16
+ import { spawn } from 'node:child_process'
17
+ import { createServer, type Server } from 'node:http'
18
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { join, dirname } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+ import { createTelemetryFacade } from '../src/runtime-core/telemetry.ts'
23
+ import { TurnTracer, formatTraceparent, parseTraceparent } from '../src/runtime-core/trace.ts'
24
+ import { McpGateway, type ClientTransport, type JsonRpcRequest, type JsonRpcResponse } from '../src/mcp/gateway.ts'
25
+
26
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
27
+ const CLI = join(ROOT, 'src', 'cli.ts')
28
+
29
+ const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
30
+
31
+ // 密钥扫描口径:夹具凭据经变量注入(赋值行无引号字面量)
32
+ const TEST_KEY = 'test-key-123'
33
+
34
+ function withTempDir(name: string, fn: (dir: string) => Promise<void>): Promise<void> {
35
+ const dir = mkdtempSync(join(tmpdir(), `car-s42-${name}-`))
36
+ return fn(dir).finally(() => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } })
37
+ }
38
+
39
+ const sse = (chunks: object[]) => chunks.map(c => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n'
40
+
41
+ // ==================== 单元:W3C 编解码(D-29) ====================
42
+
43
+ test('S42: formatTraceparent/parseTraceparent——W3C 形态 + 畸形 fail-open(null 不抛)', () => {
44
+ const tid = 'a'.repeat(32)
45
+ const sid = 'b'.repeat(16)
46
+ assert.equal(formatTraceparent(tid, sid), `00-${tid}-${sid}-01`, 'flags 01(本端记录中)')
47
+ assert.deepEqual(parseTraceparent(`00-${tid}-${sid}-01`), { traceId: tid, parentSpanId: sid })
48
+ assert.deepEqual(parseTraceparent(`00-${tid}-${sid}-ff`), { traceId: tid, parentSpanId: sid }, 'flags 任意值接受(挂链不消费 flags)')
49
+ // 畸形 fail-open:全 null 不抛(遥测面禁 fail-hard——BD-05 同源)
50
+ assert.equal(parseTraceparent('garbage'), null, '非格式串')
51
+ assert.equal(parseTraceparent(`ff-${tid}-${sid}-01`), null, 'version 非 00')
52
+ assert.equal(parseTraceparent(`00-${'a'.repeat(31)}-${sid}-01`), null, 'trace-id 错长')
53
+ assert.equal(parseTraceparent(`00-${tid}-${'b'.repeat(15)}-01`), null, 'span-id 错长')
54
+ assert.equal(parseTraceparent(`00-${'0'.repeat(32)}-${sid}-01`), null, 'trace-id 全零(W3C 无效)')
55
+ assert.equal(parseTraceparent(`00-${tid}-${'0'.repeat(16)}-01`), null, 'span-id 全零(W3C 无效)')
56
+ assert.equal(parseTraceparent(`00-${'g'.repeat(32)}-${sid}-01`), null, '非 hex')
57
+ // 自研 id 形态天然合规:16B/8B hex 往返
58
+ const rt = parseTraceparent(formatTraceparent(tid, sid))
59
+ assert.deepEqual(rt, { traceId: tid, parentSpanId: sid }, 'format→parse 往返一致')
60
+ })
61
+
62
+ // ==================== 单元:mcpToolSpan client span(D-29) ====================
63
+
64
+ test('S42: mcpToolSpan——car.mcp.tool 挂 turn + traceparent 形态;noop 门面 traceparent 零注入', async () => {
65
+ await withTempDir('client-span', async () => {
66
+ const bodies: any[] = []
67
+ const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
68
+ fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
69
+ })
70
+ const tracer = new TurnTracer(f)
71
+ const turn = tracer.startTurn('T0')
72
+ const span = turn.mcpToolSpan('helper', 'upper')
73
+ // traceparent 形态 = 00-{turn traceId}-{本 span spanId}-01
74
+ const parsed = parseTraceparent(span.traceparent)
75
+ assert.ok(parsed, 'traceparent 可解析')
76
+ assert.equal(parsed!.traceId, turn.traceId, 'client span traceId = turn traceId')
77
+ span.end('ok')
78
+ const span2 = turn.mcpToolSpan('helper', 'upper')
79
+ span2.recordException(new Error('boom'))
80
+ span2.end('error')
81
+ turn.end('completed')
82
+ await f.flush()
83
+ const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
84
+ const mcp = spans.filter((s: any) => s.name === 'car.mcp.tool')
85
+ assert.equal(mcp.length, 2, '两次外呼两个 client span')
86
+ assert.equal(mcp[0].parentSpanId, spans.find((s: any) => s.name === 'car.turn').spanId, 'client span 父 = turn spanId')
87
+ assert.equal(mcp[0].traceId, turn.traceId, '共享 trace')
88
+ const attrs = Object.fromEntries(mcp[0].attributes.map((a: any) => [a.key, a.value.stringValue]))
89
+ assert.equal(attrs['car.mcp_server'], 'helper', 'serverId 标识符面(零内容口径扩展登记)')
90
+ assert.equal(attrs['car.tool'], 'upper')
91
+ assert.equal(attrs['car.outcome'], 'ok')
92
+ // 本 span spanId 与 traceparent 三点一致(E2E-A 端到端对拍的单元基线)
93
+ const outSpanId = mcp[0].spanId
94
+ assert.equal(parsed!.parentSpanId, outSpanId, 'traceparent spanId = 出站 spanId')
95
+ const errAttrs = Object.fromEntries(mcp[1].attributes.map((a: any) => [a.key, a.value.stringValue]))
96
+ assert.equal(errAttrs['car.outcome'], 'error')
97
+ assert.equal(mcp[1].status.code, 'STATUS_CODE_ERROR', 'error 路径 D-26 形态')
98
+ // noop 门面:traceparent=''(调用方不注入 _meta——字节面兼容线)
99
+ const noopTracer = new TurnTracer(createTelemetryFacade(null))
100
+ const noopSpan = noopTracer.startTurn('T0').mcpToolSpan('helper', 'upper')
101
+ assert.equal(noopSpan.traceparent, '', 'noop 零注入面')
102
+ noopSpan.end('ok')
103
+ noopTracer.startTurn('T0').ptcRelay().complete(0, 0, 'ok') // noop relay 零抛
104
+ })
105
+ })
106
+
107
+ // ==================== 单元:startTurn 远端挂链(D-30) + ptcRelay(D-31) ====================
108
+
109
+ test('S42: startTurn opts.trace——turn span 加入远端 trace(traceId 复用 + parentSpanId)', async () => {
110
+ await withTempDir('server-link', async () => {
111
+ const bodies: any[] = []
112
+ const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
113
+ fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
114
+ })
115
+ const tracer = new TurnTracer(f)
116
+ const remoteTraceId = 'c'.repeat(32)
117
+ const remoteSpanId = 'd'.repeat(16)
118
+ const turn = tracer.startTurn('', { trace: { traceId: remoteTraceId, parentSpanId: remoteSpanId } })
119
+ turn.setTurnId('T1')
120
+ turn.end('completed')
121
+ await f.flush()
122
+ const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
123
+ const t = spans.find((s: any) => s.name === 'car.turn')
124
+ assert.equal(t.traceId, remoteTraceId, 'traceId 复用远端(加入宿主 trace)')
125
+ assert.equal(t.parentSpanId, remoteSpanId, 'parentSpanId = 远端 spanId')
126
+ // step 子 span 同 trace 挂 turn(挂链后层级不破坏)
127
+ const turn2 = tracer.startTurn('', { trace: { traceId: remoteTraceId, parentSpanId: remoteSpanId } })
128
+ turn2.stepSpan(1).end('stop')
129
+ turn2.end('completed')
130
+ await f.flush()
131
+ const spans2 = bodies.flatMap(b => b.scopeSpans[0].spans)
132
+ const step = spans2.find((s: any) => s.name === 'car.step')
133
+ assert.equal(step.traceId, remoteTraceId)
134
+ assert.equal(step.parentSpanId, spans2.filter((s: any) => s.name === 'car.turn').at(-1)!.spanId)
135
+ })
136
+ })
137
+
138
+ test('S42: ptcRelay——car.ptc span 主线程收口(worker 时刻覆盖 + 挂链 turn + error 形态)', async () => {
139
+ await withTempDir('ptc-relay', async () => {
140
+ const bodies: any[] = []
141
+ const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
142
+ fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
143
+ })
144
+ const tracer = new TurnTracer(f)
145
+ const turn = tracer.startTurn('T0')
146
+ const relay = turn.ptcRelay()
147
+ assert.deepEqual(relay.workerTrace, { traceId: turn.traceId, parentSpanId: turn.spanId }, 'workerTrace 载荷 = turn 挂链')
148
+ // ok 路径:worker 计时时刻覆盖(1000→1060 不经 now()——精确出站)
149
+ relay.complete(1000, 1060, 'ok')
150
+ // error 路径:recordException D-26 形态
151
+ turn.ptcRelay().complete(2000, 2250, 'error', new Error('boom'))
152
+ turn.end('completed')
153
+ await f.flush()
154
+ const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
155
+ const ptc = spans.filter((s: any) => s.name === 'car.ptc')
156
+ assert.equal(ptc.length, 2)
157
+ assert.equal(ptc[0].startTimeUnixNano, String(1000 * 1e6), 'startMs 时刻覆盖(非 now())')
158
+ assert.equal(ptc[0].endTimeUnixNano, String(1060 * 1e6), 'endMs 时刻覆盖')
159
+ assert.equal(ptc[0].parentSpanId, turn.spanId, 'car.ptc 挂 turn')
160
+ assert.equal(ptc[0].traceId, turn.traceId, '同 trace')
161
+ assert.equal(ptc[0].status.code, 'STATUS_CODE_UNSET')
162
+ assert.equal(ptc[1].status.code, 'STATUS_CODE_ERROR', 'error 路径 status')
163
+ const ev = ptc[1].events?.find((e: any) => e.name === 'exception')
164
+ assert.ok(ev, 'error 路径 exception 事件(D-26)')
165
+ // 负时长防线:endMs < startMs 收口为 startMs(防御下游转换器)
166
+ turn.ptcRelay().complete(3000, 2999, 'ok')
167
+ turn.end('completed')
168
+ await f.flush()
169
+ const ptc3 = bodies.flatMap(b => b.scopeSpans[0].spans).filter((s: any) => s.name === 'car.ptc').at(-1)!
170
+ assert.equal(ptc3.endTimeUnixNano, ptc3.startTimeUnixNano, '负时长钳制')
171
+ // noop 门面:workerTrace null + complete 零出站
172
+ const noopRelay = new TurnTracer(createTelemetryFacade(null)).startTurn('T0').ptcRelay()
173
+ assert.equal(noopRelay.workerTrace, null)
174
+ noopRelay.complete(0, 0, 'ok')
175
+ })
176
+ })
177
+
178
+ // ==================== 单元:gateway.callTool `_meta` 注入(D-29) ====================
179
+
180
+ test('S42: callTool traceparent 注入 params._meta;缺省无 _meta 键(字节面兼容)', async () => {
181
+ await withTempDir('meta', async () => {
182
+ const sent: JsonRpcRequest[] = []
183
+ const transport: ClientTransport = {
184
+ send: async (req) => {
185
+ sent.push(req)
186
+ // tools/list 需回工具面(登记制:无工具则 callTool 不发送直接 unknown tool)
187
+ const result = req.method === 'tools/list'
188
+ ? { tools: [{ name: 'upper', sideEffect: 'readonly' }] }
189
+ : {}
190
+ return { jsonrpc: '2.0', id: req.id, result } as JsonRpcResponse
191
+ },
192
+ alive: () => true,
193
+ close: () => {},
194
+ }
195
+ const gw = new McpGateway()
196
+ await gw.register({ serverId: 'helper', transport })
197
+ sent.length = 0
198
+ await gw.callTool('helper', 'upper', { text: 'x' }, { traceparent: formatTraceparent('a'.repeat(32), 'b'.repeat(16)) })
199
+ assert.equal((sent[0].params as any)._meta?.traceparent, formatTraceparent('a'.repeat(32), 'b'.repeat(16)), '_meta.traceparent 注入')
200
+ assert.equal(sent[0].params && 'name' in (sent[0].params as object), true, 'params.name 保持')
201
+ await gw.callTool('helper', 'upper', { text: 'x' })
202
+ const p2 = sent[1].params as Record<string, unknown>
203
+ assert.equal('_meta' in p2, false, '缺省不注入(线上 JSON-RPC 字节面不变)')
204
+ await gw.callTool('helper', 'upper', { text: 'x' }, { traceparent: '' })
205
+ assert.equal('_meta' in (sent[2].params as object), false, '空串不注入(noop 门面路径)')
206
+ })
207
+ })
208
+
209
+ // ==================== E2E-A:car run --mcp --ptc 全链(D-29/D-31 生产调用点) ====================
210
+
211
+ /** 受控 stdio MCP server(node 脚本文本):tools/call 捕获 _meta.traceparent 回显进工具结果 */
212
+ const FAKE_MCP_SERVER = `
213
+ const lines = [];
214
+ let buf = '';
215
+ process.stdin.on('data', d => {
216
+ buf += d.toString();
217
+ let nl;
218
+ while ((nl = buf.indexOf('\\n')) >= 0) {
219
+ const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
220
+ if (!line) continue;
221
+ const req = JSON.parse(line);
222
+ if (req.method === 'tools/list') {
223
+ reply(req, { tools: [{ name: 'upper', description: '大写工具', sideEffect: 'readonly', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } }] });
224
+ } else if (req.method === 'tools/call') {
225
+ const tp = (req.params._meta || {}).traceparent || 'none';
226
+ reply(req, { content: [{ type: 'text', text: String(req.params.arguments.text).toUpperCase() + '|tp:' + tp }] });
227
+ }
228
+ }
229
+ });
230
+ function reply(req, result) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); }
231
+ `
232
+
233
+ const RUN_CODE = `const r = await tools.mcp_helper_upper({ text: 'abc' })\nawait new Promise(res => setTimeout(res, 60))\nreturn r`
234
+
235
+ test('S42 E2E-A: car run 全链——_meta.traceparent 与 OTLP car.mcp.tool 三点闭合 + car.ptc relay span', async () => {
236
+ await withTempDir('full-chain', async dir => {
237
+ // 插件(run 首位参数——s32 同款装配面)
238
+ writeFileSync(join(dir, 'p.ts'), `export const manifest = { name: 'p', version: '1.0.0' }\n` +
239
+ `export default function apply() {}\n`)
240
+ writeFileSync(join(dir, 'car.config.json'), JSON.stringify({
241
+ llm: { model: 'test-model' },
242
+ mcp: { servers: { helper: { command: process.execPath, args: ['-e', FAKE_MCP_SERVER] } } },
243
+ }))
244
+ // 假 LLM:req1 → run_code(worker 桥调 MCP);req2 → 直调 MCP 工具;req3 → done
245
+ const llmBodies: any[] = []
246
+ let n = 0
247
+ const llmServer: Server = createServer((req, res) => {
248
+ let raw = ''
249
+ req.on('data', c => { raw += c })
250
+ req.on('end', () => {
251
+ llmBodies.push(JSON.parse(raw))
252
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
253
+ res.end(n++ === 0
254
+ ? sse([
255
+ { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'run_code', arguments: JSON.stringify({ code: RUN_CODE, description: '经桥调 MCP 大写' } ) } }] } }] },
256
+ { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
257
+ ])
258
+ : n === 2
259
+ ? sse([
260
+ { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c2', function: { name: 'mcp_helper_upper', arguments: JSON.stringify({ text: 'xyz' } ) } }] } }] },
261
+ { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
262
+ ])
263
+ : sse([{ choices: [{ delta: { role: 'assistant', content: '' } }] }, { choices: [{ delta: { content: 'done' } }] }, { choices: [{ delta: {}, finish_reason: 'stop' }] }]))
264
+ })
265
+ })
266
+ await new Promise<void>(r => llmServer.listen(0, '127.0.0.1', r))
267
+ const llmPort = (llmServer.address() as { port: number }).port
268
+ // OTLP 捕获端点
269
+ const otlpBodies: any[] = []
270
+ const otlp: Server = createServer((req, res) => {
271
+ let raw = ''
272
+ req.on('data', c => { raw += c })
273
+ req.on('end', () => { if (req.url === '/v1/traces') otlpBodies.push(JSON.parse(raw)); res.writeHead(200).end() })
274
+ })
275
+ await new Promise<void>(r => otlp.listen(0, '127.0.0.1', r))
276
+ const otlpPort = (otlp.address() as { port: number }).port
277
+
278
+ const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'p.ts',
279
+ '--prompt', 'uppercase please', '--base-url', `http://127.0.0.1:${llmPort}/v1`,
280
+ '--mcp', 'helper', '--ptc'], {
281
+ cwd: dir,
282
+ env: { ...process.env, OPENAI_API_KEY: TEST_KEY, CAR_ALLOW_ENV_CREDENTIALS: '1', CAR_OTEL_ENDPOINT: `http://127.0.0.1:${otlpPort}` },
283
+ stdio: ['ignore', 'pipe', 'pipe'],
284
+ })
285
+ let out = ''
286
+ let err = ''
287
+ child.stdout.on('data', d => { out += d })
288
+ child.stderr.on('data', d => { err += d })
289
+ const exitDone = new Promise<number>(resolve => child.on('exit', c => resolve(c ?? -1)))
290
+ const code = await Promise.race([exitDone, new Promise<number>(r => setTimeout(() => r(-999), 60_000))])
291
+ llmServer.close()
292
+ otlp.close()
293
+
294
+ assert.equal(code, 0, `exit=${code}\nSTDOUT:\n${out}\nSTDERR:\n${err}`)
295
+ assert.match(out, /turnEnd=completed/)
296
+ // 工具结果含回显 traceparent(假 MCP server 捕获 _meta)——桥内与直调两条路径
297
+ const toolMsgs = llmBodies.flatMap(b => b.messages).filter((m: any) => m.role === 'tool')
298
+ const echoed: string[] = []
299
+ for (const m of toolMsgs) {
300
+ const s = JSON.stringify(m.content)
301
+ for (const g of s.matchAll(/tp:(00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2})/g)) echoed.push(g[1]!)
302
+ }
303
+ assert.ok(echoed.length >= 2, `两条 MCP 路径均回显 traceparent(${JSON.stringify(echoed)})`)
304
+
305
+ // OTLP 出站对拍(三点闭合:回显 traceparent ↔ 出站 car.mcp.tool spanId ↔ 同 traceId)
306
+ await new Promise(r => setTimeout(r, 300))
307
+ const spans = otlpBodies.flatMap(b => b.scopeSpans[0].spans)
308
+ const turnSpan = spans.find((s: any) => s.name === 'car.turn')
309
+ assert.ok(turnSpan, 'turn span 出站')
310
+ const mcpSpans = spans.filter((s: any) => s.name === 'car.mcp.tool')
311
+ assert.equal(mcpSpans.length, 2, '两次 MCP 外呼两个 client span')
312
+ for (const s of mcpSpans) {
313
+ assert.equal(s.parentSpanId, turnSpan.spanId, 'client span 父 = turn')
314
+ assert.equal(s.traceId, turnSpan.traceId, '同 trace')
315
+ }
316
+ const outSpanIds = new Set(mcpSpans.map((s: any) => s.spanId))
317
+ const outTraceId = turnSpan.traceId
318
+ for (const tp of echoed) {
319
+ const p = parseTraceparent(tp)!
320
+ assert.ok(p, `回显 traceparent 可解析:${tp}`)
321
+ assert.equal(p.traceId, outTraceId, '回显 traceId == 出站 turn traceId(端到端同 trace)')
322
+ assert.ok(outSpanIds.has(p.parentSpanId), '回显 spanId == 出站 car.mcp.tool spanId(client span 对拍闭合)')
323
+ }
324
+ // car.ptc relay span:挂链 turn + worker 计时窗口(程序含 60ms sleep)
325
+ const ptcSpan = spans.find((s: any) => s.name === 'car.ptc')
326
+ assert.ok(ptcSpan, 'car.ptc relay span 出站')
327
+ assert.equal(ptcSpan.parentSpanId, turnSpan.spanId, 'car.ptc 挂 turn')
328
+ assert.equal(ptcSpan.traceId, turnSpan.traceId, 'car.ptc 同 trace')
329
+ const durMs = (Number(ptcSpan.endTimeUnixNano) - Number(ptcSpan.startTimeUnixNano)) / 1e6
330
+ assert.ok(durMs >= 50 && durMs < 5000, `worker 计时窗口 ≈ 程序 sleep(${durMs.toFixed(1)}ms)`)
331
+ assert.equal(ptcSpan.status.code, 'STATUS_CODE_UNSET', 'ok 路径无 exception')
332
+ // 零内容口径:出站体无会话内容(提示词/工具结果文本/程序体不出站)
333
+ const all = JSON.stringify(otlpBodies)
334
+ assert.equal(/uppercase please|大写工具|setTimeout/.test(all), false, 'span 出站体零内容')
335
+ })
336
+ })
337
+
338
+ // ==================== E2E-B:mcp-serve 收 traceparent 挂链(D-30 生产调用点) ====================
339
+
340
+ test('S42 E2E-B: mcp-serve——_meta.traceparent 合法挂链远端 trace;畸形 fail-open 新 trace', async () => {
341
+ await withTempDir('mcp-serve', async dir => {
342
+ const traceBodies: any[] = []
343
+ let done: (v: void) => void = () => {}
344
+ const closed = new Promise<void>(r => { done = r })
345
+ const server: Server = createServer((req, res) => {
346
+ let body = ''
347
+ req.on('data', c => { body += c })
348
+ req.on('end', () => {
349
+ if (req.url === '/v1/traces') traceBodies.push(JSON.parse(body))
350
+ res.writeHead(200).end()
351
+ })
352
+ })
353
+ await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
354
+ const port = (server.address() as { port: number }).port
355
+ try {
356
+ const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve'], {
357
+ cwd: dir,
358
+ env: { ...process.env, CAR_OTEL_ENDPOINT: `http://127.0.0.1:${port}`, CAR_OTEL_INTERVAL_MS: '0' },
359
+ stdio: ['pipe', 'pipe', 'pipe'],
360
+ })
361
+ let err = ''
362
+ child.stderr.on('data', d => { err += d })
363
+ child.on('exit', () => done())
364
+ const { deriveSessionId } = await import('../src/host/mappings.ts')
365
+ const sid = deriveSessionId('claude-code', 's42-link')
366
+ const remoteTraceId = 'c'.repeat(32)
367
+ const remoteSpanId = 'd'.repeat(16)
368
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's42-link' } } }) + '\n')
369
+ // 合法 traceparent → turn span 加入宿主 trace
370
+ child.stdin.write(JSON.stringify({
371
+ jsonrpc: '2.0', id: 2, method: 'tools/call',
372
+ params: {
373
+ name: 'session_turn',
374
+ arguments: { sessionId: sid, input: { events: [{ hostEvent: 'user_message', payload: { content: 'hi' } }, { hostEvent: 'turn_complete' }] } },
375
+ _meta: { traceparent: formatTraceparent(remoteTraceId, remoteSpanId) },
376
+ },
377
+ }) + '\n')
378
+ // 畸形 traceparent → fail-open 新 trace(不 fail-hard)
379
+ child.stdin.write(JSON.stringify({
380
+ jsonrpc: '2.0', id: 3, method: 'tools/call',
381
+ params: {
382
+ name: 'session_turn',
383
+ arguments: { sessionId: sid, input: { events: [{ hostEvent: 'user_message', payload: { content: 'ho' } }, { hostEvent: 'turn_complete' }] } },
384
+ _meta: { traceparent: 'not-a-traceparent' },
385
+ },
386
+ }) + '\n')
387
+ child.stdin.end()
388
+ let raceTimer: ReturnType<typeof setTimeout> | undefined
389
+ try {
390
+ await Promise.race([
391
+ closed,
392
+ new Promise<never>((_, rej) => { raceTimer = setTimeout(() => rej(new Error(`mcp-serve 超时\n${err}`)), 60_000) }),
393
+ ])
394
+ } finally {
395
+ if (raceTimer) clearTimeout(raceTimer)
396
+ }
397
+ const spans = traceBodies.flatMap(b => b.scopeSpans[0].spans)
398
+ const turns = spans.filter((s: any) => s.name === 'car.turn')
399
+ assert.equal(turns.length, 2, '两次 session_turn 两个 turn span')
400
+ const linked = turns.find((s: any) => s.traceId === remoteTraceId)
401
+ assert.ok(linked, '合法 traceparent:turn span 加入宿主 trace(traceId 复用)')
402
+ assert.equal(linked.parentSpanId, remoteSpanId, 'parentSpanId = 远端 spanId')
403
+ const attrs = Object.fromEntries(linked.attributes.map((a: any) => [a.key, a.value.stringValue]))
404
+ assert.equal(attrs['car.turn_id'], 'T1', 'turnId 回填不受挂链影响')
405
+ assert.equal(attrs['car.outcome'], 'completed')
406
+ const fallback = turns.find((s: any) => s.traceId !== remoteTraceId)
407
+ assert.ok(fallback, '畸形 traceparent:fail-open 新 trace')
408
+ assert.equal('parentSpanId' in fallback, false, 'fail-open 无 parent(新独立 trace)')
409
+ assert.equal(/hi|ho|user_message/.test(JSON.stringify(traceBodies)), false, 'span 出站体零内容')
410
+ } finally {
411
+ server.closeAllConnections()
412
+ server.close()
413
+ }
414
+ })
415
+ })