@amenophis1er/foreman 0.1.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 (65) hide show
  1. package/DESIGN.md +408 -0
  2. package/LICENSE +15 -0
  3. package/README.md +133 -0
  4. package/bin/foreman.mjs +58 -0
  5. package/package.json +68 -0
  6. package/scripts/prepare.mjs +48 -0
  7. package/skills/director/SKILL.md +65 -0
  8. package/src/anthropic-models.ts +54 -0
  9. package/src/ask.test.ts +88 -0
  10. package/src/ask.ts +95 -0
  11. package/src/attachments.test.ts +33 -0
  12. package/src/attachments.ts +60 -0
  13. package/src/cli.test.ts +27 -0
  14. package/src/cli.ts +297 -0
  15. package/src/codex.test.ts +328 -0
  16. package/src/codex.ts +196 -0
  17. package/src/cost-basis.test.ts +76 -0
  18. package/src/deck.test.ts +402 -0
  19. package/src/deck.ts +892 -0
  20. package/src/fork.test.ts +31 -0
  21. package/src/gateway/ledger.cjs +326 -0
  22. package/src/gateway/ledger.test.ts +255 -0
  23. package/src/gateway/llm-gateway.cjs +1411 -0
  24. package/src/gateway/llm-gateway.test.ts +478 -0
  25. package/src/gateway.test.ts +226 -0
  26. package/src/gateway.ts +309 -0
  27. package/src/instance.ts +124 -0
  28. package/src/models.test.ts +147 -0
  29. package/src/models.ts +158 -0
  30. package/src/notify/commands.test.ts +28 -0
  31. package/src/notify/commands.ts +73 -0
  32. package/src/notify/telegram.ts +259 -0
  33. package/src/notify.test.ts +343 -0
  34. package/src/notify.ts +495 -0
  35. package/src/ollama.test.ts +49 -0
  36. package/src/ollama.ts +49 -0
  37. package/src/openai-prices.test.ts +58 -0
  38. package/src/openai-prices.ts +106 -0
  39. package/src/orchestrator.test.ts +1147 -0
  40. package/src/orchestrator.ts +2325 -0
  41. package/src/planner.test.ts +60 -0
  42. package/src/planner.ts +505 -0
  43. package/src/policy.test.ts +411 -0
  44. package/src/policy.ts +599 -0
  45. package/src/preflight.ts +348 -0
  46. package/src/prices.test.ts +69 -0
  47. package/src/prices.ts +90 -0
  48. package/src/provider.test.ts +366 -0
  49. package/src/provider.ts +502 -0
  50. package/src/secrets.test.ts +143 -0
  51. package/src/secrets.ts +66 -0
  52. package/src/server.ts +1992 -0
  53. package/src/services.test.ts +53 -0
  54. package/src/services.ts +102 -0
  55. package/src/sse-events.test.ts +83 -0
  56. package/src/store.test.ts +119 -0
  57. package/src/store.ts +346 -0
  58. package/src/tailscale.test.ts +32 -0
  59. package/src/tailscale.ts +79 -0
  60. package/src/title.ts +138 -0
  61. package/src/types.ts +442 -0
  62. package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
  63. package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
  64. package/ui/dist/favicon.svg +8 -0
  65. package/ui/dist/index.html +14 -0
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Providers — who serves the model, who pays for it, and where requests go.
3
+ *
4
+ * See docs/provider-model.md for the full design. The short version, and the
5
+ * reason this is one module rather than three settings:
6
+ *
7
+ * Foreman picks credentials *ambiently*. A pinned Claude Code install
8
+ * authenticates from its own store (a file in its config dir, or — on macOS —
9
+ * a machine-wide Keychain item that no config dir relocates). Nothing in the
10
+ * environment has to name a credential for one to be used.
11
+ *
12
+ * `ANTHROPIC_BASE_URL` is honoured whatever credential the harness ends up
13
+ * using. Put those two facts together and redirecting the base URL at a
14
+ * translating gateway, while an ambient login is still reachable, sends a real
15
+ * subscription token to whatever we proxy to. That is exfiltration, not a
16
+ * failed request.
17
+ *
18
+ * So provider, credential and wire are ONE choice — a discriminated union, so
19
+ * that "subscription login" and "custom base URL" cannot both be expressed —
20
+ * and {@link providerEnv} enforces the invariant that makes the combination
21
+ * safe: any non-native wire ships an explicit credential that outranks
22
+ * anything ambient. See {@link GATEWAY_INVARIANT}.
23
+ */
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import { readFile } from 'node:fs/promises';
27
+ import { defaultInstance } from './instance.js';
28
+ import { codexHome, isStale, readCodexAuth, refreshCodexAuth } from './codex.js';
29
+ import { getSecret } from './secrets.js';
30
+ import { discoverModels } from './models.js';
31
+ import type { ModelPrice } from './prices.js';
32
+ import { isOpenAiHost, openaiPrice } from './openai-prices.js';
33
+ import type { CostBasis, ProviderRef, ClaudeInstanceRef } from './types.js';
34
+
35
+ /**
36
+ * Anthropic's real endpoint. Set explicitly on gateway providers so a reused
37
+ * environment (or a base URL inherited from the launching shell) can never
38
+ * leave a native call pointed at a gateway, or the reverse.
39
+ */
40
+ export const ANTHROPIC_NATIVE_BASE_URL = 'https://api.anthropic.com';
41
+
42
+ /**
43
+ * Gateways bind a port the OS chooses, one process per active provider — see
44
+ * gateway.ts. Deliberately not a fixed port: the value this was ported from is
45
+ * 11434, which on a user's machine is Ollama's, and colliding with the thing
46
+ * you proxy to is a poor first bug.
47
+ */
48
+
49
+ /** How an agent's requests reach a model. */
50
+ export type Wire = 'anthropic-native' | 'gateway-openai' | 'gateway-codex';
51
+
52
+ /**
53
+ * Credentials Claude Code would find on its own, in precedence order. A
54
+ * gateway provider must override every one of them: leaving any unset lets an
55
+ * ambient login win and travel to the gateway's upstream.
56
+ *
57
+ * `CLAUDE_CODE_OAUTH_TOKEN` and the API key vars are environment-level;
58
+ * the config-dir store and the macOS Keychain are not, which is why the
59
+ * override has to be a *positive* credential rather than a deletion.
60
+ */
61
+ const AMBIENT_CREDENTIAL_VARS = [
62
+ 'ANTHROPIC_API_KEY',
63
+ 'ANTHROPIC_AUTH_TOKEN',
64
+ 'CLAUDE_CODE_OAUTH_TOKEN',
65
+ ] as const;
66
+
67
+ /**
68
+ * The one rule this module exists to keep, stated so a test can name it:
69
+ *
70
+ * A non-native wire MUST carry an explicit, non-empty ANTHROPIC_API_KEY and
71
+ * an ANTHROPIC_BASE_URL pointing at the local gateway.
72
+ *
73
+ * Deleting the ambient variables is not sufficient. On macOS a Claude Code
74
+ * subscription lives in a machine-wide Keychain item that no CLAUDE_CONFIG_DIR
75
+ * relocates, so a "clean" config dir still has a login to fall back on. An
76
+ * explicit key outranks it — which is exactly the mechanism `own-login` exists
77
+ * to work around, used here in the opposite direction.
78
+ */
79
+ export const GATEWAY_INVARIANT =
80
+ 'a gateway wire must set an explicit ANTHROPIC_API_KEY and point ANTHROPIC_BASE_URL at the gateway';
81
+
82
+ /** A provider resolved against the environment and ready to dispatch. */
83
+ export interface ResolvedProvider {
84
+ kind: ProviderRef['kind'];
85
+ /** One line for preflight, badges and run history. Never a secret. */
86
+ label: string;
87
+ wire: Wire;
88
+ /** CLAUDE_CONFIG_DIR for the agent. */
89
+ configDir: string;
90
+ /** Claude Code executable; the SDK's bundled one when absent. */
91
+ executable?: string;
92
+ /** Upstream the gateway forwards to. Absent on the native wire. */
93
+ upstreamUrl?: string;
94
+ /**
95
+ * The credential to hand the agent. Present for every wire except a
96
+ * `claude-code` provider using its own stored login, which is the one case
97
+ * where ambient resolution is the intent.
98
+ */
99
+ apiKey?: string;
100
+ /** Concrete model id, where the provider pins one. */
101
+ model?: string;
102
+ /**
103
+ * Codex only: the ChatGPT account the login belongs to. The gateway reads it
104
+ * from the token's own claims per request; this is the fallback for a token
105
+ * that carries none.
106
+ */
107
+ accountId?: string;
108
+ /**
109
+ * `claude-code` only: strip inherited key credentials so the install's own
110
+ * stored login pays. The original per-project billing lever.
111
+ */
112
+ ownLogin?: boolean;
113
+ /**
114
+ * What spending on this provider *is* — see {@link CostBasis}.
115
+ *
116
+ * The SDK prices every response with Anthropic's table. Through a gateway
117
+ * the token counts are real but the prices are not, so a dollar cap would be
118
+ * enforced against fiction — and it does not merely mislead, it terminates
119
+ * working runs. Anything but `priced` caps on turns and wall-clock instead.
120
+ */
121
+ costBasis: CostBasis;
122
+ /**
123
+ * @deprecated Kept in step with {@link costBasis} for callers not yet moved
124
+ * over. `metered === false` is `costBasis !== 'priced'`, which is exactly
125
+ * the conflation the split exists to undo — do not branch on it.
126
+ */
127
+ metered: boolean;
128
+ /** Why this provider cannot run right now, if it cannot. */
129
+ problem?: string;
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Migration
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Reads the pre-provider shape. A stored `claudeInstance` (or nothing at all)
138
+ * is a `claude-code` provider — every existing project and every run recorded
139
+ * before this change keeps resolving to exactly what it did before.
140
+ */
141
+ export function providerFromLegacy(legacy?: ClaudeInstanceRef | null): ProviderRef {
142
+ return {
143
+ kind: 'claude-code',
144
+ configDir: legacy?.configDir,
145
+ executable: legacy?.executable,
146
+ ownLogin: legacy?.billing === 'own-login',
147
+ };
148
+ }
149
+
150
+ /** The provider a stored record uses, tolerating records written before providers existed. */
151
+ export function providerOf(
152
+ record: { provider?: ProviderRef; claudeInstance?: ClaudeInstanceRef },
153
+ ): ProviderRef {
154
+ return record.provider ?? providerFromLegacy(record.claudeInstance);
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Resolution
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /** Expands a leading `~` so config can be written the way people type it. */
162
+ function expandHome(p: string): string {
163
+ if (p === '~') return os.homedir();
164
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
165
+ return p;
166
+ }
167
+
168
+ function clean(value: string | undefined): string | undefined {
169
+ const trimmed = value?.trim();
170
+ return trimmed ? expandHome(trimmed) : undefined;
171
+ }
172
+
173
+ /**
174
+ * Config dir Foreman creates and owns for a provider that must NOT see an
175
+ * Anthropic login. Kept under the data root rather than beside the user's own
176
+ * installs, so it is obvious who put it there and safe to delete.
177
+ */
178
+ export function ownedConfigDir(root: string, id: string): string {
179
+ return path.join(root, 'instances', id.replace(/[^A-Za-z0-9_-]/g, '_'));
180
+ }
181
+
182
+ /**
183
+ * Resolves a provider reference against the environment.
184
+ *
185
+ * Never throws and never returns a partially-usable provider: anything that
186
+ * would prevent a run is reported in `problem`, so preflight and the composer
187
+ * can say what is wrong before a mission starts rather than three tool calls
188
+ * in.
189
+ *
190
+ * @param root Foreman's data root, for Foreman-owned config dirs.
191
+ */
192
+ export async function resolveProvider(ref: ProviderRef, root: string): Promise<ResolvedProvider> {
193
+ switch (ref.kind) {
194
+ case 'claude-code': {
195
+ // Project override wins field by field over the server default, so a
196
+ // project can pin a config dir while inheriting the executable.
197
+ const base = defaultInstance();
198
+ const configDir =
199
+ clean(ref.configDir) ?? base.configDir ?? clean(process.env.CLAUDE_CONFIG_DIR)
200
+ ?? path.join(os.homedir(), '.claude');
201
+ return {
202
+ kind: ref.kind,
203
+ wire: 'anthropic-native',
204
+ ...basis('priced'),
205
+ label: `Claude Code · ${configDir}${ref.ownLogin ? ' (its own login pays)' : ''}`,
206
+ configDir,
207
+ executable: clean(ref.executable) ?? base.executable,
208
+ ownLogin: ref.ownLogin,
209
+ };
210
+ }
211
+
212
+ case 'anthropic-api': {
213
+ const apiKey = await providerKey(root, ref.id, ref.apiKeyEnv);
214
+ return {
215
+ kind: ref.kind,
216
+ wire: 'anthropic-native',
217
+ ...basis('priced'),
218
+ label: `Anthropic API key · $${ref.apiKeyEnv}`,
219
+ // Foreman-owned: an API-key provider has no business reading a user
220
+ // install's settings, plugins or login.
221
+ configDir: ownedConfigDir(root, ref.id),
222
+ apiKey,
223
+ model: ref.model,
224
+ problem: apiKey ? undefined : missingKey(ref.apiKeyEnv),
225
+ };
226
+ }
227
+
228
+ case 'codex': {
229
+ const home = codexHome(ref.codexHome);
230
+ // Refresh here rather than at dispatch: a token that expires mid-mission
231
+ // fails every remaining turn, and the rotated refresh token is
232
+ // single-use, so the write has to happen where it can be persisted.
233
+ let auth = await readCodexAuth(home);
234
+ if (auth && isStale(auth)) auth = (await refreshCodexAuth(home, auth)) ?? auth;
235
+ const token = auth?.OPENAI_API_KEY || auth?.tokens?.access_token;
236
+ const problem = auth
237
+ ? (token ? undefined : `${home}/auth.json holds no usable credential — run \`codex login\``)
238
+ : `no Codex login at ${home}/auth.json — run \`codex login\``;
239
+ return {
240
+ kind: ref.kind,
241
+ wire: 'gateway-codex',
242
+ ...basis('unpriced'),
243
+ label: `Codex · ${home}`,
244
+ configDir: ownedConfigDir(root, ref.id),
245
+ upstreamUrl: ref.upstreamUrl ?? 'https://chatgpt.com/backend-api',
246
+ apiKey: token,
247
+ accountId: auth?.tokens?.account_id,
248
+ model: ref.model,
249
+ problem,
250
+ };
251
+ }
252
+
253
+ case 'openai-compatible': {
254
+ // A local Ollama needs no credential at all; the gateway still requires
255
+ // a non-empty key downstream (see GATEWAY_INVARIANT), so a placeholder
256
+ // stands in. It is never sent as a real secret — the upstream ignores it.
257
+ const key = ref.apiKeyEnv || ref.needsKey
258
+ ? await providerKey(root, ref.id, ref.apiKeyEnv)
259
+ : 'no-key-required';
260
+ return {
261
+ kind: ref.kind,
262
+ wire: 'gateway-openai',
263
+ // An endpoint on this machine is the operator's own hardware and
264
+ // costs nothing per token. Anything reachable only over the network
265
+ // is somebody's paid service — unpriced until a price source says
266
+ // otherwise, never free by default, because guessing "free" about a
267
+ // billed endpoint is the error that costs money.
268
+ ...basis(isPrivateHost(hostOf(ref.baseUrl)) ? 'free' : 'unpriced'),
269
+ label: `${ref.label ?? 'OpenAI-compatible'} · ${ref.baseUrl}`,
270
+ configDir: ownedConfigDir(root, ref.id),
271
+ upstreamUrl: normalizeOpenAiBaseUrl(ref.baseUrl),
272
+ apiKey: key,
273
+ model: ref.model,
274
+ problem: key ? undefined : missingKey(ref.apiKeyEnv),
275
+ };
276
+ }
277
+ }
278
+ }
279
+
280
+ /**
281
+ * A provider's credential: the key stored for it, else the named environment
282
+ * variable.
283
+ *
284
+ * Stored wins because it is the deliberate choice — someone pasted it into
285
+ * Settings for this provider. An env var is the escape hatch for a server
286
+ * started with one already exported, and for anyone who would rather Foreman
287
+ * held nothing.
288
+ */
289
+ async function providerKey(
290
+ root: string, id: string, apiKeyEnv?: string,
291
+ ): Promise<string | undefined> {
292
+ const stored = await getSecret(root, id).catch(() => null);
293
+ if (stored) return stored;
294
+ return apiKeyEnv ? clean(process.env[apiKeyEnv]) : undefined;
295
+ }
296
+
297
+ /** Says what to do about a missing key without naming a value. */
298
+ function missingKey(apiKeyEnv?: string): string {
299
+ return apiKeyEnv
300
+ ? `no key stored for this provider, and $${apiKeyEnv} is not set in the server environment`
301
+ : 'this endpoint needs a key — add one in Settings';
302
+ }
303
+
304
+ /**
305
+ * Normalises a user-supplied OpenAI-compatible base URL to the host root the
306
+ * gateway appends `/v1/...` to. Tolerates a pasted `/v1` suffix, trailing
307
+ * slashes, and a scheme-less paste (which would otherwise throw inside the
308
+ * gateway's `new URL()` and take the agent down on its first call).
309
+ */
310
+ export function normalizeOpenAiBaseUrl(url: string): string {
311
+ let base = url.trim();
312
+ if (!/^https?:\/\//i.test(base)) {
313
+ // Scheme-less is how people type a local daemon — `127.0.0.1:11434`, or a
314
+ // hostname on a private network. Defaulting those to https guarantees a
315
+ // handshake failure on the first call, so infer from the host: public
316
+ // names get https, anything that cannot plausibly hold a certificate does
317
+ // not. An explicit scheme is always honoured.
318
+ base = `${isPrivateHost(base.split('/')[0]) ? 'http' : 'https'}://${base}`;
319
+ }
320
+ return base.replace(/\/+$/, '').replace(/\/v1$/, '');
321
+ }
322
+
323
+ /** What one agent role's spend is, and what it costs when that is knowable. */
324
+ export interface RoleCost {
325
+ basis: CostBasis;
326
+ /**
327
+ * Per-token rates for the chosen model, when the endpoint publishes them.
328
+ *
329
+ * Present only where `basis` is `priced` *because of the endpoint* — an
330
+ * Anthropic-native role is also priced but carries no rates here, because
331
+ * the SDK already reports its real cost and Foreman should not second-guess
332
+ * it with a table.
333
+ */
334
+ price?: ModelPrice;
335
+ }
336
+
337
+ /**
338
+ * What a role actually costs: its basis, sharpened by the model it will run.
339
+ *
340
+ * Two facts come out of one lookup, because they come from one place.
341
+ *
342
+ * - **The model can raise the endpoint's floor.** An Ollama daemon on this
343
+ * machine is free, but the same daemon serves `:cloud` models that run on
344
+ * paid servers and signs for them with the operator's own account. Loopback
345
+ * is not proof a run is free, and that is the configuration Foreman is most
346
+ * often used in.
347
+ * - **The model can also carry a price.** Where the endpoint publishes
348
+ * per-token rates (OpenRouter does, for essentially everything it serves),
349
+ * the run becomes genuinely `priced` — with the bill-sender's own numbers,
350
+ * not a table shipped inside Foreman.
351
+ *
352
+ * Discovery never throws: when it cannot answer, the provider's own basis
353
+ * stands with no price, which is the answer we had before asking.
354
+ */
355
+ export async function roleCost(p: ResolvedProvider, model?: string): Promise<RoleCost> {
356
+ // An Anthropic-native role is already priced by the SDK, with real rates for
357
+ // real tokens. Nothing an endpoint listing says could improve on that.
358
+ if (p.wire === 'anthropic-native' || !p.upstreamUrl) return { basis: p.costBasis };
359
+ const chosen = model || p.model;
360
+ if (!chosen) return { basis: p.costBasis };
361
+
362
+ const found = await discoverModels(p.upstreamUrl, { apiKey: p.apiKey });
363
+ const m = found?.find((x) => x.id === chosen);
364
+ // Provenance, in order of authority: a rate the endpoint itself publishes
365
+ // beats everything; failing that, direct api.openai.com is priced from the
366
+ // dated list in openai-prices.ts — the one table Foreman keeps, by
367
+ // decision, with its source and verification date in its header. Any
368
+ // other endpoint that publishes nothing stays unpriced.
369
+ if (m?.price) return { basis: 'priced', price: m.price };
370
+ if (isOpenAiHost(p.upstreamUrl)) {
371
+ const listed = openaiPrice(chosen);
372
+ if (listed) return { basis: 'priced', price: listed };
373
+ }
374
+ if (!m) return { basis: p.costBasis };
375
+ // No published rates: real spend we cannot quantify, unless the endpoint is
376
+ // the operator's own hardware AND the model actually runs there.
377
+ return { basis: p.costBasis === 'free' && !m.remote ? 'free' : 'unpriced' };
378
+ }
379
+
380
+ /** The host[:port] of a base URL, however sloppily it was typed. */
381
+ function hostOf(url: string): string {
382
+ const bare = url.trim().replace(/^https?:\/\//i, '');
383
+ return bare.split('/')[0] ?? '';
384
+ }
385
+
386
+ /**
387
+ * A cost basis and the deprecated boolean that shadows it, written together.
388
+ *
389
+ * They are set in one place so they cannot drift: a resolver that set one and
390
+ * forgot the other would leave a provider whose enforcement and whose display
391
+ * disagree, which is the failure this whole split exists to remove.
392
+ */
393
+ function basis(costBasis: CostBasis): { costBasis: CostBasis; metered: boolean } {
394
+ return { costBasis, metered: costBasis === 'priced' };
395
+ }
396
+
397
+ /** Loopback, a private range, or a LAN name — somewhere https is unlikely. */
398
+ function isPrivateHost(hostPort: string): boolean {
399
+ const host = hostPort.replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
400
+ if (host === 'localhost' || host === '::1' || host.endsWith('.local') || host.endsWith('.internal')) {
401
+ return true;
402
+ }
403
+ if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host)) return true;
404
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
405
+ // A bare single-label name (`box:11434`) is a LAN host, not a public domain.
406
+ return /^[a-z0-9-]+$/.test(host);
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Environment
411
+ // ---------------------------------------------------------------------------
412
+
413
+ export interface AgentEnv {
414
+ env?: Record<string, string | undefined>;
415
+ pathToClaudeCodeExecutable?: string;
416
+ }
417
+
418
+ /**
419
+ * The `query()` options fragment for a provider — the single place that
420
+ * decides what credential an agent can reach.
421
+ *
422
+ * `env` replaces the child's environment rather than extending it, so
423
+ * process.env is spread first: dropping it would strip PATH and HOME along
424
+ * with the credentials.
425
+ *
426
+ * @throws if the result would violate {@link GATEWAY_INVARIANT}. That is a
427
+ * programming error rather than a user error, and the failure mode it guards
428
+ * against is a leaked token, so it fails loudly instead of degrading.
429
+ */
430
+ export function providerEnv(p: ResolvedProvider, gatewayUrl?: string): AgentEnv {
431
+ const env: Record<string, string | undefined> = { ...process.env };
432
+ env.CLAUDE_CONFIG_DIR = p.configDir;
433
+
434
+ if (p.wire === 'anthropic-native') {
435
+ if (p.kind === 'claude-code') {
436
+ // The one intentionally ambient case: the install's own stored login is
437
+ // the credential. `ownLogin` strips an inherited key that would outrank
438
+ // it — the original per-project billing behaviour, unchanged.
439
+ if (p.ownLogin) for (const v of AMBIENT_CREDENTIAL_VARS) delete env[v];
440
+ } else {
441
+ // An explicit Anthropic key: set it, and clear the OAuth token so a
442
+ // machine-wide subscription cannot win instead and bill the wrong party.
443
+ env.ANTHROPIC_API_KEY = p.apiKey;
444
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
445
+ env.ANTHROPIC_BASE_URL = ANTHROPIC_NATIVE_BASE_URL;
446
+ }
447
+ } else {
448
+ // Gateway wires. Every ambient credential is displaced by an explicit one
449
+ // — see GATEWAY_INVARIANT for why deleting them is not enough.
450
+ for (const v of AMBIENT_CREDENTIAL_VARS) delete env[v];
451
+ env.ANTHROPIC_API_KEY = p.apiKey;
452
+ env.ANTHROPIC_BASE_URL = gatewayUrl;
453
+
454
+ // Model aliases. Foreman's cost model IS these aliases — director opus,
455
+ // workers sonnet, run titles haiku — and on a gateway wire the SDK would
456
+ // otherwise resolve them to claude-* ids the upstream rejects, failing
457
+ // every worker.
458
+ if (p.model) {
459
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = p.model;
460
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = p.model;
461
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = p.model;
462
+ env.LLM_GATEWAY_DEFAULT_MODEL = p.model;
463
+ }
464
+
465
+ if (!env.ANTHROPIC_API_KEY || !gatewayUrl) {
466
+ throw new Error(`${GATEWAY_INVARIANT} (provider: ${p.kind})`);
467
+ }
468
+ }
469
+
470
+ const out: AgentEnv = { env };
471
+ if (p.executable) out.pathToClaudeCodeExecutable = p.executable;
472
+ return out;
473
+ }
474
+
475
+ /**
476
+ * Whether a resolved provider can actually run a mission right now. Returns
477
+ * the reason it cannot, or null.
478
+ */
479
+ export function providerProblem(p: ResolvedProvider): string | null {
480
+ if (p.problem) return p.problem;
481
+ if (p.wire !== 'anthropic-native' && !p.apiKey) return GATEWAY_INVARIANT;
482
+ return null;
483
+ }
484
+
485
+ /**
486
+ * A role's resolved provider, carrying the model the role will actually run.
487
+ *
488
+ * `providerEnv()` pins the SDK's three aliases — haiku, sonnet, opus — to the
489
+ * provider's `model`, because through a gateway an alias would otherwise go
490
+ * upstream as a literal `claude-*` id the endpoint has never heard of. A
491
+ * per-role provider (the machine's own Ollama, or Codex) arrives with no
492
+ * `model` of its own: the choice lives on the run, per role. So the aliases
493
+ * were left unpinned, and the one call that still used one — the run title,
494
+ * on the haiku alias — went out as `claude-haiku-4-5-…` and 404'd four times
495
+ * while the mission itself ran fine. The role's model is the right value for
496
+ * every alias on that role's gateway; an empty choice ("inherit") leaves the
497
+ * provider as it was.
498
+ */
499
+ export function withRoleModel(p: ResolvedProvider, roleModel?: string): ResolvedProvider {
500
+ const model = p.model || (roleModel && roleModel.trim()) || undefined;
501
+ return model === p.model ? p : { ...p, model };
502
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Secret store tests.
3
+ *
4
+ * This is the first thing Foreman keeps rather than reads, so the properties
5
+ * under test are the ones that make that defensible: the key is only ever on
6
+ * disk at 0600, it never lands anywhere else, and nothing returns it except
7
+ * the one function whose job that is.
8
+ */
9
+ import test from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
14
+ import { deleteSecret, getSecret, hasSecret, putSecret } from './secrets.js';
15
+ import { ownedConfigDir, resolveProvider } from './provider.js';
16
+
17
+ async function tmpRoot(): Promise<string> {
18
+ return mkdtemp(path.join(os.tmpdir(), 'foreman-secrets-'));
19
+ }
20
+
21
+ test('a stored key round-trips, and only through getSecret', async (t) => {
22
+ const root = await tmpRoot();
23
+ t.after(() => rm(root, { recursive: true, force: true }));
24
+
25
+ assert.equal(await hasSecret(root, 'p1'), false);
26
+ assert.equal(await getSecret(root, 'p1'), null);
27
+
28
+ await putSecret(root, 'p1', 'sk-secret-value');
29
+ assert.equal(await hasSecret(root, 'p1'), true);
30
+ assert.equal(await getSecret(root, 'p1'), 'sk-secret-value');
31
+ });
32
+
33
+ test('the key file is 0600 and lives only in the provider’s own dir', async (t) => {
34
+ const root = await tmpRoot();
35
+ t.after(() => rm(root, { recursive: true, force: true }));
36
+
37
+ await putSecret(root, 'p1', 'sk-secret-value');
38
+ const file = path.join(ownedConfigDir(root, 'p1'), 'key');
39
+ const st = await stat(file);
40
+ assert.equal(st.mode & 0o777, 0o600, 'a credential must not be group- or world-readable');
41
+
42
+ // Nothing was left behind by the tmp+rename, which would be a copy of the
43
+ // key at whatever mode the OS chose.
44
+ const left = await readdir(path.dirname(file));
45
+ assert.deepEqual(left, ['key']);
46
+ });
47
+
48
+ test('replacing a key leaves no trace of the old one', async (t) => {
49
+ const root = await tmpRoot();
50
+ t.after(() => rm(root, { recursive: true, force: true }));
51
+
52
+ await putSecret(root, 'p1', 'sk-first');
53
+ await putSecret(root, 'p1', 'sk-second');
54
+ assert.equal(await getSecret(root, 'p1'), 'sk-second');
55
+
56
+ const dir = ownedConfigDir(root, 'p1');
57
+ const files = await readdir(dir);
58
+ assert.deepEqual(files, ['key']);
59
+ const contents = await readFile(path.join(dir, 'key'), 'utf8');
60
+ assert.ok(!contents.includes('sk-first'));
61
+ });
62
+
63
+ test('deleting is idempotent — the caller wanted it gone', async (t) => {
64
+ const root = await tmpRoot();
65
+ t.after(() => rm(root, { recursive: true, force: true }));
66
+
67
+ await deleteSecret(root, 'never-existed'); // must not throw
68
+ await putSecret(root, 'p1', 'sk-x');
69
+ await deleteSecret(root, 'p1');
70
+ await deleteSecret(root, 'p1');
71
+ assert.equal(await hasSecret(root, 'p1'), false);
72
+ assert.equal(await getSecret(root, 'p1'), null);
73
+ });
74
+
75
+ test('keys never reach projects.json', async (t) => {
76
+ // The file people copy between machines and paste into issues.
77
+ const root = await tmpRoot();
78
+ t.after(() => rm(root, { recursive: true, force: true }));
79
+
80
+ await writeFile(path.join(root, 'projects.json'), JSON.stringify([
81
+ { id: 'x', provider: { kind: 'openai-compatible', id: 'p1', baseUrl: 'https://api.openai.com' } },
82
+ ]));
83
+ await putSecret(root, 'p1', 'sk-must-not-appear');
84
+ const projects = await readFile(path.join(root, 'projects.json'), 'utf8');
85
+ assert.ok(!projects.includes('sk-must-not-appear'));
86
+ });
87
+
88
+ test('a stored key beats the environment, and its absence falls back', async (t) => {
89
+ const root = await tmpRoot();
90
+ t.after(() => {
91
+ delete process.env.TEST_SECRET_FALLBACK;
92
+ return rm(root, { recursive: true, force: true });
93
+ });
94
+ process.env.TEST_SECRET_FALLBACK = 'sk-from-env';
95
+
96
+ const ref = {
97
+ kind: 'openai-compatible' as const, id: 'p1',
98
+ baseUrl: 'https://api.openai.com', apiKeyEnv: 'TEST_SECRET_FALLBACK',
99
+ };
100
+
101
+ // Nothing stored yet: the named variable answers.
102
+ assert.equal((await resolveProvider(ref, root)).apiKey, 'sk-from-env');
103
+
104
+ // Stored wins — it is the deliberate, per-provider choice.
105
+ await putSecret(root, 'p1', 'sk-stored');
106
+ assert.equal((await resolveProvider(ref, root)).apiKey, 'sk-stored');
107
+
108
+ await deleteSecret(root, 'p1');
109
+ assert.equal((await resolveProvider(ref, root)).apiKey, 'sk-from-env');
110
+ });
111
+
112
+ test('an endpoint that needs a key and has none reports it without naming one', async (t) => {
113
+ const root = await tmpRoot();
114
+ t.after(() => rm(root, { recursive: true, force: true }));
115
+
116
+ const p = await resolveProvider(
117
+ { kind: 'openai-compatible', id: 'p1', baseUrl: 'https://api.openai.com', needsKey: true },
118
+ root,
119
+ );
120
+ assert.match(p.problem ?? '', /needs a key/);
121
+ assert.equal(p.apiKey, undefined);
122
+
123
+ await putSecret(root, 'p1', 'sk-now-stored');
124
+ const ok = await resolveProvider(
125
+ { kind: 'openai-compatible', id: 'p1', baseUrl: 'https://api.openai.com', needsKey: true },
126
+ root,
127
+ );
128
+ assert.equal(ok.problem, undefined);
129
+ assert.equal(ok.apiKey, 'sk-now-stored');
130
+ assert.ok(!ok.label.includes('sk-now-stored'), 'a label must never carry a secret');
131
+ });
132
+
133
+ test('a keyless endpoint still needs no key at all', async (t) => {
134
+ // A local Ollama: `needsKey` absent and no env var named. It gets the
135
+ // placeholder that satisfies the gateway invariant, not a missing-key error.
136
+ const root = await tmpRoot();
137
+ t.after(() => rm(root, { recursive: true, force: true }));
138
+
139
+ const p = await resolveProvider(
140
+ { kind: 'openai-compatible', id: 'ollama', baseUrl: 'http://127.0.0.1:11434' }, root);
141
+ assert.equal(p.problem, undefined);
142
+ assert.ok(p.apiKey);
143
+ });