@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/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 +16 -1
- 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 +12 -0
- package/dist/lib/config.js +55 -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/oauth.js
CHANGED
|
@@ -39,6 +39,14 @@ const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
39
39
|
// both interactive login poll loops emit, so a multi-minute browser sign-in
|
|
40
40
|
// doesn't read as a hung CLI (matches the card-write retry tick in try-me).
|
|
41
41
|
const LOGIN_HEARTBEAT_MS = 15 * 1000;
|
|
42
|
+
// Refresh-token retry policy (R6 / MNE-7394). A transient AS 5xx or a dropped
|
|
43
|
+
// network connection during refresh must be retried with bounded exponential
|
|
44
|
+
// backoff rather than collapsed into the re-login signal — a one-second AS
|
|
45
|
+
// hiccup or a flaky Wi-Fi moment must not invalidate a valid, long-lived
|
|
46
|
+
// session. Per-attempt delay is REFRESH_BACKOFF_BASE_MS * 2**attemptIndex →
|
|
47
|
+
// ~1s / 2s / 4s across REFRESH_MAX_ATTEMPTS attempts.
|
|
48
|
+
const REFRESH_MAX_ATTEMPTS = 3;
|
|
49
|
+
const REFRESH_BACKOFF_BASE_MS = 1000;
|
|
42
50
|
let cachedMetadata = null;
|
|
43
51
|
/**
|
|
44
52
|
* Fetch (and process-cache) the AS metadata document. We resolve it relative to
|
|
@@ -359,40 +367,101 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
|
|
|
359
367
|
// Refresh (RFC 6749 §6)
|
|
360
368
|
// ============================================================================
|
|
361
369
|
/**
|
|
362
|
-
* Exchange a refresh token for a fresh access token.
|
|
363
|
-
*
|
|
364
|
-
*
|
|
370
|
+
* Exchange a refresh token for a fresh access token.
|
|
371
|
+
*
|
|
372
|
+
* Failure classification (R6 / MNE-7394) — a transient failure must never be
|
|
373
|
+
* indistinguishable from a revoked credential:
|
|
374
|
+
* - 2xx → return the fresh tokens (carrying the prior refresh token forward
|
|
375
|
+
* per RFC 6749 §6 when the AS omits a new one).
|
|
376
|
+
* - A 4xx whose OAuth body `error` is `invalid_grant` / `invalid_client` →
|
|
377
|
+
* return `null`, the re-login signal. RFC 6749 §5.2 defines these as the
|
|
378
|
+
* errors that mean the presented grant/client credential is genuinely no
|
|
379
|
+
* longer valid, so a fresh login is the correct remedy. No retry.
|
|
380
|
+
* - Any other 4xx (e.g. `invalid_request`, `429`, an unrecognized/absent body
|
|
381
|
+
* error, a non-JSON body) → throw. Something is wrong, but it does NOT mean
|
|
382
|
+
* the credentials are revoked, so we must not discard the session.
|
|
383
|
+
* - `res.status >= 500` or a fetch/network exception → retry with bounded
|
|
384
|
+
* exponential backoff (~1s/2s/4s, REFRESH_MAX_ATTEMPTS attempts); if the
|
|
385
|
+
* retries are exhausted, throw. Exhausting transient retries must NOT
|
|
386
|
+
* masquerade as "credentials revoked" (fail-closed, MNE-442) — throwing
|
|
387
|
+
* preserves the stored session so a later invocation can succeed once the
|
|
388
|
+
* AS recovers.
|
|
389
|
+
*
|
|
390
|
+
* `null` is therefore narrowed to exactly the two re-login errors; every other
|
|
391
|
+
* outcome is a success, a retry, or a thrown error. `opts.sleep` is injectable
|
|
392
|
+
* so tests drive the backoff without real timers.
|
|
365
393
|
*/
|
|
366
|
-
export async function refreshTokens(refreshToken, clientId) {
|
|
394
|
+
export async function refreshTokens(refreshToken, clientId, opts) {
|
|
367
395
|
if (!refreshToken || !clientId)
|
|
368
396
|
return null;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
397
|
+
const sleep = opts?.sleep ?? defaultSleep;
|
|
398
|
+
// Discovery is not part of the refresh retry budget; a discovery failure
|
|
399
|
+
// propagates as it does today (it is already thrown by discover()).
|
|
400
|
+
const meta = await discover();
|
|
401
|
+
let lastTransient = "";
|
|
402
|
+
for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt++) {
|
|
403
|
+
let res;
|
|
404
|
+
try {
|
|
405
|
+
res = await fetch(meta.token_endpoint, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: {
|
|
408
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
409
|
+
Accept: "application/json",
|
|
410
|
+
},
|
|
411
|
+
body: new URLSearchParams({
|
|
412
|
+
grant_type: "refresh_token",
|
|
413
|
+
refresh_token: refreshToken,
|
|
414
|
+
client_id: clientId,
|
|
415
|
+
}),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
// Network/fetch exception (DNS failure, connection reset, timeout) — this
|
|
420
|
+
// is unrelated to the validity of the refresh token, so retry it.
|
|
421
|
+
lastTransient = err instanceof Error ? err.message : String(err);
|
|
422
|
+
if (attempt < REFRESH_MAX_ATTEMPTS - 1) {
|
|
423
|
+
await sleep(REFRESH_BACKOFF_BASE_MS * 2 ** attempt);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
if (res.ok) {
|
|
429
|
+
const data = (await res.json());
|
|
430
|
+
const tokens = tokensFromResponse(data);
|
|
431
|
+
// Per RFC 6749 §6, a refresh response MAY omit a new refresh token, in
|
|
432
|
+
// which case the old one remains valid — preserve it so the next refresh
|
|
433
|
+
// works.
|
|
434
|
+
if (!tokens.refreshToken)
|
|
435
|
+
tokens.refreshToken = refreshToken;
|
|
436
|
+
return tokens;
|
|
437
|
+
}
|
|
438
|
+
if (res.status >= 500) {
|
|
439
|
+
// Transient AS outage — retry with backoff, then fall through to the
|
|
440
|
+
// "exhausted" throw once no attempts remain.
|
|
441
|
+
lastTransient = `HTTP ${res.status}`;
|
|
442
|
+
if (attempt < REFRESH_MAX_ATTEMPTS - 1) {
|
|
443
|
+
await sleep(REFRESH_BACKOFF_BASE_MS * 2 ** attempt);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
// 4xx — a definitive client-side rejection, never retried. Only
|
|
449
|
+
// invalid_grant / invalid_client map to the null re-login signal; every
|
|
450
|
+
// other 4xx surfaces as a thrown error rather than a silent null that would
|
|
451
|
+
// wrongly discard a valid session.
|
|
452
|
+
const body = (await res.json().catch(() => ({})));
|
|
453
|
+
if (body.error === "invalid_grant" || body.error === "invalid_client") {
|
|
384
454
|
return null;
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
tokens.refreshToken = refreshToken;
|
|
391
|
-
return tokens;
|
|
392
|
-
}
|
|
393
|
-
catch {
|
|
394
|
-
return null;
|
|
455
|
+
}
|
|
456
|
+
const detail = body.error
|
|
457
|
+
? body.error + (body.error_description ? ` — ${body.error_description}` : "")
|
|
458
|
+
: `HTTP ${res.status}`;
|
|
459
|
+
throw new Error(`Token refresh failed: ${detail}`);
|
|
395
460
|
}
|
|
461
|
+
// Retries exhausted on a 5xx or a network exception (fail-closed, MNE-442):
|
|
462
|
+
// throw rather than return null so the session is preserved for a later retry.
|
|
463
|
+
throw new Error(`Token refresh failed after ${REFRESH_MAX_ATTEMPTS} attempts ` +
|
|
464
|
+
`(last error: ${lastTransient || "unknown transient failure"}).`);
|
|
396
465
|
}
|
|
397
466
|
// ============================================================================
|
|
398
467
|
// Helpers
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protection-drift.ts — pure comparison between a COMMITTED protection-card
|
|
3
|
+
* snapshot (a file under `cards/`) and the LIVE canonical protection card
|
|
4
|
+
* returned by the platform.
|
|
5
|
+
*
|
|
6
|
+
* Split follows the convention `mnemom-api/src/composition/reconcile-flat-columns.ts`
|
|
7
|
+
* established: the decision logic is a pure, network-free module so it can be
|
|
8
|
+
* unit-tested exhaustively; the thin driver that actually fetches the live card
|
|
9
|
+
* lives in the command layer (`commands/protection.ts` → `mnemom protection drift`).
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS BEING COMPARED (read this before extending):
|
|
12
|
+
* - `committed` is a repo SNAPSHOT of the agent-scope card — human-authored,
|
|
13
|
+
* declared intent. See `cards/README.md`.
|
|
14
|
+
* - `live` is the CANONICAL COMPOSED card (`GET /v1/protection/agent/:id`,
|
|
15
|
+
* via `getProtectionCard()`), i.e. the OUTPUT of
|
|
16
|
+
* `composeProtectionCard(platform, org, teams…, agent)` with strictest-wins
|
|
17
|
+
* mode resolution.
|
|
18
|
+
* These are NOT the same object. A snapshot field matching the composed card
|
|
19
|
+
* means "the effective posture is what the repo says it should be" — it does
|
|
20
|
+
* NOT mean "publishing this snapshot would reproduce the live card". A
|
|
21
|
+
* stricter scope upstream (org / team / platform floor) can raise the composed
|
|
22
|
+
* mode above whatever the agent-scope card asks for.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Fields on the canonical card that are re-derived on every compose and
|
|
26
|
+
* therefore carry no posture meaning. Comparing them would make any drift check
|
|
27
|
+
* permanently red.
|
|
28
|
+
*
|
|
29
|
+
* - `card_version`: `compose.ts` sets it to
|
|
30
|
+
* `protection/${new Date().toISOString().slice(0, 10)}` — a wall-clock stamp
|
|
31
|
+
* of the LAST COMPOSE, not a semantic version. It changes whenever anything
|
|
32
|
+
* recomposes, and never changes while the card sits untouched.
|
|
33
|
+
* - `content_hash` / `composed_at` / `agent_protection_card_id` /
|
|
34
|
+
* `updated_at` / `created_at` / `needs_recompose`: storage + provenance
|
|
35
|
+
* bookkeeping written by the composer and the canonical-write RPC.
|
|
36
|
+
*
|
|
37
|
+
* The first version of this list was written from the composer's own field
|
|
38
|
+
* names. That was the wrong source: the list has to match what the SERIALIZER
|
|
39
|
+
* emits. A live read of `GET /v1/protection/agent/:id` on us-1 (2026-07-28)
|
|
40
|
+
* returns exactly:
|
|
41
|
+
*
|
|
42
|
+
* mode, card_id, agent_id, issued_at, thresholds, card_version,
|
|
43
|
+
* screen_surfaces, trusted_sources, protected_surface, _composition,
|
|
44
|
+
* content_hash, version
|
|
45
|
+
*
|
|
46
|
+
* so `composed_at`, `created_at`, `updated_at`, `needs_recompose` and
|
|
47
|
+
* `agent_protection_card_id` never appear at all (kept below anyway — harmless,
|
|
48
|
+
* and they do appear on the raw DB row some callers pass in), while four
|
|
49
|
+
* derived fields the serializer DOES emit were missing: `card_id` (the
|
|
50
|
+
* server-stamped canonical id, provenance `kind: server_stamped`), `issued_at`
|
|
51
|
+
* (a compose-time stamp), `_composition` (the whole provenance envelope,
|
|
52
|
+
* `layer: derived` throughout, and itself containing `_composition.composed_at`)
|
|
53
|
+
* and `version` (the serializer's envelope version, not a posture field).
|
|
54
|
+
* Without them `--strict` reports four permanent `unrecorded` holes that no
|
|
55
|
+
* snapshot can ever close, which is precisely the state that would make an
|
|
56
|
+
* operator stop trusting the check.
|
|
57
|
+
*
|
|
58
|
+
* KNOWN COST of ignoring `version`: it is the serializer's envelope version, so
|
|
59
|
+
* a schema bump (2 → 3) passes silently here. That is the intended trade — a
|
|
60
|
+
* serializer version is not a posture claim, and an envelope change that alters
|
|
61
|
+
* a posture field will still surface as a mismatch or an unrecorded field. If
|
|
62
|
+
* the envelope version ever starts carrying posture semantics, drop it from
|
|
63
|
+
* this list.
|
|
64
|
+
*/
|
|
65
|
+
export declare const VOLATILE_FIELDS: readonly string[];
|
|
66
|
+
export interface FieldMismatch {
|
|
67
|
+
/** Dotted path, e.g. `mode` or `screen_surfaces.tool_calls`. */
|
|
68
|
+
path: string;
|
|
69
|
+
committed: unknown;
|
|
70
|
+
live: unknown;
|
|
71
|
+
}
|
|
72
|
+
export interface DriftResult {
|
|
73
|
+
/**
|
|
74
|
+
* A field is present in BOTH the snapshot and the live card but the values
|
|
75
|
+
* differ. This is unambiguous drift: the repo's declared posture is not the
|
|
76
|
+
* posture actually in force. Always a failure.
|
|
77
|
+
*/
|
|
78
|
+
mismatches: FieldMismatch[];
|
|
79
|
+
/**
|
|
80
|
+
* A top-level field the LIVE card carries that the snapshot does not record
|
|
81
|
+
* at all. Not a value conflict — a COVERAGE hole: that part of the live
|
|
82
|
+
* posture has no committed representation, so it could change with no diff.
|
|
83
|
+
* Reported always; fatal only under `strict`.
|
|
84
|
+
*/
|
|
85
|
+
unrecorded: string[];
|
|
86
|
+
/**
|
|
87
|
+
* A top-level field the SNAPSHOT declares that the live card does not carry.
|
|
88
|
+
* The declared intent is simply not in force. Always a failure — treated as a
|
|
89
|
+
* mismatch against `undefined` would lose the distinction, so it is its own
|
|
90
|
+
* bucket.
|
|
91
|
+
*/
|
|
92
|
+
missingLive: string[];
|
|
93
|
+
/** Volatile fields skipped, for transparency in the report. */
|
|
94
|
+
ignored: string[];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Compare a committed snapshot against the live canonical card.
|
|
98
|
+
*
|
|
99
|
+
* Pure: no I/O, no clock, no env. Both arguments are already-parsed objects.
|
|
100
|
+
*/
|
|
101
|
+
export declare function compareProtectionCards(committed: Record<string, unknown>, live: Record<string, unknown>): DriftResult;
|
|
102
|
+
/**
|
|
103
|
+
* Policy: does this result fail the check?
|
|
104
|
+
*
|
|
105
|
+
* `mismatches` and `missingLive` always fail — the repo says one thing and the
|
|
106
|
+
* live posture is another. `unrecorded` is a coverage gap: reported by default,
|
|
107
|
+
* fatal only under `--strict`, so the check can go green today on a partial
|
|
108
|
+
* snapshot while still surfacing exactly which parts of the live posture remain
|
|
109
|
+
* unversioned. See `cards/README.md`.
|
|
110
|
+
*/
|
|
111
|
+
export declare function isDrift(result: DriftResult, opts?: {
|
|
112
|
+
strict?: boolean;
|
|
113
|
+
}): boolean;
|
|
114
|
+
/** Human-readable multi-line report. Returned, not printed, so it is testable. */
|
|
115
|
+
export declare function formatDriftReport(result: DriftResult, opts?: {
|
|
116
|
+
strict?: boolean;
|
|
117
|
+
}): string;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protection-drift.ts — pure comparison between a COMMITTED protection-card
|
|
3
|
+
* snapshot (a file under `cards/`) and the LIVE canonical protection card
|
|
4
|
+
* returned by the platform.
|
|
5
|
+
*
|
|
6
|
+
* Split follows the convention `mnemom-api/src/composition/reconcile-flat-columns.ts`
|
|
7
|
+
* established: the decision logic is a pure, network-free module so it can be
|
|
8
|
+
* unit-tested exhaustively; the thin driver that actually fetches the live card
|
|
9
|
+
* lives in the command layer (`commands/protection.ts` → `mnemom protection drift`).
|
|
10
|
+
*
|
|
11
|
+
* WHAT IS BEING COMPARED (read this before extending):
|
|
12
|
+
* - `committed` is a repo SNAPSHOT of the agent-scope card — human-authored,
|
|
13
|
+
* declared intent. See `cards/README.md`.
|
|
14
|
+
* - `live` is the CANONICAL COMPOSED card (`GET /v1/protection/agent/:id`,
|
|
15
|
+
* via `getProtectionCard()`), i.e. the OUTPUT of
|
|
16
|
+
* `composeProtectionCard(platform, org, teams…, agent)` with strictest-wins
|
|
17
|
+
* mode resolution.
|
|
18
|
+
* These are NOT the same object. A snapshot field matching the composed card
|
|
19
|
+
* means "the effective posture is what the repo says it should be" — it does
|
|
20
|
+
* NOT mean "publishing this snapshot would reproduce the live card". A
|
|
21
|
+
* stricter scope upstream (org / team / platform floor) can raise the composed
|
|
22
|
+
* mode above whatever the agent-scope card asks for.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Fields on the canonical card that are re-derived on every compose and
|
|
26
|
+
* therefore carry no posture meaning. Comparing them would make any drift check
|
|
27
|
+
* permanently red.
|
|
28
|
+
*
|
|
29
|
+
* - `card_version`: `compose.ts` sets it to
|
|
30
|
+
* `protection/${new Date().toISOString().slice(0, 10)}` — a wall-clock stamp
|
|
31
|
+
* of the LAST COMPOSE, not a semantic version. It changes whenever anything
|
|
32
|
+
* recomposes, and never changes while the card sits untouched.
|
|
33
|
+
* - `content_hash` / `composed_at` / `agent_protection_card_id` /
|
|
34
|
+
* `updated_at` / `created_at` / `needs_recompose`: storage + provenance
|
|
35
|
+
* bookkeeping written by the composer and the canonical-write RPC.
|
|
36
|
+
*
|
|
37
|
+
* The first version of this list was written from the composer's own field
|
|
38
|
+
* names. That was the wrong source: the list has to match what the SERIALIZER
|
|
39
|
+
* emits. A live read of `GET /v1/protection/agent/:id` on us-1 (2026-07-28)
|
|
40
|
+
* returns exactly:
|
|
41
|
+
*
|
|
42
|
+
* mode, card_id, agent_id, issued_at, thresholds, card_version,
|
|
43
|
+
* screen_surfaces, trusted_sources, protected_surface, _composition,
|
|
44
|
+
* content_hash, version
|
|
45
|
+
*
|
|
46
|
+
* so `composed_at`, `created_at`, `updated_at`, `needs_recompose` and
|
|
47
|
+
* `agent_protection_card_id` never appear at all (kept below anyway — harmless,
|
|
48
|
+
* and they do appear on the raw DB row some callers pass in), while four
|
|
49
|
+
* derived fields the serializer DOES emit were missing: `card_id` (the
|
|
50
|
+
* server-stamped canonical id, provenance `kind: server_stamped`), `issued_at`
|
|
51
|
+
* (a compose-time stamp), `_composition` (the whole provenance envelope,
|
|
52
|
+
* `layer: derived` throughout, and itself containing `_composition.composed_at`)
|
|
53
|
+
* and `version` (the serializer's envelope version, not a posture field).
|
|
54
|
+
* Without them `--strict` reports four permanent `unrecorded` holes that no
|
|
55
|
+
* snapshot can ever close, which is precisely the state that would make an
|
|
56
|
+
* operator stop trusting the check.
|
|
57
|
+
*
|
|
58
|
+
* KNOWN COST of ignoring `version`: it is the serializer's envelope version, so
|
|
59
|
+
* a schema bump (2 → 3) passes silently here. That is the intended trade — a
|
|
60
|
+
* serializer version is not a posture claim, and an envelope change that alters
|
|
61
|
+
* a posture field will still surface as a mismatch or an unrecorded field. If
|
|
62
|
+
* the envelope version ever starts carrying posture semantics, drop it from
|
|
63
|
+
* this list.
|
|
64
|
+
*/
|
|
65
|
+
export const VOLATILE_FIELDS = [
|
|
66
|
+
"_composition",
|
|
67
|
+
"agent_protection_card_id",
|
|
68
|
+
"card_id",
|
|
69
|
+
"card_version",
|
|
70
|
+
"composed_at",
|
|
71
|
+
"content_hash",
|
|
72
|
+
"created_at",
|
|
73
|
+
"issued_at",
|
|
74
|
+
"needs_recompose",
|
|
75
|
+
"updated_at",
|
|
76
|
+
"version",
|
|
77
|
+
];
|
|
78
|
+
function isPlainObject(v) {
|
|
79
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
80
|
+
}
|
|
81
|
+
/** Structural equality good enough for card values (scalars, arrays, nested objects). */
|
|
82
|
+
function deepEqual(a, b) {
|
|
83
|
+
if (a === b)
|
|
84
|
+
return true;
|
|
85
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
86
|
+
return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
|
|
87
|
+
}
|
|
88
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
89
|
+
const ka = Object.keys(a).sort();
|
|
90
|
+
const kb = Object.keys(b).sort();
|
|
91
|
+
return ka.length === kb.length && ka.every((k, i) => k === kb[i] && deepEqual(a[k], b[k]));
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Recurse into nested objects so a one-surface flip reports as
|
|
97
|
+
* `screen_surfaces.tool_calls` rather than dumping both whole objects.
|
|
98
|
+
* Keys the live side has but the snapshot does not, BELOW the top level, are
|
|
99
|
+
* reported as nested `unrecorded` paths (same coverage-hole semantics).
|
|
100
|
+
*/
|
|
101
|
+
function walk(committed, live, prefix, out) {
|
|
102
|
+
for (const key of Object.keys(committed)) {
|
|
103
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
104
|
+
if (!prefix && VOLATILE_FIELDS.includes(key)) {
|
|
105
|
+
out.ignored.push(path);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const c = committed[key];
|
|
109
|
+
const l = live[key];
|
|
110
|
+
if (!(key in live)) {
|
|
111
|
+
out.missingLive.push(path);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (isPlainObject(c) && isPlainObject(l)) {
|
|
115
|
+
walk(c, l, path, out);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (!deepEqual(c, l)) {
|
|
119
|
+
out.mismatches.push({ path, committed: c, live: l });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const key of Object.keys(live)) {
|
|
123
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
124
|
+
if (!prefix && VOLATILE_FIELDS.includes(key)) {
|
|
125
|
+
if (!out.ignored.includes(path))
|
|
126
|
+
out.ignored.push(path);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!(key in committed))
|
|
130
|
+
out.unrecorded.push(path);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Compare a committed snapshot against the live canonical card.
|
|
135
|
+
*
|
|
136
|
+
* Pure: no I/O, no clock, no env. Both arguments are already-parsed objects.
|
|
137
|
+
*/
|
|
138
|
+
export function compareProtectionCards(committed, live) {
|
|
139
|
+
const out = { mismatches: [], unrecorded: [], missingLive: [], ignored: [] };
|
|
140
|
+
walk(committed, live, "", out);
|
|
141
|
+
out.mismatches.sort((a, b) => a.path.localeCompare(b.path));
|
|
142
|
+
out.unrecorded.sort();
|
|
143
|
+
out.missingLive.sort();
|
|
144
|
+
out.ignored.sort();
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Policy: does this result fail the check?
|
|
149
|
+
*
|
|
150
|
+
* `mismatches` and `missingLive` always fail — the repo says one thing and the
|
|
151
|
+
* live posture is another. `unrecorded` is a coverage gap: reported by default,
|
|
152
|
+
* fatal only under `--strict`, so the check can go green today on a partial
|
|
153
|
+
* snapshot while still surfacing exactly which parts of the live posture remain
|
|
154
|
+
* unversioned. See `cards/README.md`.
|
|
155
|
+
*/
|
|
156
|
+
export function isDrift(result, opts = {}) {
|
|
157
|
+
if (result.mismatches.length > 0 || result.missingLive.length > 0)
|
|
158
|
+
return true;
|
|
159
|
+
return opts.strict === true && result.unrecorded.length > 0;
|
|
160
|
+
}
|
|
161
|
+
/** Human-readable multi-line report. Returned, not printed, so it is testable. */
|
|
162
|
+
export function formatDriftReport(result, opts = {}) {
|
|
163
|
+
const lines = [];
|
|
164
|
+
for (const m of result.mismatches) {
|
|
165
|
+
lines.push(`MISMATCH ${m.path}: committed=${JSON.stringify(m.committed)} live=${JSON.stringify(m.live)}`);
|
|
166
|
+
}
|
|
167
|
+
for (const p of result.missingLive) {
|
|
168
|
+
lines.push(`MISSING ${p}: declared in the committed snapshot, absent from the live card`);
|
|
169
|
+
}
|
|
170
|
+
for (const p of result.unrecorded) {
|
|
171
|
+
const tag = opts.strict === true ? "UNRECORDED" : "unrecorded";
|
|
172
|
+
lines.push(`${tag.padEnd(9)} ${p}: present on the live card, not represented in the snapshot`);
|
|
173
|
+
}
|
|
174
|
+
if (result.ignored.length > 0) {
|
|
175
|
+
lines.push(`ignored volatile/derived fields: ${result.ignored.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
if (lines.length === 0)
|
|
178
|
+
lines.push("no differences");
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
package/dist/lib/skills.js
CHANGED
|
@@ -42,23 +42,36 @@ export const SKILLS = [
|
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
name: "onboard",
|
|
45
|
-
status: "
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
usage: "mnemom onboard",
|
|
45
|
+
status: "available",
|
|
46
|
+
summary: "Self-onboard the calling agent end-to-end (scan → claim → declare → rating → badge).",
|
|
47
|
+
usage: "mnemom onboard [--agent <id>] [--key <key>] [--hash-proof <hex>] [--yes] [--json] [--no-open]",
|
|
49
48
|
description: "Runs the sovereignty path for the calling agent itself: scan its trust posture, " +
|
|
50
|
-
"claim its identity
|
|
51
|
-
"one command, no manifest.
|
|
49
|
+
"claim its identity (as its own device-grant principal), declare a starter alignment " +
|
|
50
|
+
"card, and surface a Trust Rating + public badge URL — one command, no manifest. " +
|
|
51
|
+
"The Trust Rating is provisional until the observer pipeline generates traces (the " +
|
|
52
|
+
"signed rating + rendered badge are computed server-side, post-hoc). Idempotent: " +
|
|
53
|
+
"re-running skips an already-claimed identity and replays an unchanged card.",
|
|
54
|
+
examples: [
|
|
55
|
+
"npx @mnemom/mnemom@latest onboard",
|
|
56
|
+
"mnemom onboard --json",
|
|
57
|
+
"mnemom onboard --agent mnm-... --key <agent-api-key>",
|
|
58
|
+
],
|
|
52
59
|
},
|
|
53
60
|
{
|
|
54
61
|
name: "wrap",
|
|
55
|
-
status: "
|
|
56
|
-
ref: "MNE-935",
|
|
62
|
+
status: "available",
|
|
57
63
|
summary: "Instrument an existing production agent through the Mnemom gateway.",
|
|
58
|
-
usage: "mnemom wrap"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
64
|
+
usage: "mnemom wrap [--provider <anthropic|openai|gemini>] [--framework <python|node>] " +
|
|
65
|
+
"[--name <name>] [--provider-key <key>] [--yes] [--json]",
|
|
66
|
+
description: "Points an existing agent's provider calls at the Mnemom gateway " +
|
|
67
|
+
"(`gateway.mnemom.ai/<provider>` + `x-mnemom-agent` header), births its identity, " +
|
|
68
|
+
"and seeds starter alignment + protection cards — bring-your-own-agent onboarding. " +
|
|
69
|
+
"Emits a drop-in code snippet for your provider SDK and next-step claim instructions.",
|
|
70
|
+
examples: [
|
|
71
|
+
"npx @mnemom/mnemom@latest wrap",
|
|
72
|
+
"mnemom wrap --provider anthropic --framework python --name my-agent",
|
|
73
|
+
"mnemom wrap --json",
|
|
74
|
+
],
|
|
62
75
|
},
|
|
63
76
|
];
|
|
64
77
|
/** All registered skills, in registry order. */
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimum-CLI-version enforcement.
|
|
3
|
+
*
|
|
4
|
+
* The Mnemom API advertises the lowest fully-supported CLI version on every
|
|
5
|
+
* response via the `X-Mnemom-Min-CLI` header (mnemom-api MIN_CLI_VERSION). This
|
|
6
|
+
* module reads that header off any API response and, if THIS CLI is below the
|
|
7
|
+
* floor, prints a loud upgrade instruction and hard-exits.
|
|
8
|
+
*
|
|
9
|
+
* Why hard-fail: an outdated CLI can silently drop request fields a newer server
|
|
10
|
+
* expects and produce a wrong-but-successful result with no error. The canonical
|
|
11
|
+
* case that motivated this: a CLI predating claim-to-org (ADR-062) accepted
|
|
12
|
+
* `--org` but never sent `org_id`, so `agents claim … --org <slug>` cheerfully
|
|
13
|
+
* landed the agent in the caller's personal org. Failing loudly beats a silent
|
|
14
|
+
* wrong outcome.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Compare dotted numeric versions. Returns true iff `current` is strictly below
|
|
18
|
+
* `floor`. Prerelease/build suffixes (after `-` or `+`) are ignored — the floor
|
|
19
|
+
* is expressed as a plain release, and a prerelease of that release is treated
|
|
20
|
+
* as the release for gating purposes (we don't block `-rc` builds of a good ver).
|
|
21
|
+
*/
|
|
22
|
+
export declare function isBelowVersion(current: string, floor: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Enforce the server-advertised minimum CLI version against `response`.
|
|
25
|
+
* No-op when the header is absent (older server, or a non-API response) or when
|
|
26
|
+
* this CLI is at/above the floor. Otherwise writes a clear message to stderr and
|
|
27
|
+
* exits with code 1.
|
|
28
|
+
*
|
|
29
|
+
* @param exit injectable for tests; defaults to process.exit
|
|
30
|
+
* @param write injectable for tests; defaults to process.stderr.write
|
|
31
|
+
*/
|
|
32
|
+
export declare function enforceMinCliVersion(response: Pick<Response, "headers">, exit?: (code: number) => never, write?: (chunk: string) => void): void;
|
|
33
|
+
/**
|
|
34
|
+
* Drop-in wrapper around global fetch for Mnemom API calls: issues the request,
|
|
35
|
+
* enforces the advertised minimum CLI version on the response, then returns it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function mnemomFetch(input: string | URL, init?: RequestInit): Promise<Response>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimum-CLI-version enforcement.
|
|
3
|
+
*
|
|
4
|
+
* The Mnemom API advertises the lowest fully-supported CLI version on every
|
|
5
|
+
* response via the `X-Mnemom-Min-CLI` header (mnemom-api MIN_CLI_VERSION). This
|
|
6
|
+
* module reads that header off any API response and, if THIS CLI is below the
|
|
7
|
+
* floor, prints a loud upgrade instruction and hard-exits.
|
|
8
|
+
*
|
|
9
|
+
* Why hard-fail: an outdated CLI can silently drop request fields a newer server
|
|
10
|
+
* expects and produce a wrong-but-successful result with no error. The canonical
|
|
11
|
+
* case that motivated this: a CLI predating claim-to-org (ADR-062) accepted
|
|
12
|
+
* `--org` but never sent `org_id`, so `agents claim … --org <slug>` cheerfully
|
|
13
|
+
* landed the agent in the caller's personal org. Failing loudly beats a silent
|
|
14
|
+
* wrong outcome.
|
|
15
|
+
*/
|
|
16
|
+
import { CLI_VERSION } from "../version.js";
|
|
17
|
+
const MIN_CLI_HEADER = "x-mnemom-min-cli";
|
|
18
|
+
/**
|
|
19
|
+
* Compare dotted numeric versions. Returns true iff `current` is strictly below
|
|
20
|
+
* `floor`. Prerelease/build suffixes (after `-` or `+`) are ignored — the floor
|
|
21
|
+
* is expressed as a plain release, and a prerelease of that release is treated
|
|
22
|
+
* as the release for gating purposes (we don't block `-rc` builds of a good ver).
|
|
23
|
+
*/
|
|
24
|
+
export function isBelowVersion(current, floor) {
|
|
25
|
+
const parse = (v) => v
|
|
26
|
+
.trim()
|
|
27
|
+
.split(/[-+]/)[0]
|
|
28
|
+
.split(".")
|
|
29
|
+
.map((n) => {
|
|
30
|
+
const parsed = Number.parseInt(n, 10);
|
|
31
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
32
|
+
});
|
|
33
|
+
const c = parse(current);
|
|
34
|
+
const f = parse(floor);
|
|
35
|
+
const len = Math.max(c.length, f.length);
|
|
36
|
+
for (let i = 0; i < len; i++) {
|
|
37
|
+
const cv = c[i] ?? 0;
|
|
38
|
+
const fv = f[i] ?? 0;
|
|
39
|
+
if (cv < fv)
|
|
40
|
+
return true;
|
|
41
|
+
if (cv > fv)
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return false; // equal → supported
|
|
45
|
+
}
|
|
46
|
+
// Guard so a command issuing several requests exits exactly once.
|
|
47
|
+
let exited = false;
|
|
48
|
+
/**
|
|
49
|
+
* Enforce the server-advertised minimum CLI version against `response`.
|
|
50
|
+
* No-op when the header is absent (older server, or a non-API response) or when
|
|
51
|
+
* this CLI is at/above the floor. Otherwise writes a clear message to stderr and
|
|
52
|
+
* exits with code 1.
|
|
53
|
+
*
|
|
54
|
+
* @param exit injectable for tests; defaults to process.exit
|
|
55
|
+
* @param write injectable for tests; defaults to process.stderr.write
|
|
56
|
+
*/
|
|
57
|
+
export function enforceMinCliVersion(response, exit = process.exit, write = (chunk) => void process.stderr.write(chunk)) {
|
|
58
|
+
// Defensive: some callers/tests provide a Response-like object without a real
|
|
59
|
+
// Headers instance. Treat a missing/incompatible headers bag as "no floor".
|
|
60
|
+
const headers = response?.headers;
|
|
61
|
+
const floor = typeof headers?.get === "function" ? headers.get(MIN_CLI_HEADER) : null;
|
|
62
|
+
if (!floor)
|
|
63
|
+
return;
|
|
64
|
+
if (!isBelowVersion(CLI_VERSION, floor))
|
|
65
|
+
return;
|
|
66
|
+
if (exited)
|
|
67
|
+
return;
|
|
68
|
+
exited = true;
|
|
69
|
+
write(`\n✖ Your Mnemom CLI (v${CLI_VERSION}) is too old for this server (minimum v${floor}).\n` +
|
|
70
|
+
` Old CLIs can silently drop newer request fields — e.g. \`--org\` on \`mnemom agents claim\`.\n\n` +
|
|
71
|
+
` Upgrade: npm i -g @mnemom/mnemom@latest\n\n`);
|
|
72
|
+
exit(1);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Drop-in wrapper around global fetch for Mnemom API calls: issues the request,
|
|
76
|
+
* enforces the advertised minimum CLI version on the response, then returns it.
|
|
77
|
+
*/
|
|
78
|
+
export async function mnemomFetch(input, init) {
|
|
79
|
+
// Preserve the exact call shape: fetch(input) when no init, so callers and
|
|
80
|
+
// tests that assert a single-arg fetch stay valid.
|
|
81
|
+
const response = init === undefined ? await fetch(input) : await fetch(input, init);
|
|
82
|
+
enforceMinCliVersion(response);
|
|
83
|
+
return response;
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemom/mnemom",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.3",
|
|
4
4
|
"description": "Transparent AI agent tracing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"smoltbot": "./dist/smoltbot-shim.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"build": "
|
|
11
|
+
"build": "tsc",
|
|
12
12
|
"dev": "tsx src/index.ts",
|
|
13
13
|
"gen:command-tree": "tsx scripts/gen-command-tree.mjs",
|
|
14
14
|
"check:command-tree": "tsx scripts/gen-command-tree.mjs --check",
|
|
@@ -19,15 +19,15 @@
|
|
|
19
19
|
"@mnemom/policy-engine": "^0.3.0",
|
|
20
20
|
"chalk": "^5.3.0",
|
|
21
21
|
"commander": "^12.0.0",
|
|
22
|
-
"js-yaml": "^4.
|
|
22
|
+
"js-yaml": "^4.3.2"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@types/js-yaml": "^4.0.9",
|
|
26
26
|
"@types/node": "^20.10.0",
|
|
27
|
-
"@vitest/coverage-v8": "^1.
|
|
28
|
-
"tsx": "^4.
|
|
29
|
-
"typescript": "^
|
|
30
|
-
"vitest": "^1.
|
|
27
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
28
|
+
"tsx": "^4.23.1",
|
|
29
|
+
"typescript": "^6.0.3",
|
|
30
|
+
"vitest": "^4.1.10"
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"dist",
|