@lqc123qwe/car-runtime 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -181,11 +181,12 @@ node --experimental-transform-types --test test/*.spec.ts
181
181
  - **遗留登记**:W1 = 1.3 头号候选(含 D-12a TLS loopback 豁免实装、car run 装配面重做〔demo_tool 撞名观察项出清〕);car run demo_tool 撞名 DEPCYCLE 为预存在边缘(登记不修,W1 出清)。规划:`car-docs/10-内核v1/1.2-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.2-收口终验报告.md`。
182
182
  - **1.2.0 正式发布(2026-09-27,S5 收官)**:npm `@lqc123qwe/car-runtime@1.2.0` 在架——**dist-tag latest → 1.2.0,rc 保留指向 1.0.0-rc.1**;SLSA provenance(slsa.dev/provenance/v1)。Release `1.2.0`(用户创建,六资产)→ **j16 run 36312617477 success** → **post-publish REMOTE_CHECK=1 复跑 12 PASS / 1 DRY-RUN / 0 FAIL(G-09 实查 PASS)** + 外部验证者等价检查(Release 实下载件 SHA256 MATCH `6d6d4da1…` + cosign Verified OK)。发布工程事件:registry CDN 缓存滞后登记(cache-buster 口径);制品自 tag 树重建后再挂(消除 1.1 的树漂移)。
183
183
 
184
- - **1.3 发布资产自动化(W4,2026-09-27 收口)**:`scripts/release-artifacts.ts` 制品构建单一事实源(本地流水线/CI j16 双端复用;SHA256SUMS 裸字节自检 G-11 口径 + SBOM G-05 同构 + releaseAssetNames 五件套清单——G-10/CI/测试三方同源)+ release.yml 重构(构建 → cosign keyless 签名〔D-15 正式轨:OIDC+Rekor,CI 零 keypair 材料〕→ `gh release upload --clobber` 五件套 → npm publish 原步不动;**upload 先于 publish**)+ D-14 资产集迁移(car-release.pub 移出——keyless 验证锚 = bundle 证书 + Rekor)+ D-14 tag 形态统一(裸版本号)。s30 六测(构建/清单/同源/yml 结构与步骤序红线)。全量 **269(266 PASS + 3 skip / 0 fail)**。1.3.0 发布 = W4 验收载体(**全链零人工拖拽资产**)。W1 三度延后登记 **1.4 头号候选**(设计账本 D-12a loopback 豁免 + D-13 --demo 保留)。规划:`car-docs/10-内核v1/1.3-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.3-收口终验报告.md`。
184
+ - **1.3 发布资产自动化(W4,2026-09-27 收口)**:`scripts/release-artifacts.ts` 制品构建单一事实源(本地流水线/CI j16 双端复用;SHA256SUMS 裸字节自检 G-11 口径 + SBOM G-05 同构 + releaseAssetNames 五件套清单——G-10/CI/测试三方同源)+ release.yml 重构(构建 → cosign keyless 签名〔D-15 正式轨:OIDC+Rekor,CI 零 keypair 材料〕→ `gh release upload --clobber` 五件套 → npm publish 原步不动;**upload 先于 publish**)+ D-14 资产集迁移(car-release.pub 移出——keyless 验证锚 = bundle 证书 + Rekor)+ D-14 tag 形态统一(裸版本号)。s30 七测(构建/清单/同源/yml 结构与步骤序红线/**name 行冒号守卫**〔1.3-BUG-2 回归钉〕)。全量 **270(267 PASS + 3 skip / 0 fail)**。W1 三度延后登记 **1.4 头号候选**(设计账本 D-12a loopback 豁免 + D-13 --demo 保留)。规划:`car-docs/10-内核v1/1.3-迭代规划.md`;收口报告:`car-docs/10-内核v1/1.3-收口终验报告.md`。
185
+ - **1.3.0 正式发布(2026-09-27,S5 收官,W4 全自动首战兑现)**:Release `1.3.0`(tag 32dad29)→ **j16 run 36320604746 全步 success**(构建 → keyless 签名 → 自动上传五件套〔零人工拖拽〕→ npm publish)→ npm `@lqc123qwe/car-runtime@1.3.0` 在架,**dist-tags latest → 1.3.0、rc 保留 1.0.0-rc.1** + provenance。**post-publish 12 PASS / 1 DRY-RUN / 0 FAIL(G-09 实查)**;外部等价检查 **SHA256 MATCH(`70767c8d…`)+ cosign keyless Verified OK**(证书身份 + Rekor——D-15 正式轨兑现,pub 退役)。发布工程事件:首发受挫 1.3-BUG-2(workflow 步骤名未引号冒号 → YAML 解析失败 → startup failure 且 release 事件静默失效;**改 workflow 必须本地 PyYAML 复验**,post-publish 必须核对 release 事件 run 真实触发)。
185
186
 
186
187
  ## 状态
187
188
 
188
- - **版本主线**:S1-S4 48/48 | M2 89/89(v0.2.0)| M3 127/127(v0.3.0)| M4 139/139(**1.0.0-rc.1 已发布**)| M5 156/156(DEC-4 预埋项转正)| M6 179/179(2026-09-15 收口)| M7 全量 195(192 PASS + 3 能力门控 skip,2026-09-19 收口)| **M5-S28 装载签名门 全量 204(201 PASS + 3 skip,2026-09-26)** | **M8 运行时底座 全量 231(228 PASS + 3 skip,2026-09-26 收口,§3.2 八模块全部收口)** | **1.1 签名收尾最小集 全量 249(246 PASS + 3 skip,2026-09-27 收口,1.1.0 已发布,enforce 翻转准备就绪)** | **1.2 会话/遥测小项 全量 263(260 PASS + 3 skip,2026-09-27 收口,1.2.0 已发布)** | **1.3 发布资产自动化 全量 269(266 PASS + 3 skip,2026-09-27 收口,发布全链去人工拖拽)**
189
+ - **版本主线**:S1-S4 48/48 | M2 89/89(v0.2.0)| M3 127/127(v0.3.0)| M4 139/139(**1.0.0-rc.1 已发布**)| M5 156/156(DEC-4 预埋项转正)| M6 179/179(2026-09-15 收口)| M7 全量 195(192 PASS + 3 能力门控 skip,2026-09-19 收口)| **M5-S28 装载签名门 全量 204(201 PASS + 3 skip,2026-09-26)** | **M8 运行时底座 全量 231(228 PASS + 3 skip,2026-09-26 收口,§3.2 八模块全部收口)** | **1.1 签名收尾最小集 全量 249(246 PASS + 3 skip,2026-09-27 收口,1.1.0 已发布,enforce 翻转准备就绪)** | **1.2 会话/遥测小项 全量 263(260 PASS + 3 skip,2026-09-27 收口,1.2.0 已发布)** | **1.3 发布资产自动化 全量 270(267 PASS + 3 skip,2026-09-27 收口,1.3.0 已发布,全链零人工拖拽兑现)**
189
190
  - **1.0.0 正式发布(2026-09-26,S29/S30)**:npm `@lqc123qwe/car-runtime@1.0.0` 在架——**dist-tag latest → 1.0.0,rc 保留指向 1.0.0-rc.1**(§1.1 口径);SLSA provenance(Actions OIDC,透明日志在案);GitHub Release v1.0.0 六资产(tgz/bundle/SHA256SUMS/SBOM/审计/公钥);**post-publish 全链 13 PASS / 0 DRY-RUN / 0 FAIL** + 外部验证者等价检查(Release 实下载件 SHA256 MATCH + cosign Verified OK)。**宽限期时钟:2026-09-26 起算,2026-12-25 到期 verifier 默认值 warn→enforce 翻转(计划内变更)**。终验报告:`car-docs/10-内核v1/M5-S30-1.0收口终验报告.md`。发布工程事件:j16 首跑 E403(npm 2FA 策略 vs granular token 无 bypass——清单预设回退路径兑现)→ bypass token re-run 绿;G-09 期望 tag 动态化(`4c84b17`)在真实发布兑现价值。
190
191
  - **1.0.0-rc.1 已发布(2026-09-09)**:npm `@lqc123qwe/car-runtime@1.0.0-rc.1`(rc + latest 双 tag,SLSA provenance 在案)+ GitHub Release 6 制品;发布预演 **13 PASS / 0 DRY-RUN / 0 FAIL**(c762b72)。S22 GO 清单已全绿收口。
191
192
  - **轨道 A 三项全部完成**:E-1 WSL2 逃逸矩阵 **20/20 PASS 零逃逸**(2026-09-07,G-07 转 PASS)| E-3 远端发布通道 **13/13 全门禁 PASS**(2026-09-09,G-08/09/10 转 PASS)| E-2 Windows koffi FFI **四判据双环境全 PASS**(S24/S25/S26,2026-09-09~10;本机非提权 + windows-latest 提权 runner 双绿,CI 门禁已接入)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lqc123qwe/car-runtime",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Composable Agent Runtime (CAR) - microkernel plugin system with sandboxed PTC, append-only hashed session log, and MCP host bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -24,10 +24,15 @@ import { runTurn } from './loop/stop.ts'
24
24
  import { Context } from './kernel/context.ts'
25
25
  import { mountPlugin } from './load/loader.ts'
26
26
  import { verifyPluginFile } from './load/sigGate.ts'
27
- import { loadCarConfig, mergeSignatureGate } from './load/config.ts'
27
+ import { loadCarConfig, mergeSignatureGate, mergeLlmConfig } from './load/config.ts'
28
28
  import { generateSigningKeypair, signPluginFile, resolveTrustRoot } from './load/sign.ts'
29
- import { doctorCredentials, doctorConnectivity, doctorKeychain, doctorSignature } from './dx/doctor.ts'
29
+ import { doctorCredentials, doctorConnectivity, doctorKeychain, doctorSignature, doctorModelReadiness } from './dx/doctor.ts'
30
+ import { RuntimeCore, createOpenAICompatAdapter } from './runtime-core/llm.ts'
31
+ import { CredentialService } from './runtime-core/credentials.ts'
32
+ import { chatStep } from './runtime-core/chatStep.ts'
33
+ import { toToolDefinitions } from './runtime-core/tools.ts'
30
34
  import { createTelemetryFacade, telemetryConfigFromEnv } from './runtime-core/telemetry.ts'
35
+ import { TurnTracer } from './runtime-core/trace.ts'
31
36
  import type { CounterName, AllowedLabels } from './telemetry/metrics.ts'
32
37
 
33
38
  const HELP = `用法: car <command> [args]
@@ -98,8 +103,11 @@ async function main(): Promise<number> {
98
103
  const [, , cmd, ...rest] = process.argv
99
104
  switch (cmd) {
100
105
  case 'run': {
106
+ // 1.4-S5(D-13):缺省 = 真实模型路径(chatStep 经 openai-compat 适配器);--demo 保留 stub 演示流。
107
+ // 无模型配置 exit 2 显式报错引导(禁静默降级)。会话/索引/签名门两路径共用。
101
108
  const file = rest[0]
102
109
  if (!file) { console.error('缺少插件文件参数'); return 2 }
110
+ const isDemo = rest.includes('--demo')
103
111
  const t0 = Date.now()
104
112
  // 环节 0:装载前签名门(M5-S28 装载接线 + 1.1-S3 配置通道:flag > env > car.config.json > 缺省 warn)
105
113
  const sig = resolveSigGate(rest)
@@ -118,29 +126,164 @@ async function main(): Promise<number> {
118
126
  // 失败非致命——索引为派生缓存,rebuild-index 兜底语义不变;SQLite 能力缺席 = 显式登记一次后停用)
119
127
  const indexUpdater = new IndexUpdater({ dbPath: join(process.cwd(), 'sessions-index.db'), onError: e => console.error(`[index-updater] ${e.message}`) })
120
128
  log.attachSink(line => { store.append(line); indexUpdater.record(log.sessionId, line, store.path) })
129
+ // 1.4-S5:插件工具收集(ToolReg 全量进 Map——执行面与声明面同一对象,声明面=授权面红线);
130
+ // setSystemPrompt 收集(last-wins,D-17)
121
131
  const hostTools: any[] = []
132
+ let pluginSystemPrompt: string | null = null
122
133
  ctx.plugin({ name: plugin.manifest.name, apply: (c) => {
123
134
  plugin.bindCore({
124
135
  registerTool: (t) => { hostTools.push(t); c.provide('tool:' + t.name, t) },
125
136
  getRegisteredTools: () => hostTools,
137
+ setSystemPrompt: (text) => { pluginSystemPrompt = text },
126
138
  })
127
- plugin.api.registerTool({
128
- name: 'demo_tool', run: async () => 'demo-ok',
129
- })
139
+ if (isDemo) {
140
+ // --demo 演示流专属脚手架(真路径不注入——1.1 规划登记的 demo_tool 撞名边缘就此出清)
141
+ plugin.api.registerTool({
142
+ name: 'demo_tool', run: async () => 'demo-ok',
143
+ })
144
+ }
130
145
  } })
131
146
  console.log(`[1/5 装配] OK(${Date.now() - t0}ms,工具: ${hostTools.map(t => t.name).join(', ') || '无'})`)
132
147
  // 环节 2-3:沙箱探测 + 事件运行 + 停止收口
133
148
  const probe = await probeCapabilities()
134
149
  const sandbox = new SandboxExecutor({ probe, audit: () => {}, workspace: process.cwd() })
135
150
  console.log(`[2/5 沙箱] ${probe.degraded ? `降级(${probe.reason})——Q-04 约束生效` : 'Landlock 就绪'}`)
136
- log.append('user', 'user', 'T0', '请调用 demo_tool 并汇报')
137
- const r = await runTurn({
138
- log, turnId: 'T0',
139
- preset: { mode: 'confirm', authorize: async () => { console.log('[授权] 确认模式:放行 demo_tool'); return true } },
140
- tools: new Map([['demo_tool', { declaredSideEffect: 'write', run: async () => 'demo-ok' } as any]]),
141
- model: async () => ({ stopReason: 'toolUse' as const, toolCalls: [{ id: 'c1', tool: 'demo_tool', args: {} }] }),
142
- })
151
+ // 工具执行面 Map(T-22 收口:declaredSideEffect 缺省 write)
152
+ const toolsMap = new Map<string, any>(hostTools.map(t => [t.name, {
153
+ declaredSideEffect: t.declaredSideEffect ?? 'write',
154
+ run: t.run,
155
+ ...(t.description !== undefined ? { description: t.description } : {}),
156
+ ...(t.parameters !== undefined ? { parameters: t.parameters } : {}),
157
+ }]))
158
+ // 取消信号(SIGINT → 中断在途流;runTurn 既有收口落 turnEnd aborted)
159
+ const signal = { aborted: false }
160
+ const onSigint = () => { signal.aborted = true; console.error('\n[取消] SIGINT——正在中断当前 turn…') }
161
+ process.once('SIGINT', onSigint)
162
+ // 1.4-S6:遥测门面(CAR_OTEL_ENDPOINT env 通道;缺省关 = noop 零出站)——turn/step span 挂装配层
163
+ const otel = createTelemetryFacade(telemetryConfigFromEnv())
164
+ const tracer = new TurnTracer(otel)
165
+ let r: Awaited<ReturnType<typeof runTurn>>
166
+ if (isDemo) {
167
+ // --demo:stub 演示流(无模型可跑——五环节叙事与既有测试/CI 承载)
168
+ log.append('user', 'user', 'T0', '请调用 demo_tool 并汇报')
169
+ r = await runTurn({
170
+ log, turnId: 'T0',
171
+ preset: { mode: 'confirm', authorize: async () => { console.log('[授权] 确认模式:放行 demo_tool'); return true } },
172
+ tools: new Map([['demo_tool', { declaredSideEffect: 'write', run: async () => 'demo-ok' } as any]]),
173
+ model: async () => ({ stopReason: 'toolUse' as const, toolCalls: [{ id: 'c1', tool: 'demo_tool', args: {} }] }),
174
+ })
175
+ } else {
176
+ // 真路径:模型配置解析(D-16 优先级 flag > env > config)+ 装配 RuntimeCore
177
+ const cfgLlm = loadCarConfig({ explicitPath: flagValue(rest, '--config') })
178
+ if (cfgLlm.error) { console.error(cfgLlm.error); return 2 }
179
+ const mtFlag = flagValue(rest, '--max-tokens')
180
+ const llm = mergeLlmConfig(process.env, cfgLlm.config, {
181
+ baseUrl: flagValue(rest, '--base-url'),
182
+ model: flagValue(rest, '--model'),
183
+ adapterId: flagValue(rest, '--adapter-id'),
184
+ ...(mtFlag !== undefined ? { maxTokens: Number(mtFlag) } : {}),
185
+ })
186
+ const prompt = flagValue(rest, '--prompt')
187
+ if (!llm.baseUrl || !llm.model || prompt === undefined) {
188
+ console.error('CAR-E-LLM-CONFIG: 真实模型路径需要 baseUrl + model + --prompt——')
189
+ console.error(' 配置:car.config.json {"llm":{"baseUrl":"https://…","model":"…"}} / env CAR_LLM_BASE_URL+CAR_LLM_MODEL / flag --base-url --model --prompt')
190
+ console.error(' 凭据:OPENAI_API_KEY(env fallback 需 CAR_ALLOW_ENV_CREDENTIALS=1 或 llm.allowEnvFallback)或 keychain;诊断:car doctor')
191
+ console.error(' (无模型演示流:--demo)')
192
+ return 2
193
+ }
194
+ const modelName = llm.model
195
+ const credService = new CredentialService()
196
+ const core = new RuntimeCore(credService)
197
+ core.bindContext(ctx)
198
+ // 适配器经根作用域 effect 可逆注册(bindContext 必须先于 register——否则走非可逆直注册分支)
199
+ core.registerLlmAdapter(createOpenAICompatAdapter({
200
+ baseUrl: llm.baseUrl,
201
+ credentials: credService,
202
+ provider: 'openai-compat',
203
+ }), { default: true })
204
+ // MCP 工具进 turn(D-18):--mcp <serverId>(可重复;server 启动规格在 car.config.json mcp.servers 登记)
205
+ const mcpIds: string[] = []
206
+ for (let i = 0; i < rest.length; i++) {
207
+ if (rest[i] === '--mcp') {
208
+ const id = rest[++i]
209
+ if (!id) { console.error('缺少 --mcp serverId'); return 2 }
210
+ mcpIds.push(id)
211
+ }
212
+ }
213
+ const mcpTransports: import('./mcp/gateway.ts').ClientTransport[] = []
214
+ if (mcpIds.length) {
215
+ const { McpGateway } = await import('./mcp/gateway.ts')
216
+ const { createStdioClientTransport } = await import('./mcp/stdioClient.ts')
217
+ const gw = new McpGateway()
218
+ for (const id of mcpIds) {
219
+ const spec = cfgLlm.config.mcp?.servers?.[id]
220
+ if (!spec) { console.error(`CAR-E-MCP: server "${id}" 未在 car.config.json mcp.servers 登记`); return 2 }
221
+ const transport = createStdioClientTransport({ command: spec.command, args: spec.args })
222
+ mcpTransports.push(transport)
223
+ const mcpTools = await gw.register({ serverId: id, transport, env: spec.env })
224
+ for (const t of mcpTools) {
225
+ const modelName = `mcp_${id}_${t.name}`
226
+ if (toolsMap.has(modelName)) { console.error(`CAR-E-MCP: 工具名冲突 ${modelName}(跨源同名禁静默覆盖)`); return 1 }
227
+ toolsMap.set(modelName, {
228
+ declaredSideEffect: t.declaredSideEffect ?? 'write',
229
+ run: async (args: any) => { const r = await gw.callTool(id, t.name, args); if (!r.ok) throw new Error(r.error ?? 'mcp call failed'); return r.result },
230
+ ...(t.description !== undefined ? { description: t.description } : {}),
231
+ ...(t.inputSchema !== undefined ? { parameters: t.inputSchema } : {}),
232
+ })
233
+ }
234
+ console.log(`[MCP] ${id}: ${mcpTools.length} 工具入声明面`)
235
+ }
236
+ }
237
+ // PTC 进真实 turn(--ptc):插件工具 Map 即 ToolBridge(声明面=授权面同源快照);
238
+ // run_code 进工具 Map(强制 write);SDK 声明块独立 system 事件(D-17)
239
+ let sdkBlock: string | null = null
240
+ if (rest.includes('--ptc')) {
241
+ const { makePtcToolDefinition, runCode } = await import('./ptc/runCode.ts')
242
+ void runCode
243
+ const { renderSdkFromRegistry } = await import('./ptc/sdk.ts')
244
+ const registry = new Map([...toolsMap].map(([name, t]) => [name, { run: t.run, description: t.description, inputSchema: t.parameters }]))
245
+ sdkBlock = renderSdkFromRegistry(registry)
246
+ const ptc = makePtcToolDefinition({ tools: registry, audit: () => {} })
247
+ toolsMap.set(ptc.name, ptc)
248
+ console.log(`[PTC] run_code 入工具面(桥接 ${registry.size} 工具;SDK 声明块进 system)`)
249
+ }
250
+ // system prompt 落链(D-17):--system flag > 插件 setSystemPrompt(last-wins);SDK 块独立追加
251
+ const systemText = flagValue(rest, '--system') ?? pluginSystemPrompt
252
+ if (systemText) log.append('runtime', 'system', 'system', systemText)
253
+ if (sdkBlock) log.append('runtime', 'system', 'system', sdkBlock)
254
+ log.append('user', 'user', 'T0', prompt)
255
+ const turn = tracer.startTurn('T0')
256
+ let stepNo = 0
257
+ r = await runTurn({
258
+ log, turnId: 'T0',
259
+ preset: { mode: 'confirm', authorize: async (call) => { console.log(`[授权] 确认模式:放行 ${call.tool}`); return true } },
260
+ tools: toolsMap,
261
+ signal,
262
+ model: () => {
263
+ // 1.4-S6:step span 挂每次 model() 调用(parent = turn span;同 turn 共享 traceId)
264
+ const step = turn.stepSpan(++stepNo)
265
+ return chatStep({
266
+ core, log, turnId: 'T0',
267
+ model: modelName,
268
+ tools: toToolDefinitions(toolsMap),
269
+ ...(llm.maxTokens !== undefined ? { maxTokens: llm.maxTokens } : {}),
270
+ ...(llm.adapterId ? { adapterId: llm.adapterId } : {}),
271
+ signal,
272
+ }).then(
273
+ res => { step.end(res.stopReason); return res },
274
+ e => { step.recordException(e); step.end('error'); throw e },
275
+ )
276
+ },
277
+ })
278
+ turn.end(r.reason)
279
+ }
280
+ process.removeListener('SIGINT', onSigint)
143
281
  console.log(`[3/5 收口] turnEnd=${r.reason}(steps=${r.steps})`)
282
+ // 1.4-S6:遥测收口 flush(显式开路径;缺省关 = 空转)
283
+ if (otel.enabled) {
284
+ await otel.shutdown()
285
+ console.error(`[otel] stats=${JSON.stringify(otel.stats())}`)
286
+ }
144
287
  // 环节 4:落盘收口(全程逐事件 fsync 已发生,此处仅关句柄核对 + 索引 pending 落盘)
145
288
  store.close()
146
289
  await indexUpdater.close()
@@ -179,6 +322,9 @@ async function main(): Promise<number> {
179
322
  // 1.1-S2 增强:签名就绪行(mode 生效值 / 信任根可解析性 / 配置文件通道发现态;诊断只读)
180
323
  const sig = doctorSignature({ cwd: process.cwd(), configPath: flagValue(rest, '--config') })
181
324
  console.log(`signature: ${sig.detail}`)
325
+ // 1.4-S3 增强:模型就绪行(baseUrl/model / 凭据通道 / envFallback;值不打印)
326
+ const mr = doctorModelReadiness({ cwd: process.cwd(), configPath: flagValue(rest, '--config') })
327
+ console.log(`model: ${mr.detail}`)
182
328
  // M6 增强:registry 连通性(不可达 = SKIPPED 显式跳过,离线不失败)
183
329
  const conn = await doctorConnectivity()
184
330
  console.log(`connectivity: ${conn.status} — ${conn.detail}${conn.status === 'PASS' ? `(${conn.latencyMs}ms)` : ''}`)
package/src/dx/doctor.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { createPublicKey } from 'node:crypto'
11
11
  import { CredentialService } from '../runtime-core/credentials.ts'
12
- import { loadCarConfig, mergeSignatureGate } from '../load/config.ts'
12
+ import { loadCarConfig, mergeSignatureGate, mergeLlmConfig } from '../load/config.ts'
13
13
 
14
14
  export interface CredentialCheck {
15
15
  name: string
@@ -90,6 +90,47 @@ export function doctorSignature(opts: { env?: NodeJS.ProcessEnv; cwd?: string; c
90
90
  return { mode, trustRoot, configFile, detail: parts.join(';') }
91
91
  }
92
92
 
93
+ export interface ModelReadinessCheck {
94
+ /** baseUrl + model 均在场 = true(可发起真实模型请求的最低配置面) */
95
+ configured: boolean
96
+ baseUrl: string
97
+ model: string
98
+ /** 凭据通道探测(存在性,值不打印):keychain 命中 / env 命中(显式开启时)/ 全落空 */
99
+ credential: 'keychain' | 'env' | 'not-found'
100
+ /** env fallback 开关态(CAR_ALLOW_ENV_CREDENTIALS=1 或 llm.allowEnvFallback) */
101
+ envFallback: 'on' | 'off'
102
+ detail: string
103
+ }
104
+
105
+ /** 1.4-S3 模型就绪检查(D-16):baseUrl/model 在场性 + 凭据通道存在性探测(离线可过;值不打印) */
106
+ export function doctorModelReadiness(opts: { env?: NodeJS.ProcessEnv; cwd?: string; configPath?: string } = {}): ModelReadinessCheck {
107
+ const env = opts.env ?? process.env
108
+ const cfg = loadCarConfig({ explicitPath: opts.configPath, cwd: opts.cwd })
109
+ const { baseUrl, model, allowEnvFallback } = mergeLlmConfig(env, cfg.error ? {} : cfg.config)
110
+ const envFallback: ModelReadinessCheck['envFallback'] = allowEnvFallback ? 'on' : 'off'
111
+ let credential: ModelReadinessCheck['credential'] = 'not-found'
112
+ if (allowEnvFallback) {
113
+ // env fallback 显式开启:env 变量存在性(OPENAI_API_KEY / CAR_LLM_API_KEY)——只探存在不取值
114
+ if (env.OPENAI_API_KEY || env.CAR_LLM_API_KEY) credential = 'env'
115
+ }
116
+ if (credential === 'not-found') {
117
+ // keychain 通道存在性(resolve 探测,A080001 全落空即 not-found;值不打印)
118
+ try {
119
+ const ref = new CredentialService().resolve('openai-compat', { env, allowEnvFallback })
120
+ credential = ref.source
121
+ } catch { /* 全落空 = not-found(登记口径,非错误) */ }
122
+ }
123
+ const configured = !!baseUrl && !!model
124
+ const parts = [
125
+ configured ? `baseUrl=${baseUrl} model=${model}` : 'baseUrl/model 未配置(car run 真路径需 llm.* 配置或 CAR_LLM_BASE_URL/CAR_LLM_MODEL——见 car doctor / 配置指南)',
126
+ credential === 'keychain' ? '凭据=keychain 命中(car-runtime/openai-compat,值不打印)'
127
+ : credential === 'env' ? '凭据=env 命中(显式开启 fallback,值不打印)'
128
+ : '凭据未就绪(keychain 未命中且 env fallback 未开或变量缺席——A080001 会引导至此)',
129
+ `envFallback=${envFallback}`,
130
+ ]
131
+ return { configured, baseUrl: baseUrl ?? '', model: model ?? '', credential, envFallback, detail: parts.join(';') }
132
+ }
133
+
93
134
  /**
94
135
  * registry 连通性检查:可达 = PASS(含延迟);不可达/显式跳过 = SKIPPED(不失败)。
95
136
  * 默认 registry 取 CAR_REGISTRY_URL,缺省 npmjs。
@@ -105,14 +146,20 @@ export async function doctorConnectivity(opts: {
105
146
  return { status: 'SKIPPED', detail: `显式离线(CAR_OFFLINE=1 或测试注入)——跳过 ${url}`, latencyMs: 0 }
106
147
  }
107
148
  const t0 = Date.now()
149
+ // 同 1.4-BUG-2:AbortSignal.timeout → 手动 controller(进程退出 libuv win32 断言预防)
150
+ const controller = new AbortController()
151
+ const abortTimer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 2000)
108
152
  try {
109
- await fetch(url, { signal: AbortSignal.timeout(opts.timeoutMs ?? 2000) })
153
+ await fetch(url, { signal: controller.signal })
110
154
  return { status: 'PASS', detail: url, latencyMs: Date.now() - t0 }
111
155
  } catch (e) {
156
+ clearTimeout(abortTimer)
112
157
  return {
113
158
  status: 'SKIPPED',
114
159
  detail: `registry 不可达(${(e as Error).name}: ${(e as Error).message})——离线环境显式跳过,不判失败`,
115
160
  latencyMs: Date.now() - t0,
116
161
  }
162
+ } finally {
163
+ clearTimeout(abortTimer)
117
164
  }
118
165
  }
@@ -27,8 +27,27 @@ export interface SandboxSigConfig {
27
27
  sigTrustRoot?: string
28
28
  }
29
29
 
30
+ export interface LlmConfig {
31
+ baseUrl?: string
32
+ model?: string
33
+ adapterId?: string
34
+ maxTokens?: number
35
+ allowEnvFallback?: boolean
36
+ }
37
+
38
+ export interface McpServerSpec {
39
+ command: string
40
+ args?: string[]
41
+ /** 启动环境变量(禁明文凭据——gateway.containsPlaintextCredential 注册时强制检查) */
42
+ env?: Record<string, string>
43
+ }
44
+
30
45
  export interface CarConfig {
31
46
  sandbox?: SandboxSigConfig
47
+ /** 1.4-S3(D-16):模型接入配置键位空间;凭据禁入(CR-05)——只放端点/模型名/开关 */
48
+ llm?: LlmConfig
49
+ /** 1.4-S7(D-18):MCP server 登记面(--mcp <id> 显式启用;serverId → stdio 启动规格) */
50
+ mcp?: { servers: Record<string, McpServerSpec> }
32
51
  }
33
52
 
34
53
  export interface ConfigLoadResult {
@@ -68,8 +87,77 @@ function validate(raw: unknown): { config: CarConfig; error?: string } {
68
87
  }
69
88
  const out: CarConfig = {}
70
89
  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.*)` }
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
73
161
  }
74
162
  if (typeof v !== 'object' || v === null || Array.isArray(v)) {
75
163
  return { config: {}, error: 'CAR-E-CONFIG: sandbox 必须为对象' }
@@ -112,6 +200,34 @@ export interface ResolvedSignatureGate {
112
200
  unsignedAllow: boolean
113
201
  }
114
202
 
203
+ /**
204
+ * 1.4-S3(D-16):模型接入配置合并(flag > env > 配置 > 缺省)。
205
+ * 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)。
208
+ */
209
+ export function mergeLlmConfig(env: NodeJS.ProcessEnv, cfg: CarConfig, flags: { baseUrl?: string; model?: string; adapterId?: string; maxTokens?: number } = {}): LlmConfig {
210
+ let baseUrl = cfg.llm?.baseUrl
211
+ if (env.CAR_LLM_BASE_URL) baseUrl = env.CAR_LLM_BASE_URL
212
+ if (flags.baseUrl) baseUrl = flags.baseUrl
213
+ let model = cfg.llm?.model
214
+ if (env.CAR_LLM_MODEL) model = env.CAR_LLM_MODEL
215
+ if (flags.model) model = flags.model
216
+ let adapterId = cfg.llm?.adapterId
217
+ if (flags.adapterId) adapterId = flags.adapterId
218
+ let maxTokens = cfg.llm?.maxTokens
219
+ if (flags.maxTokens !== undefined) maxTokens = flags.maxTokens
220
+ let allowEnvFallback = cfg.llm?.allowEnvFallback === true
221
+ if (env.CAR_ALLOW_ENV_CREDENTIALS !== undefined) allowEnvFallback = env.CAR_ALLOW_ENV_CREDENTIALS === '1'
222
+ return {
223
+ ...(baseUrl ? { baseUrl } : {}),
224
+ ...(model ? { model } : {}),
225
+ ...(adapterId ? { adapterId } : {}),
226
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
227
+ allowEnvFallback,
228
+ }
229
+ }
230
+
115
231
  /**
116
232
  * 优先级合并(flag > env > 配置 > 缺省):产出装载签名门选项。
117
233
  * 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,75 @@
1
+ /**
2
+ * 1.4-S7 · MCP stdio 客户端传输(W1 MCP 工具进 turn):spawn 子进程 + JSON-RPC 换行分隔 JSON
3
+ *
4
+ * 口径(1.4 规划 W1 MCP 块 / D-18):
5
+ * - CAR → 外部 MCP Server 方向(gateway.ts ClientTransport 的真实通道实装——此前仅测试替身);
6
+ * server 侧对偶(宿主 → CAR)见 src/host/stdio.ts;
7
+ * - 行协议:请求一行 JSON 写入 server stdin;响应按 id 匹配 pending 表(行解析容错跳过非 JSON 行);
8
+ * - send 内建 30s 超时(fail-visible——gateway.register 的 tools/list 无外层 race,防挂死);
9
+ * - stderr 不透传(server 诊断输出与协议通道隔离);close = kill(car run 收口调用)。
10
+ */
11
+ import { spawn, type ChildProcess } from 'node:child_process'
12
+ import type { ClientTransport, JsonRpcResponse } from './gateway.ts'
13
+
14
+ export interface StdioServerSpec {
15
+ command: string
16
+ args?: string[]
17
+ }
18
+
19
+ export function createStdioClientTransport(spec: StdioServerSpec): ClientTransport & { readonly alive: () => boolean } {
20
+ const child: ChildProcess = spawn(spec.command, spec.args ?? [], { stdio: ['pipe', 'pipe', 'pipe'] })
21
+ const pending = new Map<number, (res: JsonRpcResponse) => void>()
22
+ let buffer = ''
23
+ let exited = false
24
+ child.on('exit', () => { exited = true })
25
+ child.stdout!.on('data', chunk => {
26
+ buffer += chunk.toString('utf-8')
27
+ let nl: number
28
+ while ((nl = buffer.indexOf('\n')) >= 0) {
29
+ const line = buffer.slice(0, nl).trim()
30
+ buffer = buffer.slice(nl + 1)
31
+ if (!line) continue
32
+ try {
33
+ const res = JSON.parse(line) as JsonRpcResponse
34
+ const settle = pending.get(res.id as number)
35
+ if (settle) {
36
+ pending.delete(res.id as number)
37
+ settle(res)
38
+ }
39
+ } catch { /* 非 JSON 行容错跳过(server 诊断污染 stdin 协议通道时不断链) */ }
40
+ }
41
+ })
42
+ child.stderr!.on('data', () => { /* 隔离(见头注释) */ })
43
+ child.on('error', () => { exited = true })
44
+
45
+ const transport: ClientTransport & { readonly alive: () => boolean } = {
46
+ send(req) {
47
+ return new Promise((resolve, reject) => {
48
+ if (!transport.alive()) {
49
+ reject(new Error('CAR-E-MCP: stdio transport not alive(server 进程已退出)'))
50
+ return
51
+ }
52
+ const timer = setTimeout(() => {
53
+ pending.delete(req.id as number)
54
+ reject(new Error('CAR-E-MCP: stdio send timeout (30s)'))
55
+ }, 30_000)
56
+ pending.set(req.id as number, res => {
57
+ clearTimeout(timer)
58
+ resolve(res)
59
+ })
60
+ try {
61
+ child.stdin!.write(JSON.stringify(req) + '\n')
62
+ } catch (e) {
63
+ clearTimeout(timer)
64
+ pending.delete(req.id as number)
65
+ reject(e)
66
+ }
67
+ })
68
+ },
69
+ alive: () => !exited && child.exitCode === null && !child.killed,
70
+ close: () => {
71
+ try { child.kill() } catch { /* 已退出 */ }
72
+ },
73
+ }
74
+ return transport
75
+ }