auto-model-router 0.35.0 → 0.37.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.
Files changed (45) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +104 -10
  3. package/omp-extension/remote-logic.ts +15 -0
  4. package/package.json +1 -1
  5. package/src/catalog/benchmark-feeds.ts +179 -41
  6. package/src/catalog/openrouter-catalog.ts +10 -2
  7. package/src/cli/connect.ts +89 -17
  8. package/src/cli/context-token.ts +133 -0
  9. package/src/cli/credential-store.ts +65 -20
  10. package/src/cli/refresh.ts +26 -1
  11. package/src/config/defaults.ts +1 -0
  12. package/src/config/schema.ts +22 -0
  13. package/src/config/types.ts +31 -0
  14. package/src/cost/ledger-sql.ts +3 -2
  15. package/src/cost/ledger.ts +4 -0
  16. package/src/cost/types.ts +12 -0
  17. package/src/cost/views.ts +12 -0
  18. package/src/lib.ts +12 -0
  19. package/src/router/candidates.ts +17 -0
  20. package/src/router/index.ts +4 -0
  21. package/src/router/types.ts +2 -0
  22. package/src/server/catalog-view.ts +8 -2
  23. package/src/server/compaction-digest.ts +3 -0
  24. package/src/server/digest.ts +11 -0
  25. package/src/server/http.ts +46 -4
  26. package/src/server/turn.ts +5 -0
  27. package/src/util/requestid.ts +77 -0
  28. package/src/util/schema.ts +42 -3
  29. package/src/util/sqlite.ts +20 -1
  30. package/src/wire/openai/request.ts +8 -0
  31. package/src/wire/types.ts +18 -0
  32. package/test/benchmark-feeds.test.ts +194 -0
  33. package/test/catalog-view.test.ts +11 -0
  34. package/test/config.test.ts +26 -0
  35. package/test/context-token.test.ts +301 -0
  36. package/test/failover.test.ts +1 -1
  37. package/test/ledger-sql.test.ts +1 -1
  38. package/test/mcp-entry.test.ts +14 -5
  39. package/test/migrations.test.ts +11 -4
  40. package/test/reconfigure.test.ts +55 -0
  41. package/test/request-id.test.ts +424 -0
  42. package/test/schema.test.ts +2 -2
  43. package/test/tier-plan.test.ts +51 -0
  44. package/test/trust-attribution.test.ts +2 -2
  45. package/test/turn.test.ts +56 -1
@@ -37,11 +37,12 @@ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileS
37
37
  import { homedir, hostname } from "node:os";
38
38
  import { dirname, join, resolve } from "node:path";
39
39
  import { fileURLToPath } from "node:url";
40
- import { refreshAccountOf, remoteFilePath } from "../../omp-extension/remote-logic.ts";
40
+ import { readRemoteRouter, refreshAccountOf, remoteFilePath } from "../../omp-extension/remote-logic.ts";
41
41
  import { ORIGIN_ENV, SCOPE_ENV } from "../context/scope.ts";
42
42
  import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
43
43
  import { fetchSkills, installSkills, type SkillsBundle, type SkillsInstallReport, type SkillsTarget } from "./skills.ts";
44
- import { pickStore, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
44
+ import { contextAccountOf, pickStore, saveContextToken, saveRefreshToken, type StoreDeps, type StoreKind } from "./credential-store.ts";
45
+ import { ensureContextToken, type ContextToken, type McpAuth } from "./context-token.ts";
45
46
  import { flagString, type CliArgs } from "./args.ts";
46
47
  import { cursorSnippet, mergeClineProviders, mergeContinueConfig, mergeOpenCodeConfig, windsurfSnippet, type ManualSnippet } from "./harnesses.ts";
47
48
 
@@ -80,10 +81,17 @@ export interface ConnectOptions {
80
81
  skills?: SkillsBundle;
81
82
  /**
82
83
  * The remote's MCP endpoint (a team edition serving shared context): written as the
83
- * `team-context` server for omp and Claude Code with the member key, rewritten on every
84
- * refresh like models.yml. `null` removes an entry a previous connect wrote.
84
+ * `team-context` server for omp and Claude Code. `null` removes an entry a previous
85
+ * connect wrote.
86
+ *
87
+ * `token` is the team's **context token** when it mints them (`/setup/info` says
88
+ * `mcpAuth: "context-token"`): a year-long credential good for nothing but that member's
89
+ * shared context, which is what the entry carries so a key rotation every 72 hours does
90
+ * not kill a running MCP client. It is filed in the OS credential store like the refresh
91
+ * token, and remote.json records only the store, the expiry and the id. Without one —
92
+ * an older team edition — the entry carries the access key exactly as it always did.
85
93
  */
86
- mcp?: { url: string | null };
94
+ mcp?: { url: string | null; token?: string; tokenExpiresAtMs?: number; tokenId?: string };
87
95
  platform: string;
88
96
  pathHas: (bin: string) => boolean;
89
97
  }
@@ -112,9 +120,10 @@ export const MCP_SERVER_NAME = "team-context";
112
120
  /**
113
121
  * Merges the team-context server into an `mcpServers` JSON file (omp's mcp.json, Claude
114
122
  * Code's ~/.claude.json), leaving every other key and server alone; `url` null removes it.
123
+ * `bearer` is the team's context token when it mints one, else the member's access key.
115
124
  * Returns the new text, or null when nothing changes.
116
125
  */
117
- export function mergeMcpServers(before: string, url: string | null, key: string): string | null {
126
+ export function mergeMcpServers(before: string, url: string | null, bearer: string): string | null {
118
127
  let root: Record<string, unknown> = {};
119
128
  if (before.trim() !== "") {
120
129
  try {
@@ -130,7 +139,7 @@ export function mergeMcpServers(before: string, url: string | null, key: string)
130
139
  if (!(MCP_SERVER_NAME in servers)) return null;
131
140
  delete servers[MCP_SERVER_NAME];
132
141
  } else {
133
- const next = { type: "http", url, headers: { Authorization: `Bearer ${key}` } };
142
+ const next = { type: "http", url, headers: { Authorization: `Bearer ${bearer}` } };
134
143
  if (JSON.stringify(servers[MCP_SERVER_NAME]) === JSON.stringify(next)) return null;
135
144
  servers[MCP_SERVER_NAME] = next;
136
145
  }
@@ -315,6 +324,27 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
315
324
  refreshTokenStore = saveRefreshToken(rh, refreshAccount, o.refreshToken, wanted, o.storeDeps ?? { pathHas: o.pathHas });
316
325
  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)`);
317
326
  } else if (o.refreshToken !== undefined && o.refreshToken !== "") refreshTokenStore = o.store ?? pickStore(o.platform, o.pathHas);
327
+ // The context token is the other long-lived secret and goes to the same store, in its own
328
+ // slot. When this connect learned nothing about one (a plain router, or a /setup/info that
329
+ // did not answer) what the previous remote.json recorded is carried forward rather than
330
+ // dropped: the store still holds that token, and forgetting it would orphan it for a year.
331
+ let contextTokenStore: StoreKind | undefined;
332
+ const contextAccount = contextAccountOf(refreshAccount);
333
+ const contextToken = o.mcp?.token !== undefined && o.mcp.token !== "" ? o.mcp.token : undefined;
334
+ if (contextToken !== undefined && !o.dryRun) {
335
+ const wanted = o.store ?? pickStore(o.platform, o.pathHas);
336
+ contextTokenStore = saveContextToken(rh, contextAccount, contextToken, wanted, o.storeDeps ?? { pathHas: o.pathHas });
337
+ if (contextTokenStore !== wanted) report.notes.push(`the ${wanted} credential store was not usable; the shared-context token is in ${join(rh, "context.token")} (owner-readable only)`);
338
+ } else if (contextToken !== undefined) contextTokenStore = o.store ?? pickStore(o.platform, o.pathHas);
339
+ const contextFields =
340
+ contextToken !== undefined
341
+ ? { contextTokenStore: contextTokenStore!, contextAccount, ...(o.mcp?.tokenExpiresAtMs === undefined ? {} : { contextTokenExpiresAtMs: o.mcp.tokenExpiresAtMs }), ...(o.mcp?.tokenId === undefined ? {} : { contextTokenId: o.mcp.tokenId }) }
342
+ : {
343
+ ...(typeof previous.contextTokenStore === "string" ? { contextTokenStore: previous.contextTokenStore } : {}),
344
+ ...(typeof previous.contextAccount === "string" ? { contextAccount: previous.contextAccount } : {}),
345
+ ...(typeof previous.contextTokenExpiresAtMs === "number" ? { contextTokenExpiresAtMs: previous.contextTokenExpiresAtMs } : {}),
346
+ ...(typeof previous.contextTokenId === "string" ? { contextTokenId: previous.contextTokenId } : {}),
347
+ };
318
348
  write(
319
349
  report.remoteFile,
320
350
  `${JSON.stringify(
@@ -325,6 +355,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
325
355
  name: o.name,
326
356
  joinedAtMs: typeof previous.joinedAtMs === "number" ? previous.joinedAtMs : Date.now(),
327
357
  ...(refreshTokenStore !== undefined ? { refreshTokenStore, refreshAccount } : {}),
358
+ ...contextFields,
328
359
  ...(o.keyExpiresAtMs !== undefined ? { keyExpiresAtMs: o.keyExpiresAtMs } : {}),
329
360
  ...(o.refreshExpiresAtMs !== undefined ? { refreshExpiresAtMs: o.refreshExpiresAtMs } : {}),
330
361
  ...(o.device !== undefined && o.device !== "" ? { device: o.device } : {}),
@@ -438,8 +469,10 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
438
469
  }
439
470
 
440
471
  // 6c. The remote's MCP endpoint (shared context tools), for the harnesses configured above
441
- // that read a user-level mcpServers file; the member key travels in the header and is
442
- // rewritten with every refresh, like models.yml.
472
+ // that read a user-level mcpServers file. The bearer is the team's context token when it
473
+ // mints one an MCP client reads its configuration once at startup, so an entry carrying
474
+ // the 72-hour access key 401s mid-session every few days. Without a context token (an
475
+ // older team edition) it is the access key, rewritten by every refresh as it always was.
443
476
  if (o.mcp !== undefined) {
444
477
  const files: string[] = [];
445
478
  if (report.configured.some((c) => c.startsWith("omp ("))) files.push(join(agentDir, "mcp.json"));
@@ -447,7 +480,7 @@ export function connectRemote(o: ConnectOptions): ConnectReport {
447
480
  report.mcp = [];
448
481
  for (const path of files) {
449
482
  const before = existsSync(path) ? readFileSync(path, "utf8") : "";
450
- const after = mergeMcpServers(before, o.mcp.url, o.key);
483
+ const after = mergeMcpServers(before, o.mcp.url, contextToken ?? o.key);
451
484
  if (after === null) continue;
452
485
  write(path, after);
453
486
  report.mcp.push(path);
@@ -595,6 +628,14 @@ export interface IssuedCredential {
595
628
  refreshExpiresAtMs?: number;
596
629
  userId: string;
597
630
  name: string;
631
+ /**
632
+ * The team's shared-context credential, when it mints them: handed over with the rest so
633
+ * onboarding costs no extra round trip. Absent from an older team edition's answer, and
634
+ * then `connect` either mints one at /me/context-tokens or keeps using the access key.
635
+ */
636
+ contextToken?: string;
637
+ contextTokenExpiresAtMs?: number;
638
+ contextTokenId?: string;
598
639
  }
599
640
 
600
641
  /**
@@ -615,18 +656,27 @@ export async function exchangeSetupToken(url: string, token: string, device: str
615
656
  ...(typeof body.refreshExpiresAtMs === "number" ? { refreshExpiresAtMs: body.refreshExpiresAtMs } : {}),
616
657
  userId: typeof body.userId === "string" ? body.userId : "",
617
658
  name: typeof body.name === "string" ? body.name : "",
659
+ ...(typeof body.contextToken === "string" && body.contextToken !== "" ? { contextToken: body.contextToken } : {}),
660
+ ...(typeof body.contextTokenExpiresAtMs === "number" ? { contextTokenExpiresAtMs: body.contextTokenExpiresAtMs } : {}),
661
+ ...(typeof body.contextTokenId === "string" && body.contextTokenId !== "" ? { contextTokenId: body.contextTokenId } : {}),
618
662
  };
619
663
  }
620
664
 
621
- /** What a team edition says about itself at /setup/info; a plain router answers nothing. */
622
- export async function fetchSetupInfo(url: string, fetchImpl: typeof fetch = fetch): Promise<{ mcp: boolean }> {
665
+ /**
666
+ * What a team edition says about itself at /setup/info; a plain router answers nothing.
667
+ *
668
+ * `mcpAuth` says which credential belongs in the MCP entry. It is `member-key` whenever the
669
+ * field is missing — a team edition older than context tokens, a plain router, an
670
+ * unreachable remote — so nothing about those deployments changes.
671
+ */
672
+ export async function fetchSetupInfo(url: string, fetchImpl: typeof fetch = fetch): Promise<{ mcp: boolean; mcpAuth: McpAuth }> {
623
673
  try {
624
674
  const res = await fetchImpl(`${url}/setup/info`, { signal: AbortSignal.timeout(10_000) });
625
- if (!res.ok) return { mcp: false };
626
- const body = (await res.json()) as { mcp?: unknown };
627
- return { mcp: body.mcp === true };
675
+ if (!res.ok) return { mcp: false, mcpAuth: "member-key" };
676
+ const body = (await res.json()) as { mcp?: unknown; mcpAuth?: unknown };
677
+ return { mcp: body.mcp === true, mcpAuth: body.mcpAuth === "context-token" ? "context-token" : "member-key" };
628
678
  } catch {
629
- return { mcp: false };
679
+ return { mcp: false, mcpAuth: "member-key" };
630
680
  }
631
681
  }
632
682
 
@@ -666,6 +716,8 @@ export async function connectCommand(args: CliArgs): Promise<void> {
666
716
  let refreshToken = flagString(args, "refresh-token") ?? "";
667
717
  let keyExpires = Number.parseInt(flagString(args, "key-expires") ?? "", 10);
668
718
  let refreshExpires = Number.parseInt(flagString(args, "refresh-expires") ?? "", 10);
719
+ // The team's shared-context credential, when the exchange hands one over.
720
+ let issuedContext: ContextToken | undefined;
669
721
  if (setupToken !== "") {
670
722
  if (device === "") device = hostname();
671
723
  const issued = await exchangeSetupToken(url, setupToken, device);
@@ -675,6 +727,12 @@ export async function connectCommand(args: CliArgs): Promise<void> {
675
727
  refreshExpires = issued.refreshExpiresAtMs ?? Number.NaN;
676
728
  if (issued.userId !== "") userId = issued.userId;
677
729
  if (issued.name !== "") name = issued.name;
730
+ if (issued.contextToken !== undefined)
731
+ issuedContext = {
732
+ value: issued.contextToken,
733
+ ...(issued.contextTokenExpiresAtMs === undefined ? {} : { expiresAtMs: issued.contextTokenExpiresAtMs }),
734
+ ...(issued.contextTokenId === undefined ? {} : { id: issued.contextTokenId }),
735
+ };
678
736
  console.log(`credential issued for ${name === "" ? userId : name} (device ${device})`);
679
737
  }
680
738
  // Verify the key against the route every router serves before touching anything.
@@ -694,6 +752,17 @@ export async function connectCommand(args: CliArgs): Promise<void> {
694
752
  const skills = await fetchSkills(url, key, fetchImpl);
695
753
  // Its shared-context MCP endpoint, when it is a team edition serving one.
696
754
  const info = await fetchSetupInfo(url, fetchImpl);
755
+ // The credential that entry carries. A setup token's exchange hands one over; a credential
756
+ // given with --key has no exchange, so one is minted at /me/context-tokens with the key —
757
+ // both paths end up with a year-long token instead of the 72-hour access key. A machine
758
+ // that already holds one keeps it (see ensureContextToken), so re-running connect does not
759
+ // leave a trail of tokens in the member's portal.
760
+ const rh = expand(process.env.AUTO_MODEL_ROUTER_HOME ?? join(home, ".auto-model-router"), home);
761
+ const contextToken =
762
+ info.mcp && info.mcpAuth === "context-token"
763
+ ? await ensureContextToken({ url, key, mcpAuth: info.mcpAuth, routerHome: rh, remote: readRemoteRouter(rh), issued: issuedContext, name: device === "" ? hostname() : device, fetchImpl, mint: !args.flags.has("dry-run") })
764
+ : null;
765
+ if (contextToken !== null) console.log(`shared context uses a context token (${contextToken.id ?? "new"}), not the access key: a key rotation no longer restarts your MCP client`);
697
766
  const report = connectRemote({
698
767
  url,
699
768
  key,
@@ -714,7 +783,10 @@ export async function connectCommand(args: CliArgs): Promise<void> {
714
783
  ...(device === "" ? {} : { device }),
715
784
  ...(exePath === null ? {} : { exePath }),
716
785
  ...(skills.bundle === null ? {} : { skills: skills.bundle }),
717
- mcp: { url: info.mcp ? `${url}/mcp` : null },
786
+ mcp: {
787
+ url: info.mcp ? `${url}/mcp` : null,
788
+ ...(contextToken === null ? {} : { token: contextToken.value, ...(contextToken.expiresAtMs === undefined ? {} : { tokenExpiresAtMs: contextToken.expiresAtMs }), ...(contextToken.id === undefined ? {} : { tokenId: contextToken.id }) }),
789
+ },
718
790
  });
719
791
  if (skills.note !== undefined) report.notes.push(skills.note);
720
792
  if (exePath !== null) console.log(`executable ${exePath}; package files under ${packageDir}`);
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The team edition's **context token** — the credential the `team-context` MCP
3
+ * server holds.
4
+ *
5
+ * An MCP client substitutes its configuration ONCE, at startup. A member access
6
+ * key is short-lived (the team edition rotates it roughly every 72 hours), so a
7
+ * `team-context` entry carrying the access key dies mid-session every few days
8
+ * with a 401 that reads to the member as "re-authorise". The team edition
9
+ * therefore mints a second credential, `amrctx_…`, that lasts a year and can do
10
+ * exactly one thing: read and write that member's shared project context. It is
11
+ * refused on turns, on the admin API, on the portal and on SCIM.
12
+ *
13
+ * This module is the router's side of that:
14
+ *
15
+ * - `/setup/info` says `mcpAuth: "context-token"` when the team mints them.
16
+ * A team edition that predates them says `"member-key"` or nothing at all,
17
+ * and then everything below is skipped and the MCP entry keeps the access
18
+ * key exactly as before.
19
+ * - `/setup/exchange` hands one over beside the credential, so onboarding
20
+ * costs no extra round trip.
21
+ * - `POST /me/context-tokens {name}` mints one with the member key, for the
22
+ * paths that have no exchange: `connect --key`, and a refresh whose token
23
+ * is missing or close to expiry. The team binds it to the CALLING device,
24
+ * so revoking the machine revokes the token with it.
25
+ *
26
+ * The token is a long-lived secret and goes where the refresh token goes: the
27
+ * OS credential store, in its own slot (see credential-store.ts). remote.json
28
+ * records only which store holds it, its expiry and its id.
29
+ */
30
+
31
+ import { contextAccountOf, loadContextToken, type StoreDeps } from "./credential-store.ts";
32
+ import { refreshAccountOf, type RemoteRouter } from "../../omp-extension/remote-logic.ts";
33
+
34
+ /** What `/setup/info` says belongs in the MCP entry. Absent ⇒ `member-key`: an older team edition is unaffected. */
35
+ export type McpAuth = "context-token" | "member-key";
36
+
37
+ /** A context token and what is known about it. */
38
+ export interface ContextToken {
39
+ value: string;
40
+ expiresAtMs?: number;
41
+ id?: string;
42
+ }
43
+
44
+ /**
45
+ * How close to expiry a context token is renewed. A year long and renewed with
46
+ * a month to spare: a machine that connects once a month never carries a dead
47
+ * one, and a machine used daily still renews only twelve times a year.
48
+ */
49
+ export const CONTEXT_TOKEN_RENEW_AHEAD_MS = 30 * 24 * 3_600_000;
50
+
51
+ /** The token this machine already holds, read out of the store remote.json names. */
52
+ export function storedContextToken(remote: RemoteRouter, routerHome: string, storeDeps: StoreDeps = {}): ContextToken | null {
53
+ if (remote.contextTokenStore === undefined) return null;
54
+ const account = remote.contextAccount ?? contextAccountOf(remote.refreshAccount ?? refreshAccountOf(remote.url, remote.userId));
55
+ const value = loadContextToken(routerHome, account, remote.contextTokenStore, storeDeps);
56
+ if (value === null || value === "") return null;
57
+ return {
58
+ value,
59
+ ...(remote.contextTokenExpiresAtMs === undefined ? {} : { expiresAtMs: remote.contextTokenExpiresAtMs }),
60
+ ...(remote.contextTokenId === undefined ? {} : { id: remote.contextTokenId }),
61
+ };
62
+ }
63
+
64
+ /** True when a held token should be replaced: no expiry recorded, or inside the renewal window. */
65
+ export function dueForRenewal(token: ContextToken | null, nowMs = Date.now()): boolean {
66
+ if (token === null || token.value === "") return true;
67
+ // An expiry we never learned is an expiry we cannot trust; minting records one, so this converges.
68
+ if (token.expiresAtMs === undefined) return true;
69
+ return token.expiresAtMs - nowMs <= CONTEXT_TOKEN_RENEW_AHEAD_MS;
70
+ }
71
+
72
+ /**
73
+ * Mints one with the member key. Returns null on anything unexpected — an older
74
+ * team edition answers 404, a deployment that does not issue them answers 503 —
75
+ * and the caller then falls back to what it holds, or to the access key.
76
+ */
77
+ export async function mintContextToken(url: string, key: string, name: string, fetchImpl: typeof fetch = fetch): Promise<ContextToken | null> {
78
+ try {
79
+ const res = await fetchImpl(`${url}/me/context-tokens`, {
80
+ method: "POST",
81
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
82
+ body: JSON.stringify({ name }),
83
+ signal: AbortSignal.timeout(15_000),
84
+ });
85
+ if (!res.ok) return null;
86
+ const body = (await res.json().catch(() => null)) as { token?: unknown; id?: unknown; expiresAtMs?: unknown } | null;
87
+ if (body === null || typeof body.token !== "string" || body.token === "") return null;
88
+ return {
89
+ value: body.token,
90
+ ...(typeof body.expiresAtMs === "number" ? { expiresAtMs: body.expiresAtMs } : {}),
91
+ ...(typeof body.id === "string" && body.id !== "" ? { id: body.id } : {}),
92
+ };
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ export interface EnsureContextTokenOptions {
99
+ url: string;
100
+ /** A member key good right now: the one just issued, or just refreshed. */
101
+ key: string;
102
+ mcpAuth: McpAuth;
103
+ routerHome: string;
104
+ /** What this machine already recorded, when it has connected before. */
105
+ remote?: RemoteRouter | null;
106
+ /** One the setup exchange just handed over; it wins, and nothing is minted. */
107
+ issued?: ContextToken | undefined;
108
+ /** What the team should call it — the device name, so the member recognises it in the portal. */
109
+ name: string;
110
+ /** False on a dry run: report what is held, but never mint — a rehearsal must not leave a credential behind at the team. */
111
+ mint?: boolean;
112
+ fetchImpl?: typeof fetch;
113
+ storeDeps?: StoreDeps;
114
+ nowMs?: number;
115
+ }
116
+
117
+ /**
118
+ * The token the MCP entry should carry, or null to keep using the access key.
119
+ *
120
+ * Never mints when one that is held is still far from expiry: that is the whole
121
+ * point — a refresh every three days must leave the MCP configuration alone.
122
+ */
123
+ export async function ensureContextToken(o: EnsureContextTokenOptions): Promise<ContextToken | null> {
124
+ if (o.mcpAuth !== "context-token") return null;
125
+ if (o.issued !== undefined && o.issued.value !== "") return o.issued;
126
+ const held = o.remote === undefined || o.remote === null ? null : storedContextToken(o.remote, o.routerHome, o.storeDeps ?? {});
127
+ if (!dueForRenewal(held, o.nowMs ?? Date.now())) return held;
128
+ if (o.mint === false) return held;
129
+ const minted = await mintContextToken(o.url, o.key, o.name, o.fetchImpl ?? fetch);
130
+ // A mint that fails leaves what we hold in place: a token good for another day
131
+ // beats none, and none beats breaking a member's context tools over a hiccup.
132
+ return minted ?? held;
133
+ }
@@ -20,6 +20,14 @@
20
20
  * account name; it never holds the token itself once a store other than
21
21
  * `file` is in use. A remote.json written before this existed may still carry
22
22
  * the token inline; reading honours that until the next refresh moves it.
23
+ *
24
+ * The team edition's CONTEXT TOKEN (`amrctx_…`, a year long, good for nothing
25
+ * but that member's shared project context) is the same kind of secret and
26
+ * gets the same treatment, in its own slot: its own account name
27
+ * (`<refresh account>#context`) in the OS store, its own `context.token` /
28
+ * `context.dpapi` beside the refresh token's files, and its own names in
29
+ * remote.json (`contextTokenStore`, `contextAccount`). One slot never
30
+ * overwrites the other, and revoking one leaves the other alone.
23
31
  */
24
32
 
25
33
  import { spawnSync } from "node:child_process";
@@ -60,8 +68,14 @@ function dpapiUnprotect(blob: string, pathHas: (bin: string) => boolean): string
60
68
  return r.stdout.replace(/\r?\n$/, "");
61
69
  }
62
70
 
63
- const filePath = (routerHome: string): string => join(routerHome, "refresh.token");
64
- const dpapiPath = (routerHome: string): string => join(routerHome, "refresh.dpapi");
71
+ /** Which secret is being stored: they share the machinery and share nothing else. */
72
+ export type SecretSlot = "refresh" | "context";
73
+
74
+ const filePath = (routerHome: string, slot: SecretSlot = "refresh"): string => join(routerHome, slot === "refresh" ? "refresh.token" : "context.token");
75
+ const dpapiPath = (routerHome: string, slot: SecretSlot = "refresh"): string => join(routerHome, slot === "refresh" ? "refresh.dpapi" : "context.dpapi");
76
+
77
+ /** The store account a member's context token is filed under, derived from the refresh token's. */
78
+ export const contextAccountOf = (refreshAccount: string): string => `${refreshAccount}#context`;
65
79
 
66
80
  export interface StoreDeps {
67
81
  pathHas?: (bin: string) => boolean;
@@ -70,11 +84,12 @@ export interface StoreDeps {
70
84
  }
71
85
 
72
86
  /**
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.
87
+ * Saves a secret in the slot named and returns the store that took it. A store
88
+ * that fails (keychain locked, tool missing) falls back to the file, so a
89
+ * member is never left without their credential; the caller records what was
90
+ * used.
76
91
  */
77
- export function saveRefreshToken(routerHome: string, account: string, secret: string, kind: StoreKind, deps: StoreDeps = {}): StoreKind {
92
+ export function saveSecret(routerHome: string, account: string, secret: string, kind: StoreKind, slot: SecretSlot, deps: StoreDeps = {}): StoreKind {
78
93
  const pathHas = deps.pathHas ?? ((bin) => Bun.which(bin) !== null);
79
94
  mkdirSync(routerHome, { recursive: true });
80
95
  try {
@@ -84,19 +99,19 @@ export function saveRefreshToken(routerHome: string, account: string, secret: st
84
99
  }
85
100
  switch (kind) {
86
101
  case "dpapi":
87
- writeFileSync(dpapiPath(routerHome), `${dpapiProtect(secret, pathHas)}\n`, { encoding: "utf8", mode: 0o600 });
88
- rmSync(filePath(routerHome), { force: true });
102
+ writeFileSync(dpapiPath(routerHome, slot), `${dpapiProtect(secret, pathHas)}\n`, { encoding: "utf8", mode: 0o600 });
103
+ rmSync(filePath(routerHome, slot), { force: true });
89
104
  return "dpapi";
90
105
  case "keychain": {
91
106
  const r = spawnSync("security", ["add-generic-password", "-U", "-a", account, "-s", SERVICE, "-w", secret], { encoding: "utf8" });
92
107
  if (r.status !== 0) throw new Error(r.stderr.trim());
93
- rmSync(filePath(routerHome), { force: true });
108
+ rmSync(filePath(routerHome, slot), { force: true });
94
109
  return "keychain";
95
110
  }
96
111
  case "secret-service": {
97
112
  const r = spawnSync("secret-tool", ["store", `--label=${SERVICE} ${account}`, "service", SERVICE, "account", account], { encoding: "utf8", input: secret });
98
113
  if (r.status !== 0) throw new Error(r.stderr.trim());
99
- rmSync(filePath(routerHome), { force: true });
114
+ rmSync(filePath(routerHome, slot), { force: true });
100
115
  return "secret-service";
101
116
  }
102
117
  case "file":
@@ -105,23 +120,33 @@ export function saveRefreshToken(routerHome: string, account: string, secret: st
105
120
  } catch {
106
121
  // fall through to the file
107
122
  }
108
- writeFileSync(filePath(routerHome), `${secret}\n`, { encoding: "utf8", mode: 0o600 });
123
+ writeFileSync(filePath(routerHome, slot), `${secret}\n`, { encoding: "utf8", mode: 0o600 });
109
124
  try {
110
- chmodSync(filePath(routerHome), 0o600);
125
+ chmodSync(filePath(routerHome, slot), 0o600);
111
126
  } catch {
112
127
  /* Windows */
113
128
  }
114
129
  return "file";
115
130
  }
116
131
 
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 {
132
+ /** Saves the refresh token; see saveSecret. */
133
+ export function saveRefreshToken(routerHome: string, account: string, secret: string, kind: StoreKind, deps: StoreDeps = {}): StoreKind {
134
+ return saveSecret(routerHome, account, secret, kind, "refresh", deps);
135
+ }
136
+
137
+ /** Saves the team's long-lived context token, in its own slot. */
138
+ export function saveContextToken(routerHome: string, account: string, secret: string, kind: StoreKind, deps: StoreDeps = {}): StoreKind {
139
+ return saveSecret(routerHome, account, secret, kind, "context", deps);
140
+ }
141
+
142
+ /** The secret in that slot from the store `remote.json` names, or null when it is gone. */
143
+ export function loadSecret(routerHome: string, account: string, kind: StoreKind, slot: SecretSlot, deps: StoreDeps = {}): string | null {
119
144
  const pathHas = deps.pathHas ?? ((bin) => Bun.which(bin) !== null);
120
145
  try {
121
146
  if (deps.backend !== undefined) return deps.backend.load(account);
122
147
  switch (kind) {
123
148
  case "dpapi": {
124
- const p = dpapiPath(routerHome);
149
+ const p = dpapiPath(routerHome, slot);
125
150
  if (!existsSync(p)) return null;
126
151
  return dpapiUnprotect(readFileSync(p, "utf8").trim(), pathHas);
127
152
  }
@@ -134,7 +159,7 @@ export function loadRefreshToken(routerHome: string, account: string, kind: Stor
134
159
  return r.status === 0 && r.stdout !== "" ? r.stdout.replace(/\r?\n$/, "") : null;
135
160
  }
136
161
  case "file": {
137
- const p = filePath(routerHome);
162
+ const p = filePath(routerHome, slot);
138
163
  return existsSync(p) ? readFileSync(p, "utf8").trim() : null;
139
164
  }
140
165
  }
@@ -144,10 +169,20 @@ export function loadRefreshToken(routerHome: string, account: string, kind: Stor
144
169
  return null;
145
170
  }
146
171
 
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 });
172
+ /** The refresh token from the store `remote.json` names, or null when it is gone. */
173
+ export function loadRefreshToken(routerHome: string, account: string, kind: StoreKind, deps: StoreDeps = {}): string | null {
174
+ return loadSecret(routerHome, account, kind, "refresh", deps);
175
+ }
176
+
177
+ /** The context token from the store `remote.json` names, or null when it is gone. */
178
+ export function loadContextToken(routerHome: string, account: string, kind: StoreKind, deps: StoreDeps = {}): string | null {
179
+ return loadSecret(routerHome, account, kind, "context", deps);
180
+ }
181
+
182
+ /** Forgets the secret in that slot everywhere it might be. */
183
+ export function removeSecret(routerHome: string, account: string, slot: SecretSlot, deps: StoreDeps = {}): void {
184
+ rmSync(filePath(routerHome, slot), { force: true });
185
+ rmSync(dpapiPath(routerHome, slot), { force: true });
151
186
  if (deps.backend !== undefined) {
152
187
  deps.backend.remove(account);
153
188
  return;
@@ -155,3 +190,13 @@ export function removeRefreshToken(routerHome: string, account: string, deps: St
155
190
  if (process.platform === "darwin") spawnSync("security", ["delete-generic-password", "-a", account, "-s", SERVICE], { encoding: "utf8" });
156
191
  if (process.platform === "linux") spawnSync("secret-tool", ["clear", "service", SERVICE, "account", account], { encoding: "utf8" });
157
192
  }
193
+
194
+ /** Forgets the refresh token everywhere it might be. */
195
+ export function removeRefreshToken(routerHome: string, account: string, deps: StoreDeps = {}): void {
196
+ removeSecret(routerHome, account, "refresh", deps);
197
+ }
198
+
199
+ /** Forgets the context token everywhere it might be. */
200
+ export function removeContextToken(routerHome: string, account: string, deps: StoreDeps = {}): void {
201
+ removeSecret(routerHome, account, "context", deps);
202
+ }
@@ -11,6 +11,11 @@
11
11
  *
12
12
  * A refresh a day early costs nothing: the remote keeps the old key valid until
13
13
  * its own expiry, so a session still holding it is never cut.
14
+ *
15
+ * One thing a refresh deliberately does NOT rewrite: the `team-context` MCP
16
+ * entry, when the team edition mints context tokens. See context-token.ts —
17
+ * an MCP client substitutes its configuration once at startup, so a fresh key
18
+ * written there every three days is a 401 mid-session, not an update.
14
19
  */
15
20
 
16
21
  import { executablePath, materializePackage, readEmbeddedPackage } from "./embedded.ts";
@@ -21,6 +26,7 @@ import { dirname, resolve } from "node:path";
21
26
  import { fileURLToPath } from "node:url";
22
27
  import { hasRefresh, readRemoteRouter, refreshAccountOf, type RemoteRouter } from "../../omp-extension/remote-logic.ts";
23
28
  import { loadRefreshToken, type StoreDeps } from "./credential-store.ts";
29
+ import { ensureContextToken } from "./context-token.ts";
24
30
  import { routerHome } from "../../omp-extension/router-url.ts";
25
31
  import type { CliArgs } from "./args.ts";
26
32
  import { connectRemote } from "./connect.ts";
@@ -101,6 +107,22 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
101
107
  // A refresh is when the team's skills reach a machine that has not re-run connect.
102
108
  const skills = await fetchSkills(opts.remote.url, fresh.key, opts.fetchImpl ?? fetch);
103
109
  const info = await fetchSetupInfo(opts.remote.url, opts.fetchImpl ?? fetch);
110
+ // The MCP entry must NOT take the new access key: an MCP client reads its configuration
111
+ // once, at startup, so rewriting the entry every 72 hours is what makes the context tools
112
+ // die mid-session. The team's context token is held for a year and reused here untouched;
113
+ // it is minted (or renewed) only when there is none or it is within a month of expiring.
114
+ const context = info.mcp
115
+ ? await ensureContextToken({
116
+ url: opts.remote.url,
117
+ key: fresh.key,
118
+ mcpAuth: info.mcpAuth,
119
+ routerHome: rh,
120
+ remote: opts.remote,
121
+ name: fresh.device ?? opts.remote.device ?? "this machine",
122
+ fetchImpl: opts.fetchImpl ?? fetch,
123
+ ...(opts.storeDeps === undefined ? {} : { storeDeps: opts.storeDeps }),
124
+ })
125
+ : null;
104
126
  connectRemote({
105
127
  url: opts.remote.url,
106
128
  key: fresh.key,
@@ -123,7 +145,10 @@ export async function refreshAndRewrite(opts: { remote: RemoteRouter; fetchImpl?
123
145
  ...(opts.storeDeps !== undefined ? { storeDeps: opts.storeDeps } : {}),
124
146
  ...(exePath !== undefined ? { exePath } : {}),
125
147
  ...(skills.bundle === null ? {} : { skills: skills.bundle }),
126
- mcp: { url: info.mcp ? `${opts.remote.url}/mcp` : null },
148
+ mcp: {
149
+ url: info.mcp ? `${opts.remote.url}/mcp` : null,
150
+ ...(context === null ? {} : { token: context.value, ...(context.expiresAtMs === undefined ? {} : { tokenExpiresAtMs: context.expiresAtMs }), ...(context.id === undefined ? {} : { tokenId: context.id }) }),
151
+ },
127
152
  // undefined keeps whatever scope the managed models.yml block already carries.
128
153
  });
129
154
  return fresh;
@@ -105,6 +105,7 @@ export const DEFAULT_CONFIG: RouterConfig = {
105
105
  filters: {
106
106
  allow: [],
107
107
  deny: [],
108
+ providerLocks: {},
108
109
  // Free models are rate-limited hard enough that retries cost more than they save.
109
110
  includeFree: false,
110
111
  requireToolSupport: true,
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_REDACTION_RULES, validateRedactionRule } from "./redaction.ts";
3
+ import { MAX_EXTRA_SCORES } from "../catalog/benchmark-feeds.ts";
3
4
 
4
5
  /**
5
6
  * Input schema for `$AUTO_MODEL_ROUTER_HOME/config.yml`: a deep partial of
@@ -99,6 +100,26 @@ const upstream = z.strictObject({
99
100
  models: z.array(upstreamModel),
100
101
  });
101
102
 
103
+ const axisScore = z.number().min(0).max(100).optional();
104
+
105
+ /**
106
+ * One `benchmarks.extraScores` row, as a config FILE may write it. Strict here
107
+ * on purpose: a typo in a file the operator edits is an error they want told,
108
+ * exactly like every other key. The same rows arriving over `reconfigure` from a
109
+ * front door skip this schema and are sanitised at use instead (`suppliedScores`),
110
+ * where a bad row is dropped with a warning rather than failing the whole patch.
111
+ */
112
+ const extraScore = z.strictObject({
113
+ key: z.string().min(1),
114
+ // Only ever a tie-break between two rows sharing a key; absent is normal.
115
+ creator: z.string().default(""),
116
+ coding: axisScore,
117
+ intelligence: axisScore,
118
+ agentic: axisScore,
119
+ // Config supplies provenance; it may not claim a fetched feed or the local lane.
120
+ source: z.enum(["neutral", "vendor"]),
121
+ });
122
+
102
123
  const benchmarks = z.strictObject({
103
124
  enabled: z.boolean().optional(),
104
125
  artificialAnalysisApiKey: z.string().optional(),
@@ -106,6 +127,7 @@ const benchmarks = z.strictObject({
106
127
  refreshMs: z.number().nonnegative().optional(),
107
128
  timeoutMs: z.number().positive().optional(),
108
129
  useLocalScores: z.boolean().optional(),
130
+ extraScores: z.array(extraScore).max(MAX_EXTRA_SCORES).optional(),
109
131
  });
110
132
 
111
133
  const tierConfig = z.strictObject({