omp-conductor 0.18.2 → 0.19.1
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 +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- 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/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- 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/doctor.ts +379 -22
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +511 -101
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +325 -1159
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +326 -47
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
package/src/config.ts
CHANGED
|
@@ -282,6 +282,10 @@ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
|
|
|
282
282
|
maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
|
|
283
283
|
maxConcurrentWorkersPerRepo: o.maxConcurrentWorkersPerRepo ?? defaults.maxConcurrentWorkersPerRepo,
|
|
284
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,
|
|
285
289
|
planUsage: o.planUsage !== undefined ? o.planUsage : defaults.planUsage,
|
|
286
290
|
workerMaxTurns,
|
|
287
291
|
workerMaxTurnsCeiling:
|
|
@@ -1058,6 +1062,12 @@ function gatesCmdPath(rel: readonly PropertyKey[]): boolean {
|
|
|
1058
1062
|
return rel.some((s, i) => s === "gates" && typeof rel[i + 1] === "number" && rel[rel.length - 1] === "cmd");
|
|
1059
1063
|
}
|
|
1060
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
|
+
}
|
|
1061
1071
|
return key === "dailySpendUsd"
|
|
1062
1072
|
? `caps.dailySpendUsd must be a non-negative finite number or null (no cap), found ${found}`
|
|
1063
1073
|
: `caps.${key} must be a non-negative finite number, found ${found}`;
|
|
@@ -1105,6 +1115,27 @@ function finalize(data: unknown, path: string): ConductorConfig {
|
|
|
1105
1115
|
});
|
|
1106
1116
|
}
|
|
1107
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
|
+
|
|
1108
1139
|
const rawHost = root["host"] as Raw | undefined;
|
|
1109
1140
|
const host = finalizeHost(rawHost);
|
|
1110
1141
|
const rawDbBackupDir = root["dbBackupDir"];
|
|
@@ -1228,6 +1259,34 @@ function finalizeProject(
|
|
|
1228
1259
|
? rawThreshold
|
|
1229
1260
|
: undefined;
|
|
1230
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
|
+
|
|
1231
1290
|
// The fleet-owned omp settings overlay (#537): an opaque map omp's own schema
|
|
1232
1291
|
// owns, so the loader validates YAML shape only — a non-mapping is dropped
|
|
1233
1292
|
// like an unusable `modelFallbacks` entry rather than failing the load, and
|
|
@@ -1299,6 +1358,8 @@ function finalizeProject(
|
|
|
1299
1358
|
...(workerModel === undefined ? {} : { workerModel }),
|
|
1300
1359
|
...(modelFallbacks.length === 0 ? {} : { modelFallbacks }),
|
|
1301
1360
|
...(modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold }),
|
|
1361
|
+
...(workerEscalationModel === undefined ? {} : { workerEscalationModel }),
|
|
1362
|
+
...(requireOauthProviders.length === 0 ? {} : { requireOauthProviders }),
|
|
1302
1363
|
...(effectiveOmpSettings === undefined ? {} : { ompSettings: effectiveOmpSettings }),
|
|
1303
1364
|
escalation,
|
|
1304
1365
|
arm,
|
|
@@ -1594,6 +1655,25 @@ function reconcileCaps(
|
|
|
1594
1655
|
if (cap !== undefined) out.planUsage = cap;
|
|
1595
1656
|
continue;
|
|
1596
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
|
+
}
|
|
1597
1677
|
if (typeof v === "number" && Number.isFinite(v) && v >= 0) {
|
|
1598
1678
|
(out as Record<string, unknown>)[key as string] = v;
|
|
1599
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
|
+
}
|