@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/cli.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subcommands behind `foreman` that are not "start the server".
|
|
3
|
+
*
|
|
4
|
+
* doctor The preflight, and nothing else: what this machine can run, what
|
|
5
|
+
* is missing, how to fix it. Exit 0 when nothing blocks.
|
|
6
|
+
* open The dashboard, in the default browser.
|
|
7
|
+
* up/down A background server without registering anything: a detached
|
|
8
|
+
* child, a pid file and a log under ~/.foreman. For "just run it";
|
|
9
|
+
* `service install` is for "always run it".
|
|
10
|
+
* service Keep Foreman up without a terminal: a launchd agent on macOS, a
|
|
11
|
+
* systemd user unit on Linux. Start at login, restart on crash,
|
|
12
|
+
* log to ~/.foreman/logs. Foreman on the move needs the server to
|
|
13
|
+
* be up when the laptop lid is closed; this is that.
|
|
14
|
+
*/
|
|
15
|
+
import { execFile, spawn } from 'node:child_process';
|
|
16
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { openSync } from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { preflight, reportPreflight } from './preflight.js';
|
|
22
|
+
import { detectTailscale } from './tailscale.js';
|
|
23
|
+
|
|
24
|
+
const PORT = Number(process.env.PORT ?? 4177);
|
|
25
|
+
const HOME_DIR = process.env.FOREMAN_HOME || path.join(os.homedir(), '.foreman');
|
|
26
|
+
const LABEL = 'dev.foreman.server';
|
|
27
|
+
const PID_FILE = path.join(HOME_DIR, 'foreman.pid');
|
|
28
|
+
const LOG_FILE = path.join(HOME_DIR, 'logs', 'server.log');
|
|
29
|
+
|
|
30
|
+
function sh(cmd: string, args: string[]): Promise<{ code: number; out: string; err: string }> {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
execFile(cmd, args, { timeout: 20_000 }, (e, out, err) => {
|
|
33
|
+
const code = e ? (typeof (e as { code?: unknown }).code === 'number' ? (e as { code: number }).code : 1) : 0;
|
|
34
|
+
resolve({ code, out: String(out), err: String(err) });
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The PATH a login agent gets is thin; node's own dir and the usual prefixes are added so `claude`, `tailscale`, `ollama` resolve. */
|
|
40
|
+
export function servicePath(execPath: string, current = process.env.PATH ?? ''): string {
|
|
41
|
+
const parts = [path.dirname(execPath), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin', ...current.split(':')];
|
|
42
|
+
return [...new Set(parts.filter(Boolean))].join(':');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The launchd property list for the agent. Pure, so it can be read in a test and by a human. */
|
|
46
|
+
export function launchdPlist(opts: { label: string; node: string; bin: string; home: string; logDir: string; env: Record<string, string> }): string {
|
|
47
|
+
const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
48
|
+
const envXml = Object.entries(opts.env).map(([k, v]) => ` <key>${esc(k)}</key><string>${esc(v)}</string>`).join('\n');
|
|
49
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
50
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
51
|
+
<plist version="1.0">
|
|
52
|
+
<dict>
|
|
53
|
+
<key>Label</key><string>${esc(opts.label)}</string>
|
|
54
|
+
<key>ProgramArguments</key>
|
|
55
|
+
<array>
|
|
56
|
+
<string>${esc(opts.node)}</string>
|
|
57
|
+
<string>${esc(opts.bin)}</string>
|
|
58
|
+
<string>start</string>
|
|
59
|
+
</array>
|
|
60
|
+
<key>WorkingDirectory</key><string>${esc(opts.home)}</string>
|
|
61
|
+
<key>EnvironmentVariables</key>
|
|
62
|
+
<dict>
|
|
63
|
+
${envXml}
|
|
64
|
+
</dict>
|
|
65
|
+
<key>RunAtLoad</key><true/>
|
|
66
|
+
<key>KeepAlive</key><true/>
|
|
67
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
68
|
+
<key>StandardOutPath</key><string>${esc(path.join(opts.logDir, 'server.log'))}</string>
|
|
69
|
+
<key>StandardErrorPath</key><string>${esc(path.join(opts.logDir, 'server.log'))}</string>
|
|
70
|
+
</dict>
|
|
71
|
+
</plist>
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The systemd user unit. Same shape as the plist: start at login, restart on failure, one log. */
|
|
76
|
+
export function systemdUnit(opts: { node: string; bin: string; home: string; env: Record<string, string> }): string {
|
|
77
|
+
const envLines = Object.entries(opts.env).map(([k, v]) => `Environment=${k}=${v.replace(/"/g, '\\"')}`).join('\n');
|
|
78
|
+
return `[Unit]
|
|
79
|
+
Description=Foreman — autonomous mission runner
|
|
80
|
+
After=network-online.target
|
|
81
|
+
|
|
82
|
+
[Service]
|
|
83
|
+
ExecStart=${opts.node} ${opts.bin} start
|
|
84
|
+
WorkingDirectory=${opts.home}
|
|
85
|
+
Restart=always
|
|
86
|
+
RestartSec=5
|
|
87
|
+
${envLines}
|
|
88
|
+
|
|
89
|
+
[Install]
|
|
90
|
+
WantedBy=default.target
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function serviceEnv(): Record<string, string> {
|
|
95
|
+
const env: Record<string, string> = { PATH: servicePath(process.execPath), HOME: os.homedir(), FOREMAN_HOME: HOME_DIR, PORT: String(PORT) };
|
|
96
|
+
for (const k of ['FOREMAN_BIND', 'FOREMAN_BROWSER', 'FOREMAN_CLAUDE_CONFIG_DIR', 'FOREMAN_CLAUDE_EXECUTABLE', 'FOREMAN_AUTH_MODE', 'CLAUDE_CONFIG_DIR']) {
|
|
97
|
+
if (process.env[k]) env[k] = process.env[k]!;
|
|
98
|
+
}
|
|
99
|
+
return env;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function serviceInstall(bin: string): Promise<number> {
|
|
103
|
+
const logDir = path.join(HOME_DIR, 'logs');
|
|
104
|
+
await mkdir(logDir, { recursive: true });
|
|
105
|
+
const env = serviceEnv();
|
|
106
|
+
if (process.platform === 'darwin') {
|
|
107
|
+
const dir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
108
|
+
const file = path.join(dir, `${LABEL}.plist`);
|
|
109
|
+
await mkdir(dir, { recursive: true });
|
|
110
|
+
await writeFile(file, launchdPlist({ label: LABEL, node: process.execPath, bin, home: HOME_DIR, logDir, env }));
|
|
111
|
+
await chmod(file, 0o644);
|
|
112
|
+
const uid = String(os.userInfo().uid);
|
|
113
|
+
await sh('launchctl', ['bootout', `gui/${uid}/${LABEL}`]); // replace a previous registration quietly
|
|
114
|
+
const r = await sh('launchctl', ['bootstrap', `gui/${uid}`, file]);
|
|
115
|
+
if (r.code !== 0) { console.error(`launchctl bootstrap failed: ${r.err.trim() || r.out.trim()}`); return 1; }
|
|
116
|
+
console.log(`Installed ${file}\nForeman starts at login and restarts if it dies. Logs: ${path.join(logDir, 'server.log')}\nDashboard: http://localhost:${PORT}`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (process.platform === 'linux') {
|
|
120
|
+
// A container, a minimal server, WSL without systemd: no user manager to
|
|
121
|
+
// talk to. Say that, and point at the way that still works.
|
|
122
|
+
const probe = await sh('systemctl', ['--user', 'is-system-running']);
|
|
123
|
+
const noManager = probe.code !== 0 && !/degraded|running|starting/.test(probe.out);
|
|
124
|
+
if (noManager) {
|
|
125
|
+
console.error('No systemd user session here (a container, or WSL without systemd). Use `foreman up`, or run `foreman` under your own supervisor.');
|
|
126
|
+
return 2;
|
|
127
|
+
}
|
|
128
|
+
const dir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
129
|
+
const file = path.join(dir, 'foreman.service');
|
|
130
|
+
await mkdir(dir, { recursive: true });
|
|
131
|
+
await writeFile(file, systemdUnit({ node: process.execPath, bin, home: HOME_DIR, env }));
|
|
132
|
+
for (const args of [['--user', 'daemon-reload'], ['--user', 'enable', '--now', 'foreman']]) {
|
|
133
|
+
const r = await sh('systemctl', args);
|
|
134
|
+
if (r.code !== 0) { console.error(`systemctl ${args.join(' ')} failed: ${(r.err.trim() || r.out.trim()) || 'no output — is a systemd user session running?'}`); return 1; }
|
|
135
|
+
}
|
|
136
|
+
console.log(`Installed ${file}\nForeman starts at login and restarts if it dies. Logs: journalctl --user -u foreman -f\nTip: \`loginctl enable-linger $USER\` keeps it up when you are logged out.\nDashboard: http://localhost:${PORT}`);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
console.error(`No service integration for ${process.platform} yet. \`foreman up\` runs it in the background; on Windows, Task Scheduler or WSL2 with systemd keeps it up.`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function serviceUninstall(): Promise<number> {
|
|
144
|
+
if (process.platform === 'darwin') {
|
|
145
|
+
const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
146
|
+
await sh('launchctl', ['bootout', `gui/${os.userInfo().uid}/${LABEL}`]);
|
|
147
|
+
await rm(file, { force: true });
|
|
148
|
+
console.log('Removed. Foreman no longer starts at login.');
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
if (process.platform === 'linux') {
|
|
152
|
+
await sh('systemctl', ['--user', 'disable', '--now', 'foreman']);
|
|
153
|
+
await rm(path.join(os.homedir(), '.config', 'systemd', 'user', 'foreman.service'), { force: true });
|
|
154
|
+
await sh('systemctl', ['--user', 'daemon-reload']);
|
|
155
|
+
console.log('Removed. Foreman no longer starts at login.');
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
return 2;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function serviceStatus(): Promise<number> {
|
|
162
|
+
if (process.platform === 'darwin') {
|
|
163
|
+
const r = await sh('launchctl', ['print', `gui/${os.userInfo().uid}/${LABEL}`]);
|
|
164
|
+
if (r.code !== 0) { console.log('Not installed. `foreman service install` keeps Foreman running.'); return 1; }
|
|
165
|
+
const state = /state = (\w+)/.exec(r.out)?.[1] ?? 'unknown';
|
|
166
|
+
const pid = /pid = (\d+)/.exec(r.out)?.[1];
|
|
167
|
+
console.log(`Installed · ${state}${pid ? ` · pid ${pid}` : ''} · http://localhost:${PORT}`);
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
if (process.platform === 'linux') {
|
|
171
|
+
const r = await sh('systemctl', ['--user', 'is-active', 'foreman']);
|
|
172
|
+
console.log(r.out.trim() === 'active' ? `Installed · active · http://localhost:${PORT}` : `Installed? ${r.out.trim() || 'no'} — \`foreman service install\``);
|
|
173
|
+
return r.out.trim() === 'active' ? 0 : 1;
|
|
174
|
+
}
|
|
175
|
+
return 2;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function serviceLogs(): Promise<number> {
|
|
179
|
+
if (process.platform === 'linux') {
|
|
180
|
+
const child = spawn('journalctl', ['--user', '-u', 'foreman', '-f', '-n', '100'], { stdio: 'inherit' });
|
|
181
|
+
return new Promise((r) => child.on('exit', (c) => r(c ?? 0)));
|
|
182
|
+
}
|
|
183
|
+
const file = path.join(HOME_DIR, 'logs', 'server.log');
|
|
184
|
+
const child = spawn('tail', ['-n', '100', '-f', file], { stdio: 'inherit' });
|
|
185
|
+
return new Promise((r) => child.on('exit', (c) => r(c ?? 0)));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function doctor(): Promise<number> {
|
|
189
|
+
const tailnet = await detectTailscale();
|
|
190
|
+
const distDir = fileURLToPath(new URL('../ui/dist', import.meta.url));
|
|
191
|
+
const checks = await preflight({ port: PORT, foremanHome: HOME_DIR, distDir, tailnet });
|
|
192
|
+
// Port-in-use is an error for `start` and a fact for `doctor`: it usually means Foreman is already up.
|
|
193
|
+
for (const c of checks) {
|
|
194
|
+
if (c.name.startsWith('Port') && c.status === 'error') { c.status = 'warn'; c.detail = 'in use — Foreman is probably already running'; c.fix = `foreman open · or PORT=${PORT + 1} foreman`; }
|
|
195
|
+
}
|
|
196
|
+
const ok = reportPreflight(checks);
|
|
197
|
+
return ok ? 0 : 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function open(): Promise<number> {
|
|
201
|
+
const url = `http://localhost:${PORT}`;
|
|
202
|
+
// `start` is a cmd builtin, not a program; xdg-open covers the Linux desktops.
|
|
203
|
+
const r = process.platform === 'win32'
|
|
204
|
+
? await sh('cmd', ['/c', 'start', '', url])
|
|
205
|
+
: await sh(process.platform === 'darwin' ? 'open' : 'xdg-open', [url]);
|
|
206
|
+
if (r.code !== 0) console.log(url);
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function listening(port: number): Promise<boolean> {
|
|
211
|
+
try { const r = await fetch(`http://127.0.0.1:${port}/projects`, { signal: AbortSignal.timeout(1500) }); return r.ok; }
|
|
212
|
+
catch { return false; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function readPid(): Promise<number | null> {
|
|
216
|
+
try { const n = Number((await readFile(PID_FILE, 'utf8')).trim()); return Number.isInteger(n) && n > 0 ? n : null; }
|
|
217
|
+
catch { return null; }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function alive(pid: number): boolean {
|
|
221
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function up(bin: string): Promise<number> {
|
|
225
|
+
if (await listening(PORT)) { console.log(`Already up at http://localhost:${PORT}`); return 0; }
|
|
226
|
+
await mkdir(path.dirname(LOG_FILE), { recursive: true });
|
|
227
|
+
const out = openSync(LOG_FILE, 'a');
|
|
228
|
+
const child = spawn(process.execPath, [bin, 'start'], {
|
|
229
|
+
detached: true, stdio: ['ignore', out, out],
|
|
230
|
+
env: { ...process.env, FOREMAN_HOME: HOME_DIR },
|
|
231
|
+
});
|
|
232
|
+
child.unref();
|
|
233
|
+
await writeFile(PID_FILE, `${child.pid}\n`);
|
|
234
|
+
for (let i = 0; i < 40; i++) {
|
|
235
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
236
|
+
if (await listening(PORT)) {
|
|
237
|
+
console.log(`Foreman is up at http://localhost:${PORT} (pid ${child.pid}) · log: ${LOG_FILE} · stop with: foreman down`);
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
if (child.pid && !alive(child.pid)) break;
|
|
241
|
+
}
|
|
242
|
+
console.error(`Foreman did not come up. The preflight may have refused — see ${LOG_FILE}`);
|
|
243
|
+
await rm(PID_FILE, { force: true });
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function down(): Promise<number> {
|
|
248
|
+
const pid = await readPid();
|
|
249
|
+
if (!pid || !alive(pid)) {
|
|
250
|
+
await rm(PID_FILE, { force: true });
|
|
251
|
+
if (await listening(PORT)) { console.log(`Something answers on :${PORT} but it was not started with \`foreman up\` (a terminal, or the service). Stop it there.`); return 1; }
|
|
252
|
+
console.log('Not up.'); return 0;
|
|
253
|
+
}
|
|
254
|
+
process.kill(pid, 'SIGTERM');
|
|
255
|
+
for (let i = 0; i < 40 && alive(pid); i++) await new Promise((r) => setTimeout(r, 250));
|
|
256
|
+
if (alive(pid)) process.kill(pid, 'SIGKILL');
|
|
257
|
+
await rm(PID_FILE, { force: true });
|
|
258
|
+
console.log('Stopped.');
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function status(): Promise<number> {
|
|
263
|
+
const pid = await readPid();
|
|
264
|
+
const isUp = await listening(PORT);
|
|
265
|
+
if (isUp) {
|
|
266
|
+
const how = pid && alive(pid) ? `background, pid ${pid}` : 'a terminal or the service';
|
|
267
|
+
console.log(`Up at http://localhost:${PORT} (${how})`);
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
if (pid) await rm(PID_FILE, { force: true });
|
|
271
|
+
console.log(`Not up. \`foreman up\` starts it in the background, \`foreman\` in this terminal.`);
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function runCli(command: string, rest: string[], ctx: { version: string; bin: URL }): Promise<number> {
|
|
276
|
+
const bin = fileURLToPath(ctx.bin);
|
|
277
|
+
switch (command) {
|
|
278
|
+
case 'up': return up(bin);
|
|
279
|
+
case 'down': return down();
|
|
280
|
+
case 'status': return status();
|
|
281
|
+
case 'doctor': return doctor();
|
|
282
|
+
case 'open': return open();
|
|
283
|
+
case 'service': {
|
|
284
|
+
const sub = rest[0];
|
|
285
|
+
if (sub === 'install') return serviceInstall(bin);
|
|
286
|
+
if (sub === 'uninstall') return serviceUninstall();
|
|
287
|
+
if (sub === 'status') return serviceStatus();
|
|
288
|
+
if (sub === 'logs') return serviceLogs();
|
|
289
|
+
console.error('foreman service <install|uninstall|status|logs>');
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
default: return 1;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** For a test: where the plist would go. */
|
|
297
|
+
export function plistPathFor(home = os.homedir()): string { return path.join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`); }
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex credential tests.
|
|
3
|
+
*
|
|
4
|
+
* Everything here uses a temp CODEX_HOME with fixture files — the real
|
|
5
|
+
* `~/.codex` is never touched, and no test hits `auth.openai.com`: the
|
|
6
|
+
* refresh test injects a stub `fetch`.
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
|
13
|
+
import {
|
|
14
|
+
codexHome, readCodexAuth, isStale, refreshCodexAuth, codexModels,
|
|
15
|
+
} from './codex.js';
|
|
16
|
+
|
|
17
|
+
async function tmpHome(): Promise<string> {
|
|
18
|
+
return mkdtemp(path.join(os.tmpdir(), 'foreman-codex-test-'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fixtureAuth(overrides: Record<string, unknown> = {}) {
|
|
22
|
+
return {
|
|
23
|
+
auth_mode: 'chatgpt',
|
|
24
|
+
OPENAI_API_KEY: null,
|
|
25
|
+
tokens: {
|
|
26
|
+
id_token: 'id-token-value',
|
|
27
|
+
access_token: 'access-token-value',
|
|
28
|
+
refresh_token: 'refresh-token-value',
|
|
29
|
+
account_id: 'acct-123',
|
|
30
|
+
},
|
|
31
|
+
last_refresh: new Date().toISOString(),
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// codexHome
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
test('codexHome honours CODEX_HOME override before the env var, then falls back to ~/.codex', async () => {
|
|
41
|
+
const saved = process.env.CODEX_HOME;
|
|
42
|
+
try {
|
|
43
|
+
delete process.env.CODEX_HOME;
|
|
44
|
+
assert.equal(codexHome(), path.join(os.homedir(), '.codex'));
|
|
45
|
+
|
|
46
|
+
process.env.CODEX_HOME = '/from/env';
|
|
47
|
+
assert.equal(codexHome(), '/from/env');
|
|
48
|
+
|
|
49
|
+
assert.equal(codexHome('/explicit'), '/explicit');
|
|
50
|
+
} finally {
|
|
51
|
+
if (saved === undefined) delete process.env.CODEX_HOME;
|
|
52
|
+
else process.env.CODEX_HOME = saved;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// readCodexAuth
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
test('readCodexAuth returns null when auth.json is missing — no install is ordinary, not an error', async () => {
|
|
61
|
+
const home = await tmpHome();
|
|
62
|
+
try {
|
|
63
|
+
assert.equal(await readCodexAuth(home), null);
|
|
64
|
+
} finally {
|
|
65
|
+
await rm(home, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('readCodexAuth returns null on malformed JSON', async () => {
|
|
70
|
+
const home = await tmpHome();
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(path.join(home, 'auth.json'), '{ not json');
|
|
73
|
+
assert.equal(await readCodexAuth(home), null);
|
|
74
|
+
} finally {
|
|
75
|
+
await rm(home, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('readCodexAuth returns null when there is no usable credential', async () => {
|
|
80
|
+
const home = await tmpHome();
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(
|
|
83
|
+
path.join(home, 'auth.json'),
|
|
84
|
+
JSON.stringify({ auth_mode: 'chatgpt', OPENAI_API_KEY: null, tokens: {} }),
|
|
85
|
+
);
|
|
86
|
+
assert.equal(await readCodexAuth(home), null);
|
|
87
|
+
} finally {
|
|
88
|
+
await rm(home, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('readCodexAuth parses a well-formed file', async () => {
|
|
93
|
+
const home = await tmpHome();
|
|
94
|
+
try {
|
|
95
|
+
const fixture = fixtureAuth();
|
|
96
|
+
await writeFile(path.join(home, 'auth.json'), JSON.stringify(fixture));
|
|
97
|
+
const auth = await readCodexAuth(home);
|
|
98
|
+
assert.ok(auth);
|
|
99
|
+
assert.equal(auth?.tokens?.access_token, 'access-token-value');
|
|
100
|
+
} finally {
|
|
101
|
+
await rm(home, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('readCodexAuth prefers OPENAI_API_KEY over the OAuth access_token', async () => {
|
|
106
|
+
const home = await tmpHome();
|
|
107
|
+
try {
|
|
108
|
+
await writeFile(
|
|
109
|
+
path.join(home, 'auth.json'),
|
|
110
|
+
JSON.stringify(fixtureAuth({ OPENAI_API_KEY: 'sk-plain-api-key' })),
|
|
111
|
+
);
|
|
112
|
+
const auth = await readCodexAuth(home);
|
|
113
|
+
assert.ok(auth);
|
|
114
|
+
// readCodexAuth returns the whole record; the caller (provider.ts, out of
|
|
115
|
+
// scope for this module) is expected to prefer OPENAI_API_KEY when
|
|
116
|
+
// present. Assert the fixture round-trips both fields so that contract is
|
|
117
|
+
// checkable at the call site.
|
|
118
|
+
assert.equal(auth?.OPENAI_API_KEY, 'sk-plain-api-key');
|
|
119
|
+
assert.equal(auth?.tokens?.access_token, 'access-token-value');
|
|
120
|
+
} finally {
|
|
121
|
+
await rm(home, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// isStale
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
test('isStale: boundary either side of the default 25-minute threshold', () => {
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const justUnder = new Date(now - (25 * 60 * 1000 - 1000)).toISOString();
|
|
132
|
+
const justOver = new Date(now - (25 * 60 * 1000 + 1000)).toISOString();
|
|
133
|
+
|
|
134
|
+
assert.equal(isStale(fixtureAuth({ last_refresh: justUnder }) as any), false);
|
|
135
|
+
assert.equal(isStale(fixtureAuth({ last_refresh: justOver }) as any), true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('isStale: a custom max age is honoured', () => {
|
|
139
|
+
const tenMinAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
|
140
|
+
assert.equal(isStale(fixtureAuth({ last_refresh: tenMinAgo }) as any, 5 * 60 * 1000), true);
|
|
141
|
+
assert.equal(isStale(fixtureAuth({ last_refresh: tenMinAgo }) as any, 15 * 60 * 1000), false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('isStale: no last_refresh at all is treated as stale', () => {
|
|
145
|
+
const { last_refresh, ...rest } = fixtureAuth();
|
|
146
|
+
assert.equal(isStale(rest as any), true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// refreshCodexAuth
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
test('refreshCodexAuth writes the rotated token atomically and preserves unrelated fields', async () => {
|
|
154
|
+
const home = await tmpHome();
|
|
155
|
+
try {
|
|
156
|
+
const fixture = fixtureAuth();
|
|
157
|
+
await writeFile(path.join(home, 'auth.json'), JSON.stringify(fixture));
|
|
158
|
+
|
|
159
|
+
let calledUrl: string | undefined;
|
|
160
|
+
let calledBody: any;
|
|
161
|
+
const stubFetch = (async (url: string, init: any) => {
|
|
162
|
+
calledUrl = url;
|
|
163
|
+
calledBody = JSON.parse(init.body);
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
json: async () => ({
|
|
167
|
+
access_token: 'new-access-token',
|
|
168
|
+
id_token: 'new-id-token',
|
|
169
|
+
refresh_token: 'new-refresh-token-single-use',
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
172
|
+
}) as any;
|
|
173
|
+
|
|
174
|
+
const auth = await readCodexAuth(home);
|
|
175
|
+
assert.ok(auth);
|
|
176
|
+
const result = await refreshCodexAuth(home, auth!, stubFetch);
|
|
177
|
+
assert.ok(result);
|
|
178
|
+
|
|
179
|
+
assert.equal(calledUrl, 'https://auth.openai.com/oauth/token');
|
|
180
|
+
assert.equal(calledBody.grant_type, 'refresh_token');
|
|
181
|
+
assert.equal(calledBody.client_id, 'app_EMoamEEZ73f0CkXaXp7hrann');
|
|
182
|
+
assert.equal(calledBody.refresh_token, 'refresh-token-value');
|
|
183
|
+
|
|
184
|
+
// Unrelated top-level fields survive.
|
|
185
|
+
assert.equal(result?.auth_mode, 'chatgpt');
|
|
186
|
+
assert.equal(result?.OPENAI_API_KEY, null);
|
|
187
|
+
|
|
188
|
+
// Tokens are rotated.
|
|
189
|
+
assert.equal(result?.tokens?.access_token, 'new-access-token');
|
|
190
|
+
assert.equal(result?.tokens?.refresh_token, 'new-refresh-token-single-use');
|
|
191
|
+
assert.equal(result?.tokens?.account_id, 'acct-123'); // preserved, not part of the response
|
|
192
|
+
|
|
193
|
+
// The write actually landed on disk, and no .tmp file was left behind.
|
|
194
|
+
const onDisk = JSON.parse(await readFile(path.join(home, 'auth.json'), 'utf8'));
|
|
195
|
+
assert.equal(onDisk.tokens.access_token, 'new-access-token');
|
|
196
|
+
assert.equal(onDisk.tokens.refresh_token, 'new-refresh-token-single-use');
|
|
197
|
+
|
|
198
|
+
const { readdir } = await import('node:fs/promises');
|
|
199
|
+
const files = await readdir(home);
|
|
200
|
+
assert.ok(!files.some((f) => f.endsWith('.tmp')), 'no leftover tmp file');
|
|
201
|
+
} finally {
|
|
202
|
+
await rm(home, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('refreshCodexAuth returns null when there is no refresh token to send', async () => {
|
|
207
|
+
const home = await tmpHome();
|
|
208
|
+
try {
|
|
209
|
+
const auth = fixtureAuth({ tokens: { access_token: 'a' } });
|
|
210
|
+
const stubFetch = (async () => {
|
|
211
|
+
throw new Error('must not be called');
|
|
212
|
+
}) as any;
|
|
213
|
+
const result = await refreshCodexAuth(home, auth as any, stubFetch);
|
|
214
|
+
assert.equal(result, null);
|
|
215
|
+
} finally {
|
|
216
|
+
await rm(home, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('refreshCodexAuth returns null on a non-2xx response rather than throwing', async () => {
|
|
221
|
+
const home = await tmpHome();
|
|
222
|
+
try {
|
|
223
|
+
await writeFile(path.join(home, 'auth.json'), JSON.stringify(fixtureAuth()));
|
|
224
|
+
const auth = await readCodexAuth(home);
|
|
225
|
+
const stubFetch = (async () => ({ ok: false, json: async () => ({}) })) as any;
|
|
226
|
+
const result = await refreshCodexAuth(home, auth!, stubFetch);
|
|
227
|
+
assert.equal(result, null);
|
|
228
|
+
} finally {
|
|
229
|
+
await rm(home, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('refreshCodexAuth returns null when the network call throws', async () => {
|
|
234
|
+
const home = await tmpHome();
|
|
235
|
+
try {
|
|
236
|
+
await writeFile(path.join(home, 'auth.json'), JSON.stringify(fixtureAuth()));
|
|
237
|
+
const auth = await readCodexAuth(home);
|
|
238
|
+
const stubFetch = (async () => {
|
|
239
|
+
throw new Error('ECONNREFUSED');
|
|
240
|
+
}) as any;
|
|
241
|
+
const result = await refreshCodexAuth(home, auth!, stubFetch);
|
|
242
|
+
assert.equal(result, null);
|
|
243
|
+
} finally {
|
|
244
|
+
await rm(home, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('refreshCodexAuth never calls the real network (no fetch arg reaches auth.openai.com in this suite)', async () => {
|
|
249
|
+
// Sanity check on the test design itself: every refresh test above passes
|
|
250
|
+
// an explicit stub. This test just documents that refreshCodexAuth accepts
|
|
251
|
+
// one rather than always using the global fetch.
|
|
252
|
+
const home = await tmpHome();
|
|
253
|
+
try {
|
|
254
|
+
await writeFile(path.join(home, 'auth.json'), JSON.stringify(fixtureAuth()));
|
|
255
|
+
const auth = await readCodexAuth(home);
|
|
256
|
+
let called = false;
|
|
257
|
+
const stubFetch = (async () => {
|
|
258
|
+
called = true;
|
|
259
|
+
return { ok: true, json: async () => ({ access_token: 'x' }) };
|
|
260
|
+
}) as any;
|
|
261
|
+
await refreshCodexAuth(home, auth!, stubFetch);
|
|
262
|
+
assert.equal(called, true);
|
|
263
|
+
} finally {
|
|
264
|
+
await rm(home, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// codexModels
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
test('codexModels returns [] when models_cache.json is absent', async () => {
|
|
273
|
+
const home = await tmpHome();
|
|
274
|
+
try {
|
|
275
|
+
assert.deepEqual(await codexModels(home), []);
|
|
276
|
+
} finally {
|
|
277
|
+
await rm(home, { recursive: true, force: true });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('codexModels returns [] on malformed JSON', async () => {
|
|
282
|
+
const home = await tmpHome();
|
|
283
|
+
try {
|
|
284
|
+
await writeFile(path.join(home, 'models_cache.json'), 'not json');
|
|
285
|
+
assert.deepEqual(await codexModels(home), []);
|
|
286
|
+
} finally {
|
|
287
|
+
await rm(home, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('codexModels returns [] when the models field is missing or the wrong shape', async () => {
|
|
292
|
+
const home = await tmpHome();
|
|
293
|
+
try {
|
|
294
|
+
await writeFile(path.join(home, 'models_cache.json'), JSON.stringify({ fetched_at: 'x' }));
|
|
295
|
+
assert.deepEqual(await codexModels(home), []);
|
|
296
|
+
} finally {
|
|
297
|
+
await rm(home, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('codexModels reads slugs faithfully from a real-shaped fixture', async () => {
|
|
302
|
+
const home = await tmpHome();
|
|
303
|
+
try {
|
|
304
|
+
// Shaped after the actual ~/.codex/models_cache.json on a machine with
|
|
305
|
+
// Codex installed: an array of model objects keyed by `slug`, with a lot
|
|
306
|
+
// of other per-model metadata this module has no business parsing.
|
|
307
|
+
await writeFile(
|
|
308
|
+
path.join(home, 'models_cache.json'),
|
|
309
|
+
JSON.stringify({
|
|
310
|
+
fetched_at: '2026-09-04T02:56:08.542652Z',
|
|
311
|
+
etag: 'W/"1e10c2927ad7b0d7cddc841252b75cb1"',
|
|
312
|
+
client_version: '0.147.0',
|
|
313
|
+
models: [
|
|
314
|
+
{ slug: 'gpt-reserve', display_name: 'GPT-Reserve', visibility: 'hide' },
|
|
315
|
+
{ slug: 'gpt-5.6-sol', display_name: 'GPT-5.6 Sol', visibility: 'list' },
|
|
316
|
+
{ slug: 'gpt-5.6-terra' },
|
|
317
|
+
{ display_name: 'no slug here', visibility: 'list' },
|
|
318
|
+
],
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
// `gpt-reserve` is real and the API would accept it, which is exactly why
|
|
322
|
+
// it must not reach a picker: Codex marks it `visibility: hide` because it
|
|
323
|
+
// is not a thing to offer. An entry with no visibility at all is shown.
|
|
324
|
+
assert.deepEqual(await codexModels(home), ['gpt-5.6-sol', 'gpt-5.6-terra']);
|
|
325
|
+
} finally {
|
|
326
|
+
await rm(home, { recursive: true, force: true });
|
|
327
|
+
}
|
|
328
|
+
});
|