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.
- package/README.md +106 -41
- package/REFERENCE.md +866 -31
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +32 -1
- package/src/admission.ts +212 -26
- package/src/arm-challenge.ts +250 -57
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +27 -13
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +127 -2
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +52 -8
- package/src/commands/arm.ts +6 -2
- package/src/commands/context.ts +2 -0
- package/src/commands/intake.ts +4 -19
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +38 -6
- package/src/config.ts +103 -8
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1368 -529
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +431 -148
- package/src/escalate.ts +22 -11
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +587 -230
- package/src/host.ts +6 -455
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +40 -56
- package/src/orchestrator-tick.ts +564 -121
- package/src/pause.ts +233 -0
- package/src/session-host.ts +6 -41
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +343 -1160
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +252 -51
- package/src/setup.ts +87 -4
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +485 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +50 -2
- package/src/types.ts +759 -10
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +485 -11
- package/src/wake.ts +48 -0
- package/src/worker.ts +401 -14
package/src/config-schema.ts
CHANGED
|
@@ -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,
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
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
|
|
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
|
|
353
|
-
*
|
|
354
|
-
*
|
|
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
|
-
|
|
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 "
|
|
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 "
|
|
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
|
|
1324
|
-
* absent-case
|
|
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
|
+
}
|