@amenophis1er/foreman 0.1.7 → 0.1.9
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 +9 -3
- package/package.json +1 -1
- package/src/browser.ts +188 -0
- package/src/cli.ts +6 -1
- package/src/clone.test.ts +35 -0
- package/src/clone.ts +129 -0
- package/src/deck.test.ts +2 -2
- package/src/deck.ts +32 -6
- package/src/fleet-planner.ts +5 -4
- package/src/gitwork.test.ts +95 -0
- package/src/gitwork.ts +239 -0
- package/src/guard.test.ts +104 -0
- package/src/guard.ts +131 -0
- package/src/http-body.test.ts +42 -0
- package/src/http-body.ts +57 -0
- package/src/memory.test.ts +46 -0
- package/src/memory.ts +107 -0
- package/src/notify/commands.ts +1 -1
- package/src/notify/telegram.ts +1 -1
- package/src/notify.ts +5 -0
- package/src/orchestrator.test.ts +111 -0
- package/src/orchestrator.ts +121 -47
- package/src/planner.ts +7 -2
- package/src/policy.test.ts +25 -0
- package/src/policy.ts +53 -3
- package/src/preflight.ts +29 -43
- package/src/role-provider.test.ts +55 -0
- package/src/role-provider.ts +65 -0
- package/src/server.ts +567 -63
- package/src/services.test.ts +36 -1
- package/src/services.ts +60 -3
- package/src/store.test.ts +18 -0
- package/src/store.ts +16 -1
- package/src/track-record.test.ts +46 -0
- package/src/track-record.ts +149 -0
- package/src/types.ts +23 -0
- package/ui/dist/assets/index--dvfX3Mo.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-BPFc7O5V.js +0 -67
package/src/policy.test.ts
CHANGED
|
@@ -149,6 +149,31 @@ test('a file write under the root does not escape', () => {
|
|
|
149
149
|
assert.ok(bashEscapesFolder('echo hi > /tmp/out.txt', FOLDER, [ROOT]), 'outside the root still prompts');
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
test('operators inside quotes are text: a perl program with => is not a redirect', () => {
|
|
153
|
+
// The exact shape that put an approval card in front of an in-folder edit:
|
|
154
|
+
// `=>/` inside the single-quoted program read as `>` + the path `/…`.
|
|
155
|
+
assert.equal(bashEscapesFolder(`perl -0pi -e 's/\\.filter\\(\\(\\[a, b\\]\\) =>/.filter(([a]) =>/' ${ROOT}/x.tsx`, FOLDER, [ROOT]), null);
|
|
156
|
+
assert.equal(bashEscapesFolder(`set -e; D=src/f; perl -0pi -e 's/ a,\\n b,/ a,/' $D/one.tsx; perl -0pi -e 's/x =>/y/' $D/two.tsx`, FOLDER, [ROOT]), null);
|
|
157
|
+
assert.equal(bashEscapesFolder('echo "a > b; c | d & e" && echo \'x>/etc/passwd\'', FOLDER, [ROOT]), null);
|
|
158
|
+
assert.equal(bashEscapesFolder(`git commit -m "fix: handle a > b" ${ROOT}`, FOLDER, [ROOT]), null);
|
|
159
|
+
// A real redirect next to a quoted one is still seen, with its real text.
|
|
160
|
+
assert.deepEqual(bashEscape('echo "x > y" > /tmp/out.txt', FOLDER), {
|
|
161
|
+
reason: 'echo "x > y" > /tmp/out.txt — redirects output outside the mission folder', path: '/tmp/out.txt', grant: '/tmp',
|
|
162
|
+
});
|
|
163
|
+
// Quoted paths still resolve: the mask touches operators, not text.
|
|
164
|
+
assert.ok(bashEscapesFolder('rm -rf "/opt/homebrew"', FOLDER, [ROOT]));
|
|
165
|
+
// A quoted path with spaces is one token now, so it resolves and is judged.
|
|
166
|
+
assert.equal(bashEscape(`echo hi > "/tmp/o u t.txt"`, FOLDER)!.path, '/tmp/o u t.txt');
|
|
167
|
+
assert.equal(bashEscapesFolder(`echo hi > "${ROOT}/o u t.txt"`, FOLDER, [ROOT]), null);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a shell -c body is analysed as a command, so quoting cannot hide a cd', () => {
|
|
171
|
+
assert.ok(bashEscapesFolder(`sh -c 'cd /tmp && npm install'`, FOLDER, [ROOT]));
|
|
172
|
+
assert.match(bashEscapesFolder(`bash -c "mkdir -p /tmp/x/y"`, FOLDER, [ROOT])!, /creates a path outside/);
|
|
173
|
+
assert.equal(bashEscapesFolder(`sh -c 'npm test && echo ok'`, FOLDER, [ROOT]), null);
|
|
174
|
+
assert.equal(bashEscape(`bash -c 'cd /tmp/x'`, FOLDER)!.grant, '/tmp/x');
|
|
175
|
+
});
|
|
176
|
+
|
|
152
177
|
test('bashEscape carries the offending path and the directory to grant', () => {
|
|
153
178
|
// Directory-ish targets: the path itself is the grant.
|
|
154
179
|
assert.deepEqual(bashEscape('cd /tmp/x && ls', FOLDER), {
|
package/src/policy.ts
CHANGED
|
@@ -153,15 +153,52 @@ const WRITE_VERBS: Record<string, { mode: ArgMode; opts?: string[]; does: string
|
|
|
153
153
|
const FILE_OPTS = new Set(['-o', '--output', '-O', '--output-document']);
|
|
154
154
|
/** Prefixes that merely wrap the real verb (`sudo rm`, `env FOO=1 mkdir`). */
|
|
155
155
|
const WRAPPERS = new Set(['sudo', 'env', 'command', 'exec', 'nohup', 'time', 'nice', 'builtin']);
|
|
156
|
+
/** Shells whose `-c` argument is a command in its own right. */
|
|
157
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash']);
|
|
156
158
|
/** Pseudo-devices: writing to them touches nothing on disk. */
|
|
157
159
|
const DEV_SINKS = new Set(['/dev/null', '/dev/stdout', '/dev/stderr', '/dev/tty']);
|
|
158
160
|
const URL_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
159
161
|
/** `> f`, `>> f`, `2> f`, `&> f` — but not `2>&1` / `>&2` (fd duplication). */
|
|
160
162
|
const REDIRECT_RE = /(?:&>>?|\d*>>?)(?!&)\s*(\S+)/g;
|
|
161
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Shell operators inside quotes are text, not operators. The redirect scan
|
|
166
|
+
* and the segment split below work on the raw string, so a perl program in
|
|
167
|
+
* single quotes — `perl -pi -e 's/x =>/y/' f` — read as `>` followed by the
|
|
168
|
+
* path `/y/`, which resolved to the filesystem root and put an approval card
|
|
169
|
+
* in front of an ordinary in-folder edit. Operators inside a quoted region
|
|
170
|
+
* are swapped for private-use characters before the scan and swapped back by
|
|
171
|
+
* `bare()`, so tokens still carry their real text. Spaces inside quotes are
|
|
172
|
+
* masked too, so a quoted string is one token: `sh -c '…'` bodies and paths
|
|
173
|
+
* with spaces both come through whole. Quoting rules followed:
|
|
174
|
+
* single quotes take everything literally; double quotes honour backslash;
|
|
175
|
+
* a backslash outside quotes escapes the next character.
|
|
176
|
+
*/
|
|
177
|
+
const MASK: Record<string, string> = { '>': '\uE001', '<': '\uE002', '|': '\uE003', ';': '\uE004', '&': '\uE005', '\n': '\uE006', ' ': '\uE007', '\t': '\uE008' };
|
|
178
|
+
const UNMASK_RE = /[\uE001-\uE008]/g;
|
|
179
|
+
const UNMASK: Record<string, string> = Object.fromEntries(Object.entries(MASK).map(([k, v]) => [v, k]));
|
|
180
|
+
function maskQuoted(command: string): string {
|
|
181
|
+
let out = '';
|
|
182
|
+
let quote: '"' | "'" | null = null;
|
|
183
|
+
for (let i = 0; i < command.length; i++) {
|
|
184
|
+
const c = command[i];
|
|
185
|
+
if (quote === null) {
|
|
186
|
+
if (c === '\\' && i + 1 < command.length) { out += c + command[++i]; continue; }
|
|
187
|
+
if (c === '"' || c === "'") quote = c;
|
|
188
|
+
out += c;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (quote === '"' && c === '\\' && i + 1 < command.length) { out += c + command[++i]; continue; }
|
|
192
|
+
if (c === quote) { quote = null; out += c; continue; }
|
|
193
|
+
out += MASK[c] ?? c;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
const unmask = (s: string): string => s.replace(UNMASK_RE, (ch) => UNMASK[ch] ?? ch);
|
|
198
|
+
|
|
162
199
|
/** Strips one layer of matching quotes and shell grouping punctuation. */
|
|
163
200
|
function bare(token: string): string {
|
|
164
|
-
let t = token.replace(/^[(\\]+|[)]+$/g, '');
|
|
201
|
+
let t = unmask(token).replace(/^[(\\]+|[)]+$/g, '');
|
|
165
202
|
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) t = t.slice(1, -1);
|
|
166
203
|
return t;
|
|
167
204
|
}
|
|
@@ -266,7 +303,7 @@ export function bashEscape(
|
|
|
266
303
|
// appended to while we walk, and one command must see one consistent view.
|
|
267
304
|
const roots = [root, ...extraRoots];
|
|
268
305
|
const outside = (p: string): boolean => !DEV_SINKS.has(p) && !underAnyRoot(p, roots);
|
|
269
|
-
const label = (
|
|
306
|
+
const label = (raw: string): string => { const seg = unmask(raw); return seg.length > 80 ? seg.slice(0, 77) + '…' : seg; };
|
|
270
307
|
const hit = (segment: string, does: string, p: string, isDir: boolean): BashEscape => ({
|
|
271
308
|
reason: `${label(segment)} — ${does} outside the mission folder`,
|
|
272
309
|
path: p,
|
|
@@ -275,7 +312,7 @@ export function bashEscape(
|
|
|
275
312
|
|
|
276
313
|
let cwd = root; // virtual cwd, so `cd sub && rm -rf ../../x` resolves correctly
|
|
277
314
|
|
|
278
|
-
const segments = command
|
|
315
|
+
const segments = maskQuoted(command)
|
|
279
316
|
.split(/&&|\|\||;|\n|\|(?!\|)|(?<![&>\d])&(?![&>])/)
|
|
280
317
|
.map((s) => s.trim())
|
|
281
318
|
.filter(Boolean);
|
|
@@ -295,6 +332,19 @@ export function bashEscape(
|
|
|
295
332
|
const args = words.slice(i + 1);
|
|
296
333
|
const flagless = args.filter((a) => !a.startsWith('-'));
|
|
297
334
|
|
|
335
|
+
// `sh -c '…'`: the quoted body is a command of its own, run from the
|
|
336
|
+
// current virtual cwd. Analysed as one, so quoting cannot hide a `cd /tmp`
|
|
337
|
+
// — masking made the body opaque to the split above, and this is where it
|
|
338
|
+
// is looked at instead.
|
|
339
|
+
if (SHELLS.has(verb)) {
|
|
340
|
+
const c = args.indexOf('-c');
|
|
341
|
+
if (c >= 0 && c + 1 < args.length) {
|
|
342
|
+
const inner = bashEscape(args[c + 1], cwd, roots);
|
|
343
|
+
if (inner) return { ...inner, reason: `${label(segment)} — ${inner.reason.replace(/^.*? — /, '')}` };
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
298
348
|
if (verb === 'cd' || verb === 'pushd') {
|
|
299
349
|
const target = flagless[0] ?? '~'; // bare `cd` goes home
|
|
300
350
|
const p = asPath(target, cwd) ?? path.resolve(cwd, target);
|
package/src/preflight.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { defaultInstance, describeInstance, effectiveConfigDir } from './instanc
|
|
|
21
21
|
import { discoverOllama, ollamaHost } from './ollama.js';
|
|
22
22
|
import { serveHint, tailnetUrl, type Tailnet } from './tailscale.js';
|
|
23
23
|
import { codexHome, codexModels, readCodexAuth } from './codex.js';
|
|
24
|
+
import { browserCheck } from './browser.js';
|
|
24
25
|
import { createRequire } from 'node:module';
|
|
25
26
|
import { fileURLToPath } from 'node:url';
|
|
26
27
|
|
|
@@ -116,12 +117,17 @@ async function checkAuth(): Promise<Check> {
|
|
|
116
117
|
const name = 'Credentials';
|
|
117
118
|
const { mode, source, account } = await detectAuth();
|
|
118
119
|
|
|
120
|
+
// Missing credentials are a warning, not a refusal to start. The dashboard
|
|
121
|
+
// is where a first-time user reads what to do and where a project gets an
|
|
122
|
+
// API key or an endpoint of its own; a server that will not come up until
|
|
123
|
+
// something is logged in leaves them with a terminal line and no next step.
|
|
124
|
+
// Missions cannot start meanwhile — dispatch checks the provider itself.
|
|
119
125
|
if (mode === 'none') {
|
|
120
126
|
return {
|
|
121
127
|
name,
|
|
122
|
-
status: '
|
|
123
|
-
detail: 'none found —
|
|
124
|
-
fix: '
|
|
128
|
+
status: 'warn',
|
|
129
|
+
detail: 'none found — missions cannot start until a provider is set up',
|
|
130
|
+
fix: 'Sign in with Claude Code on this machine (claude, then /login) and restart · or export ANTHROPIC_API_KEY · or give a project an API key or endpoint in Settings → Provider',
|
|
125
131
|
};
|
|
126
132
|
}
|
|
127
133
|
|
|
@@ -142,8 +148,12 @@ async function checkAuth(): Promise<Check> {
|
|
|
142
148
|
return { name, status: 'ok', detail: `${MODE_LABEL[mode]} — ${who}` };
|
|
143
149
|
}
|
|
144
150
|
|
|
145
|
-
function checkInstance(): Check {
|
|
146
|
-
|
|
151
|
+
async function checkInstance(): Promise<Check> {
|
|
152
|
+
const detail = describeInstance(defaultInstance());
|
|
153
|
+
// A tick beside an install nobody is signed into contradicts the line
|
|
154
|
+
// above it; say so here too, and leave the fix to the Credentials row.
|
|
155
|
+
if ((await detectAuth()).mode === 'none') return { name: 'Claude Code', status: 'warn', detail: `${detail} · not signed in` };
|
|
156
|
+
return { name: 'Claude Code', status: 'ok', detail };
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
/**
|
|
@@ -204,7 +214,7 @@ async function checkCodex(): Promise<Check | null> {
|
|
|
204
214
|
};
|
|
205
215
|
}
|
|
206
216
|
|
|
207
|
-
async function checkPort(port: number): Promise<Check> {
|
|
217
|
+
async function checkPort(port: number, envVar: 'PORT' | 'FOREMAN_SERVICES_PORT' = 'PORT'): Promise<Check> {
|
|
208
218
|
const name = `Port ${port}`;
|
|
209
219
|
const inUse = await new Promise<boolean>((resolve) => {
|
|
210
220
|
const probe = net.createServer();
|
|
@@ -213,12 +223,17 @@ async function checkPort(port: number): Promise<Check> {
|
|
|
213
223
|
probe.listen(port, '127.0.0.1');
|
|
214
224
|
});
|
|
215
225
|
|
|
226
|
+
// The main port taken is fatal: nothing can be served. The services port
|
|
227
|
+
// taken is a fact to report — the dashboard still works, and only the
|
|
228
|
+
// crew's exposed previews are off until the port is freed or moved.
|
|
229
|
+
// PORT + 1 is a guess, and a second Foreman (a dev server beside the
|
|
230
|
+
// installed one) is the likeliest thing sitting on it.
|
|
216
231
|
return inUse
|
|
217
232
|
? {
|
|
218
233
|
name,
|
|
219
|
-
status: 'error',
|
|
220
|
-
detail: 'already in use',
|
|
221
|
-
fix: `Another Foreman may be running. Stop it, or:
|
|
234
|
+
status: envVar === 'PORT' ? 'error' : 'warn',
|
|
235
|
+
detail: envVar === 'PORT' ? 'already in use' : 'already in use — exposed dev servers will be unavailable',
|
|
236
|
+
fix: `Another Foreman may be running. Stop it, or: ${envVar}=${port + 1} npm start`,
|
|
222
237
|
}
|
|
223
238
|
: { name, status: 'ok', detail: 'free' };
|
|
224
239
|
}
|
|
@@ -247,40 +262,8 @@ async function checkHome(root: string): Promise<Check> {
|
|
|
247
262
|
* `npx playwright install chromium`).
|
|
248
263
|
*/
|
|
249
264
|
async function checkBrowser(): Promise<Check> {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const candidates: Record<string, string[]> = {
|
|
253
|
-
chrome: process.platform === 'darwin'
|
|
254
|
-
? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', path.join(os.homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')]
|
|
255
|
-
: process.platform === 'win32'
|
|
256
|
-
? [
|
|
257
|
-
path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
258
|
-
path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
259
|
-
path.join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
260
|
-
]
|
|
261
|
-
: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/opt/google/chrome/chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
|
|
262
|
-
msedge: process.platform === 'darwin' ? ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge']
|
|
263
|
-
: process.platform === 'win32' ? [path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe')]
|
|
264
|
-
: ['/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable'],
|
|
265
|
-
firefox: process.platform === 'darwin' ? ['/Applications/Firefox.app/Contents/MacOS/firefox'] : ['/usr/bin/firefox'],
|
|
266
|
-
};
|
|
267
|
-
let found: string | null = null;
|
|
268
|
-
if (want === 'chromium') {
|
|
269
|
-
// Playwright's own build, wherever the MCP's playwright-core says it lives.
|
|
270
|
-
try {
|
|
271
|
-
const req = createRequire(fileURLToPath(new URL('../node_modules/@playwright/mcp/cli.js', import.meta.url)));
|
|
272
|
-
const { chromium } = req('playwright-core') as { chromium: { executablePath(): string } };
|
|
273
|
-
const p = chromium.executablePath();
|
|
274
|
-
if (await exists(p)) found = p;
|
|
275
|
-
} catch { /* no playwright-core to ask */ }
|
|
276
|
-
return found
|
|
277
|
-
? { name, status: 'ok', detail: `Playwright Chromium — ${found}` }
|
|
278
|
-
: { name, status: 'warn', detail: 'FOREMAN_BROWSER=chromium but Playwright Chromium is not installed; browser missions will fail', fix: 'npx playwright install chromium' };
|
|
279
|
-
}
|
|
280
|
-
for (const p of candidates[want] ?? []) if (await exists(p)) { found = p; break; }
|
|
281
|
-
return found
|
|
282
|
-
? { name, status: 'ok', detail: `${want === 'chrome' ? 'Google Chrome' : want} — ${found}` }
|
|
283
|
-
: { name, status: 'warn', detail: `no ${want === 'chrome' ? 'Google Chrome' : want} found; missions with browser on will fail`, fix: 'Install Google Chrome, or: npx playwright install chromium && FOREMAN_BROWSER=chromium foreman' };
|
|
265
|
+
// One detection for the row and for the crew: see src/browser.ts.
|
|
266
|
+
return { name: 'Browser', ...(await browserCheck()) };
|
|
284
267
|
}
|
|
285
268
|
|
|
286
269
|
/** Where the phone can reach this. Says so plainly either way — the answer decides which links work. */
|
|
@@ -309,6 +292,8 @@ async function checkUi(distDir: string): Promise<Check> {
|
|
|
309
292
|
*/
|
|
310
293
|
export async function preflight(opts: {
|
|
311
294
|
port: number;
|
|
295
|
+
/** The second listener, for the crew's exposed dev servers — see services.ts. */
|
|
296
|
+
servicesPort: number;
|
|
312
297
|
foremanHome: string;
|
|
313
298
|
distDir: string;
|
|
314
299
|
/** Detected by the server before preflight; null when not on a tailnet. */
|
|
@@ -321,6 +306,7 @@ export async function preflight(opts: {
|
|
|
321
306
|
checkOllama(),
|
|
322
307
|
checkCodex(),
|
|
323
308
|
checkPort(opts.port),
|
|
309
|
+
checkPort(opts.servicesPort, 'FOREMAN_SERVICES_PORT'),
|
|
324
310
|
checkBrowser(),
|
|
325
311
|
checkHome(opts.foremanHome),
|
|
326
312
|
checkUi(opts.distDir),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { reconcileRole } from './role-provider.js';
|
|
4
|
+
|
|
5
|
+
const models = [
|
|
6
|
+
{ id: 'fable' }, { id: 'opus' }, { id: 'sonnet' }, { id: 'haiku' },
|
|
7
|
+
{ id: 'gemma4:12b', providerId: 'ollama-local' },
|
|
8
|
+
{ id: 'gpt-5.5', providerId: 'codex-local' },
|
|
9
|
+
{ id: 'kimi-k3', providerId: 'prov-abc' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
test('reconcileRole: a consistent pairing passes through untouched', () => {
|
|
13
|
+
assert.deepEqual(reconcileRole('director', 'fable', undefined, models), { providerId: undefined });
|
|
14
|
+
assert.deepEqual(reconcileRole('workers', 'gpt-5.5', 'codex-local', models), { providerId: 'codex-local' });
|
|
15
|
+
assert.deepEqual(reconcileRole('workers', 'gemma4:12b', 'ollama-local', models), { providerId: 'ollama-local' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('reconcileRole: an Anthropic alias on the Codex provider is corrected — the bug this exists for', () => {
|
|
19
|
+
const r = reconcileRole('director', 'fable', 'codex-local', models);
|
|
20
|
+
assert.equal(r.providerId, undefined);
|
|
21
|
+
assert.match(r.note ?? '', /fable is served by the project's provider, not codex-local/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('reconcileRole: a listed model moves to whichever provider lists it', () => {
|
|
25
|
+
assert.equal(reconcileRole('workers', 'gemma4:12b', undefined, models).providerId, 'ollama-local');
|
|
26
|
+
assert.equal(reconcileRole('workers', 'gpt-5.5', 'ollama-local', models).providerId, 'codex-local');
|
|
27
|
+
assert.equal(reconcileRole('director', 'kimi-k3', 'codex-local', models).providerId, 'prov-abc');
|
|
28
|
+
assert.equal(reconcileRole('director', 'FABLE', 'codex-local', models).providerId, undefined);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('reconcileRole: a provider pin without a model is stale and dropped', () => {
|
|
32
|
+
const r = reconcileRole('director', undefined, 'codex-local', models);
|
|
33
|
+
assert.equal(r.providerId, undefined);
|
|
34
|
+
assert.match(r.note ?? '', /no model was chosen/);
|
|
35
|
+
assert.deepEqual(reconcileRole('director', undefined, undefined, models), { providerId: undefined });
|
|
36
|
+
assert.deepEqual(reconcileRole('director', '', ' ', models), { providerId: undefined });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('reconcileRole: an unlisted model keeps its provider, unless it is a Claude id on a closed provider', () => {
|
|
40
|
+
// A custom endpoint's model the discovery call missed: trust the pairing.
|
|
41
|
+
assert.deepEqual(reconcileRole('workers', 'mystery-9b', 'prov-abc', models), { providerId: 'prov-abc' });
|
|
42
|
+
assert.deepEqual(reconcileRole('workers', 'mystery-9b', undefined, models), { providerId: undefined });
|
|
43
|
+
// A full claude-* id nobody listed still cannot go through Codex or Ollama.
|
|
44
|
+
const r = reconcileRole('director', 'claude-opus-4-1', 'codex-local', models);
|
|
45
|
+
assert.equal(r.providerId, undefined);
|
|
46
|
+
assert.match(r.note ?? '', /Anthropic model/);
|
|
47
|
+
assert.equal(reconcileRole('director', 'claude-opus-4-1', 'ollama-local', models).providerId, undefined);
|
|
48
|
+
// On a custom endpoint a claude-* id may be a real proxy route; left alone.
|
|
49
|
+
assert.equal(reconcileRole('director', 'claude-opus-4-1', 'prov-abc', models).providerId, 'prov-abc');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('reconcileRole: works with an empty model list (discovery failed) by the closed-provider rule alone', () => {
|
|
53
|
+
assert.equal(reconcileRole('director', 'fable', 'codex-local', []).providerId, undefined);
|
|
54
|
+
assert.equal(reconcileRole('director', 'gpt-5.5', 'codex-local', []).providerId, 'codex-local');
|
|
55
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A role's provider, checked against its model at dispatch.
|
|
3
|
+
*
|
|
4
|
+
* A run carries, per role, a model and the provider that serves it. The two
|
|
5
|
+
* are picked together in every picker, but they travel separately — through
|
|
6
|
+
* settings files, drafts, older builds and resumes — and once they drift a
|
|
7
|
+
* mission fails on its first turn with an upstream 400 nobody chose: the
|
|
8
|
+
* Claude alias `fable` sent through the Codex gateway, say, which then
|
|
9
|
+
* answers that the model "is not supported when using Codex". Nothing was
|
|
10
|
+
* checking that the provider actually serves the model.
|
|
11
|
+
*
|
|
12
|
+
* This is that check. The model list is the authority: a model the machine
|
|
13
|
+
* offers is served by whichever provider lists it, and a role is corrected
|
|
14
|
+
* to that provider whatever it arrived with. A model the list does not know
|
|
15
|
+
* is left alone, except for the one pairing that can never work: an
|
|
16
|
+
* Anthropic model on a provider that only serves its own catalogue.
|
|
17
|
+
*/
|
|
18
|
+
import { ANTHROPIC_ALIASES } from './anthropic-models.js';
|
|
19
|
+
|
|
20
|
+
export interface KnownModel {
|
|
21
|
+
id: string;
|
|
22
|
+
/** Absent means the project's own provider (Anthropic by default). */
|
|
23
|
+
providerId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Providers that serve exactly the models they list and nothing else. */
|
|
27
|
+
const CLOSED_PROVIDERS = new Set(['codex-local', 'ollama-local']);
|
|
28
|
+
|
|
29
|
+
export interface Reconciled {
|
|
30
|
+
providerId?: string;
|
|
31
|
+
/** Set when the pairing had to change; one sentence, for the transcript. */
|
|
32
|
+
note?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isAnthropic = (model: string) => ANTHROPIC_ALIASES.has(model) || /^claude-/i.test(model);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The provider a role should run on, given what it was handed and what the
|
|
39
|
+
* machine offers. Returns the same provider id and no note when nothing is
|
|
40
|
+
* wrong, which is the common case.
|
|
41
|
+
*/
|
|
42
|
+
export function reconcileRole(
|
|
43
|
+
role: 'director' | 'workers', model: string | undefined, providerId: string | undefined, models: KnownModel[],
|
|
44
|
+
): Reconciled {
|
|
45
|
+
const given = providerId?.trim() || undefined;
|
|
46
|
+
// No model means "inherit the project's default", which lives on the
|
|
47
|
+
// project's provider. A provider id left behind without a model is stale.
|
|
48
|
+
if (!model) {
|
|
49
|
+
return given ? { providerId: undefined, note: `${role}: no model was chosen, so the ${given} provider pin was dropped.` } : { providerId: undefined };
|
|
50
|
+
}
|
|
51
|
+
const want = model.trim().toLowerCase();
|
|
52
|
+
const known = models.find((m) => m.id.toLowerCase() === want);
|
|
53
|
+
if (known) {
|
|
54
|
+
const served = known.providerId || undefined;
|
|
55
|
+
if (served === given) return { providerId: given };
|
|
56
|
+
return {
|
|
57
|
+
providerId: served,
|
|
58
|
+
note: `${role}: ${model} is served by ${served ?? 'the project\'s provider'}, not ${given ?? 'the project\'s provider'}; corrected.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (given && CLOSED_PROVIDERS.has(given) && isAnthropic(model)) {
|
|
62
|
+
return { providerId: undefined, note: `${role}: ${model} is an Anthropic model and ${given} cannot serve it; running it on the project's provider instead.` };
|
|
63
|
+
}
|
|
64
|
+
return { providerId: given };
|
|
65
|
+
}
|