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.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +104 -10
- package/omp-extension/remote-logic.ts +15 -0
- package/package.json +1 -1
- package/src/catalog/benchmark-feeds.ts +179 -41
- package/src/catalog/openrouter-catalog.ts +10 -2
- package/src/cli/connect.ts +89 -17
- package/src/cli/context-token.ts +133 -0
- package/src/cli/credential-store.ts +65 -20
- package/src/cli/refresh.ts +26 -1
- package/src/config/defaults.ts +1 -0
- package/src/config/schema.ts +22 -0
- package/src/config/types.ts +31 -0
- package/src/cost/ledger-sql.ts +3 -2
- package/src/cost/ledger.ts +4 -0
- package/src/cost/types.ts +12 -0
- package/src/cost/views.ts +12 -0
- package/src/lib.ts +12 -0
- package/src/router/candidates.ts +17 -0
- package/src/router/index.ts +4 -0
- package/src/router/types.ts +2 -0
- package/src/server/catalog-view.ts +8 -2
- package/src/server/compaction-digest.ts +3 -0
- package/src/server/digest.ts +11 -0
- package/src/server/http.ts +46 -4
- package/src/server/turn.ts +5 -0
- package/src/util/requestid.ts +77 -0
- package/src/util/schema.ts +42 -3
- package/src/util/sqlite.ts +20 -1
- package/src/wire/openai/request.ts +8 -0
- package/src/wire/types.ts +18 -0
- package/test/benchmark-feeds.test.ts +194 -0
- package/test/catalog-view.test.ts +11 -0
- package/test/config.test.ts +26 -0
- package/test/context-token.test.ts +301 -0
- package/test/failover.test.ts +1 -1
- package/test/ledger-sql.test.ts +1 -1
- package/test/mcp-entry.test.ts +14 -5
- package/test/migrations.test.ts +11 -4
- package/test/reconfigure.test.ts +55 -0
- package/test/request-id.test.ts +424 -0
- package/test/schema.test.ts +2 -2
- package/test/tier-plan.test.ts +51 -0
- package/test/trust-attribution.test.ts +2 -2
- package/test/turn.test.ts +56 -1
package/src/cli/connect.ts
CHANGED
|
@@ -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
|
|
84
|
-
*
|
|
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,
|
|
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 ${
|
|
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
|
|
442
|
-
//
|
|
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
|
-
/**
|
|
622
|
-
|
|
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: {
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
74
|
-
* fails (keychain locked, tool missing) falls back to the file, so a
|
|
75
|
-
* never left without
|
|
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
|
|
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
|
-
/**
|
|
118
|
-
export function
|
|
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
|
-
/**
|
|
148
|
-
export function
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
}
|
package/src/cli/refresh.ts
CHANGED
|
@@ -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: {
|
|
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;
|
package/src/config/defaults.ts
CHANGED
package/src/config/schema.ts
CHANGED
|
@@ -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({
|