@lqc123qwe/car-runtime 1.4.0 → 1.6.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.
@@ -1,25 +1,33 @@
1
1
  /**
2
- * 1.1-S3 · 签名配置文件通道(sandbox.* 键位空间;部署设计 §4.5.5 载体落地)
2
+ * 1.1-S3 签名配置文件通道 → 1.5-S3 完整形态(car.config.ts + zod,D-22)→ 1.6-S3 jiti 载体(D-24)
3
3
  *
4
4
  * 口径:
5
- * - 载体 = car.config.json(D-11a ①,2026-09-27 裁决:零依赖 / 免代码求值 / fail-visible 简单;
6
- * 系统设计 §3.2 的 car.config.ts + zod configLoader 完整形态登记后续,届时迁移并保持键位兼容)
7
- * - 键位空间(嵌套 JSON 渲染点分键名):
8
- * sandbox.unsigned.allow (部署设计 §4.5.5 冻结键;= CAR_UNSIGNED_ALLOW 等价通道)
9
- * sandbox.sig.enforce (= CAR_SIG_ENFORCE)
10
- * sandbox.sig.trustRoot (= CAR_TRUST_ROOT;ed25519 公钥非凭据——CR-05「凭据禁入配置明文」不破)
11
- * 未登记键 / 类型错 / 坏 JSON 一律 CAR-E-CONFIG 显式拒绝(fail-visible,禁静默忽略——
12
- * 防拼写漂移导致的「配置写了但不生效」)
13
- * - 优先级(1.1 规划 §3 S3):flag > env > 配置文件 > 缺省。env 已定义即为显式意见
14
- * (CAR_SIG_ENFORCE 非 '1' = 显式 warn,可压过配置 enforce;未定义才落到配置层)。
5
+ * - 载体(D-22 + D-24 修订):发现序 **car.config.ts > car.config.json**(同目录两者并存 = ts 胜出,
6
+ * json 忽略——禁静默合并);显式 --config 按扩展名分派(.ts 走 TS 载体,其余 JSON 解析);
7
+ * TS 载体 = **jiti 自包含转译**(1.6-S3——零传递依赖 bundled 形态,第二个常规运行时依赖;
8
+ * D-22「jiti 继续登记不引入」到期出清):Node 22.19+ 加载 car.config.ts 不再依赖
9
+ * --experimental-transform-types,非可擦除语法(enum 等 transform-only 形态)可用——
10
+ * erasable-only 预检登记项随之出清(被取代);插件加载的原生类型剥离纪律不动。
11
+ * 形态 = `export default {…}`(非对象/缺 default 显式报错)。
12
+ * - **预热协议**:loadCarConfig 保持同步签名(doctor/resolveSigGate 等既有调用面不动);
13
+ * car.config.ts 须先经 warmCarConfig 异步预热入缓存(CLI 入口统一预热),未预热显式报错
14
+ * (fail-visible,禁静默回退 json——「配置写了但不生效」的漂移面必须显式化)。
15
+ * - 校验层 zod 化(D-11a ① 修订):zod ^3 为首个常规运行时依赖(用户点名登记项);
16
+ * fail-visible 语义与 CAR-E-CONFIG 错误码保持,zod issues 渲染为 `路径 原因`(首个 issue);
17
+ * 键位空间与 1.1/1.4 完全一致(sandbox.* / llm.* / mcp.*;mcp.servers 本迭代加 url/headers)。
18
+ * 未登记键 / 类型错 / 坏 JSON / 坏 TS 一律 CAR-E-CONFIG 显式拒绝(禁静默忽略)。
19
+ * - 优先级(1.1 规划 §3 S3):flag > env > 配置文件 > 缺省。env 已定义即为显式意见。
15
20
  * 无配置文件 + 无 env 时行为与 1.0 完全一致(warn / 无信任根 / 不豁免)。
16
- * - 本模块只读配置与合并优先级;门禁判定在 sigGate/verifier。发现序:显式路径 > cwd car.config.json。
21
+ * - 本模块只读配置与合并优先级;门禁判定在 sigGate/verifier。发现序:显式路径 > cwd。
22
+ * - 代码求值面(D-22 登记):car.config.ts 为本机文件信任边界(与插件同域;配置无签名机制)。
17
23
  */
18
24
  import { existsSync, readFileSync } from 'node:fs'
19
25
  import { isAbsolute, join } from 'node:path'
26
+ import { z } from 'zod'
20
27
  import type { SignatureGateOptions } from './sigGate.ts'
21
28
 
22
29
  export const CONFIG_FILENAME = 'car.config.json'
30
+ export const CONFIG_TS_FILENAME = 'car.config.ts'
23
31
 
24
32
  export interface SandboxSigConfig {
25
33
  unsignedAllow?: boolean
@@ -36,17 +44,22 @@ export interface LlmConfig {
36
44
  }
37
45
 
38
46
  export interface McpServerSpec {
39
- command: string
47
+ /** stdio 通道启动命令(与 url 互斥——1.5-S2 起二选一显式登记) */
48
+ command?: string
40
49
  args?: string[]
41
- /** 启动环境变量(禁明文凭据——gateway.containsPlaintextCredential 注册时强制检查) */
50
+ /** 启动环境变量(禁明文凭据——gateway.containsPlaintextCredential 注册时强制检查;仅 command 通道适用) */
42
51
  env?: Record<string, string>
52
+ /** 1.5-S2(D-21):远程 Streamable HTTP 通道(与 command 互斥) */
53
+ url?: string
54
+ /** 1.5-S2(D-21):远程请求头;值支持 ${ENV_VAR} 引用(凭据禁令 CR-05;仅 url 通道适用) */
55
+ headers?: Record<string, string>
43
56
  }
44
57
 
45
58
  export interface CarConfig {
46
59
  sandbox?: SandboxSigConfig
47
60
  /** 1.4-S3(D-16):模型接入配置键位空间;凭据禁入(CR-05)——只放端点/模型名/开关 */
48
61
  llm?: LlmConfig
49
- /** 1.4-S7(D-18):MCP server 登记面(--mcp <id> 显式启用;serverId → stdio 启动规格) */
62
+ /** 1.4-S7(D-18):MCP server 登记面(--mcp <id> 显式启用) */
50
63
  mcp?: { servers: Record<string, McpServerSpec> }
51
64
  }
52
65
 
@@ -54,145 +67,205 @@ export interface ConfigLoadResult {
54
67
  /** 实际读取的配置文件路径(cwd 未发现时缺省——未发现是正常态非错误) */
55
68
  path?: string
56
69
  config: CarConfig
57
- /** fail-visible:显式路径不存在 / 坏 JSON / 未登记键 / 类型错的原因(非空 = 调用方必须中止) */
70
+ /** fail-visible:显式路径不存在 / 坏 JSON / 坏 TS / 未登记键 / 类型错的原因(非空 = 调用方必须中止) */
58
71
  error?: string
59
72
  }
60
73
 
61
- /** 加载配置:显式路径(必须存在,否则报错)或 cwd 发现(未发现 = 正常态返回空配置) */
62
- export function loadCarConfig(opts: { explicitPath?: string; cwd?: string } = {}): ConfigLoadResult {
74
+ // ==================== zod 校验层(1.5-S3 迁移;错误消息含 s31/s34 回归锚点) ====================
75
+
76
+ // 叶子消息不含路径——渲染层统一拼 `${path} ${message}`(s31 锚点:'llm.maxTokens 必须为正整数')
77
+ const nonEmptyTrimmed = () =>
78
+ z.string({ invalid_type_error: '必须为非空字符串', required_error: '必须为非空字符串' })
79
+ .trim()
80
+ .refine(v => v.length > 0, { message: '必须为非空字符串' })
81
+
82
+ const stringMap = () =>
83
+ z.record(z.string(), z.string({ invalid_type_error: '必须为字符串' }), { invalid_type_error: '必须为字符串映射', required_error: '必须为字符串映射' })
84
+
85
+ const mcpServerSchema = z.object({
86
+ command: nonEmptyTrimmed().optional(),
87
+ url: nonEmptyTrimmed().refine(v => /^https?:\/\//.test(v), { message: '必须为 http(s) URL' }).optional(),
88
+ args: z.array(z.string({ invalid_type_error: '必须为字符串' }), { invalid_type_error: '必须为字符串数组' }).optional(),
89
+ env: stringMap().optional(),
90
+ headers: stringMap().optional(),
91
+ }).strict().superRefine((obj, ctx) => {
92
+ const hasCommand = typeof obj.command === 'string' && !!obj.command
93
+ const hasUrl = typeof obj.url === 'string' && !!obj.url
94
+ if (hasCommand === hasUrl) {
95
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [], message: '需要 command(stdio)或 url(远程 Streamable HTTP)恰好其一(互斥)' })
96
+ return
97
+ }
98
+ if (obj.args !== undefined && !hasCommand) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['args'], message: '仅 stdio 通道(command)适用' })
99
+ if (obj.env !== undefined && !hasCommand) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['env'], message: '仅 stdio 通道(command)适用' })
100
+ if (obj.headers !== undefined && !hasUrl) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['headers'], message: '仅远程通道(url)适用' })
101
+ })
102
+
103
+ const rawSchema = z.object({
104
+ sandbox: z.object({
105
+ unsigned: z.object({
106
+ allow: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
107
+ }).strict().optional(),
108
+ sig: z.object({
109
+ enforce: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
110
+ trustRoot: nonEmptyTrimmed().optional(),
111
+ }).strict().optional(),
112
+ }).strict().optional(),
113
+ llm: z.object({
114
+ baseUrl: nonEmptyTrimmed().optional(),
115
+ model: nonEmptyTrimmed().optional(),
116
+ adapterId: nonEmptyTrimmed().optional(),
117
+ maxTokens: z.number({ invalid_type_error: '必须为正整数', required_error: '必须为正整数' })
118
+ .refine(v => Number.isFinite(v) && Number.isInteger(v) && v > 0, { message: '必须为正整数' })
119
+ .optional(),
120
+ allowEnvFallback: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
121
+ }).strict().optional(),
122
+ mcp: z.object({
123
+ servers: z.record(z.string(), mcpServerSchema, { invalid_type_error: '必须为对象(serverId → 启动规格)' }),
124
+ }).strict().optional(),
125
+ }, { invalid_type_error: '配置顶层必须为 JSON 对象' }).strict()
126
+
127
+ /** 未登记键提示(渲染层拼 path)——与系统设计键位空间同步 */
128
+ const KEY_SPACES = '登记键位:sandbox.* / llm.* / mcp.servers.*'
129
+
130
+ /** zod issue → CAR-E-CONFIG 渲染(首个 issue;unrecognized_keys 按键路径展开) */
131
+ function renderIssue(error: z.ZodError): string {
132
+ const iss = error.issues[0]!
133
+ if (iss.code === 'unrecognized_keys') {
134
+ const keys = (iss as unknown as { keys?: string[] }).keys ?? []
135
+ const keyPath = [...iss.path, keys[0] ?? '?'].join('.')
136
+ return iss.path.length
137
+ ? `CAR-E-CONFIG: 未知键 ${keyPath}(${KEY_SPACES})`
138
+ : `CAR-E-CONFIG: 未知顶层键 "${keys[0] ?? '?'}"(${KEY_SPACES})`
139
+ }
140
+ const p = iss.path.join('.')
141
+ return p ? `CAR-E-CONFIG: ${p} ${iss.message}` : `CAR-E-CONFIG: ${iss.message}`
142
+ }
143
+
144
+ /** zod 解析 → CarConfig(扁平渲染:sandbox 嵌套键拍平——mergeSignatureGate 消费形态不变) */
145
+ function parseCarConfig(raw: unknown): { config: CarConfig; error?: string } {
146
+ const parsed = rawSchema.safeParse(raw)
147
+ if (!parsed.success) return { config: {}, error: renderIssue(parsed.error) }
148
+ const d = parsed.data
149
+ const out: CarConfig = {}
150
+ if (d.sandbox) {
151
+ const sb: SandboxSigConfig = {}
152
+ if (d.sandbox.unsigned?.allow !== undefined) sb.unsignedAllow = d.sandbox.unsigned.allow
153
+ if (d.sandbox.sig?.enforce !== undefined) sb.sigEnforce = d.sandbox.sig.enforce
154
+ if (d.sandbox.sig?.trustRoot !== undefined) sb.sigTrustRoot = d.sandbox.sig.trustRoot
155
+ out.sandbox = sb
156
+ }
157
+ if (d.llm) {
158
+ const llm: LlmConfig = {}
159
+ if (d.llm.baseUrl !== undefined) llm.baseUrl = d.llm.baseUrl
160
+ if (d.llm.model !== undefined) llm.model = d.llm.model
161
+ if (d.llm.adapterId !== undefined) llm.adapterId = d.llm.adapterId
162
+ if (d.llm.maxTokens !== undefined) llm.maxTokens = d.llm.maxTokens
163
+ if (d.llm.allowEnvFallback !== undefined) llm.allowEnvFallback = d.llm.allowEnvFallback
164
+ out.llm = llm
165
+ }
166
+ if (d.mcp) {
167
+ const servers: Record<string, McpServerSpec> = {}
168
+ for (const [sid, sv] of Object.entries(d.mcp.servers)) {
169
+ const spec: McpServerSpec = {}
170
+ if (sv.command !== undefined) spec.command = sv.command
171
+ if (sv.url !== undefined) spec.url = sv.url
172
+ if (sv.args !== undefined) spec.args = sv.args
173
+ if (sv.env !== undefined) spec.env = sv.env
174
+ if (sv.headers !== undefined) spec.headers = sv.headers
175
+ servers[sid] = spec
176
+ }
177
+ out.mcp = { servers }
178
+ }
179
+ return { config: out }
180
+ }
181
+
182
+ // ==================== 载体装载(JSON 同步 / TS 预热缓存) ====================
183
+
184
+ /** car.config.ts 预热缓存(path → 装载结果;warmCarConfig 每次以新 epoch 查询串击穿模块缓存) */
185
+ const tsCache = new Map<string, ConfigLoadResult>()
186
+ let tsEpoch = 0
187
+
188
+ /**
189
+ * 1.5-S3(D-22)→ 1.6-S3(D-24):car.config.ts 载体预热。
190
+ * jiti 自包含转译(每 epoch 新实例 + moduleCache 关 = 击穿语义与 1.5 等价)——
191
+ * Node 22.19+ 无需 --experimental-transform-types;直接调用 loadCarConfig 的库方
192
+ * 在存在 car.config.ts 时须先 await 本函数(CLI 入口统一预热,协议不变)。
193
+ */
194
+ export async function warmCarConfig(opts: { explicitPath?: string; cwd?: string } = {}): Promise<void> {
63
195
  let path: string | undefined
64
196
  if (opts.explicitPath) {
197
+ if (!opts.explicitPath.endsWith('.ts')) return // JSON 显式路径走同步通道,无需预热
65
198
  path = isAbsolute(opts.explicitPath) ? opts.explicitPath : join(opts.cwd ?? process.cwd(), opts.explicitPath)
199
+ } else {
200
+ const candidate = join(opts.cwd ?? process.cwd(), CONFIG_TS_FILENAME)
201
+ if (!existsSync(candidate)) return // cwd 发现序:无 ts 载体 = 无需预热(json 同步可读)
202
+ path = candidate
203
+ }
204
+ tsEpoch++
205
+ const result = await loadTsConfigFile(path, tsEpoch)
206
+ tsCache.set(path, result)
207
+ }
208
+
209
+ async function loadTsConfigFile(path: string, _epoch: number): Promise<ConfigLoadResult> {
210
+ try {
211
+ // D-24:jiti 转译(零传递依赖 bundled);每 epoch 新实例 + moduleCache 关——击穿语义保持;
212
+ // interopDefault 显式关(jiti 缺省 true 会把无 default 模块的 exports 整体当 default 返回,
213
+ // 「缺 default export」fail-visible 校验即失效)
214
+ const { createJiti } = await import('jiti')
215
+ const jiti = createJiti(import.meta.url, { moduleCache: false, fsCache: false, interopDefault: false })
216
+ const mod = await jiti.import(path) as { default?: unknown }
217
+ const raw = mod.default
218
+ if (raw === undefined) {
219
+ return { path, config: {}, error: `CAR-E-CONFIG: ${path} 缺 default export(car.config.ts 形态 = export default {…})` }
220
+ }
221
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
222
+ return { path, config: {}, error: `CAR-E-CONFIG: ${path} default export 必须为对象(配置顶层)` }
223
+ }
224
+ const v = parseCarConfig(raw)
225
+ return v.error ? { path, config: {}, error: v.error } : { path, config: v.config }
226
+ } catch (e) {
227
+ const err = e as { code?: string; message?: string }
228
+ return { path, config: {}, error: `CAR-E-CONFIG: car.config.ts 载入失败(${err.message ?? String(e)})——jiti 转译失败显式拒绝,不静默忽略` }
229
+ }
230
+ }
231
+
232
+ /** 加载配置:显式路径(必须存在,否则报错)或 cwd 发现(未发现 = 正常态返回空配置) */
233
+ export function loadCarConfig(opts: { explicitPath?: string; cwd?: string } = {}): ConfigLoadResult {
234
+ if (opts.explicitPath) {
235
+ const path = isAbsolute(opts.explicitPath) ? opts.explicitPath : join(opts.cwd ?? process.cwd(), opts.explicitPath)
236
+ if (path.endsWith('.ts')) {
237
+ const hit = tsCache.get(path)
238
+ if (hit) return hit
239
+ return { path, config: {}, error: `CAR-E-CONFIG: car.config.ts 未预热(CLI 入口自动 warmCarConfig;直接调用方须先 await warmCarConfig({ explicitPath }))` }
240
+ }
66
241
  if (!existsSync(path)) {
67
242
  return { path, config: {}, error: `CAR-E-CONFIG: 配置文件不存在:${path}(--config 显式指定的路径必须存在)` }
68
243
  }
69
- } else {
70
- path = join(opts.cwd ?? process.cwd(), CONFIG_FILENAME)
71
- if (!existsSync(path)) return { config: {} }
244
+ return loadJsonConfigFile(path)
245
+ }
246
+ // cwd 发现序(D-22):car.config.ts > car.config.json(并存 = ts 胜出,json 忽略)
247
+ const tsPath = join(opts.cwd ?? process.cwd(), CONFIG_TS_FILENAME)
248
+ if (existsSync(tsPath)) {
249
+ const hit = tsCache.get(tsPath)
250
+ if (hit) return hit
251
+ return { path: tsPath, config: {}, error: `CAR-E-CONFIG: car.config.ts 未预热(CLI 入口自动 warmCarConfig;直接调用方须先 await warmCarConfig({ cwd }))` }
72
252
  }
253
+ const jsonPath = join(opts.cwd ?? process.cwd(), CONFIG_FILENAME)
254
+ if (!existsSync(jsonPath)) return { config: {} }
255
+ return loadJsonConfigFile(jsonPath)
256
+ }
257
+
258
+ function loadJsonConfigFile(path: string): ConfigLoadResult {
73
259
  let raw: unknown
74
260
  try {
75
261
  raw = JSON.parse(readFileSync(path, 'utf-8'))
76
262
  } catch (e) {
77
263
  return { path, config: {}, error: `CAR-E-CONFIG: 配置文件解析失败(${(e as Error).message})——坏 JSON 显式拒绝,不静默忽略` }
78
264
  }
79
- const v = validate(raw)
265
+ const v = parseCarConfig(raw)
80
266
  return v.error ? { path, config: {}, error: v.error } : { path, config: v.config }
81
267
  }
82
268
 
83
- /** 手写校验(fail-visible;零依赖红线——不引 zod,D-11a ①) */
84
- function validate(raw: unknown): { config: CarConfig; error?: string } {
85
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
86
- return { config: {}, error: 'CAR-E-CONFIG: 配置顶层必须为 JSON 对象' }
87
- }
88
- const out: CarConfig = {}
89
- for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
90
- if (k !== 'sandbox' && k !== 'llm' && k !== 'mcp') {
91
- return { config: {}, error: `CAR-E-CONFIG: 未知顶层键 "${k}"(登记键位空间:sandbox.* / llm.* / mcp.*)` }
92
- }
93
- if (k === 'mcp') {
94
- if (typeof v !== 'object' || v === null || Array.isArray(v)) {
95
- return { config: {}, error: 'CAR-E-CONFIG: mcp 必须为对象' }
96
- }
97
- const top = v as Record<string, unknown>
98
- if (Object.keys(top).some(x => x !== 'servers')) {
99
- return { config: {}, error: `CAR-E-CONFIG: 未知键 mcp.${Object.keys(top).find(x => x !== 'servers')}(登记键位:mcp.servers)` }
100
- }
101
- const serversRaw = top.servers
102
- if (typeof serversRaw !== 'object' || serversRaw === null || Array.isArray(serversRaw)) {
103
- return { config: {}, error: 'CAR-E-CONFIG: mcp.servers 必须为对象(serverId → 启动规格)' }
104
- }
105
- const servers: Record<string, McpServerSpec> = {}
106
- for (const [sid, sv] of Object.entries(serversRaw as Record<string, unknown>)) {
107
- if (typeof sv !== 'object' || sv === null || Array.isArray(sv)) {
108
- return { config: {}, error: `CAR-E-CONFIG: mcp.servers.${sid} 必须为对象` }
109
- }
110
- const spec = sv as Record<string, unknown>
111
- if (typeof spec.command !== 'string' || !spec.command.trim()) {
112
- return { config: {}, error: `CAR-E-CONFIG: mcp.servers.${sid}.command 必须为非空字符串` }
113
- }
114
- const parsed: McpServerSpec = { command: spec.command.trim() }
115
- if (spec.args !== undefined) {
116
- if (!Array.isArray(spec.args) || spec.args.some(a => typeof a !== 'string')) {
117
- return { config: {}, error: `CAR-E-CONFIG: mcp.servers.${sid}.args 必须为字符串数组` }
118
- }
119
- parsed.args = spec.args as string[]
120
- }
121
- if (spec.env !== undefined) {
122
- if (typeof spec.env !== 'object' || spec.env === null || Array.isArray(spec.env) || Object.values(spec.env).some(x => typeof x !== 'string')) {
123
- return { config: {}, error: `CAR-E-CONFIG: mcp.servers.${sid}.env 必须为字符串映射` }
124
- }
125
- parsed.env = spec.env as Record<string, string>
126
- }
127
- const unknownKey = Object.keys(spec).find(x => x !== 'command' && x !== 'args' && x !== 'env')
128
- if (unknownKey) {
129
- return { config: {}, error: `CAR-E-CONFIG: 未知键 mcp.servers.${sid}.${unknownKey}(登记键位:command / args / env)` }
130
- }
131
- servers[sid] = parsed
132
- }
133
- out.mcp = { servers }
134
- continue
135
- }
136
- if (k === 'llm') {
137
- if (typeof v !== 'object' || v === null || Array.isArray(v)) {
138
- return { config: {}, error: 'CAR-E-CONFIG: llm 必须为对象' }
139
- }
140
- const llm: LlmConfig = {}
141
- for (const [lk, lv] of Object.entries(v as Record<string, unknown>)) {
142
- if (lk === 'baseUrl' || lk === 'model' || lk === 'adapterId') {
143
- if (typeof lv !== 'string' || !lv.trim()) return { config: {}, error: `CAR-E-CONFIG: llm.${lk} 必须为非空字符串` }
144
- if (lk === 'baseUrl') llm.baseUrl = lv.trim()
145
- else if (lk === 'model') llm.model = lv.trim()
146
- else llm.adapterId = lv.trim()
147
- } else if (lk === 'maxTokens') {
148
- if (typeof lv !== 'number' || !Number.isFinite(lv) || lv <= 0 || !Number.isInteger(lv)) {
149
- return { config: {}, error: 'CAR-E-CONFIG: llm.maxTokens 必须为正整数' }
150
- }
151
- llm.maxTokens = lv
152
- } else if (lk === 'allowEnvFallback') {
153
- if (typeof lv !== 'boolean') return { config: {}, error: 'CAR-E-CONFIG: llm.allowEnvFallback 必须为布尔' }
154
- llm.allowEnvFallback = lv
155
- } else {
156
- return { config: {}, error: `CAR-E-CONFIG: 未知键 llm.${lk}(登记键位:llm.baseUrl / llm.model / llm.adapterId / llm.maxTokens / llm.allowEnvFallback)` }
157
- }
158
- }
159
- out.llm = llm
160
- continue
161
- }
162
- if (typeof v !== 'object' || v === null || Array.isArray(v)) {
163
- return { config: {}, error: 'CAR-E-CONFIG: sandbox 必须为对象' }
164
- }
165
- const sb: SandboxSigConfig = {}
166
- for (const [sk, sv] of Object.entries(v as Record<string, unknown>)) {
167
- if (sk === 'unsigned') {
168
- if (typeof sv !== 'object' || sv === null || Array.isArray(sv)) return { config: {}, error: 'CAR-E-CONFIG: sandbox.unsigned 必须为对象' }
169
- for (const [uk, uv] of Object.entries(sv as Record<string, unknown>)) {
170
- if (uk !== 'allow') return { config: {}, error: `CAR-E-CONFIG: 未知键 sandbox.unsigned.${uk}(登记键位:sandbox.unsigned.allow)` }
171
- if (typeof uv !== 'boolean') return { config: {}, error: 'CAR-E-CONFIG: sandbox.unsigned.allow 必须为布尔' }
172
- sb.unsignedAllow = uv
173
- }
174
- } else if (sk === 'sig') {
175
- if (typeof sv !== 'object' || sv === null || Array.isArray(sv)) return { config: {}, error: 'CAR-E-CONFIG: sandbox.sig 必须为对象' }
176
- for (const [gk, gv] of Object.entries(sv as Record<string, unknown>)) {
177
- if (gk === 'enforce') {
178
- if (typeof gv !== 'boolean') return { config: {}, error: 'CAR-E-CONFIG: sandbox.sig.enforce 必须为布尔' }
179
- sb.sigEnforce = gv
180
- } else if (gk === 'trustRoot') {
181
- if (typeof gv !== 'string' || !gv.trim()) return { config: {}, error: 'CAR-E-CONFIG: sandbox.sig.trustRoot 必须为非空字符串(ed25519 spki base64)' }
182
- sb.sigTrustRoot = gv.trim()
183
- } else {
184
- return { config: {}, error: `CAR-E-CONFIG: 未知键 sandbox.sig.${gk}(登记键位:sandbox.sig.enforce / sandbox.sig.trustRoot)` }
185
- }
186
- }
187
- } else {
188
- return { config: {}, error: `CAR-E-CONFIG: 未知 sandbox 键 "${sk}"(1.1 登记键位:sandbox.unsigned.* / sandbox.sig.*)` }
189
- }
190
- }
191
- out.sandbox = sb
192
- }
193
- return { config: out }
194
- }
195
-
196
269
  /** 合并产出(mode/unsignedAllow 恒有值——比 SignatureGateOptions 的可选字段更窄,doctor 等消费方免缺省分支) */
197
270
  export interface ResolvedSignatureGate {
198
271
  mode: 'warn' | 'enforce'
@@ -203,8 +276,8 @@ export interface ResolvedSignatureGate {
203
276
  /**
204
277
  * 1.4-S3(D-16):模型接入配置合并(flag > env > 配置 > 缺省)。
205
278
  * env 已定义为显式意见(压过配置),未定义落配置层——与 mergeSignatureGate 同纪律。
206
- * 凭据不在此层(CR-05):走 CredentialService(keychain → env OPENAI_API_KEY/CAR_LLM_API_KEY,
207
- * env fallback 需显式开启 = CAR_ALLOW_ENV_CREDENTIALS=1 或 llm.allowEnvFallback)。
279
+ * 凭据不在此层(CR-05):走 CredentialService(keychain → env OPENAI_API_KEY/ANTHROPIC_API_KEY/
280
+ * CAR_LLM_API_KEY,env fallback 需显式开启 = CAR_ALLOW_ENV_CREDENTIALS=1 或 llm.allowEnvFallback)。
208
281
  */
209
282
  export function mergeLlmConfig(env: NodeJS.ProcessEnv, cfg: CarConfig, flags: { baseUrl?: string; model?: string; adapterId?: string; maxTokens?: number } = {}): LlmConfig {
210
283
  let baseUrl = cfg.llm?.baseUrl
@@ -0,0 +1,176 @@
1
+ /**
2
+ * 1.5-S2 · MCP 远程传输客户端(D-21):Streamable HTTP(2025-06-18 spec 单轨)
3
+ *
4
+ * 口径:
5
+ * - CAR → 远程 MCP Server 方向(gateway.ts ClientTransport 的第二真实通道——与 stdio 并列);
6
+ * - 懒 initialize 握手:首次 send 前自动 initialize → notifications/initialized;
7
+ * 响应 `Mcp-Session-Id` 头存在则后续请求回传(无会话头的 server 容许);
8
+ * - 响应形态双兼容:application/json 单响应 / text/event-stream 逐事件取本请求 id
9
+ * (SSE 命中即收口,流显式 destroy——悬挂 socket 是 1.4-BUG-2 同源教训);
10
+ * - **出站走 node:http(s) 不用 undici fetch**(1.4-BUG-2 已验证口径:win32 退出 libuv
11
+ * 断言 0xC0000409 与 undici socket 相关,telemetry 出站同此先例);TLS 门复用
12
+ * assertTlsOrLoopback(D-12a:http 仅豁免 loopback——本地假 server 与本地 MCP);
13
+ * - send 内建 30s 超时(与 stdio 同口径;gateway BD-02 外层 race 不变);
14
+ * - headers 凭据规则(CR-05 经配置通道执行):值支持 `${ENV_VAR}` 引用(env 缺席显式报错);
15
+ * 字面值疑似明文凭据(containsPlaintextCredential 同款模式)直接拒绝——env 引用注入
16
+ * 才是登记合规通道(展开后值不复查:引注入即目的,展开值是凭据本身);
17
+ * - legacy HTTP+SSE(2024-11-05)不做(1.5 规划 D-21 登记不追溯)。
18
+ */
19
+ import { request as httpRequest } from 'node:http'
20
+ import { request as httpsRequest } from 'node:https'
21
+ import type { ClientTransport, JsonRpcRequest, JsonRpcResponse } from './gateway.ts'
22
+ import { containsPlaintextCredential } from './gateway.ts'
23
+ import { assertTlsOrLoopback } from '../runtime-core/llm.ts'
24
+
25
+ export const MCP_PROTOCOL_VERSION = '2025-06-18'
26
+
27
+ export interface HttpMcpServerSpec {
28
+ url: string
29
+ /** 已展开的请求头(明文禁令与 ${ENV} 展开由 resolveMcpHeaders 承担——直接传入者自证合规) */
30
+ headers?: Record<string, string>
31
+ /** 测试注入;缺省 30s(send 内建超时,与 stdio 同口径) */
32
+ timeoutMs?: number
33
+ }
34
+
35
+ /** node:http(s) 响应的最小面(真实 res 兼容;测试可注入替身) */
36
+ export interface RawHttpResponse {
37
+ status: number
38
+ headers: Record<string, string | string[] | undefined>
39
+ /** 流式响应体(node:http res 本身即 AsyncIterable<Uint8Array>;destroy 显式丢弃) */
40
+ body: AsyncIterable<Uint8Array> & { destroy(): void }
41
+ }
42
+
43
+ /** node:http(s) POST 单发(1.4-BUG-2 口径:出站不走 undici;TLS 门由 createHttpMcpTransport 把守) */
44
+ export function rawPost(url: string, headers: Record<string, string>, body: string, timeoutMs: number): Promise<RawHttpResponse> {
45
+ const u = new URL(url)
46
+ const send = u.protocol === 'https:' ? httpsRequest : httpRequest
47
+ return new Promise((resolve, reject) => {
48
+ const req = send(u, { method: 'POST', headers }, res => {
49
+ resolve({
50
+ status: res.statusCode ?? 0,
51
+ headers: res.headers,
52
+ body: res as unknown as RawHttpResponse['body'],
53
+ })
54
+ })
55
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`CAR-E-MCP: http transport timeout (${timeoutMs}ms)`)))
56
+ req.on('error', reject)
57
+ req.end(body)
58
+ })
59
+ }
60
+
61
+ /**
62
+ * ${ENV_VAR} 头引用展开(D-21):值内任意位置的 `${NAME}` 均替换为 env.NAME(如
63
+ * `Bearer ${TOKEN}`);env 缺席显式报错(fail-visible,禁静默空值);字面值疑似明文凭据
64
+ * 直接拒绝(按原始值检查——展开值是凭据本身,引注入即目的)。
65
+ */
66
+ export function resolveMcpHeaders(raw: Record<string, string>, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
67
+ const out: Record<string, string> = {}
68
+ for (const [k, v] of Object.entries(raw)) {
69
+ const cred = containsPlaintextCredential({ [k]: v })
70
+ if (cred) throw new Error(`CAR-E-MCP: headers.${k}——${cred}(配置明文禁令 CR-05;请改用 \`\${ENV_VAR}\` 引用注入)`)
71
+ out[k] = v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_all, name: string) => {
72
+ const val = env[name]
73
+ if (val === undefined || val === '') {
74
+ throw new Error(`CAR-E-MCP: headers.${k} 引用的环境变量 ${name} 未设置(fail-visible,禁静默空值)`)
75
+ }
76
+ return val
77
+ })
78
+ }
79
+ return out
80
+ }
81
+
82
+ export function createHttpMcpTransport(spec: HttpMcpServerSpec): ClientTransport {
83
+ // D-12a TLS 门(与 LLM 适配器同款):https 直接通过;http 仅豁免 loopback
84
+ assertTlsOrLoopback(spec.url.replace(/\/+$/, ''))
85
+ const timeoutMs = spec.timeoutMs ?? 30_000
86
+ let sessionId: string | null = null
87
+ let initialized = false
88
+ let initPromise: Promise<void> | null = null
89
+ let closed = false
90
+
91
+ const baseHeaders = (): Record<string, string> => ({
92
+ 'content-type': 'application/json',
93
+ 'accept': 'application/json, text/event-stream',
94
+ ...(sessionId ? { 'mcp-session-id': sessionId } : {}),
95
+ ...spec.headers,
96
+ })
97
+
98
+ /** 流全读(JSON 响应) */
99
+ async function readAll(res: RawHttpResponse): Promise<Buffer> {
100
+ const parts: Buffer[] = []
101
+ for await (const chunk of res.body) parts.push(Buffer.from(chunk))
102
+ return Buffer.concat(parts)
103
+ }
104
+
105
+ /** SSE 响应逐事件取本请求 id(data JSON 自带 id;命中即收口,流显式 destroy) */
106
+ async function readSseForId(res: RawHttpResponse, id: number): Promise<JsonRpcResponse> {
107
+ const decoder = new TextDecoder()
108
+ let buf = ''
109
+ try {
110
+ for await (const chunk of res.body) {
111
+ buf += decoder.decode(chunk, { stream: true })
112
+ let nl: number
113
+ while ((nl = buf.indexOf('\n')) >= 0) {
114
+ const line = buf.slice(0, nl).replace(/\r$/, '')
115
+ buf = buf.slice(nl + 1)
116
+ if (!line.startsWith('data:')) continue
117
+ const data = line.slice(5).trim()
118
+ if (!data) continue
119
+ let evt: any
120
+ try { evt = JSON.parse(data) } catch { continue }
121
+ if (evt?.id === id) return evt as JsonRpcResponse
122
+ }
123
+ }
124
+ } finally {
125
+ try { res.body.destroy() } catch { /* 流已自然结束 */ }
126
+ }
127
+ throw new Error('CAR-E-MCP: SSE 流结束未见本请求响应(阶段:read-response)')
128
+ }
129
+
130
+ async function postAndRead(body: unknown, matchId: number | null, phase: string): Promise<JsonRpcResponse> {
131
+ const res = await rawPost(spec.url, baseHeaders(), JSON.stringify(body), timeoutMs)
132
+ const sid = res.headers['mcp-session-id']
133
+ if (typeof sid === 'string') sessionId = sid
134
+ if (res.status < 200 || res.status >= 300) {
135
+ try { res.body.destroy() } catch { /* 已结束 */ }
136
+ throw new Error(`CAR-E-MCP: HTTP ${res.status}(阶段:${phase}——server 拒绝或不可达)`)
137
+ }
138
+ const ct = String(res.headers['content-type'] ?? '')
139
+ if (ct.includes('text/event-stream')) return readSseForId(res, matchId ?? Number.NaN)
140
+ const json = JSON.parse((await readAll(res)).toString('utf-8')) as JsonRpcResponse
141
+ return json
142
+ }
143
+
144
+ /** 懒 initialize 握手(首次 send 前恰好一次):initialize → 会话头捕获 → notifications/initialized */
145
+ async function doInitialize(): Promise<void> {
146
+ const initRes = await postAndRead({
147
+ jsonrpc: '2.0', id: -1, method: 'initialize',
148
+ params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'car-runtime', version: '0.0.0' } },
149
+ }, -1, 'initialize')
150
+ if (initRes.error) {
151
+ throw new Error(`CAR-E-MCP: initialize 被拒:${initRes.error.message}(阶段:initialize)`)
152
+ }
153
+ // notifications/initialized:无 id 通知——2xx/202 即视为送达(无响应体,显式丢弃)
154
+ const res = await rawPost(spec.url, baseHeaders(), JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), timeoutMs)
155
+ try { res.body.destroy() } catch { /* 已结束 */ }
156
+ if (res.status < 200 || res.status >= 300) {
157
+ throw new Error(`CAR-E-MCP: notifications/initialized HTTP ${res.status}(阶段:initialized)`)
158
+ }
159
+ }
160
+
161
+ const transport: ClientTransport = {
162
+ send(req: JsonRpcRequest): Promise<JsonRpcResponse> {
163
+ return (async (): Promise<JsonRpcResponse> => {
164
+ if (closed) throw new Error('CAR-E-MCP: http transport closed(server 已收口)')
165
+ if (!initialized) {
166
+ initPromise ??= doInitialize().then(() => { initialized = true })
167
+ try { await initPromise } catch (e) { initPromise = null; throw e }
168
+ }
169
+ return postAndRead(req, req.id, 'request')
170
+ })()
171
+ },
172
+ alive: () => !closed,
173
+ close: () => { closed = true },
174
+ }
175
+ return transport
176
+ }
@@ -48,11 +48,17 @@ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
48
48
  throw new Error(`CAR-E-N1: 模型请求前快照与日志投影失配 @ atSeq=${asserted.failedAt}(Model-visible means logged 违规)`)
49
49
  }
50
50
 
51
- // 2. 请求消息 = 投影(角色系统:user/assistant/toolResult 与 LlmRequest 对齐;
52
- // assistant 工具调用以 { toolCall } content 形态原样透传——deriveMessages 投影即模型可见流)
51
+ // 2. 请求消息 = 投影(角色系统:user/assistant/toolResult 与 LlmRequest 对齐);
52
+ // toolCall 事件投影形态为 { role:'assistant', toolCall }(log.ts 口径)——此处归一化为
53
+ // content {toolCall} 载荷(适配器历史回路映射的唯一消费源)。
54
+ // 1.5-BUG-1:此前仅透传 m.content,toolCall 属性被丢弃——工具调用历史在后续请求
55
+ // 静默消失(真实 provider 对无 tool_calls 前驱的 tool 消息直接 400);anthropic E2E
56
+ // 生产调用点核查(1.5-GO-5)暴露,openai-compat 同样受益。
53
57
  const messages = log.deriveMessages().map(m => ({
54
58
  role: m.role as 'system' | 'user' | 'assistant' | 'toolResult',
55
- content: m.content,
59
+ content: (m as unknown as { toolCall?: unknown }).toolCall !== undefined
60
+ ? { toolCall: (m as unknown as { toolCall: unknown }).toolCall }
61
+ : m.content,
56
62
  }))
57
63
 
58
64
  // 4. 流式消费(3. 凭据解析在适配器首块前,A080001 由此传播)
@@ -37,6 +37,11 @@ const PROVIDER_ENV_VARS: Record<string, string[]> = {
37
37
  }
38
38
  const GENERIC_ENV_VARS = ['CAR_LLM_API_KEY']
39
39
 
40
+ /** provider 候选 env 变量(doctor 存在性探测等诊断面复用;值永不打印) */
41
+ export function providerEnvVars(provider: string): string[] {
42
+ return [...(PROVIDER_ENV_VARS[provider] ?? []), ...GENERIC_ENV_VARS]
43
+ }
44
+
40
45
  /** keychain 服务名(命名空间隔离:car-runtime/<provider>) */
41
46
  export function keychainService(provider: string): string {
42
47
  return `car-runtime/${provider}`
@@ -102,7 +107,7 @@ export class CredentialService {
102
107
 
103
108
  const allowEnv = opts.allowEnvFallback === true || env.CAR_ALLOW_ENV_CREDENTIALS === '1'
104
109
  if (allowEnv) {
105
- const candidates = [...(PROVIDER_ENV_VARS[provider] ?? []), ...GENERIC_ENV_VARS]
110
+ const candidates = providerEnvVars(provider)
106
111
  for (const name of candidates) {
107
112
  searched.push(`env:${name}`)
108
113
  if (env[name]) {