@mnemom/mnemom 0.16.2 → 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/README.md +1 -0
- package/dist/commands/agents.d.ts +14 -0
- package/dist/commands/agents.js +100 -2
- package/dist/commands/card.d.ts +43 -0
- package/dist/commands/card.js +153 -102
- package/dist/commands/logs.js +11 -1
- package/dist/commands/onboard.d.ts +59 -0
- package/dist/commands/onboard.js +395 -0
- package/dist/commands/org.d.ts +13 -0
- package/dist/commands/org.js +63 -2
- package/dist/commands/protection.d.ts +10 -0
- package/dist/commands/protection.js +109 -0
- package/dist/commands/status.js +5 -0
- package/dist/commands/try-me.js +9 -0
- package/dist/commands/usage.d.ts +35 -0
- package/dist/commands/usage.js +265 -0
- package/dist/commands/wrap.d.ts +25 -0
- package/dist/commands/wrap.js +331 -0
- package/dist/index.js +192 -6
- package/dist/lib/agent-config.d.ts +27 -0
- package/dist/lib/agent-config.js +86 -0
- package/dist/lib/api.d.ts +122 -1
- package/dist/lib/api.js +128 -183
- package/dist/lib/auth.js +21 -1
- package/dist/lib/cli-config.d.ts +33 -0
- package/dist/lib/cli-config.js +70 -0
- package/dist/lib/config.d.ts +10 -0
- package/dist/lib/config.js +39 -3
- package/dist/lib/keyed-identity.d.ts +35 -0
- package/dist/lib/keyed-identity.js +363 -0
- package/dist/lib/oauth.d.ts +26 -4
- package/dist/lib/oauth.js +98 -29
- package/dist/lib/protection-drift.d.ts +117 -0
- package/dist/lib/protection-drift.js +180 -0
- package/dist/lib/skills.js +25 -12
- package/dist/lib/version-gate.d.ts +37 -0
- package/dist/lib/version-gate.js +84 -0
- package/package.json +7 -7
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
|
+
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -13,6 +13,16 @@ 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;
|
|
16
26
|
/** Override the active API base for this process (clears on undefined/empty). */
|
|
17
27
|
export declare function setApiUrlOverride(url: string | undefined): void;
|
|
18
28
|
export declare function getApiUrl(): string;
|
package/dist/lib/config.js
CHANGED
|
@@ -34,12 +34,44 @@ 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
|
+
}
|
|
37
64
|
// Process-level base overrides. MNEMOM_ENV only selects prod/staging/local;
|
|
38
65
|
// to target an arbitrary ring (preview/us-1, test) a command can set an
|
|
39
66
|
// explicit override (e.g. `try-me --api <ring>`) or the operator can export
|
|
40
67
|
// MNEMOM_API_URL / MNEMOM_GATEWAY_URL / MNEMOM_WEBSITE_URL. Crucially these
|
|
41
68
|
// flow through EVERY surface — including OAuth discovery + the one-click claim
|
|
42
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.
|
|
43
75
|
let apiUrlOverride;
|
|
44
76
|
/** Override the active API base for this process (clears on undefined/empty). */
|
|
45
77
|
export function setApiUrlOverride(url) {
|
|
@@ -50,12 +82,16 @@ function envBase(name) {
|
|
|
50
82
|
const v = process.env[name]?.trim();
|
|
51
83
|
return v ? v.replace(/\/+$/, "") : undefined;
|
|
52
84
|
}
|
|
85
|
+
function ringUrls() {
|
|
86
|
+
const ring = getRing();
|
|
87
|
+
return ring ? RING_URLS[ring] : undefined;
|
|
88
|
+
}
|
|
53
89
|
export function getApiUrl() {
|
|
54
|
-
return apiUrlOverride ?? envBase("MNEMOM_API_URL") ?? API_URLS[getEnvironment()];
|
|
90
|
+
return (apiUrlOverride ?? envBase("MNEMOM_API_URL") ?? ringUrls()?.api ?? API_URLS[getEnvironment()]);
|
|
55
91
|
}
|
|
56
92
|
export function getGatewayUrl() {
|
|
57
|
-
return envBase("MNEMOM_GATEWAY_URL") ?? GATEWAY_URLS[getEnvironment()];
|
|
93
|
+
return envBase("MNEMOM_GATEWAY_URL") ?? ringUrls()?.gateway ?? GATEWAY_URLS[getEnvironment()];
|
|
58
94
|
}
|
|
59
95
|
export function getWebsiteUrl() {
|
|
60
|
-
return envBase("MNEMOM_WEBSITE_URL") ?? WEBSITE_URLS[getEnvironment()];
|
|
96
|
+
return envBase("MNEMOM_WEBSITE_URL") ?? ringUrls()?.website ?? WEBSITE_URLS[getEnvironment()];
|
|
61
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
|
+
}
|
package/dist/lib/oauth.d.ts
CHANGED
|
@@ -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.
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
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),
|