@lqc123qwe/car-runtime 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,31 @@
1
1
  /**
2
- * 1.1-S3 · 签名配置文件通道(sandbox.* 键位空间;部署设计 §4.5.5 载体落地)
2
+ * 1.1-S3 签名配置文件通道 → 1.5-S3 完整形态(car.config.ts + zod,D-22)
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):发现序 **car.config.ts > car.config.json**(同目录两者并存 = ts 胜出,
6
+ * json 忽略——禁静默合并);显式 --config 按扩展名分派(.ts 走 TS 载体,其余 JSON 解析);
7
+ * TS 载体 = 动态 import()(Node 原生类型剥离,插件加载同纪律——jiti 继续登记不引入);
8
+ * 形态 = `export default {…}`(非对象/缺 default 显式报错)。
9
+ * - **预热协议**:loadCarConfig 保持同步签名(doctor/resolveSigGate 等既有调用面不动);
10
+ * car.config.ts 须先经 warmCarConfig 异步预热入缓存(CLI 入口统一预热),未预热显式报错
11
+ * (fail-visible,禁静默回退 json——「配置写了但不生效」的漂移面必须显式化)。
12
+ * - 校验层 zod 化(D-11a ① 修订):zod ^3 为首个常规运行时依赖(用户点名登记项);
13
+ * fail-visible 语义与 CAR-E-CONFIG 错误码保持,zod issues 渲染为 `路径 原因`(首个 issue);
14
+ * 键位空间与 1.1/1.4 完全一致(sandbox.* / llm.* / mcp.*;mcp.servers 本迭代加 url/headers)。
15
+ * 未登记键 / 类型错 / 坏 JSON / 坏 TS 一律 CAR-E-CONFIG 显式拒绝(禁静默忽略)。
16
+ * - 优先级(1.1 规划 §3 S3):flag > env > 配置文件 > 缺省。env 已定义即为显式意见。
15
17
  * 无配置文件 + 无 env 时行为与 1.0 完全一致(warn / 无信任根 / 不豁免)。
16
- * - 本模块只读配置与合并优先级;门禁判定在 sigGate/verifier。发现序:显式路径 > cwd car.config.json。
18
+ * - 本模块只读配置与合并优先级;门禁判定在 sigGate/verifier。发现序:显式路径 > cwd。
19
+ * - 代码求值面(D-22 登记):car.config.ts 为本机文件信任边界(与插件同域;配置无签名机制)。
17
20
  */
18
21
  import { existsSync, readFileSync } from 'node:fs'
19
22
  import { isAbsolute, join } from 'node:path'
23
+ import { pathToFileURL } from 'node:url'
24
+ import { z } from 'zod'
20
25
  import type { SignatureGateOptions } from './sigGate.ts'
21
26
 
22
27
  export const CONFIG_FILENAME = 'car.config.json'
28
+ export const CONFIG_TS_FILENAME = 'car.config.ts'
23
29
 
24
30
  export interface SandboxSigConfig {
25
31
  unsignedAllow?: boolean
@@ -27,84 +33,234 @@ export interface SandboxSigConfig {
27
33
  sigTrustRoot?: string
28
34
  }
29
35
 
36
+ export interface LlmConfig {
37
+ baseUrl?: string
38
+ model?: string
39
+ adapterId?: string
40
+ maxTokens?: number
41
+ allowEnvFallback?: boolean
42
+ }
43
+
44
+ export interface McpServerSpec {
45
+ /** stdio 通道启动命令(与 url 互斥——1.5-S2 起二选一显式登记) */
46
+ command?: string
47
+ args?: string[]
48
+ /** 启动环境变量(禁明文凭据——gateway.containsPlaintextCredential 注册时强制检查;仅 command 通道适用) */
49
+ env?: Record<string, string>
50
+ /** 1.5-S2(D-21):远程 Streamable HTTP 通道(与 command 互斥) */
51
+ url?: string
52
+ /** 1.5-S2(D-21):远程请求头;值支持 ${ENV_VAR} 引用(凭据禁令 CR-05;仅 url 通道适用) */
53
+ headers?: Record<string, string>
54
+ }
55
+
30
56
  export interface CarConfig {
31
57
  sandbox?: SandboxSigConfig
58
+ /** 1.4-S3(D-16):模型接入配置键位空间;凭据禁入(CR-05)——只放端点/模型名/开关 */
59
+ llm?: LlmConfig
60
+ /** 1.4-S7(D-18):MCP server 登记面(--mcp <id> 显式启用) */
61
+ mcp?: { servers: Record<string, McpServerSpec> }
32
62
  }
33
63
 
34
64
  export interface ConfigLoadResult {
35
65
  /** 实际读取的配置文件路径(cwd 未发现时缺省——未发现是正常态非错误) */
36
66
  path?: string
37
67
  config: CarConfig
38
- /** fail-visible:显式路径不存在 / 坏 JSON / 未登记键 / 类型错的原因(非空 = 调用方必须中止) */
68
+ /** fail-visible:显式路径不存在 / 坏 JSON / 坏 TS / 未登记键 / 类型错的原因(非空 = 调用方必须中止) */
39
69
  error?: string
40
70
  }
41
71
 
42
- /** 加载配置:显式路径(必须存在,否则报错)或 cwd 发现(未发现 = 正常态返回空配置) */
43
- export function loadCarConfig(opts: { explicitPath?: string; cwd?: string } = {}): ConfigLoadResult {
72
+ // ==================== zod 校验层(1.5-S3 迁移;错误消息含 s31/s34 回归锚点) ====================
73
+
74
+ // 叶子消息不含路径——渲染层统一拼 `${path} ${message}`(s31 锚点:'llm.maxTokens 必须为正整数')
75
+ const nonEmptyTrimmed = () =>
76
+ z.string({ invalid_type_error: '必须为非空字符串', required_error: '必须为非空字符串' })
77
+ .trim()
78
+ .refine(v => v.length > 0, { message: '必须为非空字符串' })
79
+
80
+ const stringMap = () =>
81
+ z.record(z.string(), z.string({ invalid_type_error: '必须为字符串' }), { invalid_type_error: '必须为字符串映射', required_error: '必须为字符串映射' })
82
+
83
+ const mcpServerSchema = z.object({
84
+ command: nonEmptyTrimmed().optional(),
85
+ url: nonEmptyTrimmed().refine(v => /^https?:\/\//.test(v), { message: '必须为 http(s) URL' }).optional(),
86
+ args: z.array(z.string({ invalid_type_error: '必须为字符串' }), { invalid_type_error: '必须为字符串数组' }).optional(),
87
+ env: stringMap().optional(),
88
+ headers: stringMap().optional(),
89
+ }).strict().superRefine((obj, ctx) => {
90
+ const hasCommand = typeof obj.command === 'string' && !!obj.command
91
+ const hasUrl = typeof obj.url === 'string' && !!obj.url
92
+ if (hasCommand === hasUrl) {
93
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [], message: '需要 command(stdio)或 url(远程 Streamable HTTP)恰好其一(互斥)' })
94
+ return
95
+ }
96
+ if (obj.args !== undefined && !hasCommand) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['args'], message: '仅 stdio 通道(command)适用' })
97
+ if (obj.env !== undefined && !hasCommand) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['env'], message: '仅 stdio 通道(command)适用' })
98
+ if (obj.headers !== undefined && !hasUrl) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['headers'], message: '仅远程通道(url)适用' })
99
+ })
100
+
101
+ const rawSchema = z.object({
102
+ sandbox: z.object({
103
+ unsigned: z.object({
104
+ allow: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
105
+ }).strict().optional(),
106
+ sig: z.object({
107
+ enforce: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
108
+ trustRoot: nonEmptyTrimmed().optional(),
109
+ }).strict().optional(),
110
+ }).strict().optional(),
111
+ llm: z.object({
112
+ baseUrl: nonEmptyTrimmed().optional(),
113
+ model: nonEmptyTrimmed().optional(),
114
+ adapterId: nonEmptyTrimmed().optional(),
115
+ maxTokens: z.number({ invalid_type_error: '必须为正整数', required_error: '必须为正整数' })
116
+ .refine(v => Number.isFinite(v) && Number.isInteger(v) && v > 0, { message: '必须为正整数' })
117
+ .optional(),
118
+ allowEnvFallback: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
119
+ }).strict().optional(),
120
+ mcp: z.object({
121
+ servers: z.record(z.string(), mcpServerSchema, { invalid_type_error: '必须为对象(serverId → 启动规格)' }),
122
+ }).strict().optional(),
123
+ }, { invalid_type_error: '配置顶层必须为 JSON 对象' }).strict()
124
+
125
+ /** 未登记键提示(渲染层拼 path)——与系统设计键位空间同步 */
126
+ const KEY_SPACES = '登记键位:sandbox.* / llm.* / mcp.servers.*'
127
+
128
+ /** zod issue → CAR-E-CONFIG 渲染(首个 issue;unrecognized_keys 按键路径展开) */
129
+ function renderIssue(error: z.ZodError): string {
130
+ const iss = error.issues[0]!
131
+ if (iss.code === 'unrecognized_keys') {
132
+ const keys = (iss as unknown as { keys?: string[] }).keys ?? []
133
+ const keyPath = [...iss.path, keys[0] ?? '?'].join('.')
134
+ return iss.path.length
135
+ ? `CAR-E-CONFIG: 未知键 ${keyPath}(${KEY_SPACES})`
136
+ : `CAR-E-CONFIG: 未知顶层键 "${keys[0] ?? '?'}"(${KEY_SPACES})`
137
+ }
138
+ const p = iss.path.join('.')
139
+ return p ? `CAR-E-CONFIG: ${p} ${iss.message}` : `CAR-E-CONFIG: ${iss.message}`
140
+ }
141
+
142
+ /** zod 解析 → CarConfig(扁平渲染:sandbox 嵌套键拍平——mergeSignatureGate 消费形态不变) */
143
+ function parseCarConfig(raw: unknown): { config: CarConfig; error?: string } {
144
+ const parsed = rawSchema.safeParse(raw)
145
+ if (!parsed.success) return { config: {}, error: renderIssue(parsed.error) }
146
+ const d = parsed.data
147
+ const out: CarConfig = {}
148
+ if (d.sandbox) {
149
+ const sb: SandboxSigConfig = {}
150
+ if (d.sandbox.unsigned?.allow !== undefined) sb.unsignedAllow = d.sandbox.unsigned.allow
151
+ if (d.sandbox.sig?.enforce !== undefined) sb.sigEnforce = d.sandbox.sig.enforce
152
+ if (d.sandbox.sig?.trustRoot !== undefined) sb.sigTrustRoot = d.sandbox.sig.trustRoot
153
+ out.sandbox = sb
154
+ }
155
+ if (d.llm) {
156
+ const llm: LlmConfig = {}
157
+ if (d.llm.baseUrl !== undefined) llm.baseUrl = d.llm.baseUrl
158
+ if (d.llm.model !== undefined) llm.model = d.llm.model
159
+ if (d.llm.adapterId !== undefined) llm.adapterId = d.llm.adapterId
160
+ if (d.llm.maxTokens !== undefined) llm.maxTokens = d.llm.maxTokens
161
+ if (d.llm.allowEnvFallback !== undefined) llm.allowEnvFallback = d.llm.allowEnvFallback
162
+ out.llm = llm
163
+ }
164
+ if (d.mcp) {
165
+ const servers: Record<string, McpServerSpec> = {}
166
+ for (const [sid, sv] of Object.entries(d.mcp.servers)) {
167
+ const spec: McpServerSpec = {}
168
+ if (sv.command !== undefined) spec.command = sv.command
169
+ if (sv.url !== undefined) spec.url = sv.url
170
+ if (sv.args !== undefined) spec.args = sv.args
171
+ if (sv.env !== undefined) spec.env = sv.env
172
+ if (sv.headers !== undefined) spec.headers = sv.headers
173
+ servers[sid] = spec
174
+ }
175
+ out.mcp = { servers }
176
+ }
177
+ return { config: out }
178
+ }
179
+
180
+ // ==================== 载体装载(JSON 同步 / TS 预热缓存) ====================
181
+
182
+ /** car.config.ts 预热缓存(path → 装载结果;warmCarConfig 每次以新 epoch 查询串击穿模块缓存) */
183
+ const tsCache = new Map<string, ConfigLoadResult>()
184
+ let tsEpoch = 0
185
+
186
+ /**
187
+ * 1.5-S3(D-22):car.config.ts 载体预热(动态 import 与插件加载同纪律——Node 原生类型剥离,
188
+ * Node 22 须 --experimental-transform-types / Node ≥23.6 原生支持;报错文案引导)。
189
+ * CLI 入口统一预热;直接调用 loadCarConfig 的库方在存在 car.config.ts 时须先 await 本函数。
190
+ */
191
+ export async function warmCarConfig(opts: { explicitPath?: string; cwd?: string } = {}): Promise<void> {
44
192
  let path: string | undefined
45
193
  if (opts.explicitPath) {
194
+ if (!opts.explicitPath.endsWith('.ts')) return // JSON 显式路径走同步通道,无需预热
46
195
  path = isAbsolute(opts.explicitPath) ? opts.explicitPath : join(opts.cwd ?? process.cwd(), opts.explicitPath)
196
+ } else {
197
+ const candidate = join(opts.cwd ?? process.cwd(), CONFIG_TS_FILENAME)
198
+ if (!existsSync(candidate)) return // cwd 发现序:无 ts 载体 = 无需预热(json 同步可读)
199
+ path = candidate
200
+ }
201
+ tsEpoch++
202
+ const result = await loadTsConfigFile(path, tsEpoch)
203
+ tsCache.set(path, result)
204
+ }
205
+
206
+ async function loadTsConfigFile(path: string, epoch: number): Promise<ConfigLoadResult> {
207
+ try {
208
+ const mod = (await import(`${pathToFileURL(path).href}?cfg-epoch=${epoch}`)) as { default?: unknown }
209
+ const raw = mod.default
210
+ if (raw === undefined) {
211
+ return { path, config: {}, error: `CAR-E-CONFIG: ${path} 缺 default export(car.config.ts 形态 = export default {…})` }
212
+ }
213
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
214
+ return { path, config: {}, error: `CAR-E-CONFIG: ${path} default export 必须为对象(配置顶层)` }
215
+ }
216
+ const v = parseCarConfig(raw)
217
+ return v.error ? { path, config: {}, error: v.error } : { path, config: v.config }
218
+ } catch (e) {
219
+ const err = e as { code?: string; message?: string }
220
+ const hint = err.code === 'ERR_UNKNOWN_FILE_EXTENSION' || /Unknown file extension|loader/i.test(err.message ?? '')
221
+ ? `——当前 Node 未启用 .ts 载体(须 --experimental-transform-types 或 Node ≥23.6 原生类型剥离)`
222
+ : ''
223
+ return { path, config: {}, error: `CAR-E-CONFIG: car.config.ts 载入失败(${err.message})${hint}` }
224
+ }
225
+ }
226
+
227
+ /** 加载配置:显式路径(必须存在,否则报错)或 cwd 发现(未发现 = 正常态返回空配置) */
228
+ export function loadCarConfig(opts: { explicitPath?: string; cwd?: string } = {}): ConfigLoadResult {
229
+ if (opts.explicitPath) {
230
+ const path = isAbsolute(opts.explicitPath) ? opts.explicitPath : join(opts.cwd ?? process.cwd(), opts.explicitPath)
231
+ if (path.endsWith('.ts')) {
232
+ const hit = tsCache.get(path)
233
+ if (hit) return hit
234
+ return { path, config: {}, error: `CAR-E-CONFIG: car.config.ts 未预热(CLI 入口自动 warmCarConfig;直接调用方须先 await warmCarConfig({ explicitPath }))` }
235
+ }
47
236
  if (!existsSync(path)) {
48
237
  return { path, config: {}, error: `CAR-E-CONFIG: 配置文件不存在:${path}(--config 显式指定的路径必须存在)` }
49
238
  }
50
- } else {
51
- path = join(opts.cwd ?? process.cwd(), CONFIG_FILENAME)
52
- if (!existsSync(path)) return { config: {} }
239
+ return loadJsonConfigFile(path)
240
+ }
241
+ // cwd 发现序(D-22):car.config.ts > car.config.json(并存 = ts 胜出,json 忽略)
242
+ const tsPath = join(opts.cwd ?? process.cwd(), CONFIG_TS_FILENAME)
243
+ if (existsSync(tsPath)) {
244
+ const hit = tsCache.get(tsPath)
245
+ if (hit) return hit
246
+ return { path: tsPath, config: {}, error: `CAR-E-CONFIG: car.config.ts 未预热(CLI 入口自动 warmCarConfig;直接调用方须先 await warmCarConfig({ cwd }))` }
53
247
  }
248
+ const jsonPath = join(opts.cwd ?? process.cwd(), CONFIG_FILENAME)
249
+ if (!existsSync(jsonPath)) return { config: {} }
250
+ return loadJsonConfigFile(jsonPath)
251
+ }
252
+
253
+ function loadJsonConfigFile(path: string): ConfigLoadResult {
54
254
  let raw: unknown
55
255
  try {
56
256
  raw = JSON.parse(readFileSync(path, 'utf-8'))
57
257
  } catch (e) {
58
258
  return { path, config: {}, error: `CAR-E-CONFIG: 配置文件解析失败(${(e as Error).message})——坏 JSON 显式拒绝,不静默忽略` }
59
259
  }
60
- const v = validate(raw)
260
+ const v = parseCarConfig(raw)
61
261
  return v.error ? { path, config: {}, error: v.error } : { path, config: v.config }
62
262
  }
63
263
 
64
- /** 手写校验(fail-visible;零依赖红线——不引 zod,D-11a ①) */
65
- function validate(raw: unknown): { config: CarConfig; error?: string } {
66
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
67
- return { config: {}, error: 'CAR-E-CONFIG: 配置顶层必须为 JSON 对象' }
68
- }
69
- const out: CarConfig = {}
70
- for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
71
- if (k !== 'sandbox') {
72
- return { config: {}, error: `CAR-E-CONFIG: 未知顶层键 "${k}"(1.1 登记键位空间仅 sandbox.*)` }
73
- }
74
- if (typeof v !== 'object' || v === null || Array.isArray(v)) {
75
- return { config: {}, error: 'CAR-E-CONFIG: sandbox 必须为对象' }
76
- }
77
- const sb: SandboxSigConfig = {}
78
- for (const [sk, sv] of Object.entries(v as Record<string, unknown>)) {
79
- if (sk === 'unsigned') {
80
- if (typeof sv !== 'object' || sv === null || Array.isArray(sv)) return { config: {}, error: 'CAR-E-CONFIG: sandbox.unsigned 必须为对象' }
81
- for (const [uk, uv] of Object.entries(sv as Record<string, unknown>)) {
82
- if (uk !== 'allow') return { config: {}, error: `CAR-E-CONFIG: 未知键 sandbox.unsigned.${uk}(登记键位:sandbox.unsigned.allow)` }
83
- if (typeof uv !== 'boolean') return { config: {}, error: 'CAR-E-CONFIG: sandbox.unsigned.allow 必须为布尔' }
84
- sb.unsignedAllow = uv
85
- }
86
- } else if (sk === 'sig') {
87
- if (typeof sv !== 'object' || sv === null || Array.isArray(sv)) return { config: {}, error: 'CAR-E-CONFIG: sandbox.sig 必须为对象' }
88
- for (const [gk, gv] of Object.entries(sv as Record<string, unknown>)) {
89
- if (gk === 'enforce') {
90
- if (typeof gv !== 'boolean') return { config: {}, error: 'CAR-E-CONFIG: sandbox.sig.enforce 必须为布尔' }
91
- sb.sigEnforce = gv
92
- } else if (gk === 'trustRoot') {
93
- if (typeof gv !== 'string' || !gv.trim()) return { config: {}, error: 'CAR-E-CONFIG: sandbox.sig.trustRoot 必须为非空字符串(ed25519 spki base64)' }
94
- sb.sigTrustRoot = gv.trim()
95
- } else {
96
- return { config: {}, error: `CAR-E-CONFIG: 未知键 sandbox.sig.${gk}(登记键位:sandbox.sig.enforce / sandbox.sig.trustRoot)` }
97
- }
98
- }
99
- } else {
100
- return { config: {}, error: `CAR-E-CONFIG: 未知 sandbox 键 "${sk}"(1.1 登记键位:sandbox.unsigned.* / sandbox.sig.*)` }
101
- }
102
- }
103
- out.sandbox = sb
104
- }
105
- return { config: out }
106
- }
107
-
108
264
  /** 合并产出(mode/unsignedAllow 恒有值——比 SignatureGateOptions 的可选字段更窄,doctor 等消费方免缺省分支) */
109
265
  export interface ResolvedSignatureGate {
110
266
  mode: 'warn' | 'enforce'
@@ -112,6 +268,34 @@ export interface ResolvedSignatureGate {
112
268
  unsignedAllow: boolean
113
269
  }
114
270
 
271
+ /**
272
+ * 1.4-S3(D-16):模型接入配置合并(flag > env > 配置 > 缺省)。
273
+ * env 已定义为显式意见(压过配置),未定义落配置层——与 mergeSignatureGate 同纪律。
274
+ * 凭据不在此层(CR-05):走 CredentialService(keychain → env OPENAI_API_KEY/ANTHROPIC_API_KEY/
275
+ * CAR_LLM_API_KEY,env fallback 需显式开启 = CAR_ALLOW_ENV_CREDENTIALS=1 或 llm.allowEnvFallback)。
276
+ */
277
+ export function mergeLlmConfig(env: NodeJS.ProcessEnv, cfg: CarConfig, flags: { baseUrl?: string; model?: string; adapterId?: string; maxTokens?: number } = {}): LlmConfig {
278
+ let baseUrl = cfg.llm?.baseUrl
279
+ if (env.CAR_LLM_BASE_URL) baseUrl = env.CAR_LLM_BASE_URL
280
+ if (flags.baseUrl) baseUrl = flags.baseUrl
281
+ let model = cfg.llm?.model
282
+ if (env.CAR_LLM_MODEL) model = env.CAR_LLM_MODEL
283
+ if (flags.model) model = flags.model
284
+ let adapterId = cfg.llm?.adapterId
285
+ if (flags.adapterId) adapterId = flags.adapterId
286
+ let maxTokens = cfg.llm?.maxTokens
287
+ if (flags.maxTokens !== undefined) maxTokens = flags.maxTokens
288
+ let allowEnvFallback = cfg.llm?.allowEnvFallback === true
289
+ if (env.CAR_ALLOW_ENV_CREDENTIALS !== undefined) allowEnvFallback = env.CAR_ALLOW_ENV_CREDENTIALS === '1'
290
+ return {
291
+ ...(baseUrl ? { baseUrl } : {}),
292
+ ...(model ? { model } : {}),
293
+ ...(adapterId ? { adapterId } : {}),
294
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
295
+ allowEnvFallback,
296
+ }
297
+ }
298
+
115
299
  /**
116
300
  * 优先级合并(flag > env > 配置 > 缺省):产出装载签名门选项。
117
301
  * flagEnforce = CLI --sig-enforce;env 已定义即为显式意见(压过配置),未定义落配置层。
@@ -85,24 +85,38 @@ export function parseManifest(raw: unknown, file = '<manifest>'): Manifest {
85
85
  return m
86
86
  }
87
87
 
88
- export interface ToolReg { name: string; run: (args: any) => Promise<unknown> }
88
+ export interface ToolReg {
89
+ name: string
90
+ run: (args: any) => Promise<unknown>
91
+ // —— 1.4-S1 声明面加法(进模型 ToolDefinition;缺省收口见 runtime-core/tools.ts T-22)——
92
+ /** 工具描述(进模型 function.description) */
93
+ description?: string
94
+ /** 入参 JSON Schema(进模型 function.parameters) */
95
+ parameters?: unknown
96
+ /** 副作用声明(缺省 write 收口——T-22 同款:未声明按最高约束) */
97
+ declaredSideEffect?: 'readonly' | 'write'
98
+ }
89
99
  export interface CommandReg { name: string; run: (args: string[]) => Promise<void> }
90
100
  export interface PluginApi {
91
101
  registerTool(tool: ToolReg): void
92
102
  registerCommand(cmd: CommandReg): void
93
103
  getRegisteredTools(): ToolReg[]
104
+ /** 1.4-S2:插件设定 system prompt(session 级;last-wins;bindCore 时冲刷进宿主收集器,落 'system' 事件) */
105
+ setSystemPrompt(text: string): void
94
106
  }
95
107
 
96
108
  interface HostBinding {
97
109
  registerTool?(tool: ToolReg): void
98
110
  registerCommand?(cmd: CommandReg): void
99
111
  getRegisteredTools(): ToolReg[]
112
+ setSystemPrompt?(text: string): void
100
113
  }
101
114
 
102
115
  class ApiImpl {
103
116
  #host: HostBinding | undefined
104
117
  #pendingTools: ToolReg[] = []
105
118
  #pendingCommands: CommandReg[] = []
119
+ #pendingSystemPrompt: string | null = null
106
120
  registerTool(tool: ToolReg): void {
107
121
  if (this.#host?.registerTool) { this.#host.registerTool(tool); return }
108
122
  this.#pendingTools.push(tool) // 注册类动作始终可用:Stub 期收集,bind 后冲刷
@@ -111,6 +125,10 @@ class ApiImpl {
111
125
  if (this.#host?.registerCommand) { this.#host.registerCommand(cmd); return }
112
126
  this.#pendingCommands.push(cmd)
113
127
  }
128
+ setSystemPrompt(text: string): void {
129
+ if (this.#host?.setSystemPrompt) { this.#host.setSystemPrompt(text); return }
130
+ this.#pendingSystemPrompt = text // last-wins:多次设定取最后一次
131
+ }
114
132
  getRegisteredTools(): ToolReg[] {
115
133
  // 依赖宿主绑定的能力查询:bindCore 前一调用即抛错(Stub 显式失败)
116
134
  if (!this.#host) throw new Error('CAR-STUB: "getRegisteredTools" called before bindCore(启动期显式失败换运行期时序正确)')
@@ -120,8 +138,10 @@ class ApiImpl {
120
138
  this.#host = host
121
139
  for (const t of this.#pendingTools) host.registerTool?.(t)
122
140
  for (const c of this.#pendingCommands) host.registerCommand?.(c)
141
+ if (this.#pendingSystemPrompt !== null) host.setSystemPrompt?.(this.#pendingSystemPrompt)
123
142
  this.#pendingTools = []
124
143
  this.#pendingCommands = []
144
+ this.#pendingSystemPrompt = null
125
145
  }
126
146
  }
127
147
 
package/src/loop/stop.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * - 取消:未派发调用补记成对事件(toolCall + 合成错误 toolResult),日志无缺口
13
13
  */
14
14
  import type { SessionLog } from '../session/log.ts'
15
+ import { CarM8Error } from '../runtime-core/errors.ts'
15
16
 
16
17
  export type StepOutcome = 'completed' | 'max-tokens' | 'null'
17
18
  export type TurnEndReason = 'completed' | 'max-tokens' | 'aborted' | 'error' | 'blocked' | 'interrupted'
@@ -23,6 +24,9 @@ export interface ToolDef {
23
24
  concludesTurn?: boolean
24
25
  terminate?: boolean
25
26
  run(args: Record<string, unknown>): Promise<unknown>
27
+ // —— 1.4-S1 声明面加法(进模型 ToolDefinition;执行面与声明面同一对象——声明面=授权面红线)——
28
+ description?: string
29
+ parameters?: unknown
26
30
  }
27
31
  /** 模型单步响应(provider 形态;stopReason 为不可变透传——ADR-001) */
28
32
  export interface ModelStep {
@@ -193,7 +197,14 @@ export async function runTurn(opts: {
193
197
  trail.push({ seq, reason: 'aborted' })
194
198
  return { reason: 'aborted', steps, endReasonTrail: trail }
195
199
  }
196
- const seq = log.append('runtime', 'turnEnd', turnId, null, { reason: 'error', detail: String(e) }).seq
200
+ // 1.4-S4:CarM8Error 保形——code/userHint/retryable 进 turnEnd(A080001 引导文案「请运行
201
+ // car doctor」可达用户;非 CarM8Error 维持 String(e) 口径不变)
202
+ const m8 = e instanceof CarM8Error
203
+ const detail = m8 ? `${e.slug}: ${e.message}` : String(e)
204
+ const seq = log.append('runtime', 'turnEnd', turnId, null, {
205
+ reason: 'error', detail,
206
+ ...(m8 ? { code: e.code, userHint: e.userHint, retryable: e.retryable } : {}),
207
+ }).seq
197
208
  trail.push({ seq, reason: 'error' })
198
209
  return { reason: 'error', steps, endReasonTrail: trail }
199
210
  }
@@ -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
+ }