@mnemom/mnemom 0.16.1 → 0.16.3

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/dist/lib/auth.js CHANGED
@@ -117,7 +117,14 @@ export async function getAccessToken() {
117
117
  if (auth.expiresAt > now + 60) {
118
118
  return auth.accessToken;
119
119
  }
120
- // Auto-refresh
120
+ // Auto-refresh. A *thrown* error from refreshStoredTokens() (transient
121
+ // exhaustion / non-revoke 4xx from oauthRefreshTokens(), MNE-7394) is
122
+ // intentionally allowed to propagate here rather than being coerced to the
123
+ // `null` re-login signal — a momentary AS outage must not masquerade as
124
+ // "credentials revoked". Only a genuine invalid_grant/invalid_client returns
125
+ // null (see refreshStoredTokens). Refining caller-side handling (distinguish
126
+ // propagate-vs-degrade) is tracked under MNE-7394's parent lane card
127
+ // (keith/oauth-token-lifetime).
121
128
  const refreshed = await refreshStoredTokens(auth);
122
129
  if (refreshed)
123
130
  return refreshed.accessToken;
@@ -139,6 +146,9 @@ export async function forceRefreshAccessToken() {
139
146
  const auth = getAuthInfo();
140
147
  if (!auth?.refreshToken)
141
148
  return null;
149
+ // As in getAccessToken, a thrown transient-exhaustion / non-revoke error from
150
+ // refreshStoredTokens() propagates rather than being coerced to null (MNE-7394;
151
+ // caller-side refinement tracked under keith/oauth-token-lifetime).
142
152
  const refreshed = await refreshStoredTokens(auth);
143
153
  return refreshed?.accessToken ?? null;
144
154
  }
@@ -226,6 +236,16 @@ export async function loginWithDeviceFlow() {
226
236
  * Refresh the stored tokens via the OAuth refresh_token grant. Requires both a
227
237
  * refresh token and the client_id they were issued to; returns null (rather
228
238
  * than throwing) when refresh isn't possible so callers degrade to re-login.
239
+ *
240
+ * Note: oauthRefreshTokens() returns null ONLY on a definitive revoke
241
+ * (invalid_grant/invalid_client) or missing credentials; on transient
242
+ * exhaustion (repeated 5xx / network faults) or any other non-revoke 4xx it
243
+ * *throws* (MNE-7394 / R6). That thrown error is intentionally allowed to
244
+ * propagate through this function to its callers rather than being coerced to
245
+ * null, so a momentary blip never presents as "credentials revoked". Refining
246
+ * the caller-side handling (propagate-vs-degrade in
247
+ * getAccessToken/forceRefreshAccessToken) is tracked under MNE-7394's parent
248
+ * lane card (keith/oauth-token-lifetime).
229
249
  */
230
250
  async function refreshStoredTokens(auth) {
231
251
  if (!auth.refreshToken || !auth.clientId)
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Persistent CLI preference store — ~/.mnemom/config.json.
3
+ *
4
+ * Deliberately SEPARATE from auth.ts → auth.json: auth.json holds bearer
5
+ * credentials and is wiped on logout; config.json holds preferences that
6
+ * survive re-login. Today it holds one preference: the ACTIVE ORG.
7
+ *
8
+ * Why an active org exists at all: `mnemom login` binds no org — the OAuth
9
+ * token carries only scope + expiry (see lib/auth.ts), so every org-scoped
10
+ * command historically needed an explicit `--org` or silently defaulted to
11
+ * the caller's personal org (the "why did my agent land in Personal?"
12
+ * footgun). `mnemom org use <slug>` records a durable default; commands
13
+ * resolve org as: explicit flag > active org > loud personal-org fallback.
14
+ *
15
+ * The stored value is the VALIDATED membership snapshot ({org_id, slug,
16
+ * name}) so consumers can send org_id without a per-command round-trip.
17
+ * Membership can change after it's stored — consumers must treat a server
18
+ * 403 as the truth (the claim path already renders the teaching list).
19
+ */
20
+ export interface ActiveOrg {
21
+ org_id: string;
22
+ slug: string;
23
+ name: string;
24
+ }
25
+ export interface CliConfig {
26
+ activeOrg?: ActiveOrg;
27
+ }
28
+ /** Load the config; a missing or corrupt file degrades to `{}`. */
29
+ export declare function loadCliConfig(): CliConfig;
30
+ /** Persist (or with `null`, clear) the active org. */
31
+ export declare function setActiveOrg(org: ActiveOrg | null): void;
32
+ /** The active org, or undefined when none is set. */
33
+ export declare function getActiveOrg(): ActiveOrg | undefined;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Persistent CLI preference store — ~/.mnemom/config.json.
3
+ *
4
+ * Deliberately SEPARATE from auth.ts → auth.json: auth.json holds bearer
5
+ * credentials and is wiped on logout; config.json holds preferences that
6
+ * survive re-login. Today it holds one preference: the ACTIVE ORG.
7
+ *
8
+ * Why an active org exists at all: `mnemom login` binds no org — the OAuth
9
+ * token carries only scope + expiry (see lib/auth.ts), so every org-scoped
10
+ * command historically needed an explicit `--org` or silently defaulted to
11
+ * the caller's personal org (the "why did my agent land in Personal?"
12
+ * footgun). `mnemom org use <slug>` records a durable default; commands
13
+ * resolve org as: explicit flag > active org > loud personal-org fallback.
14
+ *
15
+ * The stored value is the VALIDATED membership snapshot ({org_id, slug,
16
+ * name}) so consumers can send org_id without a per-command round-trip.
17
+ * Membership can change after it's stored — consumers must treat a server
18
+ * 403 as the truth (the claim path already renders the teaching list).
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { MNEMOM_DIR } from "./config.js";
23
+ function configFile() {
24
+ return path.join(MNEMOM_DIR, "config.json");
25
+ }
26
+ /** Load the config; a missing or corrupt file degrades to `{}`. */
27
+ export function loadCliConfig() {
28
+ try {
29
+ if (!fs.existsSync(configFile()))
30
+ return {};
31
+ const parsed = JSON.parse(fs.readFileSync(configFile(), "utf-8"));
32
+ return parsed && typeof parsed === "object" ? parsed : {};
33
+ }
34
+ catch {
35
+ return {};
36
+ }
37
+ }
38
+ function saveCliConfig(config) {
39
+ if (!fs.existsSync(MNEMOM_DIR)) {
40
+ // 0700 to match the auth store — the directory also holds credentials.
41
+ fs.mkdirSync(MNEMOM_DIR, { recursive: true, mode: 0o700 });
42
+ }
43
+ const resolvedPath = path.resolve(configFile());
44
+ const tmpFile = `${resolvedPath}.${process.pid}.tmp`;
45
+ // No secrets in here, but keep the same owner-only posture as auth.json —
46
+ // the active org is still account metadata. Write-then-rename = atomic.
47
+ fs.writeFileSync(tmpFile, JSON.stringify(config, null, 2), { mode: 0o600 });
48
+ try {
49
+ fs.chmodSync(tmpFile, 0o600);
50
+ }
51
+ catch {
52
+ /* best effort on platforms without POSIX perms */
53
+ }
54
+ fs.renameSync(tmpFile, resolvedPath);
55
+ }
56
+ /** Persist (or with `null`, clear) the active org. */
57
+ export function setActiveOrg(org) {
58
+ const config = loadCliConfig();
59
+ if (org === null) {
60
+ delete config.activeOrg;
61
+ }
62
+ else {
63
+ config.activeOrg = org;
64
+ }
65
+ saveCliConfig(config);
66
+ }
67
+ /** The active org, or undefined when none is set. */
68
+ export function getActiveOrg() {
69
+ return loadCliConfig().activeOrg;
70
+ }
@@ -13,6 +13,18 @@ export type Environment = "production" | "staging" | "local";
13
13
  * Defaults to production.
14
14
  */
15
15
  export declare function getEnvironment(): Environment;
16
+ export type CellRing = "us-1";
17
+ /** Explicitly select a ring for this process (clears on undefined). Wins over MNEMOM_RING. */
18
+ export declare function setRing(ring: CellRing | undefined): void;
19
+ /**
20
+ * Resolve the active ring, if any: an explicit `setRing()` call wins over
21
+ * the `MNEMOM_RING` env var. Returns undefined for an unset or unrecognized
22
+ * value — an unknown ring name is deliberately NOT resolved to production;
23
+ * callers fall through to the existing Environment-based URLs instead.
24
+ */
25
+ export declare function getRing(): CellRing | undefined;
26
+ /** Override the active API base for this process (clears on undefined/empty). */
27
+ export declare function setApiUrlOverride(url: string | undefined): void;
16
28
  export declare function getApiUrl(): string;
17
29
  export declare function getGatewayUrl(): string;
18
30
  export declare function getWebsiteUrl(): string;
@@ -34,12 +34,64 @@ export function getEnvironment() {
34
34
  return env;
35
35
  return "production";
36
36
  }
37
+ const RING_URLS = {
38
+ "us-1": {
39
+ api: "https://api-us1.mnemom.ai",
40
+ gateway: "https://gateway-us1.mnemom.ai",
41
+ website: "https://preview.mnemom.ai",
42
+ },
43
+ };
44
+ function isCellRing(value) {
45
+ return Object.prototype.hasOwnProperty.call(RING_URLS, value);
46
+ }
47
+ let ringOverride;
48
+ /** Explicitly select a ring for this process (clears on undefined). Wins over MNEMOM_RING. */
49
+ export function setRing(ring) {
50
+ ringOverride = ring;
51
+ }
52
+ /**
53
+ * Resolve the active ring, if any: an explicit `setRing()` call wins over
54
+ * the `MNEMOM_RING` env var. Returns undefined for an unset or unrecognized
55
+ * value — an unknown ring name is deliberately NOT resolved to production;
56
+ * callers fall through to the existing Environment-based URLs instead.
57
+ */
58
+ export function getRing() {
59
+ if (ringOverride)
60
+ return ringOverride;
61
+ const env = process.env.MNEMOM_RING?.trim();
62
+ return env && isCellRing(env) ? env : undefined;
63
+ }
64
+ // Process-level base overrides. MNEMOM_ENV only selects prod/staging/local;
65
+ // to target an arbitrary ring (preview/us-1, test) a command can set an
66
+ // explicit override (e.g. `try-me --api <ring>`) or the operator can export
67
+ // MNEMOM_API_URL / MNEMOM_GATEWAY_URL / MNEMOM_WEBSITE_URL. Crucially these
68
+ // flow through EVERY surface — including OAuth discovery + the one-click claim
69
+ // login — so a non-prod ring's sign-in no longer falls back to the prod AS.
70
+ //
71
+ // Precedence (highest wins): apiUrlOverride > raw MNEMOM_*_URL env >
72
+ // ring (setRing()/MNEMOM_RING) > Environment table. A ring only fills the
73
+ // gap the raw overrides leave — it never has to be perfectly bypass-able,
74
+ // because the raw overrides above it in the chain always can.
75
+ let apiUrlOverride;
76
+ /** Override the active API base for this process (clears on undefined/empty). */
77
+ export function setApiUrlOverride(url) {
78
+ const trimmed = url?.trim().replace(/\/+$/, "");
79
+ apiUrlOverride = trimmed || undefined;
80
+ }
81
+ function envBase(name) {
82
+ const v = process.env[name]?.trim();
83
+ return v ? v.replace(/\/+$/, "") : undefined;
84
+ }
85
+ function ringUrls() {
86
+ const ring = getRing();
87
+ return ring ? RING_URLS[ring] : undefined;
88
+ }
37
89
  export function getApiUrl() {
38
- return API_URLS[getEnvironment()];
90
+ return (apiUrlOverride ?? envBase("MNEMOM_API_URL") ?? ringUrls()?.api ?? API_URLS[getEnvironment()]);
39
91
  }
40
92
  export function getGatewayUrl() {
41
- return GATEWAY_URLS[getEnvironment()];
93
+ return envBase("MNEMOM_GATEWAY_URL") ?? ringUrls()?.gateway ?? GATEWAY_URLS[getEnvironment()];
42
94
  }
43
95
  export function getWebsiteUrl() {
44
- return WEBSITE_URLS[getEnvironment()];
96
+ return envBase("MNEMOM_WEBSITE_URL") ?? ringUrls()?.website ?? WEBSITE_URLS[getEnvironment()];
45
97
  }
@@ -0,0 +1,35 @@
1
+ import { type ValidationCheck } from "../commands/protection.js";
2
+ export declare const KEYED_MODES: readonly ["observe", "nudge", "enforce"];
3
+ export type KeyedMode = (typeof KEYED_MODES)[number];
4
+ export declare const EVAL_BATTERY_MODEL: "claude-opus-5";
5
+ export interface KeyedModeEntry {
6
+ label?: unknown;
7
+ mode?: unknown;
8
+ model?: unknown;
9
+ agent_id?: unknown;
10
+ agent_hash?: unknown;
11
+ secret_ref?: unknown;
12
+ snapshot?: unknown;
13
+ }
14
+ export interface KeyedDirectLane {
15
+ label?: unknown;
16
+ mode?: unknown;
17
+ path?: unknown;
18
+ secret_ref?: unknown;
19
+ model?: unknown;
20
+ snapshot?: unknown;
21
+ agent_id?: unknown;
22
+ agent_hash?: unknown;
23
+ }
24
+ /**
25
+ * Validate the keyed-mode manifest against the three parsed snapshots.
26
+ *
27
+ * @param manifest Parsed `cards/keyed-modes.manifest.yaml` (`{ entries: [...] }`).
28
+ * @param snapshots Map of snapshot filename → parsed protection card, keyed by
29
+ * the `snapshot:` value each manifest entry declares.
30
+ * @returns Flat list of `{ name, passed, message }` checks (mirrors
31
+ * ValidationCheck) so callers can render/aggregate. A returned list
32
+ * with every `passed === true` means the record is internally
33
+ * consistent; any `passed === false` is a failing invariant.
34
+ */
35
+ export declare function validateKeyedModeManifest(manifest: Record<string, unknown>, snapshots: Record<string, Record<string, unknown>>): ValidationCheck[];
@@ -0,0 +1,363 @@
1
+ // ============================================================================
2
+ // Keyed-mode identity manifest validator (issue #1429 / MNE-5522)
3
+ //
4
+ // Pure, I/O-free validator for the three-mode keyed-identity record: the
5
+ // `cards/keyed-modes.manifest.yaml` binding plus the three protection-card
6
+ // snapshots it references. It asserts the CROSS-ENTRY invariant — exactly the
7
+ // three modes {observe, nudge, enforce}, one each, distinct identities, each
8
+ // snapshot valid and matching the manifest — and REUSES `validateProtectionCard`
9
+ // (cli/src/commands/protection.ts) for per-card ADR-037 validation rather than
10
+ // re-implementing card rules (MNE-437: no logic-bearing duplication).
11
+ //
12
+ // No I/O, no process.exit: callers read the returned ValidationCheck[] and
13
+ // render/aggregate. This is the deterministic guard exercised by the manifest
14
+ // `test` verb (cli vitest): against inline fixtures AND against the real
15
+ // committed cards/ files (the on-disk integration test in
16
+ // cli/src/__tests__/keyed-identity.test.ts).
17
+ // ============================================================================
18
+ import { validateProtectionCard } from "../commands/protection.js";
19
+ // The three enforcement modes a keyed identity may be pinned to. `off` is a
20
+ // valid protection-card mode but NOT a keyed-mode target — a keyed identity
21
+ // exists precisely to exercise one of the three ACTIVE modes side-by-side.
22
+ export const KEYED_MODES = ["observe", "nudge", "enforce"];
23
+ // The evaluation-battery model, pinned as ONE config fact (issue #1460 / D13 /
24
+ // MNE-5707). This is the compile-time mirror of the AUTHORITATIVE `model:` field
25
+ // in cards/keyed-modes.manifest.yaml — the manifest is the single source of
26
+ // truth; this constant lets the validator (and its drift-guard test) assert the
27
+ // manifest has not silently diverged from the value the codebase expects.
28
+ // Holding the model fixed across all four lanes is what keeps the downstream
29
+ // cost probe valid; a changed model invalidates it. Re-opening the model choice
30
+ // is OUT of scope (MNE-5707).
31
+ export const EVAL_BATTERY_MODEL = "claude-opus-5";
32
+ function isObject(v) {
33
+ return typeof v === "object" && v !== null && !Array.isArray(v);
34
+ }
35
+ /**
36
+ * Validate the keyed-mode manifest against the three parsed snapshots.
37
+ *
38
+ * @param manifest Parsed `cards/keyed-modes.manifest.yaml` (`{ entries: [...] }`).
39
+ * @param snapshots Map of snapshot filename → parsed protection card, keyed by
40
+ * the `snapshot:` value each manifest entry declares.
41
+ * @returns Flat list of `{ name, passed, message }` checks (mirrors
42
+ * ValidationCheck) so callers can render/aggregate. A returned list
43
+ * with every `passed === true` means the record is internally
44
+ * consistent; any `passed === false` is a failing invariant.
45
+ */
46
+ export function validateKeyedModeManifest(manifest, snapshots) {
47
+ const checks = [];
48
+ const rawEntries = manifest.entries;
49
+ if (!Array.isArray(rawEntries)) {
50
+ checks.push({
51
+ name: "manifest.entries",
52
+ passed: false,
53
+ message: "Required: manifest must have an `entries` array (one object per keyed identity).",
54
+ });
55
+ return checks;
56
+ }
57
+ const entries = rawEntries;
58
+ // ── Pinned battery model: one authoritative config fact, no lane divergence ──
59
+ // `manifest.model` must be present, a string, and equal to the codebase's
60
+ // EVAL_BATTERY_MODEL mirror; any entry declaring its OWN `model` must match it
61
+ // (a divergent per-lane model would invalidate the cost probe — MNE-440).
62
+ checks.push(...modelConsistencyChecks(manifest, entries));
63
+ // ── Per-snapshot ADR-037 validation (reuse validateProtectionCard) ──
64
+ // Each entry references a snapshot by filename; validate the referenced card.
65
+ for (let i = 0; i < entries.length; i++) {
66
+ const entry = entries[i];
67
+ const label = typeof entry.label === "string" ? entry.label : `entries[${i}]`;
68
+ const snapshotName = entry.snapshot;
69
+ if (typeof snapshotName !== "string" || snapshotName.length === 0) {
70
+ checks.push({
71
+ name: `${label}.snapshot`,
72
+ passed: false,
73
+ message: "Required: entry must reference a snapshot filename (string).",
74
+ });
75
+ continue;
76
+ }
77
+ const card = snapshots[snapshotName];
78
+ if (!isObject(card)) {
79
+ checks.push({
80
+ name: `${label}.snapshot`,
81
+ passed: false,
82
+ message: `Snapshot "${snapshotName}" was not provided (or did not parse to an object).`,
83
+ });
84
+ continue;
85
+ }
86
+ const cardChecks = validateProtectionCard(card);
87
+ const failed = cardChecks.filter((c) => !c.passed);
88
+ if (failed.length > 0) {
89
+ checks.push({
90
+ name: `${label}.snapshot`,
91
+ passed: false,
92
+ message: `Snapshot "${snapshotName}" is not a valid protection card: ${failed
93
+ .map((f) => `${f.name}: ${f.message}`)
94
+ .join("; ")}`,
95
+ });
96
+ }
97
+ else {
98
+ checks.push({
99
+ name: `${label}.snapshot`,
100
+ passed: true,
101
+ message: `${snapshotName} is a valid protection card`,
102
+ });
103
+ }
104
+ }
105
+ // ── Mode coverage: exactly {observe, nudge, enforce}, one each ──
106
+ const modeCounts = new Map();
107
+ for (const entry of entries) {
108
+ if (typeof entry.mode === "string") {
109
+ modeCounts.set(entry.mode, (modeCounts.get(entry.mode) ?? 0) + 1);
110
+ }
111
+ }
112
+ const missing = KEYED_MODES.filter((m) => !modeCounts.has(m));
113
+ const duplicated = KEYED_MODES.filter((m) => (modeCounts.get(m) ?? 0) > 1);
114
+ const unexpected = [...modeCounts.keys()].filter((m) => !KEYED_MODES.includes(m));
115
+ if (missing.length === 0 && duplicated.length === 0 && unexpected.length === 0) {
116
+ checks.push({
117
+ name: "modes.coverage",
118
+ passed: true,
119
+ message: `covers exactly ${KEYED_MODES.join(", ")}, one each`,
120
+ });
121
+ }
122
+ else {
123
+ const problems = [];
124
+ if (missing.length > 0)
125
+ problems.push(`missing: ${missing.join(", ")}`);
126
+ if (duplicated.length > 0)
127
+ problems.push(`duplicated: ${duplicated.join(", ")}`);
128
+ if (unexpected.length > 0)
129
+ problems.push(`unexpected: ${unexpected.join(", ")}`);
130
+ checks.push({
131
+ name: "modes.coverage",
132
+ passed: false,
133
+ message: `Must cover exactly ${KEYED_MODES.join(", ")}, one each. ${problems.join("; ")}.`,
134
+ });
135
+ }
136
+ // ── Distinct agent_id across entries ──
137
+ checks.push(distinctnessCheck(entries, "agent_id"));
138
+ // ── Distinct agent_hash across entries ──
139
+ checks.push(distinctnessCheck(entries, "agent_hash"));
140
+ // ── Each entry's mode equals its referenced snapshot's mode ──
141
+ for (let i = 0; i < entries.length; i++) {
142
+ const entry = entries[i];
143
+ const label = typeof entry.label === "string" ? entry.label : `entries[${i}]`;
144
+ const snapshotName = entry.snapshot;
145
+ if (typeof snapshotName !== "string")
146
+ continue; // already reported above
147
+ const card = snapshots[snapshotName];
148
+ if (!isObject(card))
149
+ continue; // already reported above
150
+ if (entry.mode !== card.mode) {
151
+ checks.push({
152
+ name: `${label}.mode`,
153
+ passed: false,
154
+ message: `Manifest mode "${String(entry.mode)}" does not match snapshot "${snapshotName}" mode "${String(card.mode)}".`,
155
+ });
156
+ }
157
+ else {
158
+ checks.push({
159
+ name: `${label}.mode`,
160
+ passed: true,
161
+ message: `mode "${String(entry.mode)}" matches snapshot`,
162
+ });
163
+ }
164
+ }
165
+ // ── Each entry's agent_id matches its referenced snapshot's agent_id ──
166
+ for (let i = 0; i < entries.length; i++) {
167
+ const entry = entries[i];
168
+ const label = typeof entry.label === "string" ? entry.label : `entries[${i}]`;
169
+ const snapshotName = entry.snapshot;
170
+ if (typeof snapshotName !== "string")
171
+ continue; // already reported above
172
+ const card = snapshots[snapshotName];
173
+ if (!isObject(card))
174
+ continue; // already reported above
175
+ if (entry.agent_id !== card.agent_id) {
176
+ checks.push({
177
+ name: `${label}.agent_id`,
178
+ passed: false,
179
+ message: `Manifest agent_id "${String(entry.agent_id)}" does not match snapshot "${snapshotName}" agent_id "${String(card.agent_id)}".`,
180
+ });
181
+ }
182
+ else {
183
+ checks.push({
184
+ name: `${label}.agent_id`,
185
+ passed: true,
186
+ message: `agent_id "${String(entry.agent_id)}" matches snapshot`,
187
+ });
188
+ }
189
+ }
190
+ // ── Direct (true-off) calibration lane shape ──
191
+ checks.push(...validateDirectLane(manifest, entries));
192
+ return checks;
193
+ }
194
+ /**
195
+ * Model-consistency checks: the manifest pins one authoritative `model`, and no
196
+ * entry may declare a divergent per-lane `model`. Returns one `model.pinned`
197
+ * check plus, for any entry that declares its own `model`, one per-entry check.
198
+ */
199
+ function modelConsistencyChecks(manifest, entries) {
200
+ const checks = [];
201
+ const model = manifest.model;
202
+ if (typeof model !== "string" || model.length === 0) {
203
+ checks.push({
204
+ name: "model.pinned",
205
+ passed: false,
206
+ message: "Required: manifest must pin `model` (a non-empty string) — the single evaluation-battery model held fixed across all lanes.",
207
+ });
208
+ return checks;
209
+ }
210
+ if (model !== EVAL_BATTERY_MODEL) {
211
+ checks.push({
212
+ name: "model.pinned",
213
+ passed: false,
214
+ message: `Manifest model "${model}" does not equal EVAL_BATTERY_MODEL "${EVAL_BATTERY_MODEL}". The pinned battery model must not silently diverge (MNE-5707).`,
215
+ });
216
+ }
217
+ else {
218
+ checks.push({
219
+ name: "model.pinned",
220
+ passed: true,
221
+ message: `model pinned to "${model}" (matches EVAL_BATTERY_MODEL)`,
222
+ });
223
+ }
224
+ // Any entry that declares its own `model` must match the authoritative value.
225
+ for (let i = 0; i < entries.length; i++) {
226
+ const entry = entries[i];
227
+ if (entry.model === undefined)
228
+ continue; // no per-lane override (the norm)
229
+ const label = typeof entry.label === "string" ? entry.label : `entries[${i}]`;
230
+ if (entry.model !== model) {
231
+ checks.push({
232
+ name: `${label}.model`,
233
+ passed: false,
234
+ message: `Entry model "${String(entry.model)}" diverges from the pinned manifest model "${model}". All lanes must use the identical model (MNE-440).`,
235
+ });
236
+ }
237
+ else {
238
+ checks.push({
239
+ name: `${label}.model`,
240
+ passed: true,
241
+ message: `entry model matches the pinned "${model}"`,
242
+ });
243
+ }
244
+ }
245
+ return checks;
246
+ }
247
+ /**
248
+ * Validate the fourth, DIRECT (true-off) calibration lane. It is a distinct
249
+ * top-level section, kept OUT of the three-mode `entries` coverage count. Asserts
250
+ * it declares exactly the direct-lane shape (`mode: off`, `path: direct`, a
251
+ * non-empty `secret_ref` distinct from every entry's) and declares NONE of the
252
+ * gateway-only fields (`snapshot`/`agent_id`/`agent_hash`) nor a per-lane `model`
253
+ * — the direct lane never touches the gateway, so any of those would be
254
+ * misleading dead config (MNE-440).
255
+ */
256
+ function validateDirectLane(manifest, entries) {
257
+ const checks = [];
258
+ const raw = manifest.direct_lane;
259
+ if (!isObject(raw)) {
260
+ checks.push({
261
+ name: "direct_lane",
262
+ passed: false,
263
+ message: "Required: manifest must declare a `direct_lane` object (the true-off calibration path that bypasses the gateway).",
264
+ });
265
+ return checks;
266
+ }
267
+ const lane = raw;
268
+ // mode must be exactly "off" (true-off calibration).
269
+ if (lane.mode !== "off") {
270
+ checks.push({
271
+ name: "direct_lane.mode",
272
+ passed: false,
273
+ message: `direct_lane.mode must be "off" (true-off calibration); got "${String(lane.mode)}".`,
274
+ });
275
+ }
276
+ else {
277
+ checks.push({ name: "direct_lane.mode", passed: true, message: 'mode "off" (true-off)' });
278
+ }
279
+ // path must be exactly "direct".
280
+ if (lane.path !== "direct") {
281
+ checks.push({
282
+ name: "direct_lane.path",
283
+ passed: false,
284
+ message: `direct_lane.path must be "direct" (it bypasses the gateway); got "${String(lane.path)}".`,
285
+ });
286
+ }
287
+ else {
288
+ checks.push({ name: "direct_lane.path", passed: true, message: 'path "direct"' });
289
+ }
290
+ // secret_ref must be a non-empty string, distinct from every entry's.
291
+ if (typeof lane.secret_ref !== "string" || lane.secret_ref.length === 0) {
292
+ checks.push({
293
+ name: "direct_lane.secret_ref",
294
+ passed: false,
295
+ message: "direct_lane.secret_ref must be a non-empty string (secret-store reference name).",
296
+ });
297
+ }
298
+ else {
299
+ const entryRefs = new Set(entries.map((e) => e.secret_ref).filter((r) => typeof r === "string"));
300
+ if (entryRefs.has(lane.secret_ref)) {
301
+ checks.push({
302
+ name: "direct_lane.secret_ref",
303
+ passed: false,
304
+ message: `direct_lane.secret_ref "${lane.secret_ref}" must be distinct from every entry's secret_ref (the direct lane uses its own provider key).`,
305
+ });
306
+ }
307
+ else {
308
+ checks.push({
309
+ name: "direct_lane.secret_ref",
310
+ passed: true,
311
+ message: `secret_ref "${lane.secret_ref}" is distinct from all entry secret_refs`,
312
+ });
313
+ }
314
+ }
315
+ // Forbidden gateway-only / per-lane fields: declaring any is misleading dead
316
+ // config, because the direct lane never traverses the gateway (MNE-440).
317
+ const forbidden = ["snapshot", "agent_id", "agent_hash", "model"].filter((f) => lane[f] !== undefined);
318
+ if (forbidden.length > 0) {
319
+ checks.push({
320
+ name: "direct_lane.forbidden_fields",
321
+ passed: false,
322
+ message: `direct_lane must NOT declare ${forbidden.join(", ")}: it never traverses the gateway (no identity/card) and uses the authoritative top-level manifest.model. Remove the field(s) to avoid misleading dead config (MNE-440).`,
323
+ });
324
+ }
325
+ else {
326
+ checks.push({
327
+ name: "direct_lane.forbidden_fields",
328
+ passed: true,
329
+ message: "declares no gateway-only identity/card/model fields",
330
+ });
331
+ }
332
+ return checks;
333
+ }
334
+ /** Assert a field is present and mutually distinct across all entries. */
335
+ function distinctnessCheck(entries, field) {
336
+ const values = entries.map((e) => e[field]);
337
+ if (values.some((v) => typeof v !== "string" || v.length === 0)) {
338
+ return {
339
+ name: `${field}.distinct`,
340
+ passed: false,
341
+ message: `Every entry must declare a non-empty ${field}.`,
342
+ };
343
+ }
344
+ const seen = new Set();
345
+ const dupes = new Set();
346
+ for (const v of values) {
347
+ if (seen.has(v))
348
+ dupes.add(v);
349
+ seen.add(v);
350
+ }
351
+ if (dupes.size > 0) {
352
+ return {
353
+ name: `${field}.distinct`,
354
+ passed: false,
355
+ message: `${field} must be distinct across entries; repeated: ${[...dupes].join(", ")}.`,
356
+ };
357
+ }
358
+ return {
359
+ name: `${field}.distinct`,
360
+ passed: true,
361
+ message: `${values.length} distinct ${field} value(s)`,
362
+ };
363
+ }
@@ -105,11 +105,33 @@ export declare function loginWithDevice(opts?: {
105
105
  sleep?: (ms: number) => Promise<void>;
106
106
  }): Promise<DeviceResult>;
107
107
  /**
108
- * Exchange a refresh token for a fresh access token. Returns null if refresh is
109
- * not possible (no refresh token, or the AS rejects it — e.g. revoked/expired),
110
- * so callers can fall back to prompting for re-login rather than crashing.
108
+ * Exchange a refresh token for a fresh access token.
109
+ *
110
+ * Failure classification (R6 / MNE-7394) a transient failure must never be
111
+ * indistinguishable from a revoked credential:
112
+ * - 2xx → return the fresh tokens (carrying the prior refresh token forward
113
+ * per RFC 6749 §6 when the AS omits a new one).
114
+ * - A 4xx whose OAuth body `error` is `invalid_grant` / `invalid_client` →
115
+ * return `null`, the re-login signal. RFC 6749 §5.2 defines these as the
116
+ * errors that mean the presented grant/client credential is genuinely no
117
+ * longer valid, so a fresh login is the correct remedy. No retry.
118
+ * - Any other 4xx (e.g. `invalid_request`, `429`, an unrecognized/absent body
119
+ * error, a non-JSON body) → throw. Something is wrong, but it does NOT mean
120
+ * the credentials are revoked, so we must not discard the session.
121
+ * - `res.status >= 500` or a fetch/network exception → retry with bounded
122
+ * exponential backoff (~1s/2s/4s, REFRESH_MAX_ATTEMPTS attempts); if the
123
+ * retries are exhausted, throw. Exhausting transient retries must NOT
124
+ * masquerade as "credentials revoked" (fail-closed, MNE-442) — throwing
125
+ * preserves the stored session so a later invocation can succeed once the
126
+ * AS recovers.
127
+ *
128
+ * `null` is therefore narrowed to exactly the two re-login errors; every other
129
+ * outcome is a success, a retry, or a thrown error. `opts.sleep` is injectable
130
+ * so tests drive the backoff without real timers.
111
131
  */
112
- export declare function refreshTokens(refreshToken: string, clientId: string): Promise<OAuthTokens | null>;
132
+ export declare function refreshTokens(refreshToken: string, clientId: string, opts?: {
133
+ sleep?: (ms: number) => Promise<void>;
134
+ }): Promise<OAuthTokens | null>;
113
135
  /**
114
136
  * Open `url` in the user's default browser WITHOUT a shell. The URL is always
115
137
  * passed as a separate argv element (never concatenated into a command string),