@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.
- package/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- package/ui/dist/index.html +14 -0
package/src/provider.ts
ADDED
|
@@ -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
|
+
});
|