omp-conductor 0.18.1 → 0.19.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 (69) hide show
  1. package/README.md +106 -41
  2. package/REFERENCE.md +866 -31
  3. package/agents/to-spec.md +6 -2
  4. package/package.json +1 -1
  5. package/schema/config.schema.json +32 -1
  6. package/src/admission.ts +212 -26
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +288 -1
  9. package/src/briefs/orchestrator.md +27 -13
  10. package/src/briefs/to-spec.md +6 -2
  11. package/src/cli.ts +127 -2
  12. package/src/command-help.ts +9 -1
  13. package/src/command-manifest.ts +52 -8
  14. package/src/commands/arm.ts +6 -2
  15. package/src/commands/context.ts +2 -0
  16. package/src/commands/intake.ts +4 -19
  17. package/src/commands/message.ts +26 -2
  18. package/src/commands/reconcile-units.ts +104 -0
  19. package/src/commands/release-composition.ts +232 -0
  20. package/src/commands/resume.ts +2 -27
  21. package/src/commands/setup.ts +101 -16
  22. package/src/commands/stats.ts +11 -30
  23. package/src/commands/tail.ts +31 -1
  24. package/src/commands/upgrade.ts +20 -3
  25. package/src/commands/verb.ts +2 -1
  26. package/src/commands/watch.ts +4 -17
  27. package/src/config-schema.ts +38 -6
  28. package/src/config.ts +103 -8
  29. package/src/credential-class.ts +366 -0
  30. package/src/daemon.ts +1368 -529
  31. package/src/dashboard/app.js +504 -2
  32. package/src/dashboard/controls.ts +336 -0
  33. package/src/dashboard/index.html +30 -0
  34. package/src/dashboard/server.ts +271 -30
  35. package/src/dashboard/style.css +116 -0
  36. package/src/dashboard/transcript.ts +173 -0
  37. package/src/decisions.ts +19 -11
  38. package/src/doctor.ts +431 -148
  39. package/src/escalate.ts +22 -11
  40. package/src/failure-class.ts +59 -0
  41. package/src/fleet.ts +587 -230
  42. package/src/host.ts +6 -455
  43. package/src/omp-settings.ts +19 -0
  44. package/src/omp.ts +40 -56
  45. package/src/orchestrator-tick.ts +564 -121
  46. package/src/pause.ts +233 -0
  47. package/src/session-host.ts +6 -41
  48. package/src/settlement.ts +159 -2
  49. package/src/setup-answers.ts +97 -0
  50. package/src/setup-host.ts +343 -1160
  51. package/src/setup-install.ts +204 -27
  52. package/src/setup-wizard.ts +252 -51
  53. package/src/setup.ts +87 -4
  54. package/src/spend-telemetry.ts +117 -0
  55. package/src/stats.ts +35 -0
  56. package/src/status-render.ts +485 -19
  57. package/src/store.ts +1229 -55
  58. package/src/telegram-freshness.ts +269 -0
  59. package/src/to-spec.ts +50 -2
  60. package/src/types.ts +759 -10
  61. package/src/unblock.ts +22 -0
  62. package/src/unit-reconcile.ts +303 -0
  63. package/src/upgrade-verify.ts +8 -1
  64. package/src/upgrade.ts +299 -12
  65. package/src/verbs/actions.ts +124 -10
  66. package/src/verbs/protocol.ts +70 -2
  67. package/src/verbs/server.ts +485 -11
  68. package/src/wake.ts +48 -0
  69. package/src/worker.ts +401 -14
@@ -31,6 +31,7 @@ import {
31
31
  DEFAULT_AUTHORITY,
32
32
  DEFAULT_CAPS,
33
33
  DEFAULT_PROJECT_POLICY,
34
+ DEFAULT_REVIEW_ADJUDICATOR_ROLE,
34
35
  DEFAULT_REVIEW_MAX_ROUNDS,
35
36
  DEFAULT_REVIEW_STRICTNESS,
36
37
  DRAFT_POLICIES,
@@ -41,6 +42,7 @@ import {
41
42
  RELEASE_REQUIREMENTS,
42
43
  RELEASE_SHAPES,
43
44
  REPORT_SCOPES,
45
+ REVIEW_ADJUDICATOR_RE,
44
46
  REVIEW_MAX_ROUNDS_MAX,
45
47
  REVIEW_MAX_ROUNDS_MIN,
46
48
  REVIEW_STRICTNESS,
@@ -123,6 +125,14 @@ const capsSchema = z
123
125
  .min(0)
124
126
  .nullable()
125
127
  .describe(`Rolling-day spend ceiling; null means no spend gate (default ${DEFAULT_CAPS.dailySpendUsd})`),
128
+ maxRunSpendUsd: z
129
+ .number()
130
+ .positive()
131
+ .nullable()
132
+ .describe(
133
+ "Ceiling on one run, reserved from the daily budget before it launches; " +
134
+ "null derives it from dailySpendUsd. Must not exceed dailySpendUsd",
135
+ ),
126
136
  planUsage: z.union([planUsageCap, z.null()]).describe(`Plan-allowance guard, or null for unmetered`),
127
137
  workerMaxTurns: z.number().min(0).describe(`Turn ceiling for one worker (default ${DEFAULT_CAPS.workerMaxTurns})`),
128
138
  workerMaxTurnsCeiling: z.number().min(0).describe(`Maximum turn budget assignable to one issue's next attempt`),
@@ -265,11 +275,15 @@ const armSchema = z
265
275
 
266
276
  /**
267
277
  * The per-project review policy (#678): how strictly green PRs are reviewed
268
- * and returned, and the hard ceiling on review rounds per PR lifecycle. Absent
269
- * or a legacy config without the key loads as `medium` with
270
- * {@link DEFAULT_REVIEW_MAX_ROUNDS} rounds the recommended default for a new
271
- * project, materialised deterministically for every existing one, with the
272
- * schema bounds and the runtime defaults read from the same constants.
278
+ * and returned, the hard ceiling on review rounds per PR lifecycle, and which
279
+ * OMP model role carries the terminal review-ceiling adjudication (#875).
280
+ * Absent or a legacy config without the keys loads as `medium` with
281
+ * {@link DEFAULT_REVIEW_MAX_ROUNDS} rounds and
282
+ * {@link DEFAULT_REVIEW_ADJUDICATOR_ROLE} as adjudicator the recommended
283
+ * defaults for a new project, materialised deterministically for every
284
+ * existing one, with the schema bounds and the runtime defaults read from the
285
+ * same constants. The adjudicator names a role only; a provider/model is
286
+ * refused here, because OMP owns exact provider selection.
273
287
  */
274
288
  const reviewSchema = z
275
289
  .object({
@@ -280,9 +294,16 @@ const reviewSchema = z
280
294
  .min(REVIEW_MAX_ROUNDS_MIN)
281
295
  .max(REVIEW_MAX_ROUNDS_MAX)
282
296
  .default(DEFAULT_REVIEW_MAX_ROUNDS),
297
+ adjudicator: z
298
+ .string()
299
+ .regex(
300
+ REVIEW_ADJUDICATOR_RE,
301
+ "must name one OMP model role — a single role token like \"task\" (never a provider/model, which omp owns)",
302
+ )
303
+ .default(DEFAULT_REVIEW_ADJUDICATOR_ROLE),
283
304
  })
284
305
  .strict()
285
- .describe("Review strictness and round ceiling for green PRs");
306
+ .describe("Review strictness, round ceiling and adjudicator role for green PRs");
286
307
 
287
308
  const releasePolicySchema = z.union([
288
309
  releasePolicyLegacyEnum,
@@ -386,6 +407,17 @@ const projectSchema = z
386
407
  // usable, exactly like `workerModel`.
387
408
  modelFallbacks: z.unknown().optional(),
388
409
  modelFallbackThreshold: z.unknown().optional(),
410
+ // One stronger opaque omp selector the first spinning cap in a chain
411
+ // retries on (#807). Normalised (trimmed, blank dropped) by the loader
412
+ // like `workerModel`, and deliberately separate from `modelFallbacks`:
413
+ // that chain answers provider faults only.
414
+ workerEscalationModel: z.unknown().optional(),
415
+ // Providers that must bill to a subscription credential (#852). Normalised
416
+ // by the loader (trimmed, blanks and non-strings dropped) like
417
+ // `modelFallbacks`, so a malformed entry weakens the fence's *scope* rather
418
+ // than failing the whole config load — and an entry that survives is one the
419
+ // fence will actually enforce.
420
+ requireOauthProviders: z.unknown().optional(),
389
421
  // The fleet-owned omp settings overlay (#537): an opaque map omp's own
390
422
  // schema owns. Conductor validates YAML shape only — the loader keeps it
391
423
  // when it is a mapping and drops anything else, like `modelFallbacks`.
package/src/config.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  DEFAULT_PROJECT_POLICY,
29
29
  DEFAULT_REPORT_POLICY,
30
30
  DEFAULT_REPORT_SCOPE,
31
+ DEFAULT_REVIEW_ADJUDICATOR_ROLE,
31
32
  DEFAULT_REVIEW_MAX_ROUNDS,
32
33
  DEFAULT_REVIEW_POLICY,
33
34
  DEFAULT_REVIEW_STRICTNESS,
@@ -281,6 +282,10 @@ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
281
282
  maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
282
283
  maxConcurrentWorkersPerRepo: o.maxConcurrentWorkersPerRepo ?? defaults.maxConcurrentWorkersPerRepo,
283
284
  dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
285
+ // Same `!== undefined` reading as `dailySpendUsd`: an explicit `null` is a
286
+ // deliberate "derive the per-run allowance from the daily cap" (#851), not
287
+ // an omission that should inherit a global override.
288
+ maxRunSpendUsd: o.maxRunSpendUsd !== undefined ? o.maxRunSpendUsd : defaults.maxRunSpendUsd,
284
289
  planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage,
285
290
  workerMaxTurns,
286
291
  workerMaxTurnsCeiling:
@@ -349,12 +354,20 @@ export function resolveArmProof(p: ProjectConfig): ArmProof {
349
354
  * The loader always materialises `review` (finalizeProject), so this is only
350
355
  * ever the fallback for a `ProjectConfig` that never went through it — a
351
356
  * hand-built one in a test, or a config written before the key existed.
352
- * Absent resolves to {@link DEFAULT_REVIEW_POLICY}, the documented migration
353
- * default every existing install deterministically upgrades to. A fresh object
354
- * each call, so mutating the result cannot reach the config it came from.
357
+ * Absent (or a legacy config that stopped at strictness and maxRounds)
358
+ * resolves to {@link DEFAULT_REVIEW_POLICY}, the documented migration default
359
+ * every existing install deterministically upgrades to the adjudicator role
360
+ * included, so no consumer ever reads a policy without one (#875). A fresh
361
+ * object each call, so mutating the result cannot reach the config it came
362
+ * from.
355
363
  */
356
364
  export function resolveReview(p: ProjectConfig): ReviewPolicy {
357
- return { ...(p.review ?? DEFAULT_REVIEW_POLICY) };
365
+ const base = p.review ?? DEFAULT_REVIEW_POLICY;
366
+ return {
367
+ strictness: base.strictness ?? DEFAULT_REVIEW_STRICTNESS,
368
+ maxRounds: base.maxRounds ?? DEFAULT_REVIEW_MAX_ROUNDS,
369
+ adjudicator: base.adjudicator ?? DEFAULT_REVIEW_ADJUDICATOR_ROLE,
370
+ };
358
371
  }
359
372
 
360
373
  /**
@@ -889,11 +902,14 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
889
902
  return `policy must be an object with "merge" and "release" sections`;
890
903
  }
891
904
  if (rel.length === 1 && relStr === "review") {
892
- return `review must be an object with "strictness" and "maxRounds"`;
905
+ return `review must be an object with "strictness", "maxRounds" and "adjudicator"`;
893
906
  }
894
907
  if (rel.length === 2 && rel[0] === "review" && rel[1] === "maxRounds") {
895
908
  return `review.maxRounds must be an integer between ${REVIEW_MAX_ROUNDS_MIN} and ${REVIEW_MAX_ROUNDS_MAX}, found ${found()}`;
896
909
  }
910
+ if (rel.length === 2 && rel[0] === "review" && rel[1] === "adjudicator") {
911
+ return `review.adjudicator must be a string naming one OMP model role, found ${found()}`;
912
+ }
897
913
  if (rel.length === 2 && relStr === "policy") return `${pathStr} must be an object`;
898
914
  if (rel.length === 1 && relStr === "caps") return `caps must be an object`;
899
915
  if (rel.length === 2 && relStr === "caps" && rel[1] === "planUsage") {
@@ -944,7 +960,7 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
944
960
  if (issue.code === "unrecognized_keys") {
945
961
  const keys = (issue.keys ?? []).join(", ");
946
962
  if (pathStr === "policy") return `policy has unknown key(s): ${keys} — expected "merge" or "release"`;
947
- if (pathStr === "review") return `review has unknown key(s): ${keys} — expected "strictness" and "maxRounds"`;
963
+ if (pathStr === "review") return `review has unknown key(s): ${keys} — expected "strictness", "maxRounds" and "adjudicator"`;
948
964
  if (pathStr === "policy.merge") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_MERGE_KEYS}`;
949
965
  if (pathStr === "policy.release") return `${pathStr} has unknown key(s): ${keys} — expected ${POLICY_RELEASE_KEYS}`;
950
966
  if (pathStr === "caps.planUsage") {
@@ -1046,6 +1062,12 @@ function gatesCmdPath(rel: readonly PropertyKey[]): boolean {
1046
1062
  return rel.some((s, i) => s === "gates" && typeof rel[i + 1] === "number" && rel[rel.length - 1] === "cmd");
1047
1063
  }
1048
1064
  function capProblem(key: string, found: string): string {
1065
+ if (key === "maxRunSpendUsd") {
1066
+ return (
1067
+ "caps.maxRunSpendUsd must be a positive finite number or null " +
1068
+ `(derive it from dailySpendUsd), found ${found}`
1069
+ );
1070
+ }
1049
1071
  return key === "dailySpendUsd"
1050
1072
  ? `caps.dailySpendUsd must be a non-negative finite number or null (no cap), found ${found}`
1051
1073
  : `caps.${key} must be a non-negative finite number, found ${found}`;
@@ -1093,6 +1115,27 @@ function finalize(data: unknown, path: string): ConductorConfig {
1093
1115
  });
1094
1116
  }
1095
1117
 
1118
+ // Cross-field, and checked on the EFFECTIVE caps each project will run
1119
+ // under (#851): a per-run reservation larger than the day's budget can never
1120
+ // be satisfied, so every candidate would be held forever by a gate the
1121
+ // operator believed was a ceiling. Checked after resolution because the two
1122
+ // values may come from different layers — a global `dailySpendUsd` and a
1123
+ // per-project `maxRunSpendUsd`.
1124
+ for (const project of projects) {
1125
+ const effective = resolveCaps(project, defaults);
1126
+ if (
1127
+ effective.maxRunSpendUsd !== null &&
1128
+ effective.dailySpendUsd !== null &&
1129
+ effective.maxRunSpendUsd > effective.dailySpendUsd
1130
+ ) {
1131
+ problems.push(
1132
+ `project "${project.name}": caps.maxRunSpendUsd ($${String(effective.maxRunSpendUsd)}) must not exceed ` +
1133
+ `caps.dailySpendUsd ($${String(effective.dailySpendUsd)}) — a per-run reservation larger than the day's ` +
1134
+ "budget can never be admitted",
1135
+ );
1136
+ }
1137
+ }
1138
+
1096
1139
  const rawHost = root["host"] as Raw | undefined;
1097
1140
  const host = finalizeHost(rawHost);
1098
1141
  const rawDbBackupDir = root["dbBackupDir"];
@@ -1216,6 +1259,34 @@ function finalizeProject(
1216
1259
  ? rawThreshold
1217
1260
  : undefined;
1218
1261
 
1262
+ // The one-shot cap escalation target (#807): a hint like `workerModel`, and
1263
+ // trimmed here because it is handed to the harness verbatim as a selector.
1264
+ // Blank or the wrong type is dropped rather than failing the load, which is
1265
+ // exactly the "no escalation target configured" state settlement reports.
1266
+ const rawEscalationModel = p["workerEscalationModel"];
1267
+ const workerEscalationModel =
1268
+ typeof rawEscalationModel === "string" && rawEscalationModel.trim() !== ""
1269
+ ? rawEscalationModel.trim()
1270
+ : undefined;
1271
+
1272
+ // Providers whose routes must bill to a subscription credential (#852).
1273
+ // Trimmed and de-duplicated here because each entry is compared against the
1274
+ // provider id the harness itself reports, and a stray blank or a repeat would
1275
+ // spend a probe subprocess proving the same thing twice. A non-array, or an
1276
+ // entry of the wrong type, is dropped like an unusable `modelFallbacks` entry:
1277
+ // absent means today's dispatch, byte for byte.
1278
+ const rawRequireOauth = p["requireOauthProviders"];
1279
+ const requireOauthProviders = Array.isArray(rawRequireOauth)
1280
+ ? [
1281
+ ...new Set(
1282
+ rawRequireOauth
1283
+ .filter((entry): entry is string => typeof entry === "string")
1284
+ .map((entry) => entry.trim())
1285
+ .filter((entry) => entry !== ""),
1286
+ ),
1287
+ ]
1288
+ : [];
1289
+
1219
1290
  // The fleet-owned omp settings overlay (#537): an opaque map omp's own schema
1220
1291
  // owns, so the loader validates YAML shape only — a non-mapping is dropped
1221
1292
  // like an unusable `modelFallbacks` entry rather than failing the load, and
@@ -1287,6 +1358,8 @@ function finalizeProject(
1287
1358
  ...(workerModel === undefined ? {} : { workerModel }),
1288
1359
  ...(modelFallbacks.length === 0 ? {} : { modelFallbacks }),
1289
1360
  ...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
1361
+ ...(workerEscalationModel === undefined ? {} : { workerEscalationModel }),
1362
+ ...(requireOauthProviders.length === 0 ? {} : { requireOauthProviders }),
1290
1363
  ...(effectiveOmpSettings === undefined ? {} : { ompSettings: effectiveOmpSettings }),
1291
1364
  escalation,
1292
1365
  arm,
@@ -1320,14 +1393,17 @@ function finalizeArm(parsed: Raw | undefined): ProjectConfig["arm"] {
1320
1393
  * {@link DEFAULT_REVIEW_POLICY}, the documented migration default, so an
1321
1394
  * upgrade changes no existing project's behaviour beyond what its operators
1322
1395
  * then choose in setup. zod has already rejected anything outside the
1323
- * strictness vocabulary or the validated round range, so this only fills the
1324
- * absent-case default.
1396
+ * strictness vocabulary, the validated round range or the adjudicator role
1397
+ * token grammar, so this only fills the absent-case defaults — the
1398
+ * adjudicator included (#875), deterministically the same role a new
1399
+ * project's setup starts from.
1325
1400
  */
1326
1401
  function finalizeReview(parsed: Raw | undefined): ProjectConfig["review"] {
1327
1402
  if (parsed === undefined) return { ...DEFAULT_REVIEW_POLICY };
1328
1403
  return {
1329
1404
  strictness: (parsed["strictness"] as ReviewStrictness | undefined) ?? DEFAULT_REVIEW_STRICTNESS,
1330
1405
  maxRounds: (parsed["maxRounds"] as number | undefined) ?? DEFAULT_REVIEW_MAX_ROUNDS,
1406
+ adjudicator: (parsed["adjudicator"] as string | undefined) ?? DEFAULT_REVIEW_ADJUDICATOR_ROLE,
1331
1407
  };
1332
1408
  }
1333
1409
 
@@ -1579,6 +1655,25 @@ function reconcileCaps(
1579
1655
  if (cap !== undefined) out.planUsage = cap;
1580
1656
  continue;
1581
1657
  }
1658
+ if (key === "maxRunSpendUsd") {
1659
+ // Positive or null, never 0 (#851): `dailySpendUsd: 0` is a meaningful
1660
+ // hard stop, but a per-run allowance of 0 reserves nothing and admits
1661
+ // nothing — it is a misconfiguration wearing a cap's clothes, so it is
1662
+ // named rather than obeyed.
1663
+ if (v === null) {
1664
+ out.maxRunSpendUsd = null;
1665
+ continue;
1666
+ }
1667
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) {
1668
+ out.maxRunSpendUsd = v;
1669
+ continue;
1670
+ }
1671
+ problems.push(
1672
+ `${label}.maxRunSpendUsd must be a positive finite number or null (derive it from dailySpendUsd), ` +
1673
+ `found ${JSON.stringify(v)}`,
1674
+ );
1675
+ continue;
1676
+ }
1582
1677
  if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
1583
1678
  (out as Record<string, unknown>)[key as string] = v;
1584
1679
  continue;
@@ -0,0 +1,366 @@
1
+ /**
2
+ * The credential-class fence (#852).
3
+ *
4
+ * On 2026-08-21 an Anthropic OAuth credential's refresh failed with
5
+ * `invalid_grant`. The exact selector `anthropic/claude-opus-5` still resolved —
6
+ * correct provider, correct model — but through the *other* Anthropic
7
+ * credential, an API key. One run then spent an estimated $25.16 against
8
+ * API-key billing while the operator expected subscription billing.
9
+ *
10
+ * ## Why conductor has to own this
11
+ *
12
+ * The harness cannot express it. `AuthStorage.getApiKey`
13
+ * (`pi-ai/src/auth-storage.ts:5290-5356`, installed 17.2.10) resolves
14
+ * first-match-wins across runtime override → config override → stored OAuth →
15
+ * login-sourced API key → provider env var → other stored API key → fallback
16
+ * resolver, and **an exact `provider/model` selector does not stop that
17
+ * cascade**. A definitive OAuth refresh failure disables that row
18
+ * (`auth-storage.ts:5162-5232`) and the same call descends into the API-key
19
+ * legs. Three surfaces that look like levers are not:
20
+ *
21
+ * - `models.yml` `auth: "oauth"` forces OAuth-style request *shaping* and sets
22
+ * `isOAuth` metadata (`model-registry.ts:636-690`); it selects nothing, and a
23
+ * custom model still requires an `apiKey` unless `auth: none`.
24
+ * - A provider-id variant is not a class pin: `xai-oauth` is a real distinct
25
+ * provider whose own descriptor also accepts `XAI_API_KEY`
26
+ * (`pi-catalog/src/provider-models/descriptors.ts:485-493`), and
27
+ * `anthropic-oauth` does not exist — Anthropic's id is `anthropic`, whose env
28
+ * resolver accepts both sources (`pi-ai/src/registry/anthropic.ts:7-26`).
29
+ * - No setting, env var, selector suffix or request option expresses it:
30
+ * `AuthApiKeyOptions` is `{ baseUrl, modelId, signal, forceRefresh }`
31
+ * (`auth-storage.ts:796-813`).
32
+ *
33
+ * ## Why this is a pre-launch fence and not an audit
34
+ *
35
+ * There is no after-the-fact answer to "which class served that request".
36
+ * `getApiKey` returns `string | undefined` and never names the class it chose
37
+ * (`auth-storage.ts:5302`); `AssistantMessage` carries no credential field
38
+ * (`pi-ai/src/types.ts:858-912`); and session `credential_pin` entries are
39
+ * OAuth-only *and* change-only (`session-entries.ts:185-202`), so steady OAuth
40
+ * writes nothing, API-key use writes nothing, and neither a stale pin nor a
41
+ * missing one proves anything. A transcript audit of billing class would be a
42
+ * fabrication, so this module does not attempt one.
43
+ *
44
+ * What the harness *does* expose, token-free, is its current state — and that is
45
+ * enough to refuse before spending anything.
46
+ *
47
+ * ## The residue, stated rather than papered over
48
+ *
49
+ * This is a state check, not an atomic guarantee. If OAuth becomes invalid
50
+ * *during* a request, the installed resolver may still disable it and fall
51
+ * through to a same-provider API key, and nothing conductor can pass prevents
52
+ * that. The only hard lever is omp's own `disabledProviders`, which removes the
53
+ * provider before credential checks — and also removes the subscription route,
54
+ * which is why it stays an operator action rather than conductor's automatic
55
+ * response. The honest fix is upstream: an `AuthApiKeyOptions` that can require a
56
+ * credential class and refuse rather than descend.
57
+ *
58
+ * ## Why the requirement is per provider, never per run
59
+ *
60
+ * A credential belongs to a provider, not to a model — a model has none of its
61
+ * own. And a run's provider is not knowable in advance: `workerModel` is an
62
+ * opaque omp selector (it may be a role alias), and the provider-fault fallback
63
+ * chain (#286) can move a live run onto another provider mid-flight. So a
64
+ * per-run provider inference would be a guess dressed as a fact. The declared
65
+ * unit is therefore "this provider must bill to its subscription", and the
66
+ * verdict is the same for every candidate while that is untrue.
67
+ */
68
+
69
+ import { join } from "node:path";
70
+
71
+ /**
72
+ * The harness's own answer about one provider, as read through its exported
73
+ * API. Deliberately narrow: an origin kind and disabled-credential *causes*, and
74
+ * nothing that could carry credential material.
75
+ */
76
+ export interface CredentialClassProbe {
77
+ provider: string;
78
+ /**
79
+ * `getCredentialOrigin(provider)` — the currently winning source
80
+ * (`auth-storage.ts:2689-2714`). Absent means the provider resolves to nothing
81
+ * at all, which is a different refusal from "resolves to the wrong class".
82
+ */
83
+ origin?: { kind: string; envVar?: string };
84
+ /**
85
+ * `listDisabledCredentials(provider)` (`auth-storage.ts:6235-6247`), reduced to
86
+ * class and cause. The cause is what makes a refusal actionable — an operator
87
+ * reading `invalid_grant` knows to re-authenticate — and it is not secret.
88
+ */
89
+ disabled: readonly { type: string; cause: string }[];
90
+ }
91
+
92
+ /** Why a fence refused, or that it did not. */
93
+ export type CredentialClassVerdict =
94
+ | { ok: true }
95
+ | { ok: false; reason: string };
96
+
97
+ /** The runnable probe's own output: one provider per request, or the failure. */
98
+ export type CredentialClassProbeResult =
99
+ | { ok: true; probe: CredentialClassProbe }
100
+ | { ok: false; reason: string };
101
+
102
+ /**
103
+ * Does `provider` currently bill to its subscription?
104
+ *
105
+ * Pure, so the whole decision is testable without a credential store, a
106
+ * subprocess or a harness — which matters because this is the function whose
107
+ * wrong answer costs either a fleet stall or a surprise invoice.
108
+ *
109
+ * The gate is `origin.kind === "oauth"` and nothing looser. In particular
110
+ * `env` is refused even when the environment variable is an OAuth *token*
111
+ * (`ANTHROPIC_OAUTH_TOKEN` is one): conductor cannot see whether that value is a
112
+ * subscription token or a key, and a fence that guesses is not a fence.
113
+ */
114
+ export function oauthRequirementVerdict(probe: CredentialClassProbe): CredentialClassVerdict {
115
+ const { provider, origin } = probe;
116
+ // The disabled row is not the verdict — it is the remediation. Reported for
117
+ // any refusal, because "OAuth is disabled because the refresh returned
118
+ // invalid_grant" is the difference between an operator re-authenticating in a
119
+ // minute and an operator reading source for an hour.
120
+ const disabledOauth = probe.disabled.filter((row) => row.type === "oauth");
121
+ const because =
122
+ disabledOauth.length === 0
123
+ ? ""
124
+ : ` The ${provider} OAuth credential is disabled: ${disabledOauth
125
+ .map((row) => row.cause)
126
+ .join("; ")}.`;
127
+ const remedy =
128
+ ` Re-authenticate ${provider} (\`omp auth login ${provider}\`), or take the whole provider out of` +
129
+ ` play with omp's own disabledProviders — conductor will not dispatch onto an API key it was told` +
130
+ " not to use.";
131
+
132
+ if (origin === undefined) {
133
+ return {
134
+ ok: false,
135
+ reason:
136
+ `${provider} requires subscription (OAuth) credentials, and no ${provider} credential resolves at` +
137
+ ` all.${because}${remedy}`,
138
+ };
139
+ }
140
+ if (origin.kind === "oauth") return { ok: true };
141
+ const named =
142
+ origin.kind === "env" && origin.envVar !== undefined
143
+ ? `an environment variable (${origin.envVar})`
144
+ : `a ${origin.kind} credential`;
145
+ return {
146
+ ok: false,
147
+ reason:
148
+ `${provider} requires subscription (OAuth) credentials, but it currently resolves to ${named}, so a` +
149
+ ` dispatch would bill to that instead.${because}${remedy}`,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Every declared provider's verdict, in declaration order, refusing on the first
155
+ * that fails.
156
+ *
157
+ * One refusal at a time on purpose: a hold names the one thing to fix, and a
158
+ * concatenation of provider problems reads as a configuration essay rather than
159
+ * an action.
160
+ */
161
+ export async function oauthFenceVerdict(
162
+ providers: readonly string[],
163
+ probe: (provider: string) => Promise<CredentialClassProbeResult>,
164
+ ): Promise<CredentialClassVerdict> {
165
+ for (const provider of providers) {
166
+ const result = await probe(provider);
167
+ if (!result.ok) {
168
+ // A probe that cannot answer is a refusal, not a pass. The whole point of
169
+ // the fence is that the expensive outcome is dispatching blind, and
170
+ // "we could not check" is indistinguishable from "it is wrong" in cost.
171
+ return {
172
+ ok: false,
173
+ reason:
174
+ `${provider} requires subscription (OAuth) credentials and that could not be verified: ` +
175
+ `${result.reason}. Refusing rather than dispatching unverified.`,
176
+ };
177
+ }
178
+ const verdict = oauthRequirementVerdict(result.probe);
179
+ if (!verdict.ok) return verdict;
180
+ }
181
+ return { ok: true };
182
+ }
183
+
184
+ /** How the probe subprocess is run, injected so tests need no harness. */
185
+ export interface CredentialProbeDeps {
186
+ run?: (args: readonly string[]) => Promise<{ code: number; stdout: string; stderr: string }>;
187
+ /** This module's own path, so the child runs the same file it was built with. */
188
+ modulePath?: string;
189
+ }
190
+
191
+ const REAL_RUN = async (
192
+ args: readonly string[],
193
+ ): Promise<{ code: number; stdout: string; stderr: string }> => {
194
+ const child = Bun.spawn([...args], {
195
+ stdin: "ignore",
196
+ stdout: "pipe",
197
+ stderr: "pipe",
198
+ env: process.env,
199
+ });
200
+ const stdout = new Response(child.stdout).text();
201
+ const stderr = new Response(child.stderr).text();
202
+ const code = await child.exited;
203
+ return { code, stdout: await stdout, stderr: await stderr };
204
+ };
205
+
206
+ /**
207
+ * Ask the harness about one provider, out of process.
208
+ *
209
+ * Out of process for two reasons, and the second is the load-bearing one. It
210
+ * keeps the harness's module graph and its credential database out of the
211
+ * long-lived daemon; and it means every read is taken *now*, against whatever
212
+ * the harness currently believes, rather than against a pool this process
213
+ * loaded once and cached. A fence reading a stale in-memory pool would pass a
214
+ * credential that was disabled an hour ago.
215
+ *
216
+ * This mirrors `harness-loader.ts` exactly — a runnable module, spawned with
217
+ * `--no-install`, whose stdout is one JSON line — because that is already how
218
+ * this package asks the installed harness a question it must not answer itself.
219
+ */
220
+ export async function probeCredentialClass(
221
+ provider: string,
222
+ deps: CredentialProbeDeps = {},
223
+ ): Promise<CredentialClassProbeResult> {
224
+ const run = deps.run ?? REAL_RUN;
225
+ const modulePath = deps.modulePath ?? join(import.meta.dir, "credential-class.ts");
226
+ let result: { code: number; stdout: string; stderr: string };
227
+ try {
228
+ result = await run(["bun", "--no-install", modulePath, provider]);
229
+ } catch (err) {
230
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
231
+ }
232
+ if (result.code !== 0) {
233
+ const detail = (result.stderr.trim() || result.stdout.trim()).split("\n")[0] ?? `exit ${result.code}`;
234
+ return { ok: false, reason: detail };
235
+ }
236
+ return parseProbeOutput(provider, result.stdout);
237
+ }
238
+
239
+ /**
240
+ * The child's stdout, validated rather than trusted.
241
+ *
242
+ * Exported because the parse is where a malformed answer either becomes a clean
243
+ * refusal or becomes a fence that silently passes. Anything unexpected — not
244
+ * JSON, wrong provider, missing `disabled` array — is a refusal.
245
+ */
246
+ export function parseProbeOutput(provider: string, stdout: string): CredentialClassProbeResult {
247
+ let parsed: unknown;
248
+ try {
249
+ parsed = JSON.parse(stdout.trim());
250
+ } catch {
251
+ return { ok: false, reason: "the credential probe did not answer with JSON" };
252
+ }
253
+ if (parsed === null || typeof parsed !== "object") {
254
+ return { ok: false, reason: "the credential probe answered with no object" };
255
+ }
256
+ const answeredFor = Reflect.get(parsed, "provider");
257
+ if (answeredFor !== provider) {
258
+ // Never accept an answer about a provider nobody asked about: that is how a
259
+ // fence comes to approve the wrong route.
260
+ return {
261
+ ok: false,
262
+ reason: `the credential probe answered for ${String(answeredFor)}, not ${provider}`,
263
+ };
264
+ }
265
+ const rawDisabled = Reflect.get(parsed, "disabled");
266
+ if (!Array.isArray(rawDisabled)) {
267
+ return { ok: false, reason: "the credential probe reported no disabled-credential list" };
268
+ }
269
+ const disabled: { type: string; cause: string }[] = [];
270
+ for (const row of rawDisabled) {
271
+ if (row === null || typeof row !== "object") continue;
272
+ const type = Reflect.get(row, "type");
273
+ const cause = Reflect.get(row, "cause");
274
+ if (typeof type !== "string") continue;
275
+ disabled.push({ type, cause: typeof cause === "string" ? cause : "no cause recorded" });
276
+ }
277
+ const rawOrigin = Reflect.get(parsed, "origin");
278
+ let origin: { kind: string; envVar?: string } | undefined;
279
+ if (rawOrigin !== null && typeof rawOrigin === "object") {
280
+ const kind = Reflect.get(rawOrigin, "kind");
281
+ const envVar = Reflect.get(rawOrigin, "envVar");
282
+ if (typeof kind === "string") {
283
+ origin = { kind, ...(typeof envVar === "string" ? { envVar } : {}) };
284
+ }
285
+ }
286
+ return { ok: true, probe: { provider, ...(origin === undefined ? {} : { origin }), disabled } };
287
+ }
288
+
289
+ /**
290
+ * The two harness shapes this module needs, declared here rather than inlined.
291
+ *
292
+ * The harness is a *peer* dependency: it is absent from this package's own
293
+ * install and resolves from wherever the operator installed omp. Its types are
294
+ * therefore not available to `tsc` here, so the surface conductor relies on is
295
+ * written down — which is better documentation than an inferred type anyway,
296
+ * because it is exactly the contract that would break on a harness upgrade.
297
+ */
298
+ interface HarnessAuthStorage {
299
+ reload?: () => Promise<void>;
300
+ getCredentialOrigin: (provider: string) => { kind: string; envVar?: string } | undefined;
301
+ listDisabledCredentials: (provider?: string) => Promise<readonly { type: string; cause: string }[]>;
302
+ }
303
+
304
+ interface HarnessAuthModule {
305
+ AuthStorage: new (store: unknown) => HarnessAuthStorage;
306
+ SqliteAuthCredentialStore: new (db: unknown) => { close: () => void };
307
+ }
308
+
309
+ interface HarnessPathsModule {
310
+ getAgentDbPath: (agentDir?: string) => string;
311
+ }
312
+
313
+ /**
314
+ * The child half: read the harness's state for one provider and print it.
315
+ *
316
+ * Everything here is the harness's own exported API — never a query against
317
+ * `agent.db`, whose schema is not conductor's to know. Nothing printed can carry
318
+ * credential material: an origin kind, an optional environment-variable *name*,
319
+ * and disabled classes with their causes.
320
+ *
321
+ * The imports are dynamic and their specifiers resolved at runtime because they
322
+ * genuinely cannot be static: the harness is an absent peer at build time (a
323
+ * literal specifier would fail `tsc`), it must resolve from *this package's*
324
+ * install root rather than Bun's ambient cache — the same anchoring
325
+ * `harness-loader.ts` exists to guarantee — and loading it at module scope would
326
+ * pull the whole harness and its credential database into the daemon, which is
327
+ * the thing running the probe out of process avoids.
328
+ */
329
+ if (import.meta.main) {
330
+ const provider = process.argv[2] ?? "";
331
+ try {
332
+ if (provider === "") throw new Error("usage: credential-class.ts <provider>");
333
+ const { Database } = await import("bun:sqlite");
334
+ const auth = (await import(
335
+ Bun.resolveSync("@oh-my-pi/pi-coding-agent/session/auth-storage", import.meta.dir)
336
+ )) as HarnessAuthModule;
337
+ // The agent database's location is the harness's convention, taken from the
338
+ // harness — hardcoding `~/.omp/agent/agent.db` here would be a second
339
+ // opinion about where credentials live.
340
+ const paths = (await import(
341
+ Bun.resolveSync("@oh-my-pi/pi-utils", import.meta.dir)
342
+ )) as HarnessPathsModule;
343
+ const store = new auth.SqliteAuthCredentialStore(new Database(paths.getAgentDbPath()));
344
+ try {
345
+ const storage = new auth.AuthStorage(store);
346
+ // `reload` is what makes this read current rather than constructor-fresh:
347
+ // it rebuilds the active pool from the store, so a credential disabled
348
+ // since the row was written is absent here exactly as it is for the
349
+ // resolver that would have used it.
350
+ await storage.reload?.();
351
+ const origin = storage.getCredentialOrigin(provider);
352
+ const disabled = (await storage.listDisabledCredentials(provider)).map((row) => ({
353
+ type: row.type,
354
+ cause: row.cause,
355
+ }));
356
+ process.stdout.write(
357
+ `${JSON.stringify({ provider, ...(origin === undefined ? {} : { origin }), disabled })}\n`,
358
+ );
359
+ } finally {
360
+ store.close();
361
+ }
362
+ } catch (cause) {
363
+ process.stderr.write(`${cause instanceof Error ? cause.message : String(cause)}\n`);
364
+ process.exitCode = 1;
365
+ }
366
+ }