@lqc123qwe/car-runtime 1.0.0 → 1.2.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.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * 1.1-S3 · 签名配置文件通道(sandbox.* 键位空间;部署设计 §4.5.5 载体落地)
3
+ *
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;未定义才落到配置层)。
15
+ * 无配置文件 + 无 env 时行为与 1.0 完全一致(warn / 无信任根 / 不豁免)。
16
+ * - 本模块只读配置与合并优先级;门禁判定在 sigGate/verifier。发现序:显式路径 > cwd car.config.json。
17
+ */
18
+ import { existsSync, readFileSync } from 'node:fs'
19
+ import { isAbsolute, join } from 'node:path'
20
+ import type { SignatureGateOptions } from './sigGate.ts'
21
+
22
+ export const CONFIG_FILENAME = 'car.config.json'
23
+
24
+ export interface SandboxSigConfig {
25
+ unsignedAllow?: boolean
26
+ sigEnforce?: boolean
27
+ sigTrustRoot?: string
28
+ }
29
+
30
+ export interface CarConfig {
31
+ sandbox?: SandboxSigConfig
32
+ }
33
+
34
+ export interface ConfigLoadResult {
35
+ /** 实际读取的配置文件路径(cwd 未发现时缺省——未发现是正常态非错误) */
36
+ path?: string
37
+ config: CarConfig
38
+ /** fail-visible:显式路径不存在 / 坏 JSON / 未登记键 / 类型错的原因(非空 = 调用方必须中止) */
39
+ error?: string
40
+ }
41
+
42
+ /** 加载配置:显式路径(必须存在,否则报错)或 cwd 发现(未发现 = 正常态返回空配置) */
43
+ export function loadCarConfig(opts: { explicitPath?: string; cwd?: string } = {}): ConfigLoadResult {
44
+ let path: string | undefined
45
+ if (opts.explicitPath) {
46
+ path = isAbsolute(opts.explicitPath) ? opts.explicitPath : join(opts.cwd ?? process.cwd(), opts.explicitPath)
47
+ if (!existsSync(path)) {
48
+ return { path, config: {}, error: `CAR-E-CONFIG: 配置文件不存在:${path}(--config 显式指定的路径必须存在)` }
49
+ }
50
+ } else {
51
+ path = join(opts.cwd ?? process.cwd(), CONFIG_FILENAME)
52
+ if (!existsSync(path)) return { config: {} }
53
+ }
54
+ let raw: unknown
55
+ try {
56
+ raw = JSON.parse(readFileSync(path, 'utf-8'))
57
+ } catch (e) {
58
+ return { path, config: {}, error: `CAR-E-CONFIG: 配置文件解析失败(${(e as Error).message})——坏 JSON 显式拒绝,不静默忽略` }
59
+ }
60
+ const v = validate(raw)
61
+ return v.error ? { path, config: {}, error: v.error } : { path, config: v.config }
62
+ }
63
+
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
+ /** 合并产出(mode/unsignedAllow 恒有值——比 SignatureGateOptions 的可选字段更窄,doctor 等消费方免缺省分支) */
109
+ export interface ResolvedSignatureGate {
110
+ mode: 'warn' | 'enforce'
111
+ trustRootPublicKey?: string
112
+ unsignedAllow: boolean
113
+ }
114
+
115
+ /**
116
+ * 优先级合并(flag > env > 配置 > 缺省):产出装载签名门选项。
117
+ * flagEnforce = CLI --sig-enforce;env 已定义即为显式意见(压过配置),未定义落配置层。
118
+ */
119
+ export function mergeSignatureGate(env: NodeJS.ProcessEnv, cfg: CarConfig, flagEnforce?: boolean): ResolvedSignatureGate {
120
+ // cfg 为校验器产出的扁平形状(sandbox.{unsignedAllow,sigEnforce,sigTrustRoot},JSON 嵌套键已拍平)
121
+ let mode: 'warn' | 'enforce' = cfg.sandbox?.sigEnforce === true ? 'enforce' : 'warn'
122
+ if (env.CAR_SIG_ENFORCE !== undefined) mode = env.CAR_SIG_ENFORCE === '1' ? 'enforce' : 'warn'
123
+ if (flagEnforce) mode = 'enforce'
124
+ let trustRootPublicKey = cfg.sandbox?.sigTrustRoot
125
+ if (env.CAR_TRUST_ROOT) trustRootPublicKey = env.CAR_TRUST_ROOT
126
+ let unsignedAllow = cfg.sandbox?.unsignedAllow === true
127
+ if (env.CAR_UNSIGNED_ALLOW !== undefined) unsignedAllow = env.CAR_UNSIGNED_ALLOW === '1'
128
+ return { mode, trustRootPublicKey: trustRootPublicKey || undefined, unsignedAllow }
129
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 1.1-S1 · plugin-sign 签名原语(keygen / sign 纯函数;verify 复用 sigGate.verifyPluginFile)
3
+ *
4
+ * 口径(与 M2-S6 verifier / M5-S28 sigGate 签验同源,逐字对齐签名强制启用指南 §2):
5
+ * - 被签物 = 插件入口 .ts 文件裸字节 sha256 的 hex 串;被签消息 = hex 字符串的 UTF-8 字节
6
+ * (sigGate.ts 头注释同源;哈希对裸字节求——G-11 教训:禁止对编码文本求哈希)
7
+ * - 私钥 = ed25519 PKCS8 DER 文件(<前缀>.priv);公钥 = SPKI DER base64(<前缀>.pub,
8
+ * 即 CAR_TRUST_ROOT 信任根值);sidecar = `<file>.minisig` 单段 base64(sigGate 读取口径)
9
+ * - 本模块只产出密钥与签名;门禁判定(warn/enforce 分级、fail-closed 分级)在 verifier/sigGate——
10
+ * sign 不判门。边界登记(1.1 规划 W2-1):minisign 离线单轨;sigstore keyless 主轨延后(1.2 候选)
11
+ */
12
+ import { createHash, generateKeyPairSync, sign as cryptoSign } from 'node:crypto'
13
+ import { readFileSync, writeFileSync } from 'node:fs'
14
+
15
+ export interface SigningKeypair {
16
+ /** PKCS8 DER 私钥字节(写 <前缀>.priv;离线保管不入仓库——secrets 红线) */
17
+ privateKeyDer: Buffer
18
+ /** SPKI DER base64 公钥(写 <前缀>.pub;即 CAR_TRUST_ROOT 信任根值) */
19
+ publicKeyBase64: string
20
+ }
21
+
22
+ /** ed25519 签名密钥对生成(指南 §2 第 1 步 node -e 流程固化) */
23
+ export function generateSigningKeypair(): SigningKeypair {
24
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519')
25
+ return {
26
+ privateKeyDer: privateKey.export({ type: 'pkcs8', format: 'der' }) as Buffer,
27
+ publicKeyBase64: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
28
+ }
29
+ }
30
+
31
+ export interface SignResult {
32
+ file: string
33
+ sidecar: string
34
+ /** 文件裸字节 sha256 hex(横幅指纹与审计留痕口径同 sigGate.manifestHash) */
35
+ manifestHash: string
36
+ }
37
+
38
+ /** 对单文件签名并写 sidecar `<file>.minisig`(指南 §2 第 2 步 node -e 流程固化) */
39
+ export function signPluginFile(file: string, privateKeyDer: Buffer): SignResult {
40
+ const manifestHash = createHash('sha256').update(readFileSync(file)).digest('hex')
41
+ const minisig = cryptoSign(null, Buffer.from(manifestHash), { key: privateKeyDer, format: 'der', type: 'pkcs8' }).toString('base64')
42
+ const sidecar = `${file}.minisig`
43
+ writeFileSync(sidecar, minisig)
44
+ return { file, sidecar, manifestHash }
45
+ }
46
+
47
+ /** 信任根解析(plugin-sign verify 用):显式 flag > CAR_TRUST_ROOT env > <前缀>.pub 文件 */
48
+ export function resolveTrustRoot(opts: { flag?: string; env?: NodeJS.ProcessEnv; pubFilePath?: string }): { value?: string; source: 'flag' | 'env' | 'pubfile' | 'none' } {
49
+ if (opts.flag) return { value: opts.flag, source: 'flag' }
50
+ const envVal = opts.env?.CAR_TRUST_ROOT
51
+ if (envVal) return { value: envVal, source: 'env' }
52
+ const pub = opts.pubFilePath
53
+ if (pub) {
54
+ try {
55
+ const v = readFileSync(pub, 'utf-8').trim()
56
+ if (v) return { value: v, source: 'pubfile' }
57
+ } catch { /* pub 文件不可读 → 视同未提供,由调用方显式报错 */ }
58
+ }
59
+ return { value: undefined, source: 'none' }
60
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * M8 · SQ-07 模型接入集成(chat → 流式消费 → assistant 落 M7 → ModelStep 交 M4 状态机)
3
+ *
4
+ * 时序对齐(§3.2.M8.4 SQ-07 逐行):
5
+ * 1. 请求前断言:assertModelVisibleLogged(配合 M7,N1 不变量前置);
6
+ * 2. 请求消息 = log.deriveMessages() 投影——Model-visible means logged 由构造保证
7
+ * (本层不引入任何未落链的模型可见输入:新增可见输入必须新增事件类型,§3.5.5 红线;
8
+ * system prompt 组装属 M4/M2 职责,v1 不在本层旁路);
9
+ * 3. 凭据解析在适配器内(resolve→reveal,A080001 在首块前抛出 → runTurn catch → turn error,
10
+ * 用户文案引导 car doctor);
11
+ * 4. 流式消费:delta 过 StreamRedactor(跨 chunk 命中不泄漏)+ toolCallDelta 聚合
12
+ * (半截 JSON 不解析——ADR-001 同纪律,length 时只交 truncatedTools);
13
+ * 5. assistant 文本事件落 M7(脱敏后 + secretsRedacted 计数留痕 meta);
14
+ * 6. finishReason 不可变透传为 ModelStep.stopReason(error/aborted → 'error',BD-04 收口
15
+ * 由 runTurn 落 turnEnd);工具调用事件由 runTurn executeBatch 落(本层不重复)。
16
+ */
17
+ import type { SessionLog } from '../session/log.ts'
18
+ import type { ModelStep, ToolCall } from '../loop/stop.ts'
19
+ import type { RuntimeCore } from './llm.ts'
20
+ import type { ToolDefinition } from './types.ts'
21
+ import { StreamRedactor } from './redaction.ts'
22
+ import { scanSecrets } from '../security/secrets.ts'
23
+ import { providerUnreachable } from './errors.ts'
24
+
25
+ export interface ChatStepOptions {
26
+ core: RuntimeCore
27
+ log: SessionLog
28
+ turnId: string
29
+ /** 模型名(provider 侧路由) */
30
+ model: string
31
+ /** 工具声明面(ToolDefinition 含 declaredSideEffect;不出站权限面仅 schema) */
32
+ tools: ToolDefinition[]
33
+ maxTokens?: number
34
+ adapterId?: string
35
+ }
36
+
37
+ /** ModelStep 加法扩展:脱敏计数留痕(S15 secretsRedacted 同名口径) */
38
+ export type ChatStepResult = ModelStep & { secretsRedacted: number }
39
+
40
+ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
41
+ const { core, log, turnId, model, tools, maxTokens, adapterId } = opts
42
+
43
+ // 1. 请求前断言(SQ-07 #2):N1 失守 = 带病请求,显式拒绝
44
+ const asserted = log.assertModelVisibleLogged()
45
+ if (!asserted.ok) {
46
+ throw new Error(`CAR-E-N1: 模型请求前快照与日志投影失配 @ atSeq=${asserted.failedAt}(Model-visible means logged 违规)`)
47
+ }
48
+
49
+ // 2. 请求消息 = 投影(角色系统:user/assistant/toolResult 与 LlmRequest 对齐;
50
+ // assistant 工具调用以 { toolCall } content 形态原样透传——deriveMessages 投影即模型可见流)
51
+ const messages = log.deriveMessages().map(m => ({
52
+ role: m.role as 'user' | 'assistant' | 'toolResult',
53
+ content: m.content,
54
+ }))
55
+
56
+ // 4. 流式消费(3. 凭据解析在适配器首块前,A080001 由此传播)
57
+ const redactor = new StreamRedactor()
58
+ let text = ''
59
+ const calls = new Map<number, { id?: string; name?: string; argsBuf: string }>()
60
+ let stopReason: ModelStep['stopReason'] | undefined
61
+ let errorDetail: string | undefined
62
+
63
+ const stream = core.chat({
64
+ model,
65
+ messages,
66
+ tools,
67
+ ...(maxTokens != null ? { maxTokens } : {}),
68
+ ...(adapterId ? { adapterId } : {}),
69
+ ...(opts.log.sessionId ? { metadata: { sessionId: opts.log.sessionId, turnId, traceId: `${opts.log.sessionId}:${turnId}` } } : {}),
70
+ })
71
+ for await (const chunk of stream) {
72
+ if (chunk.delta !== undefined) text += redactor.push(chunk.delta)
73
+ if (chunk.toolCallDelta) {
74
+ const idx = chunk.toolCallDelta.index ?? 0
75
+ const cur = calls.get(idx) ?? { argsBuf: '' }
76
+ if (chunk.toolCallDelta.id) cur.id = chunk.toolCallDelta.id
77
+ if (chunk.toolCallDelta.name) cur.name = chunk.toolCallDelta.name
78
+ if (chunk.toolCallDelta.argumentsDelta) cur.argsBuf += chunk.toolCallDelta.argumentsDelta
79
+ calls.set(idx, cur)
80
+ }
81
+ if (chunk.error) errorDetail = chunk.error.message
82
+ if (chunk.finishReason !== undefined) stopReason = chunk.finishReason === 'aborted' ? 'error' : chunk.finishReason
83
+ }
84
+ if (stopReason === undefined) {
85
+ // 适配器层已保证首 finishReason(AL-05);防御性兜底显式化
86
+ throw new Error('CAR-E-LLM-FINISH: 流结束无 finishReason(适配器契约违规)')
87
+ }
88
+ text += redactor.flush()
89
+
90
+ // 5. assistant 文本落 M7(脱敏后 + 计数留痕;空文本不产事件)
91
+ // flush 后兜底复扫只计数不复写:流式遮蔽已生效,>0 即流式边界逃逸的审计信号
92
+ let secretsRedacted = 0
93
+ if (text.length) {
94
+ secretsRedacted = scanSecrets(text).hits.length
95
+ log.append('model', 'assistant', turnId, text, { secretsRedacted })
96
+ }
97
+
98
+ // 6. finishReason → ModelStep(不可变透传;length 不解析 args——ADR-001 解析前收口)
99
+ if (stopReason === 'length') {
100
+ const truncated = [...calls.values()].filter(c => c.id || c.name).map(c => ({ id: c.id ?? `tc-${c.name ?? 'unknown'}`, tool: c.name ?? 'unknown' }))
101
+ return { stopReason: 'length', ...(text ? { text } : {}), truncatedTools: truncated, secretsRedacted }
102
+ }
103
+ if (stopReason === 'toolUse') {
104
+ const toolCalls: ToolCall[] = []
105
+ for (const [idx, c] of [...calls.entries()].sort((a, b) => a[0] - b[0])) {
106
+ if (!c.name) continue
107
+ let args: Record<string, unknown>
108
+ try { args = c.argsBuf ? JSON.parse(c.argsBuf) as Record<string, unknown> : {} } catch {
109
+ // toolUse 声明下 args 非法 JSON = 协议违规显式化(非 length 场景无 truncatedTools 口径)
110
+ throw new Error(`CAR-E-LLM-PROTO: 工具调用 ${c.name} 参数非法 JSON(finishReason=${stopReason},${errorDetail ?? '无附加错误'})`)
111
+ }
112
+ toolCalls.push({ id: c.id ?? `tc-${idx}-${c.name}`, tool: c.name, args })
113
+ }
114
+ return { stopReason: 'toolUse', ...(text ? { text } : {}), ...(toolCalls.length ? { toolCalls } : {}), secretsRedacted }
115
+ }
116
+ // error(含 aborted 映射):以 B080001 抛出 → runTurn catch → turnEnd error 携带 detail(BD-04 收口)
117
+ if (stopReason === 'error') {
118
+ throw providerUnreachable(errorDetail ?? 'provider 返回 error 终止(无附加说明)')
119
+ }
120
+ return { stopReason: 'stop', ...(text ? { text } : {}), secretsRedacted }
121
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * M8 · 凭据门(resolveCredential ——《系统设计》§3.2.M8.2 凭据行 / SQ-07 凭据解析 / O-13)
3
+ *
4
+ * 口径:
5
+ * - 解析链:OS Keychain(C-03)→ env fallback(**需用户显式开启**——§3.2.M8.3 字段表
6
+ * 「env 需用户显式开启 fallback」);全落空 = CarM8Error A080001(不重试,引导 car doctor);
7
+ * - Keychain 通道按平台探测(能力探测三件套:命令在场的显式探测,缺席显式降级不静默):
8
+ * darwin = `security find-generic-password`、linux = `secret-tool`、
9
+ * win32 = 零依赖读通道缺席(cmdkey 仅列举不可读值)→ 显式降级登记;
10
+ * - CredentialRef 不落明文(O-13):ref 只含 provider/source/origin;明文经 reveal() 按需取用,
11
+ * reveal 必须经审计回调(provider+source 留痕,值不入审计——值入日志即泄露面,secrets 红线);
12
+ * - 环境变量约定:OPENAI_API_KEY(openai-compat)/ ANTHROPIC_API_KEY(anthropic)/
13
+ * CAR_LLM_API_KEY(通用兜底);显式开启 = opts.allowEnvFallback 或 CAR_ALLOW_ENV_CREDENTIALS=1。
14
+ */
15
+ import { spawnSync } from 'node:child_process'
16
+ import { CarM8Error, credentialMissing } from './errors.ts'
17
+ import type { CredentialRef, CredentialSource } from './types.ts'
18
+
19
+ export interface CredentialResolveOptions {
20
+ /** 测试注入;缺省取 process.env */
21
+ env?: NodeJS.ProcessEnv
22
+ /** env fallback 显式开启(规格要求默认关;CAR_ALLOW_ENV_CREDENTIALS=1 等效) */
23
+ allowEnvFallback?: boolean
24
+ /**
25
+ * keychain 读取器注入(测试替身):入参 keychain 服务名,返回明文或 null(不存在)。
26
+ * 缺省用平台命令真实读取;平台无读通道(win32)= 通道不可用(显式降级)。
27
+ */
28
+ keychainReader?: (service: string) => string | null
29
+ /** reveal 审计回调(值不入参——审计全量留痕但零明文) */
30
+ audit?: (event: { action: 'resolve' | 'reveal'; provider: string; source: CredentialSource; origin: string }) => void
31
+ }
32
+
33
+ /** provider → 候选 env 变量(按序检索;CAR_LLM_API_KEY 为通用兜底) */
34
+ const PROVIDER_ENV_VARS: Record<string, string[]> = {
35
+ 'openai-compat': ['OPENAI_API_KEY'],
36
+ anthropic: ['ANTHROPIC_API_KEY'],
37
+ }
38
+ const GENERIC_ENV_VARS = ['CAR_LLM_API_KEY']
39
+
40
+ /** keychain 服务名(命名空间隔离:car-runtime/<provider>) */
41
+ export function keychainService(provider: string): string {
42
+ return `car-runtime/${provider}`
43
+ }
44
+
45
+ interface KeychainChannel { reader: ((service: string) => string | null) | null; note: string }
46
+
47
+ /** 平台 keychain 通道探测:命令在场(darwin/linux)→ 真实读取器;无读通道(win32)→ 显式 null */
48
+ export function probeKeychainChannel(platform: NodeJS.Platform = process.platform): KeychainChannel {
49
+ if (platform === 'darwin') {
50
+ return { reader: service => {
51
+ const r = spawnSync('security', ['find-generic-password', '-s', service, '-w'], { encoding: 'utf-8', timeout: 5000 })
52
+ if (r.status !== 0 || r.stdout == null) return null
53
+ return r.stdout.replace(/\r?\n$/, '') || null
54
+ }, note: 'darwin security' }
55
+ }
56
+ if (platform === 'linux') {
57
+ return { reader: service => {
58
+ const r = spawnSync('secret-tool', ['lookup', 'service', service], { encoding: 'utf-8', timeout: 5000 })
59
+ if (r.status !== 0 || r.stdout == null) return null
60
+ return r.stdout.replace(/\r?\n$/, '') || null
61
+ }, note: 'linux secret-tool' }
62
+ }
63
+ // win32 及其他:无零依赖读通道——显式不可用(禁静默假装有 keychain)
64
+ return { reader: null, note: `keychain 读通道在 ${platform} 缺席(零依赖实现无 OS 凭据库读取面)——显式降级` }
65
+ }
66
+
67
+ export class CredentialService {
68
+ readonly #channel: KeychainChannel
69
+ readonly #platform: NodeJS.Platform
70
+
71
+ constructor(opts: { keychainReader?: (service: string) => string | null; platform?: NodeJS.Platform } = {}) {
72
+ this.#platform = opts.platform ?? process.platform
73
+ this.#channel = opts.keychainReader
74
+ ? { reader: opts.keychainReader, note: 'injected reader' }
75
+ : probeKeychainChannel(this.#platform)
76
+ }
77
+
78
+ /** keychain 通道状态(doctor 展示/测试断言;缺席显式登记不静默) */
79
+ get channelStatus(): { available: boolean; note: string } {
80
+ return { available: this.#channel.reader !== null, note: this.#channel.note }
81
+ }
82
+
83
+ /**
84
+ * 解析凭据引用(天然幂等;返回值不含明文)。
85
+ * 检索顺序:keychain →(显式开启时)env;全落空 = A080001。
86
+ */
87
+ resolve(provider: string, opts: CredentialResolveOptions = {}): CredentialRef {
88
+ const env = opts.env ?? process.env
89
+ const searched: string[] = []
90
+ const service = keychainService(provider)
91
+
92
+ if (this.#channel.reader) {
93
+ searched.push(`keychain:${service}`)
94
+ const value = this.#channel.reader(service)
95
+ if (value) {
96
+ opts.audit?.({ action: 'resolve', provider, source: 'keychain', origin: service })
97
+ return { provider, source: 'keychain', origin: service }
98
+ }
99
+ } else {
100
+ searched.push(`keychain:${service}(通道缺席)`)
101
+ }
102
+
103
+ const allowEnv = opts.allowEnvFallback === true || env.CAR_ALLOW_ENV_CREDENTIALS === '1'
104
+ if (allowEnv) {
105
+ const candidates = [...(PROVIDER_ENV_VARS[provider] ?? []), ...GENERIC_ENV_VARS]
106
+ for (const name of candidates) {
107
+ searched.push(`env:${name}`)
108
+ if (env[name]) {
109
+ opts.audit?.({ action: 'resolve', provider, source: 'env', origin: name })
110
+ return { provider, source: 'env', origin: name, degraded: this.#channel.reader ? undefined : this.#channel.note }
111
+ }
112
+ }
113
+ } else {
114
+ searched.push('env(未显式开启 fallback)')
115
+ }
116
+
117
+ throw credentialMissing(provider, searched)
118
+ }
119
+
120
+ /** 按引用取明文(仅 provider 适配器请求时点调用;每次经审计留痕,值不入日志不入错误) */
121
+ reveal(ref: CredentialRef, opts: CredentialResolveOptions = {}): string {
122
+ const env = opts.env ?? process.env
123
+ if (ref.source === 'keychain') {
124
+ if (!this.#channel.reader) throw new CarM8Error('A080001', 'CAR-E-CRED-MISSING', `keychain 通道缺席,无法取值(${ref.origin})`, '未找到模型服务凭据,请运行 car doctor', false)
125
+ const value = this.#channel.reader(ref.origin)
126
+ if (!value) throw credentialMissing(ref.provider, [`keychain:${ref.origin}(resolve 后消失)`])
127
+ opts.audit?.({ action: 'reveal', provider: ref.provider, source: ref.source, origin: ref.origin })
128
+ return value
129
+ }
130
+ const value = env[ref.origin]
131
+ if (!value) throw credentialMissing(ref.provider, [`env:${ref.origin}(resolve 后消失)`])
132
+ opts.audit?.({ action: 'reveal', provider: ref.provider, source: ref.source, origin: ref.origin })
133
+ return value
134
+ }
135
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * M8 · 错误面(《系统设计》§3.5.1 六位错误码 + 代码库 CAR-E-* 前缀双轨)
3
+ *
4
+ * - A080001 凭据缺失:不重试,用户文案引导 car doctor(SQ-07 凭据缺失分支);
5
+ * - B080001 LLM Provider 不可达/重试耗尽:退避后重试(§3.5.4 基线——2 次指数退避 1s/2s);
6
+ * - 两条均为「错误路径不改写 finishReason 语义」的守门错误:流中途失败以
7
+ * finishReason='error' chunk 收口(BD-04),首块前失败以 CarError 抛出(runTurn catch → turn error)。
8
+ */
9
+
10
+ export type M8ErrorCode = 'A080001' | 'B080001'
11
+
12
+ export class CarM8Error extends Error {
13
+ constructor(
14
+ /** 六位错误码(§3.5.1 注册表) */
15
+ readonly code: M8ErrorCode,
16
+ /** CAR-E-* 代码库前缀(与 CAR-E-DEPCYCLE 等同风格) */
17
+ readonly slug: string,
18
+ message: string,
19
+ /** 用户文案(§3.5.1 注册表原文) */
20
+ readonly userHint?: string,
21
+ /** 重试建议(A080001 不重试 / B080001 退避后重试) */
22
+ readonly retryable = false,
23
+ ) {
24
+ super(`${slug}: ${message}(${code})`)
25
+ this.name = 'CarM8Error'
26
+ }
27
+ }
28
+
29
+ export function credentialMissing(provider: string, searched: string[]): CarM8Error {
30
+ return new CarM8Error(
31
+ 'A080001',
32
+ 'CAR-E-CRED-MISSING',
33
+ `未找到模型服务凭据(provider=${provider},已检索:${searched.join('、') || '无可用通道'})`,
34
+ '未找到模型服务凭据,请运行 car doctor',
35
+ false,
36
+ )
37
+ }
38
+
39
+ export function providerUnreachable(detail: string): CarM8Error {
40
+ return new CarM8Error(
41
+ 'B080001',
42
+ 'CAR-E-LLM-UNREACHABLE',
43
+ `LLM Provider 不可达/重试耗尽:${detail}`,
44
+ '模型服务暂时不可用,本轮已停止',
45
+ true,
46
+ )
47
+ }