@lqc123qwe/car-runtime 1.4.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.
- package/.github/workflows/ci.yml +12 -0
- package/README.md +16 -1
- package/package.json +4 -1
- package/src/cli.ts +32 -15
- package/src/dx/doctor.ts +9 -7
- package/src/load/config.ts +205 -137
- package/src/mcp/httpClient.ts +176 -0
- package/src/runtime-core/chatStep.ts +9 -3
- package/src/runtime-core/credentials.ts +6 -1
- package/src/runtime-core/llm.ts +289 -75
- package/test/s33-anthropic-adapter.spec.ts +354 -0
- package/test/s34-mcp-remote-transport.spec.ts +250 -0
- package/test/s35-config-ts-zod.spec.ts +145 -0
package/src/load/config.ts
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 1.1-S3
|
|
2
|
+
* 1.1-S3 签名配置文件通道 → 1.5-S3 完整形态(car.config.ts + zod,D-22)
|
|
3
3
|
*
|
|
4
4
|
* 口径:
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
@@ -36,17 +42,22 @@ export interface LlmConfig {
|
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export interface McpServerSpec {
|
|
39
|
-
|
|
45
|
+
/** stdio 通道启动命令(与 url 互斥——1.5-S2 起二选一显式登记) */
|
|
46
|
+
command?: string
|
|
40
47
|
args?: string[]
|
|
41
|
-
/** 启动环境变量(禁明文凭据——gateway.containsPlaintextCredential
|
|
48
|
+
/** 启动环境变量(禁明文凭据——gateway.containsPlaintextCredential 注册时强制检查;仅 command 通道适用) */
|
|
42
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>
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
export interface CarConfig {
|
|
46
57
|
sandbox?: SandboxSigConfig
|
|
47
58
|
/** 1.4-S3(D-16):模型接入配置键位空间;凭据禁入(CR-05)——只放端点/模型名/开关 */
|
|
48
59
|
llm?: LlmConfig
|
|
49
|
-
/** 1.4-S7(D-18):MCP server 登记面(--mcp <id>
|
|
60
|
+
/** 1.4-S7(D-18):MCP server 登记面(--mcp <id> 显式启用) */
|
|
50
61
|
mcp?: { servers: Record<string, McpServerSpec> }
|
|
51
62
|
}
|
|
52
63
|
|
|
@@ -54,145 +65,202 @@ export interface ConfigLoadResult {
|
|
|
54
65
|
/** 实际读取的配置文件路径(cwd 未发现时缺省——未发现是正常态非错误) */
|
|
55
66
|
path?: string
|
|
56
67
|
config: CarConfig
|
|
57
|
-
/** fail-visible:显式路径不存在 / 坏 JSON / 未登记键 / 类型错的原因(非空 = 调用方必须中止) */
|
|
68
|
+
/** fail-visible:显式路径不存在 / 坏 JSON / 坏 TS / 未登记键 / 类型错的原因(非空 = 调用方必须中止) */
|
|
58
69
|
error?: string
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
|
|
62
|
-
|
|
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> {
|
|
63
192
|
let path: string | undefined
|
|
64
193
|
if (opts.explicitPath) {
|
|
194
|
+
if (!opts.explicitPath.endsWith('.ts')) return // JSON 显式路径走同步通道,无需预热
|
|
65
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
|
+
}
|
|
66
236
|
if (!existsSync(path)) {
|
|
67
237
|
return { path, config: {}, error: `CAR-E-CONFIG: 配置文件不存在:${path}(--config 显式指定的路径必须存在)` }
|
|
68
238
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 }))` }
|
|
72
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 {
|
|
73
254
|
let raw: unknown
|
|
74
255
|
try {
|
|
75
256
|
raw = JSON.parse(readFileSync(path, 'utf-8'))
|
|
76
257
|
} catch (e) {
|
|
77
258
|
return { path, config: {}, error: `CAR-E-CONFIG: 配置文件解析失败(${(e as Error).message})——坏 JSON 显式拒绝,不静默忽略` }
|
|
78
259
|
}
|
|
79
|
-
const v =
|
|
260
|
+
const v = parseCarConfig(raw)
|
|
80
261
|
return v.error ? { path, config: {}, error: v.error } : { path, config: v.config }
|
|
81
262
|
}
|
|
82
263
|
|
|
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
264
|
/** 合并产出(mode/unsignedAllow 恒有值——比 SignatureGateOptions 的可选字段更窄,doctor 等消费方免缺省分支) */
|
|
197
265
|
export interface ResolvedSignatureGate {
|
|
198
266
|
mode: 'warn' | 'enforce'
|
|
@@ -203,8 +271,8 @@ export interface ResolvedSignatureGate {
|
|
|
203
271
|
/**
|
|
204
272
|
* 1.4-S3(D-16):模型接入配置合并(flag > env > 配置 > 缺省)。
|
|
205
273
|
* env 已定义为显式意见(压过配置),未定义落配置层——与 mergeSignatureGate 同纪律。
|
|
206
|
-
* 凭据不在此层(CR-05):走 CredentialService(keychain → env OPENAI_API_KEY/
|
|
207
|
-
* env fallback 需显式开启 = CAR_ALLOW_ENV_CREDENTIALS=1 或 llm.allowEnvFallback)。
|
|
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)。
|
|
208
276
|
*/
|
|
209
277
|
export function mergeLlmConfig(env: NodeJS.ProcessEnv, cfg: CarConfig, flags: { baseUrl?: string; model?: string; adapterId?: string; maxTokens?: number } = {}): LlmConfig {
|
|
210
278
|
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
|
-
//
|
|
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.
|
|
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 =
|
|
110
|
+
const candidates = providerEnvVars(provider)
|
|
106
111
|
for (const name of candidates) {
|
|
107
112
|
searched.push(`env:${name}`)
|
|
108
113
|
if (env[name]) {
|