harness-dispatch 0.4.0 → 0.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.
Files changed (140) hide show
  1. package/README.md +228 -86
  2. package/config.default.yaml +416 -397
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +2 -2
  5. package/dist/auth.js.map +1 -1
  6. package/dist/billing.d.ts +35 -4
  7. package/dist/billing.d.ts.map +1 -1
  8. package/dist/billing.js +48 -17
  9. package/dist/billing.js.map +1 -1
  10. package/dist/bin.d.ts.map +1 -1
  11. package/dist/bin.js +253 -23
  12. package/dist/bin.js.map +1 -1
  13. package/dist/breaker-store.d.ts +60 -9
  14. package/dist/breaker-store.d.ts.map +1 -1
  15. package/dist/breaker-store.js +246 -50
  16. package/dist/breaker-store.js.map +1 -1
  17. package/dist/circuit-breaker.d.ts +35 -0
  18. package/dist/circuit-breaker.d.ts.map +1 -1
  19. package/dist/circuit-breaker.js +68 -11
  20. package/dist/circuit-breaker.js.map +1 -1
  21. package/dist/config/coercions.d.ts +31 -0
  22. package/dist/config/coercions.d.ts.map +1 -0
  23. package/dist/config/coercions.js +169 -0
  24. package/dist/config/coercions.js.map +1 -0
  25. package/dist/config/env-interpolation.d.ts +36 -0
  26. package/dist/config/env-interpolation.d.ts.map +1 -0
  27. package/dist/config/env-interpolation.js +73 -0
  28. package/dist/config/env-interpolation.js.map +1 -0
  29. package/dist/config/protocol.d.ts +51 -0
  30. package/dist/config/protocol.d.ts.map +1 -0
  31. package/dist/config/protocol.js +300 -0
  32. package/dist/config/protocol.js.map +1 -0
  33. package/dist/config/validation.d.ts +72 -0
  34. package/dist/config/validation.d.ts.map +1 -0
  35. package/dist/config/validation.js +189 -0
  36. package/dist/config/validation.js.map +1 -0
  37. package/dist/config.d.ts +19 -2
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +229 -511
  40. package/dist/config.js.map +1 -1
  41. package/dist/dispatch-log.d.ts.map +1 -1
  42. package/dist/dispatch-log.js +2 -3
  43. package/dist/dispatch-log.js.map +1 -1
  44. package/dist/dispatchers/generic-cli.d.ts +15 -1
  45. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  46. package/dist/dispatchers/generic-cli.js +65 -5
  47. package/dist/dispatchers/generic-cli.js.map +1 -1
  48. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  49. package/dist/dispatchers/openai-compatible.js +52 -2
  50. package/dist/dispatchers/openai-compatible.js.map +1 -1
  51. package/dist/dispatchers/shared/kill-tree.d.ts +17 -4
  52. package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -1
  53. package/dist/dispatchers/shared/kill-tree.js +28 -4
  54. package/dist/dispatchers/shared/kill-tree.js.map +1 -1
  55. package/dist/dispatchers/shared/rate-limit-headers.d.ts +17 -0
  56. package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -1
  57. package/dist/dispatchers/shared/rate-limit-headers.js +27 -0
  58. package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -1
  59. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  60. package/dist/dispatchers/shared/stream-subprocess.js +5 -0
  61. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  62. package/dist/dispatchers/shared/subprocess.d.ts.map +1 -1
  63. package/dist/dispatchers/shared/subprocess.js +3 -0
  64. package/dist/dispatchers/shared/subprocess.js.map +1 -1
  65. package/dist/dispatchers/shared/which-available.d.ts.map +1 -1
  66. package/dist/dispatchers/shared/which-available.js +16 -2
  67. package/dist/dispatchers/shared/which-available.js.map +1 -1
  68. package/dist/file-lock.d.ts +17 -0
  69. package/dist/file-lock.d.ts.map +1 -0
  70. package/dist/file-lock.js +143 -0
  71. package/dist/file-lock.js.map +1 -0
  72. package/dist/http/server.d.ts +11 -0
  73. package/dist/http/server.d.ts.map +1 -1
  74. package/dist/http/server.js +331 -162
  75. package/dist/http/server.js.map +1 -1
  76. package/dist/job-runner.js +23 -5
  77. package/dist/job-runner.js.map +1 -1
  78. package/dist/jobs.d.ts +91 -1
  79. package/dist/jobs.d.ts.map +1 -1
  80. package/dist/jobs.js +647 -36
  81. package/dist/jobs.js.map +1 -1
  82. package/dist/leaderboard.d.ts +24 -1
  83. package/dist/leaderboard.d.ts.map +1 -1
  84. package/dist/leaderboard.js +94 -2
  85. package/dist/leaderboard.js.map +1 -1
  86. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  87. package/dist/mcp/config-hot-reload.js +1 -1
  88. package/dist/mcp/config-hot-reload.js.map +1 -1
  89. package/dist/mcp/dispatcher-factory.d.ts +16 -1
  90. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  91. package/dist/mcp/dispatcher-factory.js +41 -3
  92. package/dist/mcp/dispatcher-factory.js.map +1 -1
  93. package/dist/mcp/tools.d.ts +23 -4
  94. package/dist/mcp/tools.d.ts.map +1 -1
  95. package/dist/mcp/tools.js +163 -16
  96. package/dist/mcp/tools.js.map +1 -1
  97. package/dist/observability/index.d.ts +16 -3
  98. package/dist/observability/index.d.ts.map +1 -1
  99. package/dist/observability/index.js +23 -7
  100. package/dist/observability/index.js.map +1 -1
  101. package/dist/observability/spans.d.ts.map +1 -1
  102. package/dist/observability/spans.js +4 -1
  103. package/dist/observability/spans.js.map +1 -1
  104. package/dist/quota.d.ts +88 -22
  105. package/dist/quota.d.ts.map +1 -1
  106. package/dist/quota.js +163 -58
  107. package/dist/quota.js.map +1 -1
  108. package/dist/route-policy.d.ts.map +1 -1
  109. package/dist/route-policy.js +13 -3
  110. package/dist/route-policy.js.map +1 -1
  111. package/dist/router.d.ts +25 -1
  112. package/dist/router.d.ts.map +1 -1
  113. package/dist/router.js +94 -40
  114. package/dist/router.js.map +1 -1
  115. package/dist/safety.d.ts.map +1 -1
  116. package/dist/safety.js +11 -2
  117. package/dist/safety.js.map +1 -1
  118. package/dist/state-dir.d.ts +17 -0
  119. package/dist/state-dir.d.ts.map +1 -0
  120. package/dist/state-dir.js +21 -0
  121. package/dist/state-dir.js.map +1 -0
  122. package/dist/status.d.ts +36 -0
  123. package/dist/status.d.ts.map +1 -1
  124. package/dist/status.js +61 -4
  125. package/dist/status.js.map +1 -1
  126. package/dist/types.d.ts +50 -1
  127. package/dist/types.d.ts.map +1 -1
  128. package/dist/working-dir.d.ts +13 -0
  129. package/dist/working-dir.d.ts.map +1 -1
  130. package/dist/working-dir.js +27 -0
  131. package/dist/working-dir.js.map +1 -1
  132. package/dist/workspace-lock.d.ts +40 -0
  133. package/dist/workspace-lock.d.ts.map +1 -0
  134. package/dist/workspace-lock.js +258 -0
  135. package/dist/workspace-lock.js.map +1 -0
  136. package/dist/workspaces.d.ts +15 -0
  137. package/dist/workspaces.d.ts.map +1 -1
  138. package/dist/workspaces.js +104 -12
  139. package/dist/workspaces.js.map +1 -1
  140. package/package.json +98 -96
package/dist/config.js CHANGED
@@ -18,6 +18,41 @@ import { fileURLToPath } from "node:url";
18
18
  import yaml from "js-yaml";
19
19
  import which from "which";
20
20
  import { normalizeSafetyProfile, } from "./safety.js";
21
+ import { inferredPaidUsagePossible } from "./billing.js";
22
+ import { authSourceFrom, billingKindFrom, bool, confidenceFrom, endpointModeFrom, endpointProviderFrom, inferEndpointProvider, int, num, providerFrom, str, surfaceFrom, thinkingFrom, wireProtocolFrom, workspacePolicyFrom, } from "./config/coercions.js";
23
+ import { warnUnknownRouteKeys, warnUnknownSafetyEnums, warnUnknownTopLevelKeys, } from "./config/validation.js";
24
+ import { parseProtocolFields, protocolFrom, stringArrayFrom } from "./config/protocol.js";
25
+ import { interpolateTree } from "./config/env-interpolation.js";
26
+ /**
27
+ * PATH lookups, memoised for the life of the process.
28
+ *
29
+ * loadConfig() runs on every CLI invocation and on every config reload, and
30
+ * each lookup is a real filesystem walk — ~2-3s per harness on Windows. A CLI
31
+ * that resolves once will resolve the same way a second later, and a
32
+ * long-running server re-reads config on hot reload where re-probing bought
33
+ * nothing.
34
+ *
35
+ * Deliberately NOT persisted across processes: installing a harness should
36
+ * take effect on the next command, not after a cache expiry someone has to
37
+ * discover.
38
+ */
39
+ const whichCache = new Map();
40
+ const defaultWhich = async (cmd) => {
41
+ const cached = whichCache.get(cmd);
42
+ if (cached !== undefined)
43
+ return cached;
44
+ const lookup = (async () => {
45
+ try {
46
+ const r = await which(cmd, { nothrow: true });
47
+ return r ?? null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ })();
53
+ whichCache.set(cmd, lookup);
54
+ return lookup;
55
+ };
21
56
  // ---------------------------------------------------------------------------
22
57
  // Built-in harness defaults — loaded from the package's own bundled
23
58
  // config.default.yaml's `clis:` list, NOT hardcoded here. Claude Code,
@@ -30,15 +65,6 @@ import { normalizeSafetyProfile, } from "./safety.js";
30
65
  // doesn't matter to JS).
31
66
  // ---------------------------------------------------------------------------
32
67
  const SAFETY_PROFILES = ["read_only", "workspace_edit", "full_auto"];
33
- /** The full set of `{{name}}` tokens expandToken() in generic-cli.ts understands. */
34
- const KNOWN_ARG_PLACEHOLDERS = new Set([
35
- "{{prompt}}",
36
- "{{model}}",
37
- "{{safety}}",
38
- "{{working_dir}}",
39
- "{{file_dirs}}",
40
- "{{native_args}}",
41
- ]);
42
68
  /**
43
69
  * `harness: generic` is not a harness definition — it's the escape hatch a
44
70
  * user's own entry selects when adding a wholly new CLI, so its "defaults"
@@ -71,6 +97,19 @@ export const PROTOCOL_PRESETS = Object.fromEntries(Object.entries(CLI_DEFAULTS)
71
97
  // billing.ts/safety.ts/router.ts) and must not change. This mapping only
72
98
  // controls what shows up as the service/route name, so it can follow the
73
99
  // same `*_cli` convention `endpoints:` uses for `*_api` (e.g. gemini_api).
100
+ /**
101
+ * Commands auto-detect probes on PATH, exported so `doctor` can name them.
102
+ *
103
+ * A zero-route install used to report "0 ready route(s)" and stop, which tells
104
+ * a new user nothing about what was looked for or what to install. Keyed the
105
+ * same way as AUTO_DETECT_NAME so the two cannot drift.
106
+ */
107
+ export const AUTO_DETECT_COMMANDS = {
108
+ claude_code_cli: "claude",
109
+ codex_cli: "codex",
110
+ cursor_cli: "cursor-agent",
111
+ antigravity_cli: "agy",
112
+ };
74
113
  const AUTO_DETECT_NAME = {
75
114
  claude_code: "claude_code_cli",
76
115
  codex: "codex_cli",
@@ -80,208 +119,6 @@ const AUTO_DETECT_NAME = {
80
119
  // ---------------------------------------------------------------------------
81
120
  // Env var interpolation (${VAR_NAME})
82
121
  // ---------------------------------------------------------------------------
83
- const ENV_VAR_RE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
84
- /**
85
- * `unsetVars` collects the names of ${VAR} references that resolved to
86
- * nothing because the env var isn't set at all — distinct from a var
87
- * deliberately set to "". Without this, a typo'd or forgotten env var
88
- * (${ANTHROPIC_API_KEY} when the real name is ${ANTHROPIC_KEY}) silently
89
- * becomes an empty string: `str()` then drops the field entirely, a route
90
- * that needed an api_key loses it with zero feedback, and doctor reports
91
- * the route "ready" right up until the first real call 401s.
92
- */
93
- function interpolateEnv(value, unsetVars) {
94
- const m = ENV_VAR_RE.exec(value);
95
- if (!m)
96
- return value;
97
- const name = m[1];
98
- if (!(name in process.env))
99
- unsetVars.add(name);
100
- return process.env[name] ?? "";
101
- }
102
- /** Walk an object tree and replace any "${VAR}" string leaves with env values. */
103
- function interpolateTree(node, unsetVars) {
104
- if (typeof node === "string") {
105
- return interpolateEnv(node, unsetVars);
106
- }
107
- if (Array.isArray(node)) {
108
- return node.map((v) => interpolateTree(v, unsetVars));
109
- }
110
- if (node !== null && typeof node === "object") {
111
- const out = {};
112
- for (const [k, v] of Object.entries(node)) {
113
- out[k] = interpolateTree(v, unsetVars);
114
- }
115
- return out;
116
- }
117
- return node;
118
- }
119
- const defaultWhich = async (cmd) => {
120
- try {
121
- const r = await which(cmd, { nothrow: true });
122
- return r ?? null;
123
- }
124
- catch {
125
- return null;
126
- }
127
- };
128
- // ---------------------------------------------------------------------------
129
- // Normalization helpers
130
- // ---------------------------------------------------------------------------
131
- function num(v, def) {
132
- if (typeof v === "number")
133
- return v;
134
- if (typeof v === "string" && v !== "") {
135
- const n = Number(v);
136
- if (!Number.isNaN(n))
137
- return n;
138
- }
139
- return def;
140
- }
141
- function int(v, def) {
142
- return Math.trunc(num(v, def));
143
- }
144
- function bool(v, def) {
145
- if (typeof v === "boolean")
146
- return v;
147
- if (typeof v === "string") {
148
- if (v === "true")
149
- return true;
150
- if (v === "false")
151
- return false;
152
- }
153
- return def;
154
- }
155
- function str(v) {
156
- if (typeof v !== "string")
157
- return undefined;
158
- if (v === "")
159
- return undefined;
160
- return v;
161
- }
162
- function thinkingFrom(v) {
163
- if (v === "low" || v === "medium" || v === "high")
164
- return v;
165
- return undefined;
166
- }
167
- function providerFrom(v) {
168
- if (v === "anthropic" ||
169
- v === "openai" ||
170
- v === "cursor" ||
171
- v === "google" ||
172
- v === "local" ||
173
- v === "custom") {
174
- return v;
175
- }
176
- return undefined;
177
- }
178
- function surfaceFrom(v) {
179
- if (v === "claude_code" ||
180
- v === "claude_agent_sdk" ||
181
- v === "anthropic_api" ||
182
- v === "codex_cli" ||
183
- v === "codex_sdk" ||
184
- v === "openai_api" ||
185
- v === "cursor_agent_cli" ||
186
- v === "antigravity_cli" ||
187
- v === "gemini_api" ||
188
- v === "vertex_ai" ||
189
- v === "openai_compatible" ||
190
- v === "local_endpoint" ||
191
- v === "custom") {
192
- return v;
193
- }
194
- return undefined;
195
- }
196
- function authSourceFrom(v) {
197
- if (v === "product_login" ||
198
- v === "api_key" ||
199
- v === "oauth_session" ||
200
- v === "local_network" ||
201
- v === "configured_endpoint" ||
202
- v === "unknown") {
203
- return v;
204
- }
205
- return undefined;
206
- }
207
- function billingKindFrom(v) {
208
- if (v === "local_compute" ||
209
- v === "included_plan_usage" ||
210
- v === "included_plan_then_flexible_credits" ||
211
- v === "included_credit_then_optional_overage" ||
212
- v === "included_usage_then_on_demand" ||
213
- v === "metered_api" ||
214
- v === "free_quota" ||
215
- v === "unknown") {
216
- return v;
217
- }
218
- return undefined;
219
- }
220
- function confidenceFrom(v) {
221
- if (v === "documented" || v === "inferred" || v === "unknown" || v === "unsupported") {
222
- return v;
223
- }
224
- return undefined;
225
- }
226
- function endpointModeFrom(v) {
227
- if (v === "provider_cloud" ||
228
- v === "direct_openai_compatible" ||
229
- v === "harness_native_endpoint") {
230
- return v;
231
- }
232
- return undefined;
233
- }
234
- function endpointProviderFrom(v) {
235
- if (v === "ollama" ||
236
- v === "lmstudio" ||
237
- v === "openai_compatible" ||
238
- v === "anthropic_gateway" ||
239
- v === "gemini_proxy" ||
240
- v === "custom") {
241
- return v;
242
- }
243
- return undefined;
244
- }
245
- function wireProtocolFrom(v) {
246
- if (v === "openai_chat_completions" ||
247
- v === "anthropic_messages" ||
248
- v === "gemini_generate_content" ||
249
- v === "provider_native" ||
250
- v === "unknown") {
251
- return v;
252
- }
253
- return undefined;
254
- }
255
- function workspacePolicyFrom(v) {
256
- if (v === "shared" || v === "shared_locked" || v === "git_worktree" || v === "copy") {
257
- return v;
258
- }
259
- return undefined;
260
- }
261
- function inferEndpointProvider(baseUrl) {
262
- if (!baseUrl)
263
- return "custom";
264
- try {
265
- const url = new URL(baseUrl);
266
- const host = url.hostname.toLowerCase();
267
- const port = url.port;
268
- if ((host === "localhost" || host === "127.0.0.1" || host === "::1") && port === "11434") {
269
- return "ollama";
270
- }
271
- if ((host === "localhost" || host === "127.0.0.1" || host === "::1") && port === "1234") {
272
- return "lmstudio";
273
- }
274
- }
275
- catch {
276
- // Fall through to substring checks for partial or nonstandard URLs.
277
- }
278
- const lower = baseUrl.toLowerCase();
279
- if (lower.includes("ollama"))
280
- return "ollama";
281
- if (lower.includes("lmstudio") || lower.includes("lm-studio"))
282
- return "lmstudio";
283
- return "custom";
284
- }
285
122
  function endpointFields(raw, type, baseUrl) {
286
123
  const endpointMode = endpointModeFrom(raw.endpoint_mode) ??
287
124
  (type === "openai_compatible" ? "direct_openai_compatible" : undefined);
@@ -306,7 +143,7 @@ function endpointFields(raw, type, baseUrl) {
306
143
  * `retention:`. Parsed once here so legacy `services:` configs and modern
307
144
  * `clis:`/`endpoints:` configs behave identically.
308
145
  */
309
- function topLevelSettings(raw) {
146
+ function topLevelSettings(raw, policyWarnings = []) {
310
147
  const out = {};
311
148
  const telemetryRaw = raw.telemetry;
312
149
  if (telemetryRaw !== null && typeof telemetryRaw === "object") {
@@ -320,8 +157,46 @@ function topLevelSettings(raw) {
320
157
  if (Number.isFinite(days) && days >= 0)
321
158
  out.retention = { jobsDays: days };
322
159
  }
160
+ const leaderboardRaw = raw.leaderboard;
161
+ if (leaderboardRaw !== null && typeof leaderboardRaw === "object") {
162
+ const enabled = leaderboardRaw.enabled;
163
+ if (typeof enabled === "boolean")
164
+ out.leaderboard = { enabled };
165
+ }
166
+ const maxRuns = num(raw.max_concurrent_runs, Number.NaN);
167
+ if (Number.isFinite(maxRuns) && maxRuns >= 0)
168
+ out.maxConcurrentRuns = Math.floor(maxRuns);
169
+ else if (raw.max_concurrent_runs !== undefined) {
170
+ // Present but unusable. Silently falling back to the default meant a
171
+ // caller who set a concurrency bound got a different one and was never
172
+ // told — and this value governs how many agent CLIs run at once.
173
+ policyWarnings.push(`max_concurrent_runs: ${JSON.stringify(raw.max_concurrent_runs)} is not a ` +
174
+ `non-negative number — IGNORED, the default applies instead.`);
175
+ }
323
176
  return out;
324
177
  }
178
+ /**
179
+ * `effective_safety` as either one profile or a per-request map.
180
+ *
181
+ * An unrecognised value is dropped rather than guessed at, and an unrecognised
182
+ * KEY or value inside the map is dropped individually — a typo must not
183
+ * silently widen the floor for a request it was meant to restrict.
184
+ */
185
+ function effectiveSafetyFrom(raw) {
186
+ const single = normalizeSafetyProfile(raw);
187
+ if (single !== undefined)
188
+ return single;
189
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
190
+ return undefined;
191
+ const out = {};
192
+ for (const [key, value] of Object.entries(raw)) {
193
+ const requested = normalizeSafetyProfile(key);
194
+ const floor = normalizeSafetyProfile(value);
195
+ if (requested !== undefined && floor !== undefined)
196
+ out[requested] = floor;
197
+ }
198
+ return Object.keys(out).length > 0 ? out : undefined;
199
+ }
325
200
  function billingFields(raw) {
326
201
  const out = {};
327
202
  const provider = providerFrom(raw.provider);
@@ -350,6 +225,16 @@ function billingFields(raw) {
350
225
  out.billingNotes = notes;
351
226
  if (safetyProfile !== undefined)
352
227
  out.safetyProfile = safetyProfile;
228
+ // effective_safety was handled by the `clis:` builder and the legacy
229
+ // `services:` path but not here, so an `endpoints:` entry declaring it was
230
+ // silently ignored — the fourth instance of this file's parallel-field-list
231
+ // defect, and the first one caught by a test rather than by a user or a
232
+ // reviewer. Practically fail-safe (openai_compatible routes are read_only by
233
+ // construction, so the ignored value could only have widened them), but the
234
+ // inconsistency is the bug.
235
+ const effectiveSafety = effectiveSafetyFrom(raw.effective_safety);
236
+ if (effectiveSafety !== undefined)
237
+ out.effectiveSafety = effectiveSafety;
353
238
  const workspacePolicy = workspacePolicyFrom(raw.workspace_policy);
354
239
  if (workspacePolicy !== undefined)
355
240
  out.workspacePolicy = workspacePolicy;
@@ -374,276 +259,6 @@ function escalateOnFrom(raw) {
374
259
  }
375
260
  return out.length > 0 ? out : ["plan", "review"];
376
261
  }
377
- function stringArrayFrom(raw) {
378
- if (!Array.isArray(raw))
379
- return undefined;
380
- const out = raw.filter((v) => typeof v === "string");
381
- return out.length > 0 ? out : undefined;
382
- }
383
- /**
384
- * Parse `protocol:` for any route (see CliProtocolConfig in types.ts).
385
- * Three shapes:
386
- * - A string ("cursor", "codex", ...) — looked up in PROTOCOL_PRESETS by
387
- * name. Lets a config select a known protocol without retyping it, and
388
- * is the extension point for "others add further protocols" — a new
389
- * named entry in the shipped config.default.yaml is immediately selectable here,
390
- * no code changes.
391
- * - An object with `extends: <preset name>` — starts from that preset and
392
- * overrides only the fields present, for the common "95% the same, one
393
- * flag different" case. safety merges per-profile (overriding just
394
- * full_auto doesn't erase read_only/workspace_edit from the preset).
395
- * - A plain object — the full protocol, no preset involved (unchanged
396
- * behavior from before presets existed).
397
- *
398
- * Returns undefined — with a warning — for anything malformed, so a broken
399
- * block degrades to "route unusable" (isAvailable() checks for a missing
400
- * protocol) rather than a half-built dispatcher silently doing the wrong
401
- * thing.
402
- */
403
- function protocolFrom(raw, routeLabel, warnings) {
404
- if (typeof raw === "string") {
405
- const preset = PROTOCOL_PRESETS[raw];
406
- if (!preset) {
407
- warnings.push(`${routeLabel}: protocol "${raw}" is not a known preset (expected one of: ` +
408
- `${Object.keys(PROTOCOL_PRESETS).join(", ")}) — entry ignored.`);
409
- return undefined;
410
- }
411
- return preset;
412
- }
413
- if (raw === null || typeof raw !== "object")
414
- return undefined;
415
- const r = raw;
416
- let base;
417
- if (typeof r.extends === "string") {
418
- base = PROTOCOL_PRESETS[r.extends];
419
- if (!base) {
420
- warnings.push(`${routeLabel}: protocol.extends "${r.extends}" is not a known preset (expected one of: ` +
421
- `${Object.keys(PROTOCOL_PRESETS).join(", ")}) — entry ignored.`);
422
- return undefined;
423
- }
424
- }
425
- return parseProtocolFields(r, routeLabel, warnings, base);
426
- }
427
- function parseProtocolFields(r, routeLabel, warnings, base) {
428
- const args = stringArrayFrom(r.args) ?? base?.args;
429
- if (args === undefined) {
430
- warnings.push(`${routeLabel}: protocol.args is required — entry ignored.`);
431
- return undefined;
432
- }
433
- const outputRaw = r.output;
434
- let output = base?.output;
435
- if (outputRaw !== undefined) {
436
- if (outputRaw === null || typeof outputRaw !== "object") {
437
- warnings.push(`${routeLabel}: protocol.output must be an object — entry ignored.`);
438
- return undefined;
439
- }
440
- const o = outputRaw;
441
- if (o.mode !== "text" && o.mode !== "json_field" && o.mode !== "jsonl_stream") {
442
- warnings.push(`${routeLabel}: protocol.output.mode must be one of text | json_field | jsonl_stream — entry ignored.`);
443
- return undefined;
444
- }
445
- output = { mode: o.mode };
446
- const fields = stringArrayFrom(o.fields);
447
- if (fields !== undefined)
448
- output.fields = fields;
449
- const usageRaw = o.usage;
450
- if (usageRaw !== null && typeof usageRaw === "object") {
451
- const u = usageRaw;
452
- const input = stringArrayFrom(u.input);
453
- const outputTokens = stringArrayFrom(u.output);
454
- if (input !== undefined && outputTokens !== undefined)
455
- output.usage = { input, output: outputTokens };
456
- }
457
- const eventRulesRaw = o.event_rules;
458
- if (Array.isArray(eventRulesRaw)) {
459
- const eventRules = [];
460
- for (const [i, ruleRaw] of eventRulesRaw.entries()) {
461
- const rule = eventRuleFrom(ruleRaw, `${routeLabel}: protocol.output.event_rules[${i}]`, warnings);
462
- if (rule)
463
- eventRules.push(rule);
464
- }
465
- if (eventRules.length > 0)
466
- output.eventRules = eventRules;
467
- }
468
- const errorRaw = o.error;
469
- if (errorRaw !== null && typeof errorRaw === "object") {
470
- const e = errorRaw;
471
- const field = str(e.field);
472
- if (field !== undefined) {
473
- const messageFields = stringArrayFrom(e.message_fields);
474
- output.error = messageFields !== undefined ? { field, messageFields } : { field };
475
- }
476
- else {
477
- warnings.push(`${routeLabel}: protocol.output.error.field is required — error detection ignored.`);
478
- }
479
- }
480
- }
481
- if (output === undefined) {
482
- warnings.push(`${routeLabel}: protocol.output is required — entry ignored.`);
483
- return undefined;
484
- }
485
- const protocol = { args, output };
486
- const stdin = typeof r.stdin === "boolean" ? r.stdin : base?.stdin;
487
- if (stdin !== undefined)
488
- protocol.stdin = stdin;
489
- const modelRaw = r.model;
490
- if (modelRaw !== undefined && modelRaw !== null) {
491
- const m = modelRaw;
492
- if (typeof m.flag === "string" && m.flag) {
493
- protocol.model = { flag: m.flag };
494
- }
495
- else {
496
- warnings.push(`${routeLabel}: protocol.model set but missing a "flag" string — ignored.`);
497
- }
498
- }
499
- else if (base?.model) {
500
- protocol.model = base.model;
501
- }
502
- const workingDirRaw = r.working_dir;
503
- if (workingDirRaw !== undefined && workingDirRaw !== null) {
504
- const wd = workingDirRaw;
505
- if (typeof wd.flag === "string" && wd.flag) {
506
- protocol.workingDir = { flag: wd.flag };
507
- const extraArgsWhenSet = stringArrayFrom(wd.extra_args_when_set);
508
- if (extraArgsWhenSet !== undefined)
509
- protocol.workingDir.extraArgsWhenSet = extraArgsWhenSet;
510
- if (wd.fallback === "home")
511
- protocol.workingDir.fallback = "home";
512
- }
513
- else {
514
- warnings.push(`${routeLabel}: protocol.working_dir set but missing a "flag" string — ignored.`);
515
- }
516
- }
517
- else if (base?.workingDir) {
518
- protocol.workingDir = base.workingDir;
519
- }
520
- const fileDirsRaw = r.file_dirs;
521
- if (fileDirsRaw !== undefined && fileDirsRaw !== null) {
522
- const fd = fileDirsRaw;
523
- if (typeof fd.flag === "string" && fd.flag) {
524
- protocol.fileDirs = { flag: fd.flag };
525
- }
526
- else {
527
- warnings.push(`${routeLabel}: protocol.file_dirs set but missing a "flag" string — ignored.`);
528
- }
529
- }
530
- else if (base?.fileDirs) {
531
- protocol.fileDirs = base.fileDirs;
532
- }
533
- const fileListHeader = str(r.file_list_header) ?? base?.fileListHeader;
534
- if (fileListHeader !== undefined)
535
- protocol.fileListHeader = fileListHeader;
536
- const fileListBullet = (typeof r.file_list_bullet === "string" ? r.file_list_bullet : undefined) ?? base?.fileListBullet;
537
- if (fileListBullet !== undefined)
538
- protocol.fileListBullet = fileListBullet;
539
- const apiKeyEnvVar = str(r.api_key_env_var) ?? base?.apiKeyEnvVar;
540
- if (apiKeyEnvVar !== undefined)
541
- protocol.apiKeyEnvVar = apiKeyEnvVar;
542
- const safetyRaw = r.safety;
543
- const safety = { ...base?.safety };
544
- if (safetyRaw !== null && typeof safetyRaw === "object") {
545
- for (const profile of SAFETY_PROFILES) {
546
- const profileArgs = stringArrayFrom(safetyRaw[profile]);
547
- if (profileArgs !== undefined)
548
- safety[profile] = profileArgs;
549
- }
550
- }
551
- if (Object.keys(safety).length > 0)
552
- protocol.safety = safety;
553
- if (typeof r.success_requires_output === "boolean") {
554
- protocol.successRequiresOutput = r.success_requires_output;
555
- }
556
- else if (base?.successRequiresOutput !== undefined) {
557
- protocol.successRequiresOutput = base.successRequiresOutput;
558
- }
559
- const endpointNativeArgsRaw = r.endpoint_native_args;
560
- if (endpointNativeArgsRaw !== null && typeof endpointNativeArgsRaw === "object") {
561
- const ena = {};
562
- for (const [k, v] of Object.entries(endpointNativeArgsRaw)) {
563
- const args2 = stringArrayFrom(v);
564
- if (args2 !== undefined)
565
- ena[k] = args2;
566
- }
567
- if (Object.keys(ena).length > 0)
568
- protocol.endpointNativeArgs = ena;
569
- }
570
- else if (base?.endpointNativeArgs) {
571
- protocol.endpointNativeArgs = base.endpointNativeArgs;
572
- }
573
- // Placeholder sanity checks on the FINAL merged args (so `extends:` results
574
- // are covered too). A typo'd placeholder is the most likely user error in a
575
- // hand-written protocol, and without these warnings it doesn't just fail
576
- // silently — it "succeeds": the CLI receives the literal "{{promt}}" token,
577
- // never receives the prompt, exits 0, and the run reports ok.
578
- // Matches embedded forms too ("--flag={{prompt}}"), not just whole-token
579
- // typos — expansion only ever substitutes a token that IS a placeholder,
580
- // so anything merely containing one goes through literally.
581
- for (const token of protocol.args) {
582
- if (token.includes("{{") && !KNOWN_ARG_PLACEHOLDERS.has(token)) {
583
- warnings.push(`${routeLabel}: protocol.args contains unrecognized placeholder "${token}" — it will be ` +
584
- `passed to the CLI as a literal argument, not substituted. Placeholders only work as ` +
585
- `a whole standalone argument. Known placeholders: ` +
586
- `${[...KNOWN_ARG_PLACEHOLDERS].join(", ")}.`);
587
- }
588
- }
589
- if (!protocol.stdin && !protocol.args.includes("{{prompt}}")) {
590
- warnings.push(`${routeLabel}: protocol.args has no {{prompt}} placeholder and stdin is not true — ` +
591
- `the prompt is never sent to the CLI. Add "{{prompt}}" to args, or set stdin: true.`);
592
- }
593
- return protocol;
594
- }
595
- function eventRuleFrom(raw, label, warnings) {
596
- if (raw === null || typeof raw !== "object") {
597
- warnings.push(`${label}: must be an object — ignored.`);
598
- return undefined;
599
- }
600
- const r = raw;
601
- // "when" is optional — omitted or {} means "matches every line" (e.g. a
602
- // usage rule that should fire regardless of event type, matching Codex's
603
- // original unconditional `if (event.usage) {...}` check).
604
- const whenRaw = r.when ?? {};
605
- if (whenRaw === null || typeof whenRaw !== "object" || Array.isArray(whenRaw)) {
606
- warnings.push(`${label}: "when" must be a {field: value} map — ignored.`);
607
- return undefined;
608
- }
609
- const when = {};
610
- for (const [k, v] of Object.entries(whenRaw)) {
611
- if (typeof v === "string")
612
- when[k] = v;
613
- }
614
- const emit = r.emit;
615
- if (emit !== "text" &&
616
- emit !== "tool_use" &&
617
- emit !== "thinking" &&
618
- emit !== "usage" &&
619
- emit !== "error") {
620
- warnings.push(`${label}: "emit" must be one of text | tool_use | thinking | usage | error — ignored.`);
621
- return undefined;
622
- }
623
- const rule = { when, emit };
624
- const textField = str(r.text_field);
625
- if (textField !== undefined)
626
- rule.textField = textField;
627
- const nameField = str(r.name_field);
628
- if (nameField !== undefined)
629
- rule.nameField = nameField;
630
- const inputField = str(r.input_field);
631
- if (inputField !== undefined)
632
- rule.inputField = inputField;
633
- const chunkField = str(r.chunk_field);
634
- if (chunkField !== undefined)
635
- rule.chunkField = chunkField;
636
- const inputTokenFields = stringArrayFrom(r.input_token_fields);
637
- if (inputTokenFields !== undefined)
638
- rule.inputTokenFields = inputTokenFields;
639
- const outputTokenFields = stringArrayFrom(r.output_token_fields);
640
- if (outputTokenFields !== undefined)
641
- rule.outputTokenFields = outputTokenFields;
642
- const messageField = str(r.message_field);
643
- if (messageField !== undefined)
644
- rule.messageField = messageField;
645
- return rule;
646
- }
647
262
  // ---------------------------------------------------------------------------
648
263
  // Legacy full-format parser (YAML with top-level `services:` key)
649
264
  // ---------------------------------------------------------------------------
@@ -733,7 +348,7 @@ function buildLegacyConfig(raw) {
733
348
  })(),
734
349
  ...billingFields(svc),
735
350
  ...(() => {
736
- const effectiveSafety = normalizeSafetyProfile(svc.effective_safety);
351
+ const effectiveSafety = effectiveSafetyFrom(svc.effective_safety);
737
352
  return effectiveSafety !== undefined ? { effectiveSafety } : {};
738
353
  })(),
739
354
  ...(() => {
@@ -752,7 +367,7 @@ function buildLegacyConfig(raw) {
752
367
  // same behavior as clis: entries, so `harness: claude_code` here
753
368
  // works without repeating the whole protocol block.
754
369
  const harnessDefaults = CLI_DEFAULTS[str(svc.harness) ?? ""];
755
- const protocol = protocolFrom(svc.protocol, `services "${name}"`, warnings) ?? harnessDefaults?.protocol;
370
+ const protocol = protocolFrom(svc.protocol, `services "${name}"`, warnings, PROTOCOL_PRESETS) ?? harnessDefaults?.protocol;
756
371
  return protocol !== undefined ? { protocol } : {};
757
372
  })(),
758
373
  };
@@ -763,7 +378,7 @@ function buildLegacyConfig(raw) {
763
378
  ...(Array.isArray(raw.disabled)
764
379
  ? { disabled: raw.disabled.slice() }
765
380
  : {}),
766
- ...topLevelSettings(raw),
381
+ ...topLevelSettings(raw, warnings),
767
382
  ...(warnings.length > 0 ? { configWarnings: warnings } : {}),
768
383
  };
769
384
  return cfg;
@@ -834,11 +449,27 @@ function buildCliServiceConfig(name, defaults, override, apiKeys, warnings = [])
834
449
  return billingKind !== undefined ? { billingKind } : {};
835
450
  })(),
836
451
  ...(() => {
452
+ // A DECLARED billing_kind beats the harness default.
453
+ //
454
+ // `harness: generic` defaults paidUsagePossible to true (correctly — an
455
+ // unknown command might cost money). But a route declaring
456
+ // `billing_kind: local_compute` has said it cannot, and the default
457
+ // still won: status showed `billing=local_compute paid=possible`, two
458
+ // fields of the same record contradicting each other, and the route was
459
+ // skipped by billing policy. Nothing about a declared non-paid kind
460
+ // should leave the paid flag set by a fallback.
461
+ //
462
+ // An explicit paid_usage_possible still wins over both, and an api_key
463
+ // still forces true — a key means a metered account exists regardless of
464
+ // what the kind claims.
465
+ const declaredKind = billingKindFrom(override.billing_kind);
837
466
  const paidUsagePossible = typeof override.paid_usage_possible === "boolean"
838
467
  ? override.paid_usage_possible
839
468
  : apiKey
840
469
  ? true
841
- : defaults.paidUsagePossible;
470
+ : declaredKind !== undefined
471
+ ? inferredPaidUsagePossible(declaredKind)
472
+ : defaults.paidUsagePossible;
842
473
  return paidUsagePossible !== undefined ? { paidUsagePossible } : {};
843
474
  })(),
844
475
  ...(typeof override.allow_paid_usage === "boolean"
@@ -857,7 +488,19 @@ function buildCliServiceConfig(name, defaults, override, apiKeys, warnings = [])
857
488
  return safetyProfile !== undefined ? { safetyProfile } : {};
858
489
  })(),
859
490
  ...(() => {
860
- const effectiveSafety = normalizeSafetyProfile(override.effective_safety) ?? defaults.effectiveSafety;
491
+ // `clis:` entries never read this. It was handled for `services:` and
492
+ // `endpoints:` (both go through billingFields, which this builder does
493
+ // not call), so `workspace_policy: copy` on the primary, documented way
494
+ // to define a route was silently dropped and the route fell back to
495
+ // shared_locked — the LESS isolated default, with no warning, because
496
+ // the value itself is perfectly valid. The fail-open enum check added
497
+ // earlier cannot catch this: the key is right and the value is right;
498
+ // nothing was reading it.
499
+ const workspacePolicy = workspacePolicyFrom(override.workspace_policy);
500
+ return workspacePolicy !== undefined ? { workspacePolicy } : {};
501
+ })(),
502
+ ...(() => {
503
+ const effectiveSafety = effectiveSafetyFrom(override.effective_safety) ?? defaults.effectiveSafety;
861
504
  return effectiveSafety !== undefined ? { effectiveSafety } : {};
862
505
  })(),
863
506
  ...(() => {
@@ -875,7 +518,7 @@ function buildCliServiceConfig(name, defaults, override, apiKeys, warnings = [])
875
518
  // override is given, or when the override is malformed — protocolFrom
876
519
  // already warned in the latter case; failing the whole route over a
877
520
  // typo'd override would be worse than keeping the known-good default.
878
- const protocol = protocolFrom(override.protocol, `clis "${name}"`, warnings) ?? defaults.protocol;
521
+ const protocol = protocolFrom(override.protocol, `clis "${name}"`, warnings, PROTOCOL_PRESETS) ?? defaults.protocol;
879
522
  return protocol !== undefined ? { protocol } : {};
880
523
  })(),
881
524
  };
@@ -883,19 +526,32 @@ function buildCliServiceConfig(name, defaults, override, apiKeys, warnings = [])
883
526
  async function detectServices(disabled, apiKeys, overrides, whichFn) {
884
527
  const services = {};
885
528
  const disabledSet = new Set(disabled);
886
- for (const [harness, defaults] of Object.entries(CLI_DEFAULTS)) {
529
+ // Probe every harness AT ONCE.
530
+ //
531
+ // This was a sequential await per harness. Each `which` costs real time on
532
+ // Windows — measured 2.8s / 3.3s / 2.7s / 2.1s for claude / codex /
533
+ // cursor-agent / agy on a machine where all four are installed, so ~11s per
534
+ // loadConfig() call, and loadConfig runs on every CLI invocation. `status`
535
+ // and `doctor` took ~17s, and the test suite went red with timeouts on any
536
+ // developer machine that actually has the harnesses installed — it passed in
537
+ // CI only because CI is bare. The probes are independent, so there was never
538
+ // a reason to serialise them.
539
+ const candidates = Object.entries(CLI_DEFAULTS)
887
540
  // "generic" has no installable binary of its own — it exists only for
888
541
  // explicit clis: entries (addClis), never auto-detection.
889
- if (harness === "generic")
890
- continue;
891
- const name = AUTO_DETECT_NAME[harness] ?? harness;
892
- if (disabledSet.has(name))
893
- continue;
894
- const found = await whichFn(defaults.command);
895
- if (!found)
542
+ .filter(([harness]) => harness !== "generic")
543
+ .map(([harness, defaults]) => ({
544
+ harness,
545
+ defaults,
546
+ name: AUTO_DETECT_NAME[harness] ?? harness,
547
+ }))
548
+ .filter(({ name }) => !disabledSet.has(name));
549
+ const found = await Promise.all(candidates.map(async (c) => ((await whichFn(c.defaults.command)) ? c : undefined)));
550
+ for (const c of found) {
551
+ if (!c)
896
552
  continue;
897
- const override = overrides[name] ?? {};
898
- services[name] = buildCliServiceConfig(name, defaults, override, apiKeys);
553
+ const override = overrides[c.name] ?? {};
554
+ services[c.name] = buildCliServiceConfig(c.name, c.defaults, override, apiKeys);
899
555
  }
900
556
  return services;
901
557
  }
@@ -917,6 +573,7 @@ function addClis(services, raw, apiKeys, warnings) {
917
573
  warnings.push(`clis[${index}]: missing required "name" and/or "harness" — entry ignored.`);
918
574
  continue;
919
575
  }
576
+ warnUnknownRouteKeys(entry, `clis[${index}] "${name}"`, warnings);
920
577
  const defaults = CLI_DEFAULTS[harness];
921
578
  if (!defaults) {
922
579
  warnings.push(`clis[${index}] "${name}": unrecognized harness "${harness}" (expected one of: ` +
@@ -943,7 +600,7 @@ function addClis(services, raw, apiKeys, warnings) {
943
600
  // once, from here, since the entry gets skipped before that second
944
601
  // parse ever runs.
945
602
  const validation = [];
946
- if (protocolFrom(entry.protocol, `clis[${index}] "${name}"`, validation) === undefined) {
603
+ if (protocolFrom(entry.protocol, `clis[${index}] "${name}"`, validation, PROTOCOL_PRESETS) === undefined) {
947
604
  warnings.push(...validation);
948
605
  continue;
949
606
  }
@@ -968,16 +625,29 @@ function collectApiKeys(raw) {
968
625
  }
969
626
  return apiKeys;
970
627
  }
971
- function addEndpoints(services, raw) {
628
+ function addEndpoints(services, raw, apiKeys, warnings = []) {
972
629
  const endpoints = Array.isArray(raw.endpoints)
973
630
  ? raw.endpoints
974
631
  : [];
975
- for (const ep of endpoints) {
632
+ for (const [index, ep] of endpoints.entries()) {
976
633
  const name = str(ep.name);
634
+ warnUnknownRouteKeys(ep, `endpoints[${index}] "${name ?? "?"}"`, warnings);
977
635
  const baseUrl = str(ep.base_url);
978
636
  const model = str(ep.model);
979
- if (!name || !baseUrl || !model)
637
+ if (!name || !baseUrl || !model) {
638
+ // Silently dropping the entry was the same class this file keeps
639
+ // producing: the equivalent `clis:` mistake warns loudly, this one left
640
+ // `doctor` reporting "ok config-warnings" while three endpoints had
641
+ // vanished.
642
+ const missing = [
643
+ !name ? "name" : undefined,
644
+ !baseUrl ? "base_url" : undefined,
645
+ !model ? "model" : undefined,
646
+ ].filter((v) => v !== undefined);
647
+ warnings.push(`endpoints[${index}]${name ? ` "${name}"` : ""}: missing required ` +
648
+ `${missing.join(", ")} — entry ignored.`);
980
649
  continue;
650
+ }
981
651
  const svc = {
982
652
  name,
983
653
  enabled: bool(ep.enabled, true),
@@ -985,13 +655,30 @@ function addEndpoints(services, raw) {
985
655
  baseUrl,
986
656
  model,
987
657
  command: "",
988
- ...(str(ep.api_key) !== undefined ? { apiKey: str(ep.api_key) } : {}),
658
+ // The top-level `api_keys:` block was honoured for `clis:` (see
659
+ // buildCliServiceConfig) and silently ignored here, so an endpoint whose
660
+ // credential lived there had NO key at runtime — and `configure` could
661
+ // not round-trip a reference that had never reached the service. Same
662
+ // class as workspace_policy: a documented key read for one route shape
663
+ // and dropped for another.
664
+ ...(str(ep.api_key) !== undefined
665
+ ? { apiKey: str(ep.api_key) }
666
+ : apiKeys[name]
667
+ ? { apiKey: apiKeys[name] }
668
+ : {}),
989
669
  weight: num(ep.weight, 0.6),
990
670
  tier: int(ep.tier, 3),
991
671
  cliCapability: num(ep.cli_capability, 1.0),
992
672
  ...(str(ep.leaderboard_model) !== undefined
993
673
  ? { leaderboardModel: str(ep.leaderboard_model) }
994
674
  : {}),
675
+ // escalate_on was honoured here and escalate_model was not — the fifth
676
+ // instance of this file's parallel-field-list defect, and the second
677
+ // found by review rather than by the parity test, which pins 16 shared
678
+ // keys and did not include this one. It does now.
679
+ ...(str(ep.escalate_model) !== undefined
680
+ ? { escalateModel: str(ep.escalate_model) }
681
+ : {}),
995
682
  escalateOn: escalateOnFrom(ep.escalate_on),
996
683
  capabilities: capsFrom(ep.capabilities),
997
684
  ...billingFields({
@@ -1040,19 +727,41 @@ export async function loadConfig(path, opts = {}) {
1040
727
  const whichFn = opts.whichFn ?? defaultWhich;
1041
728
  let raw = {};
1042
729
  const unsetEnvVars = new Set();
730
+ const envRefs = new Map();
1043
731
  if (path) {
1044
732
  try {
1045
733
  const text = await fs.readFile(path, "utf-8");
1046
734
  const parsed = yaml.load(text);
1047
735
  if (parsed && typeof parsed === "object") {
1048
- raw = interpolateTree(parsed, unsetEnvVars);
736
+ raw = interpolateTree(parsed, unsetEnvVars, envRefs);
1049
737
  }
1050
738
  }
1051
739
  catch (err) {
1052
- // File not found -> auto-detect mode. Any other error -> rethrow.
1053
740
  const e = err;
1054
- if (e.code !== "ENOENT")
741
+ if (e.code === "ENOENT") {
742
+ // configure names an OUTPUT path, so a file that is not there yet is
743
+ // its normal first run — it alone passes allowMissing.
744
+ if (opts.allowMissing === true) {
745
+ // Fall through to auto-detect with an empty `raw`.
746
+ }
747
+ else {
748
+ // Otherwise an explicit --config that does not exist is a typo, not
749
+ // a request for auto-detection. Continuing printed a confident,
750
+ // healthy route table built from defaults, so a mistyped path looked
751
+ // like a working config. The implicit fallback (no path given at
752
+ // all) never reaches here and is unchanged.
753
+ throw new Error(`config file not found: ${path}. Check the path, or omit --config to ` +
754
+ `auto-detect installed harness CLIs.`);
755
+ }
756
+ }
757
+ else if (err instanceof yaml.YAMLException) {
758
+ // A YAML syntax error used to escape as a raw js-yaml stack trace that
759
+ // never named the file it came from.
760
+ throw new Error(`config file ${path} is not valid YAML: ${err.message}`);
761
+ }
762
+ else {
1055
763
  throw err;
764
+ }
1056
765
  }
1057
766
  }
1058
767
  const envVarWarning = unsetEnvVars.size > 0
@@ -1063,10 +772,16 @@ export async function loadConfig(path, opts = {}) {
1063
772
  // Legacy full format: has a `services:` key -> use as-is.
1064
773
  if (raw.services && typeof raw.services === "object") {
1065
774
  const legacyCfg = buildLegacyConfig(raw);
775
+ const enumWarnings = [];
776
+ warnUnknownSafetyEnums(raw, enumWarnings);
777
+ if (enumWarnings.length > 0) {
778
+ legacyCfg.configWarnings = [...(legacyCfg.configWarnings ?? []), ...enumWarnings];
779
+ }
780
+ const withRefs = envRefs.size > 0 ? { ...legacyCfg, envRefs } : legacyCfg;
1066
781
  if (envVarWarning !== undefined) {
1067
- return { ...legacyCfg, configWarnings: [...(legacyCfg.configWarnings ?? []), envVarWarning] };
782
+ return { ...withRefs, configWarnings: [...(withRefs.configWarnings ?? []), envVarWarning] };
1068
783
  }
1069
- return legacyCfg;
784
+ return withRefs;
1070
785
  }
1071
786
  const disabled = Array.isArray(raw.disabled)
1072
787
  ? raw.disabled.slice()
@@ -1090,13 +805,16 @@ export async function loadConfig(path, opts = {}) {
1090
805
  const apiKeys = collectApiKeys(raw);
1091
806
  const services = await detectServices(disabled, apiKeys, overrides, whichFn);
1092
807
  addClis(services, raw, apiKeys, warnings);
1093
- addEndpoints(services, raw);
808
+ addEndpoints(services, raw, apiKeys, warnings);
809
+ warnUnknownSafetyEnums(raw, warnings);
810
+ warnUnknownTopLevelKeys(raw, warnings);
1094
811
  if (envVarWarning !== undefined)
1095
812
  warnings.push(envVarWarning);
1096
813
  const cfg = {
1097
814
  services,
1098
815
  disabled,
1099
- ...topLevelSettings(raw),
816
+ ...topLevelSettings(raw, warnings),
817
+ ...(envRefs.size > 0 ? { envRefs } : {}),
1100
818
  ...(warnings.length > 0 ? { configWarnings: warnings } : {}),
1101
819
  };
1102
820
  return cfg;
@@ -1196,7 +914,7 @@ function cliDefaultsFrom(raw, warnings) {
1196
914
  ...(billingKind !== undefined ? { billingKind } : {}),
1197
915
  ...(typeof raw.paid_usage_possible === "boolean" ? { paidUsagePossible: raw.paid_usage_possible } : {}),
1198
916
  ...(() => {
1199
- const effectiveSafety = normalizeSafetyProfile(raw.effective_safety);
917
+ const effectiveSafety = effectiveSafetyFrom(raw.effective_safety);
1200
918
  return effectiveSafety !== undefined ? { effectiveSafety } : {};
1201
919
  })(),
1202
920
  ...(() => {