auto-model-router 0.8.1 → 0.9.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/.omp-plugin/marketplace.json +2 -2
- package/README.md +14 -4
- package/omp-extension/remote-logic.ts +27 -1
- package/package.json +1 -1
- package/src/cli/connect.ts +46 -8
- package/src/cli/credential-store.ts +157 -0
- package/src/cli/refresh.ts +20 -7
- package/test/remote.test.ts +104 -7
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.9.0",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.9.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -1312,10 +1312,20 @@ refresh token beside the key (`--refresh-token`, `--key-expires`, `--refresh-exp
|
|
|
1312
1312
|
new key a day before expiry, at session start, and re-writes every config `connect`
|
|
1313
1313
|
wrote; `auto-model-router refresh` does the same by hand (`--force` to do it early), and
|
|
1314
1314
|
`auto-model-router token` prints a key that is good right now, refreshing first if needed —
|
|
1315
|
-
the shape a harness key-helper wants
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1315
|
+
the shape a harness key-helper wants. The remote keeps the old key valid until its own
|
|
1316
|
+
expiry, so a session still holding it is never cut. A refresh token presented twice means
|
|
1317
|
+
the credential was copied: the remote revokes that device, and the machine onboards again.
|
|
1318
|
+
|
|
1319
|
+
The refresh token, the long-lived secret, does not sit in a file: `connect` puts it in the
|
|
1320
|
+
operating system's credential store — DPAPI on Windows (ciphertext in
|
|
1321
|
+
`<router home>/refresh.dpapi`, decryptable only by that Windows user on that machine),
|
|
1322
|
+
the login keychain on macOS, the Secret Service on Linux — and `remote.json` only names
|
|
1323
|
+
which store holds it. `<router home>/refresh.token`, owner-readable only, is the fallback
|
|
1324
|
+
when no store is usable (a CI box), and the note at the end of `connect` says when that
|
|
1325
|
+
happened. **Claude Code** gets its settings file written instead of an environment: the
|
|
1326
|
+
`env` block carries `ANTHROPIC_BASE_URL`, and `apiKeyHelper` runs
|
|
1327
|
+
`auto-model-router token`, so no key is in its environment or on disk for it and a
|
|
1328
|
+
short-lived key rotates underneath a running session.
|
|
1319
1329
|
|
|
1320
1330
|
In remote mode omp sends `X-Agentdox-Scope` derived from the workspace folder, so one
|
|
1321
1331
|
remote router serves every repo on the machine with that repo's shared context. The remote
|
|
@@ -22,8 +22,16 @@ export interface RemoteRouter {
|
|
|
22
22
|
userId: string;
|
|
23
23
|
name: string;
|
|
24
24
|
joinedAtMs: number;
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Present only in a remote.json written before the credential store existed:
|
|
27
|
+
* the token inline. New files name the store instead (`refreshTokenStore`) and
|
|
28
|
+
* the token is read from it when a refresh happens (src/cli/refresh.ts).
|
|
29
|
+
*/
|
|
26
30
|
refreshToken?: string;
|
|
31
|
+
/** Which OS store holds the refresh token: dpapi (Windows), keychain (macOS), secret-service (Linux) or file. */
|
|
32
|
+
refreshTokenStore?: "dpapi" | "keychain" | "secret-service" | "file";
|
|
33
|
+
/** The account the store files it under (`<userId>@<remote host>`). */
|
|
34
|
+
refreshAccount?: string;
|
|
27
35
|
keyExpiresAtMs?: number;
|
|
28
36
|
refreshExpiresAtMs?: number;
|
|
29
37
|
/** What the remote calls this machine. */
|
|
@@ -46,6 +54,8 @@ export function parseRemoteRouter(text: string): RemoteRouter | null {
|
|
|
46
54
|
name: typeof raw.name === "string" ? raw.name : "",
|
|
47
55
|
joinedAtMs: typeof raw.joinedAtMs === "number" ? raw.joinedAtMs : 0,
|
|
48
56
|
...(typeof raw.refreshToken === "string" && raw.refreshToken !== "" ? { refreshToken: raw.refreshToken } : {}),
|
|
57
|
+
...(raw.refreshTokenStore === "dpapi" || raw.refreshTokenStore === "keychain" || raw.refreshTokenStore === "secret-service" || raw.refreshTokenStore === "file" ? { refreshTokenStore: raw.refreshTokenStore } : {}),
|
|
58
|
+
...(typeof raw.refreshAccount === "string" && raw.refreshAccount !== "" ? { refreshAccount: raw.refreshAccount } : {}),
|
|
49
59
|
...(typeof raw.keyExpiresAtMs === "number" ? { keyExpiresAtMs: raw.keyExpiresAtMs } : {}),
|
|
50
60
|
...(typeof raw.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: raw.refreshExpiresAtMs } : {}),
|
|
51
61
|
...(typeof raw.device === "string" && raw.device !== "" ? { device: raw.device } : {}),
|
|
@@ -55,6 +65,22 @@ export function parseRemoteRouter(text: string): RemoteRouter | null {
|
|
|
55
65
|
}
|
|
56
66
|
}
|
|
57
67
|
|
|
68
|
+
/** True when this machine can trade for a new key: a refresh token inline, or a store that holds one. */
|
|
69
|
+
export function hasRefresh(remote: RemoteRouter): boolean {
|
|
70
|
+
return (remote.refreshToken !== undefined && remote.refreshToken !== "") || remote.refreshTokenStore !== undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The account a remote user's refresh token is filed under in the OS store. */
|
|
74
|
+
export function refreshAccountOf(url: string, userId: string): string {
|
|
75
|
+
let host = url;
|
|
76
|
+
try {
|
|
77
|
+
host = new URL(url).host;
|
|
78
|
+
} catch {
|
|
79
|
+
/* keep the raw url */
|
|
80
|
+
}
|
|
81
|
+
return `${userId === "" ? "member" : userId}@${host}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
58
84
|
export function readRemoteRouter(routerHome: string): RemoteRouter | null {
|
|
59
85
|
for (const path of [remoteFilePath(routerHome), join(routerHome, LEGACY_FILE)]) {
|
|
60
86
|
if (!existsSync(path)) continue;
|
package/package.json
CHANGED
package/src/cli/connect.ts
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* $HERMES_HOME/plugins and .env points them at the remote
|
|
15
15
|
* Codex ~/.codex/config.toml gains the auto-model-router provider
|
|
16
16
|
* Aider ~/.aider.conf.yml gains the base URL, key and model
|
|
17
|
-
* Claude Code
|
|
17
|
+
* Claude Code ~/.claude/settings.json gains the base URL (its `env` block) and
|
|
18
|
+
* `apiKeyHelper` running `auto-model-router token`, so no key
|
|
19
|
+
* sits in its environment or on disk for it
|
|
18
20
|
*
|
|
19
21
|
* Every write is idempotent and announced. `--profile` persists the
|
|
20
22
|
* environment lines (shell rc on POSIX, user environment on Windows).
|
|
@@ -26,7 +28,8 @@ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileS
|
|
|
26
28
|
import { homedir } from "node:os";
|
|
27
29
|
import { dirname, join, resolve } from "node:path";
|
|
28
30
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import { remoteFilePath } from "../../omp-extension/remote-logic.ts";
|
|
31
|
+
import { refreshAccountOf, remoteFilePath } from "../../omp-extension/remote-logic.ts";
|
|
32
|
+
import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
|
|
30
33
|
import { flagString, type CliArgs } from "./args.ts";
|
|
31
34
|
|
|
32
35
|
export interface ConnectOptions {
|
|
@@ -48,6 +51,9 @@ export interface ConnectOptions {
|
|
|
48
51
|
agentdoxScope?: string;
|
|
49
52
|
/** Short-lived credential fields from a remote that issues them; absent for a permanent key. */
|
|
50
53
|
refreshToken?: string;
|
|
54
|
+
/** Which store takes the refresh token; picked from the platform when absent. Tests inject a backend. */
|
|
55
|
+
store?: StoreKind;
|
|
56
|
+
storeDeps?: StoreDeps;
|
|
51
57
|
keyExpiresAtMs?: number;
|
|
52
58
|
refreshExpiresAtMs?: number;
|
|
53
59
|
device?: string;
|
|
@@ -223,6 +229,16 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
223
229
|
const rh = routerHomeOf(o);
|
|
224
230
|
report.remoteFile = remoteFilePath(rh);
|
|
225
231
|
const previous = existsSync(report.remoteFile) ? (JSON.parse(readFileSync(report.remoteFile, "utf8")) as Record<string, unknown>) : {};
|
|
232
|
+
// The refresh token is the long-lived secret: it goes to the OS credential store, and
|
|
233
|
+
// remote.json only says which one. The access key stays in the file: it is short-lived,
|
|
234
|
+
// and the extensions need it without a subprocess on every poll.
|
|
235
|
+
let refreshTokenStore: StoreKind | undefined;
|
|
236
|
+
const refreshAccount = refreshAccountOf(o.url, o.userId);
|
|
237
|
+
if (o.refreshToken !== undefined && o.refreshToken !== "" && !o.dryRun) {
|
|
238
|
+
const wanted = o.store ?? pickStore(o.platform, o.pathHas);
|
|
239
|
+
refreshTokenStore = saveRefreshToken(rh, refreshAccount, o.refreshToken, wanted, o.storeDeps ?? { pathHas: o.pathHas });
|
|
240
|
+
if (refreshTokenStore !== wanted) report.notes.push(`the ${wanted} credential store was not usable; the refresh token is in ${join(rh, "refresh.token")} (owner-readable only)`);
|
|
241
|
+
} else if (o.refreshToken !== undefined && o.refreshToken !== "") refreshTokenStore = o.store ?? pickStore(o.platform, o.pathHas);
|
|
226
242
|
write(
|
|
227
243
|
report.remoteFile,
|
|
228
244
|
`${JSON.stringify(
|
|
@@ -232,7 +248,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
232
248
|
userId: o.userId,
|
|
233
249
|
name: o.name,
|
|
234
250
|
joinedAtMs: typeof previous.joinedAtMs === "number" ? previous.joinedAtMs : Date.now(),
|
|
235
|
-
...(
|
|
251
|
+
...(refreshTokenStore !== undefined ? { refreshTokenStore, refreshAccount } : {}),
|
|
236
252
|
...(o.keyExpiresAtMs !== undefined ? { keyExpiresAtMs: o.keyExpiresAtMs } : {}),
|
|
237
253
|
...(o.refreshExpiresAtMs !== undefined ? { refreshExpiresAtMs: o.refreshExpiresAtMs } : {}),
|
|
238
254
|
...(o.device !== undefined && o.device !== "" ? { device: o.device } : {}),
|
|
@@ -300,11 +316,33 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
|
|
|
300
316
|
report.configured.push(`Aider (${aiderConf})`);
|
|
301
317
|
} else report.skipped.push("Aider (not found)");
|
|
302
318
|
|
|
303
|
-
// 6. Claude Code:
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
319
|
+
// 6. Claude Code: its settings file carries the base URL (the `env` block) and a key
|
|
320
|
+
// helper, a command it runs for the key — so the key is never in its environment or
|
|
321
|
+
// on disk for it. Without a refresh token the helper still works (it prints the key it
|
|
322
|
+
// holds); the helper is what lets a short-lived key rotate underneath a running session.
|
|
323
|
+
const claudeDir = join(o.home, ".claude");
|
|
324
|
+
if (wants(o, "claude") && (o.pathHas("claude") || existsSync(claudeDir))) {
|
|
325
|
+
const settingsPath = join(claudeDir, "settings.json");
|
|
326
|
+
let settings: Record<string, unknown> = {};
|
|
327
|
+
const before = existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : "";
|
|
328
|
+
try {
|
|
329
|
+
settings = before === "" ? {} : (JSON.parse(before) as Record<string, unknown>);
|
|
330
|
+
} catch {
|
|
331
|
+
report.notes.push(`${settingsPath} is not valid JSON; left alone — set env.ANTHROPIC_BASE_URL and apiKeyHelper by hand`);
|
|
332
|
+
settings = {};
|
|
333
|
+
}
|
|
334
|
+
const env: Record<string, unknown> = { ...((settings.env as Record<string, unknown> | undefined) ?? {}), ANTHROPIC_BASE_URL: o.url };
|
|
335
|
+
// Never leave a stale key beside the helper: the helper is the source now.
|
|
336
|
+
delete env.ANTHROPIC_API_KEY;
|
|
337
|
+
const entry = resolve(o.packageDir, "src", "index.ts").replaceAll("\\", "/");
|
|
338
|
+
const next = { ...settings, env, apiKeyHelper: `bun run "${entry}" token` };
|
|
339
|
+
const after = `${JSON.stringify(next, null, 2)}\n`;
|
|
340
|
+
if (after !== before) {
|
|
341
|
+
if (before !== "" && !o.dryRun) writeFileSync(`${settingsPath}.${new Date().toISOString().replaceAll(":", "-")}.bak`, before, "utf8");
|
|
342
|
+
write(settingsPath, after);
|
|
343
|
+
}
|
|
344
|
+
report.configured.push(`Claude Code (${settingsPath}: env.ANTHROPIC_BASE_URL + apiKeyHelper; open a new session)`);
|
|
345
|
+
} else report.skipped.push("Claude Code (not on PATH and no ~/.claude)");
|
|
308
346
|
report.envLines.unshift(`AUTO_MODEL_ROUTER_URL=${o.url}`, `AUTO_MODEL_ROUTER_API_KEY=${o.key}`);
|
|
309
347
|
report.envLines = [...new Set(report.envLines)];
|
|
310
348
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the refresh token lives on a member's machine.
|
|
3
|
+
*
|
|
4
|
+
* The access key has to sit in harness config files (a harness needs a literal
|
|
5
|
+
* bearer), and it is short-lived. The refresh token is the long-lived secret,
|
|
6
|
+
* so it goes to the operating system's credential store instead of a file:
|
|
7
|
+
*
|
|
8
|
+
* Windows DPAPI (CurrentUser scope): the token is encrypted so that only
|
|
9
|
+
* this Windows user on this machine can decrypt it, and the
|
|
10
|
+
* ciphertext is kept in `<router home>/refresh.dpapi`. Built in;
|
|
11
|
+
* no module to install. The plaintext passes to PowerShell through
|
|
12
|
+
* an environment variable, never an argument.
|
|
13
|
+
* macOS the login keychain, through `security` (service
|
|
14
|
+
* `auto-model-router`, one account per remote user).
|
|
15
|
+
* Linux the Secret Service through `secret-tool` when it is installed.
|
|
16
|
+
* file `<router home>/refresh.token`, owner-readable only — the fallback
|
|
17
|
+
* when none of the above works, and the choice on CI boxes.
|
|
18
|
+
*
|
|
19
|
+
* `remote.json` records which store holds it (`refreshTokenStore`) and the
|
|
20
|
+
* account name; it never holds the token itself once a store other than
|
|
21
|
+
* `file` is in use. A remote.json written before this existed may still carry
|
|
22
|
+
* the token inline; reading honours that until the next refresh moves it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
export type StoreKind = "dpapi" | "keychain" | "secret-service" | "file";
|
|
30
|
+
|
|
31
|
+
const SERVICE = "auto-model-router";
|
|
32
|
+
|
|
33
|
+
/** The store this platform offers, given which tools are on PATH. */
|
|
34
|
+
export function pickStore(platform: string, pathHas: (bin: string) => boolean): StoreKind {
|
|
35
|
+
if (platform === "win32") return pathHas("powershell") || pathHas("pwsh") ? "dpapi" : "file";
|
|
36
|
+
if (platform === "darwin") return pathHas("security") ? "keychain" : "file";
|
|
37
|
+
if (platform === "linux") return pathHas("secret-tool") ? "secret-service" : "file";
|
|
38
|
+
return "file";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const powershell = (pathHas: (bin: string) => boolean): string => (pathHas("pwsh") ? "pwsh" : "powershell");
|
|
42
|
+
|
|
43
|
+
function dpapiProtect(secret: string, pathHas: (bin: string) => boolean): string {
|
|
44
|
+
const r = spawnSync(
|
|
45
|
+
powershell(pathHas),
|
|
46
|
+
["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Security; [Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($env:AMR_SECRET), $null, 'CurrentUser'))"],
|
|
47
|
+
{ encoding: "utf8", env: { ...process.env, AMR_SECRET: secret } },
|
|
48
|
+
);
|
|
49
|
+
if (r.status !== 0 || r.stdout.trim() === "") throw new Error(`DPAPI protect failed: ${r.stderr.trim() || r.status}`);
|
|
50
|
+
return r.stdout.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function dpapiUnprotect(blob: string, pathHas: (bin: string) => boolean): string {
|
|
54
|
+
const r = spawnSync(
|
|
55
|
+
powershell(pathHas),
|
|
56
|
+
["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Security; [Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($env:AMR_BLOB), $null, 'CurrentUser'))"],
|
|
57
|
+
{ encoding: "utf8", env: { ...process.env, AMR_BLOB: blob } },
|
|
58
|
+
);
|
|
59
|
+
if (r.status !== 0) throw new Error(`DPAPI unprotect failed: ${r.stderr.trim() || r.status}`);
|
|
60
|
+
return r.stdout.replace(/\r?\n$/, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const filePath = (routerHome: string): string => join(routerHome, "refresh.token");
|
|
64
|
+
const dpapiPath = (routerHome: string): string => join(routerHome, "refresh.dpapi");
|
|
65
|
+
|
|
66
|
+
export interface StoreDeps {
|
|
67
|
+
pathHas?: (bin: string) => boolean;
|
|
68
|
+
/** Injected in tests to stand in for the platform tools. */
|
|
69
|
+
backend?: { save(account: string, secret: string): void; load(account: string): string | null; remove(account: string): void };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Saves the refresh token and returns the store that took it. A store that
|
|
74
|
+
* fails (keychain locked, tool missing) falls back to the file, so a member is
|
|
75
|
+
* never left without a refresh token; the caller records what was used.
|
|
76
|
+
*/
|
|
77
|
+
export function saveRefreshToken(routerHome: string, account: string, secret: string, kind: StoreKind, deps: StoreDeps = {}): StoreKind {
|
|
78
|
+
const pathHas = deps.pathHas ?? ((bin) => Bun.which(bin) !== null);
|
|
79
|
+
mkdirSync(routerHome, { recursive: true });
|
|
80
|
+
try {
|
|
81
|
+
if (deps.backend !== undefined) {
|
|
82
|
+
deps.backend.save(account, secret);
|
|
83
|
+
return kind;
|
|
84
|
+
}
|
|
85
|
+
switch (kind) {
|
|
86
|
+
case "dpapi":
|
|
87
|
+
writeFileSync(dpapiPath(routerHome), `${dpapiProtect(secret, pathHas)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
88
|
+
rmSync(filePath(routerHome), { force: true });
|
|
89
|
+
return "dpapi";
|
|
90
|
+
case "keychain": {
|
|
91
|
+
const r = spawnSync("security", ["add-generic-password", "-U", "-a", account, "-s", SERVICE, "-w", secret], { encoding: "utf8" });
|
|
92
|
+
if (r.status !== 0) throw new Error(r.stderr.trim());
|
|
93
|
+
rmSync(filePath(routerHome), { force: true });
|
|
94
|
+
return "keychain";
|
|
95
|
+
}
|
|
96
|
+
case "secret-service": {
|
|
97
|
+
const r = spawnSync("secret-tool", ["store", `--label=${SERVICE} ${account}`, "service", SERVICE, "account", account], { encoding: "utf8", input: secret });
|
|
98
|
+
if (r.status !== 0) throw new Error(r.stderr.trim());
|
|
99
|
+
rmSync(filePath(routerHome), { force: true });
|
|
100
|
+
return "secret-service";
|
|
101
|
+
}
|
|
102
|
+
case "file":
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// fall through to the file
|
|
107
|
+
}
|
|
108
|
+
writeFileSync(filePath(routerHome), `${secret}\n`, { encoding: "utf8", mode: 0o600 });
|
|
109
|
+
try {
|
|
110
|
+
chmodSync(filePath(routerHome), 0o600);
|
|
111
|
+
} catch {
|
|
112
|
+
/* Windows */
|
|
113
|
+
}
|
|
114
|
+
return "file";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The refresh token from the store `remote.json` names, or null when it is gone. */
|
|
118
|
+
export function loadRefreshToken(routerHome: string, account: string, kind: StoreKind, deps: StoreDeps = {}): string | null {
|
|
119
|
+
const pathHas = deps.pathHas ?? ((bin) => Bun.which(bin) !== null);
|
|
120
|
+
try {
|
|
121
|
+
if (deps.backend !== undefined) return deps.backend.load(account);
|
|
122
|
+
switch (kind) {
|
|
123
|
+
case "dpapi": {
|
|
124
|
+
const p = dpapiPath(routerHome);
|
|
125
|
+
if (!existsSync(p)) return null;
|
|
126
|
+
return dpapiUnprotect(readFileSync(p, "utf8").trim(), pathHas);
|
|
127
|
+
}
|
|
128
|
+
case "keychain": {
|
|
129
|
+
const r = spawnSync("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w"], { encoding: "utf8" });
|
|
130
|
+
return r.status === 0 ? r.stdout.replace(/\r?\n$/, "") : null;
|
|
131
|
+
}
|
|
132
|
+
case "secret-service": {
|
|
133
|
+
const r = spawnSync("secret-tool", ["lookup", "service", SERVICE, "account", account], { encoding: "utf8" });
|
|
134
|
+
return r.status === 0 && r.stdout !== "" ? r.stdout.replace(/\r?\n$/, "") : null;
|
|
135
|
+
}
|
|
136
|
+
case "file": {
|
|
137
|
+
const p = filePath(routerHome);
|
|
138
|
+
return existsSync(p) ? readFileSync(p, "utf8").trim() : null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Forgets the token everywhere it might be. */
|
|
148
|
+
export function removeRefreshToken(routerHome: string, account: string, deps: StoreDeps = {}): void {
|
|
149
|
+
rmSync(filePath(routerHome), { force: true });
|
|
150
|
+
rmSync(dpapiPath(routerHome), { force: true });
|
|
151
|
+
if (deps.backend !== undefined) {
|
|
152
|
+
deps.backend.remove(account);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (process.platform === "darwin") spawnSync("security", ["delete-generic-password", "-a", account, "-s", SERVICE], { encoding: "utf8" });
|
|
156
|
+
if (process.platform === "linux") spawnSync("secret-tool", ["clear", "service", SERVICE, "account", account], { encoding: "utf8" });
|
|
157
|
+
}
|
package/src/cli/refresh.ts
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { dirname, resolve } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import { readRemoteRouter, type RemoteRouter } from "../../omp-extension/remote-logic.ts";
|
|
19
|
+
import { hasRefresh, readRemoteRouter, refreshAccountOf, type RemoteRouter } from "../../omp-extension/remote-logic.ts";
|
|
20
|
+
import { loadRefreshToken, type StoreDeps } from "./credential-store.ts";
|
|
20
21
|
import { routerHome } from "../../omp-extension/router-url.ts";
|
|
21
22
|
import type { CliArgs } from "./args.ts";
|
|
22
23
|
import { connectRemote } from "./connect.ts";
|
|
@@ -34,7 +35,7 @@ export interface RefreshedCredential {
|
|
|
34
35
|
|
|
35
36
|
/** True when the credential can and should be traded now: it has a refresh token and its key is near or past expiry. */
|
|
36
37
|
export function shouldRefresh(remote: RemoteRouter, nowMs = Date.now()): boolean {
|
|
37
|
-
if (remote
|
|
38
|
+
if (!hasRefresh(remote)) return false;
|
|
38
39
|
if (remote.keyExpiresAtMs === undefined) return false;
|
|
39
40
|
return remote.keyExpiresAtMs - nowMs <= REFRESH_AHEAD_MS;
|
|
40
41
|
}
|
|
@@ -49,13 +50,21 @@ export class RefreshError extends Error {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** The refresh token: inline from an older remote.json, else from the OS store remote.json names. */
|
|
54
|
+
export function resolveRefreshToken(remote: RemoteRouter, routerHome: string, storeDeps: StoreDeps = {}): string | null {
|
|
55
|
+
if (remote.refreshToken !== undefined && remote.refreshToken !== "") return remote.refreshToken;
|
|
56
|
+
if (remote.refreshTokenStore === undefined) return null;
|
|
57
|
+
return loadRefreshToken(routerHome, remote.refreshAccount ?? refreshAccountOf(remote.url, remote.userId), remote.refreshTokenStore, storeDeps);
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
/** Trades the refresh token at the remote for the next credential. */
|
|
53
|
-
export async function refreshCredential(remote: RemoteRouter, fetchImpl: typeof fetch = fetch): Promise<RefreshedCredential> {
|
|
54
|
-
|
|
61
|
+
export async function refreshCredential(remote: RemoteRouter, fetchImpl: typeof fetch = fetch, routerHomeDir: string = routerHome(), storeDeps: StoreDeps = {}): Promise<RefreshedCredential> {
|
|
62
|
+
const token = resolveRefreshToken(remote, routerHomeDir, storeDeps);
|
|
63
|
+
if (token === null || token === "") throw new RefreshError("no_refresh_token", "this machine holds no refresh token (or its credential store no longer has it); onboard it again with a setup token");
|
|
55
64
|
const res = await fetchImpl(`${remote.url}/auth/refresh`, {
|
|
56
65
|
method: "POST",
|
|
57
66
|
headers: { "content-type": "application/json" },
|
|
58
|
-
body: JSON.stringify({ refreshToken:
|
|
67
|
+
body: JSON.stringify({ refreshToken: token }),
|
|
59
68
|
signal: AbortSignal.timeout(15_000),
|
|
60
69
|
});
|
|
61
70
|
const body = (await res.json().catch(() => null)) as { key?: unknown; keyExpiresAtMs?: unknown; refreshToken?: unknown; refreshExpiresAtMs?: unknown; device?: unknown; error?: { code?: string; message?: string } } | null;
|
|
@@ -76,8 +85,9 @@ export async function refreshCredential(remote: RemoteRouter, fetchImpl: typeof
|
|
|
76
85
|
* harness configs `connect` manages. Returns the fresh credential. `home` and
|
|
77
86
|
* `packageDir` are injectable for tests.
|
|
78
87
|
*/
|
|
79
|
-
export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?: typeof fetch; home?: string; packageDir?: string; env?: Record<string, string | undefined>; platform?: string; pathHas?: (bin: string) => boolean }): Promise<RefreshedCredential> {
|
|
80
|
-
const
|
|
88
|
+
export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?: typeof fetch; home?: string; packageDir?: string; env?: Record<string, string | undefined>; platform?: string; pathHas?: (bin: string) => boolean; routerHome?: string; storeDeps?: StoreDeps }): Promise<RefreshedCredential> {
|
|
89
|
+
const rh = opts.routerHome ?? routerHome();
|
|
90
|
+
const fresh = await refreshCredential(opts.remote, opts.fetchImpl ?? fetch, rh, opts.storeDeps ?? {});
|
|
81
91
|
const home = opts.home ?? (process.env.HOME !== undefined && process.env.HOME !== "" ? process.env.HOME : homedir());
|
|
82
92
|
const packageDir = opts.packageDir ?? resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
83
93
|
connectRemote({
|
|
@@ -97,6 +107,9 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
|
|
|
97
107
|
packageDir,
|
|
98
108
|
platform: opts.platform ?? process.platform,
|
|
99
109
|
pathHas: opts.pathHas ?? ((bin) => Bun.which(bin) !== null),
|
|
110
|
+
// The store that already holds it keeps it; a machine never silently changes store.
|
|
111
|
+
...(opts.remote.refreshTokenStore !== undefined ? { store: opts.remote.refreshTokenStore } : {}),
|
|
112
|
+
...(opts.storeDeps !== undefined ? { storeDeps: opts.storeDeps } : {}),
|
|
100
113
|
// undefined keeps whatever scope the managed models.yml block already carries.
|
|
101
114
|
});
|
|
102
115
|
return fresh;
|
package/test/remote.test.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
|
|
6
6
|
import { addExtensions, codexBlock, connectRemote, setDotenv, type ConnectOptions } from "../src/cli/connect.ts";
|
|
7
7
|
import { parseRemoteRouter, readRemoteRouter, remoteProviderRegistration } from "../omp-extension/remote-logic.ts";
|
|
8
8
|
import { existingBlockScope, hasForeignRouterProvider, mergeModelsYml, renderRemoteModelsYml } from "../src/cli/connect.ts";
|
|
9
|
-
import { refreshAndRewrite, refreshCredential, RefreshError, shouldRefresh } from "../src/cli/refresh.ts";
|
|
9
|
+
import { refreshAndRewrite, refreshCredential, RefreshError, resolveRefreshToken, shouldRefresh } from "../src/cli/refresh.ts";
|
|
10
|
+
import { loadRefreshToken, pickStore, removeRefreshToken, saveRefreshToken } from "../src/cli/credential-store.ts";
|
|
11
|
+
import { hasRefresh, refreshAccountOf } from "../omp-extension/remote-logic.ts";
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Remote mode: remote.json puts the omp extensions on a router elsewhere,
|
|
@@ -78,7 +80,8 @@ describe("connect", () => {
|
|
|
78
80
|
expect(readFileSync(join(home, ".codex", "config.toml"), "utf8")).toContain("[model_providers.auto-model-router]");
|
|
79
81
|
expect(readFileSync(join(home, ".aider.conf.yml"), "utf8")).toContain("openai-api-base: https://team.example/v1");
|
|
80
82
|
expect(r1.configured.join("\n")).toMatch(/omp[\s\S]*Hermes[\s\S]*Codex[\s\S]*Aider[\s\S]*Claude Code/);
|
|
81
|
-
|
|
83
|
+
// Claude Code is configured through its settings file now, so nothing ANTHROPIC_* rides in the environment.
|
|
84
|
+
expect(r1.envLines).toEqual(["AUTO_MODEL_ROUTER_URL=https://team.example", "AUTO_MODEL_ROUTER_API_KEY=amrt_key"]);
|
|
82
85
|
// Running again changes nothing.
|
|
83
86
|
const snapshot = [ompCfg, readFileSync(join(home, ".codex", "config.toml"), "utf8"), readFileSync(join(home, ".aider.conf.yml"), "utf8")];
|
|
84
87
|
connectRemote(o);
|
|
@@ -99,7 +102,8 @@ describe("connect", () => {
|
|
|
99
102
|
connectRemote(o2);
|
|
100
103
|
const rc = readFileSync(join(h2, ".zshrc"), "utf8");
|
|
101
104
|
expect(rc.split("# auto-model-router remote").length).toBe(2);
|
|
102
|
-
expect(rc).toContain("export
|
|
105
|
+
expect(rc).toContain("export AUTO_MODEL_ROUTER_URL=https://team.example");
|
|
106
|
+
expect(rc).not.toContain("ANTHROPIC_");
|
|
103
107
|
rmSync(home, { recursive: true, force: true });
|
|
104
108
|
rmSync(h2, { recursive: true, force: true });
|
|
105
109
|
});
|
|
@@ -203,16 +207,21 @@ describe("short-lived remote credentials", () => {
|
|
|
203
207
|
const { connectRemote } = await import("../src/cli/connect.ts");
|
|
204
208
|
connectRemote({ url: "https://team.example", key: "amrt_old", userId: "u_ada", name: "Ada", refreshToken: "amrr_r1", keyExpiresAtMs: 1, refreshExpiresAtMs: 2, device: "laptop", agentdoxScope: "omp-router", profile: false, dryRun: false, only: ["omp"], env, home, packageDir: process.cwd(), platform: "linux", pathHas: () => false });
|
|
205
209
|
const before = JSON.parse(readFileSync(join(routerHome, "remote.json"), "utf8")) as Record<string, unknown>;
|
|
206
|
-
|
|
210
|
+
// The refresh token is in the store (the file here: no platform tool on PATH); remote.json only names it.
|
|
211
|
+
expect(before).toMatchObject({ key: "amrt_old", keyExpiresAtMs: 1, device: "laptop", refreshTokenStore: "file", refreshAccount: "u_ada@team.example" });
|
|
212
|
+
expect(before.refreshToken).toBeUndefined();
|
|
213
|
+
expect(readFileSync(join(routerHome, "refresh.token"), "utf8").trim()).toBe("amrr_r1");
|
|
207
214
|
const models0 = readFileSync(join(agent, "models.yml"), "utf8");
|
|
208
215
|
expect(models0).toContain("apiKey: amrt_old");
|
|
209
216
|
expect(existingBlockScope(models0)).toBe("omp-router");
|
|
210
217
|
// Then a refresh, which knows nothing about the scope.
|
|
211
218
|
const fetchImpl = (async () => Response.json({ key: "amrt_new", keyExpiresAtMs: 50, refreshToken: "amrr_r2", refreshExpiresAtMs: 90 })) as unknown as typeof fetch;
|
|
212
|
-
const fresh = await refreshAndRewrite({ remote: parseRemoteRouter(readFileSync(join(routerHome, "remote.json"), "utf8"))!, fetchImpl, home, packageDir: process.cwd(), env, platform: "linux", pathHas: () => false });
|
|
219
|
+
const fresh = await refreshAndRewrite({ remote: parseRemoteRouter(readFileSync(join(routerHome, "remote.json"), "utf8"))!, fetchImpl, home, packageDir: process.cwd(), env, platform: "linux", pathHas: () => false, routerHome });
|
|
213
220
|
expect(fresh.key).toBe("amrt_new");
|
|
214
221
|
const after = JSON.parse(readFileSync(join(routerHome, "remote.json"), "utf8")) as Record<string, unknown>;
|
|
215
|
-
expect(after).toMatchObject({ key: "amrt_new",
|
|
222
|
+
expect(after).toMatchObject({ key: "amrt_new", keyExpiresAtMs: 50, refreshExpiresAtMs: 90, device: "laptop", joinedAtMs: before.joinedAtMs, refreshTokenStore: "file" });
|
|
223
|
+
expect(after.refreshToken).toBeUndefined();
|
|
224
|
+
expect(readFileSync(join(routerHome, "refresh.token"), "utf8").trim()).toBe("amrr_r2");
|
|
216
225
|
const models1 = readFileSync(join(agent, "models.yml"), "utf8");
|
|
217
226
|
expect(models1).toContain("apiKey: amrt_new");
|
|
218
227
|
expect(models1).not.toContain("amrt_old");
|
|
@@ -222,3 +231,91 @@ describe("short-lived remote credentials", () => {
|
|
|
222
231
|
}
|
|
223
232
|
});
|
|
224
233
|
});
|
|
234
|
+
|
|
235
|
+
describe("the refresh token lives in the OS credential store", () => {
|
|
236
|
+
const NL = String.fromCharCode(10);
|
|
237
|
+
// A fake backend stands in for DPAPI / the keychain / secret-service.
|
|
238
|
+
const vault = new Map<string, string>();
|
|
239
|
+
const backend = { save: (a: string, s: string) => void vault.set(a, s), load: (a: string) => vault.get(a) ?? null, remove: (a: string) => void vault.delete(a) };
|
|
240
|
+
|
|
241
|
+
test("the store is picked from the platform and its tools; the file is the fallback everywhere", () => {
|
|
242
|
+
expect(pickStore("win32", (b) => b === "powershell")).toBe("dpapi");
|
|
243
|
+
expect(pickStore("win32", () => false)).toBe("file");
|
|
244
|
+
expect(pickStore("darwin", (b) => b === "security")).toBe("keychain");
|
|
245
|
+
expect(pickStore("linux", (b) => b === "secret-tool")).toBe("secret-service");
|
|
246
|
+
expect(pickStore("linux", () => false)).toBe("file");
|
|
247
|
+
expect(refreshAccountOf("https://team.example:8790/", "u_ada")).toBe("u_ada@team.example:8790");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("save/load through a store, and the file fallback keeps the token owner-readable", () => {
|
|
251
|
+
const home = mkdtempSync(join(tmpdir(), "amr-store-"));
|
|
252
|
+
try {
|
|
253
|
+
expect(saveRefreshToken(home, "u@t", "amrr_x", "keychain", { backend })).toBe("keychain");
|
|
254
|
+
expect(loadRefreshToken(home, "u@t", "keychain", { backend })).toBe("amrr_x");
|
|
255
|
+
expect(existsSync(join(home, "refresh.token"))).toBe(false); // nothing on disk
|
|
256
|
+
expect(saveRefreshToken(home, "u@t", "amrr_f", "file")).toBe("file");
|
|
257
|
+
expect(loadRefreshToken(home, "u@t", "file")).toBe("amrr_f");
|
|
258
|
+
removeRefreshToken(home, "u@t", { backend });
|
|
259
|
+
expect(loadRefreshToken(home, "u@t", "keychain", { backend })).toBeNull();
|
|
260
|
+
expect(existsSync(join(home, "refresh.token"))).toBe(false);
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(home, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("connect files the token in the store and remote.json only names it; refresh reads it back; an older inline token still works", async () => {
|
|
267
|
+
const home = mkdtempSync(join(tmpdir(), "amr-store2-"));
|
|
268
|
+
const agent = join(home, ".omp", "agent");
|
|
269
|
+
mkdirSync(agent, { recursive: true });
|
|
270
|
+
writeFileSync(join(agent, "config.yml"), "extensions: []" + NL);
|
|
271
|
+
const rh = join(home, ".auto-model-router");
|
|
272
|
+
const env = { HOME: home, PI_CODING_AGENT_DIR: agent, AUTO_MODEL_ROUTER_HOME: rh, HERMES_HOME: join(home, "no-hermes") };
|
|
273
|
+
try {
|
|
274
|
+
const { connectRemote } = await import("../src/cli/connect.ts");
|
|
275
|
+
connectRemote({ url: "https://team.example", key: "amrt_k1", userId: "u_ada", name: "Ada", refreshToken: "amrr_r1", keyExpiresAtMs: 1, refreshExpiresAtMs: 2, device: "laptop", store: "keychain", storeDeps: { backend }, profile: false, dryRun: false, only: ["omp"], env, home, packageDir: process.cwd(), platform: "darwin", pathHas: () => false });
|
|
276
|
+
const written = readFileSync(join(rh, "remote.json"), "utf8");
|
|
277
|
+
expect(written).not.toContain("amrr_r1");
|
|
278
|
+
const remote = parseRemoteRouter(written)!;
|
|
279
|
+
expect(remote).toMatchObject({ refreshTokenStore: "keychain", refreshAccount: "u_ada@team.example" });
|
|
280
|
+
expect(remote.refreshToken).toBeUndefined();
|
|
281
|
+
expect(hasRefresh(remote)).toBe(true);
|
|
282
|
+
expect(resolveRefreshToken(remote, rh, { backend })).toBe("amrr_r1");
|
|
283
|
+
// The refresh trades the stored token and files the new one in the same store.
|
|
284
|
+
const seen: string[] = [];
|
|
285
|
+
const fetchImpl = (async (_u: string | URL | Request, init?: RequestInit) => {
|
|
286
|
+
seen.push(String(init?.body));
|
|
287
|
+
return Response.json({ key: "amrt_k2", keyExpiresAtMs: 50, refreshToken: "amrr_r2", refreshExpiresAtMs: 90 });
|
|
288
|
+
}) as unknown as typeof fetch;
|
|
289
|
+
await refreshAndRewrite({ remote, fetchImpl, home, packageDir: process.cwd(), env, platform: "darwin", pathHas: () => false, routerHome: rh, storeDeps: { backend } });
|
|
290
|
+
expect(seen[0]).toBe(JSON.stringify({ refreshToken: "amrr_r1" }));
|
|
291
|
+
expect(vault.get("u_ada@team.example")).toBe("amrr_r2");
|
|
292
|
+
expect(readFileSync(join(rh, "remote.json"), "utf8")).not.toContain("amrr_r2");
|
|
293
|
+
// An older remote.json with the token inline is honoured until its next refresh.
|
|
294
|
+
const legacy = parseRemoteRouter(JSON.stringify({ url: "https://t", key: "k", refreshToken: "amrr_inline" }))!;
|
|
295
|
+
expect(resolveRefreshToken(legacy, rh)).toBe("amrr_inline");
|
|
296
|
+
} finally {
|
|
297
|
+
rmSync(home, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("connect writes Claude Code's settings: base URL in env, a key helper instead of a key, other settings kept", async () => {
|
|
302
|
+
const home = mkdtempSync(join(tmpdir(), "amr-claude-"));
|
|
303
|
+
const claude = join(home, ".claude");
|
|
304
|
+
mkdirSync(claude, { recursive: true });
|
|
305
|
+
writeFileSync(join(claude, "settings.json"), JSON.stringify({ theme: "dark", env: { ANTHROPIC_API_KEY: "sk-old", FOO: "bar" } }, null, 2) + NL);
|
|
306
|
+
const env = { HOME: home, PI_CODING_AGENT_DIR: join(home, "no-omp"), AUTO_MODEL_ROUTER_HOME: join(home, ".auto-model-router"), HERMES_HOME: join(home, "no-hermes") };
|
|
307
|
+
try {
|
|
308
|
+
const { connectRemote } = await import("../src/cli/connect.ts");
|
|
309
|
+
const r = connectRemote({ url: "https://team.example", key: "amrt_k", userId: "u_ada", name: "Ada", profile: false, dryRun: false, only: ["claude"], env, home, packageDir: "/pkg", platform: "linux", pathHas: () => false });
|
|
310
|
+
expect(r.configured.some((c) => c.startsWith("Claude Code ("))).toBe(true);
|
|
311
|
+
const s = JSON.parse(readFileSync(join(claude, "settings.json"), "utf8")) as { theme: string; env: Record<string, string>; apiKeyHelper: string };
|
|
312
|
+
expect(s.theme).toBe("dark");
|
|
313
|
+
expect(s.env).toEqual({ FOO: "bar", ANTHROPIC_BASE_URL: "https://team.example" }); // the stale key is gone
|
|
314
|
+
expect(s.apiKeyHelper).toMatch(/^bun run ".*\/pkg\/src\/index\.ts" token$/); // an absolute path, drive letter and all on Windows
|
|
315
|
+
expect(r.envLines.some((l) => l.startsWith("ANTHROPIC_API_KEY="))).toBe(false);
|
|
316
|
+
expect(readdirSync(claude).some((f) => f.startsWith("settings.json.") && f.endsWith(".bak"))).toBe(true); // the previous file was kept
|
|
317
|
+
} finally {
|
|
318
|
+
rmSync(home, { recursive: true, force: true });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|