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.
@@ -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.8.1",
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.8.1",
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 (Claude Code's `apiKeyHelper`). The remote keeps the
1316
- old key valid until its own expiry, so a session still holding it is never cut. A refresh
1317
- token presented twice means the credential was copied: the remote revokes that device, and
1318
- the machine onboards again.
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
- /** Present when the remote issues short-lived keys: trades for the next key (see src/cli/refresh.ts). */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -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 ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY (printed; --profile persists)
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
- ...(o.refreshToken !== undefined && o.refreshToken !== "" ? { refreshToken: o.refreshToken } : {}),
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: environment only.
304
- if (wants(o, "claude") && o.pathHas("claude")) {
305
- report.envLines.push(`ANTHROPIC_BASE_URL=${o.url}`, `ANTHROPIC_API_KEY=${o.key}`);
306
- report.configured.push("Claude Code (environment)");
307
- } else report.skipped.push("Claude Code (not on PATH)");
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
+ }
@@ -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.refreshToken === undefined || remote.refreshToken === "") return false;
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
- if (remote.refreshToken === undefined || remote.refreshToken === "") throw new RefreshError("no_refresh_token", "this machine holds no refresh token; onboard it again with a setup token");
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: remote.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 fresh = await refreshCredential(opts.remote, opts.fetchImpl ?? fetch);
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;
@@ -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
- expect(r1.envLines).toEqual(["AUTO_MODEL_ROUTER_URL=https://team.example", "AUTO_MODEL_ROUTER_API_KEY=amrt_key", "ANTHROPIC_BASE_URL=https://team.example", "ANTHROPIC_API_KEY=amrt_key"]);
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 ANTHROPIC_BASE_URL=https://team.example");
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
- expect(before).toMatchObject({ key: "amrt_old", refreshToken: "amrr_r1", keyExpiresAtMs: 1, device: "laptop" });
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", refreshToken: "amrr_r2", keyExpiresAtMs: 50, refreshExpiresAtMs: 90, device: "laptop", joinedAtMs: before.joinedAtMs });
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
+ });