@mnemom/mnemom 0.17.0-next.7 → 0.17.0-next.9

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.
@@ -15,7 +15,7 @@
15
15
  * resolvers in lib/code.ts keep the built-in default as the final fallback);
16
16
  * the caller folds the plain-value settings under the CLI flags.
17
17
  */
18
- import { type LaunchShape } from "./agent.js";
18
+ import { type GoalModeDefault, type LaunchShape } from "./agent.js";
19
19
  /** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
20
20
  export declare const LAUNCHER_CONFIG_PATH: string;
21
21
  /** Where the CLI resolves the Anthropic key from; advisory ordering/restriction. */
@@ -29,6 +29,8 @@ export interface LauncherConfig {
29
29
  cli?: string;
30
30
  launch?: LaunchShape;
31
31
  keySource?: KeySource;
32
+ /** Standing goal-alignment default when neither --goal nor --implicit is given. */
33
+ goalMode?: GoalModeDefault;
32
34
  guardrails?: {
33
35
  maxTurns?: number;
34
36
  budgetUsd?: number;
@@ -19,12 +19,13 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
19
19
  import { dirname, join } from "node:path";
20
20
  import { parse as parseToml, stringify as stringifyToml, TomlError } from "smol-toml";
21
21
  import { MNEMOM_DIR } from "./config.js";
22
- import { isPositiveInt, isPositiveNumber } from "./agent.js";
22
+ import { isPositiveInt, isPositiveNumber, } from "./agent.js";
23
23
  /** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
24
24
  export const LAUNCHER_CONFIG_PATH = join(MNEMOM_DIR, "launcher.toml");
25
25
  const LAUNCH_SHAPES = ["terminal", "remote-control", "server"];
26
26
  const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
27
27
  const KEY_SOURCES = ["env", "store", "prompt"];
28
+ const GOAL_MODES = ["implicit", "off"];
28
29
  function coerceLaunch(raw) {
29
30
  const v = raw.trim();
30
31
  if (LAUNCH_SHAPES.includes(v))
@@ -43,6 +44,12 @@ function coerceKeySource(raw) {
43
44
  return v;
44
45
  throw new Error(`key_source must be one of ${KEY_SOURCES.join(", ")} (got '${raw}').`);
45
46
  }
47
+ function coerceGoalMode(raw) {
48
+ const v = raw.trim().toLowerCase();
49
+ if (GOAL_MODES.includes(v))
50
+ return v;
51
+ throw new Error(`goal_mode must be one of ${GOAL_MODES.join(", ")} (got '${raw}'). A sealed contract needs a statement, so it is always a per-launch --goal.`);
52
+ }
46
53
  function coerceNonEmpty(name, raw) {
47
54
  const v = raw.trim();
48
55
  if (!v)
@@ -112,6 +119,14 @@ export const CONFIG_KEYS = [
112
119
  c.keySource = coerceKeySource(v);
113
120
  },
114
121
  },
122
+ {
123
+ key: "goal_mode",
124
+ describe: `Goal alignment when no --goal/--implicit is given (${GOAL_MODES.join("|")}); a sealed contract needs a per-launch --goal`,
125
+ get: (c) => c.goalMode,
126
+ set: (c, v) => {
127
+ c.goalMode = coerceGoalMode(v);
128
+ },
129
+ },
115
130
  {
116
131
  key: "guardrails.max_turns",
117
132
  describe: "Contract guardrail: turn ceiling (positive integer)",
@@ -176,6 +191,8 @@ export function parseLauncherConfig(text) {
176
191
  cfg.launch = coerceLaunch(str(raw.launch) ?? "");
177
192
  if (raw.key_source !== undefined)
178
193
  cfg.keySource = coerceKeySource(str(raw.key_source) ?? "");
194
+ if (raw.goal_mode !== undefined)
195
+ cfg.goalMode = coerceGoalMode(str(raw.goal_mode) ?? "");
179
196
  const g = raw.guardrails;
180
197
  if (g !== undefined) {
181
198
  if (typeof g !== "object" || g === null || Array.isArray(g)) {
@@ -232,6 +249,8 @@ export function serializeLauncherConfig(cfg) {
232
249
  doc.launch = cfg.launch;
233
250
  if (cfg.keySource !== undefined)
234
251
  doc.key_source = cfg.keySource;
252
+ if (cfg.goalMode !== undefined)
253
+ doc.goal_mode = cfg.goalMode;
235
254
  const g = cfg.guardrails;
236
255
  if (g && (g.maxTurns !== undefined || g.budgetUsd !== undefined || g.stallTurns !== undefined)) {
237
256
  const gr = {};
@@ -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),
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. Returns null if refresh is
363
- * not possible (no refresh token, or the AS rejects it — e.g. revoked/expired),
364
- * so callers can fall back to prompting for re-login rather than crashing.
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
- try {
370
- const meta = await discover();
371
- const res = await fetch(meta.token_endpoint, {
372
- method: "POST",
373
- headers: {
374
- "Content-Type": "application/x-www-form-urlencoded",
375
- Accept: "application/json",
376
- },
377
- body: new URLSearchParams({
378
- grant_type: "refresh_token",
379
- refresh_token: refreshToken,
380
- client_id: clientId,
381
- }),
382
- });
383
- if (!res.ok)
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
- const data = (await res.json());
386
- const tokens = tokensFromResponse(data);
387
- // Per RFC 6749 §6, a refresh response MAY omit a new refresh token, in which
388
- // case the old one remains valid — preserve it so the next refresh works.
389
- if (!tokens.refreshToken)
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
package/dist/rc-proxy.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  // * Inside api.anthropic.com, only `POST /v1/messages[?…]` is rewritten to the Mnemom
20
20
  // gateway door: the real Anthropic key rides as `x-api-key` (API-key billing), plus
21
21
  // `x-mnemom-agent`, `x-mnemom-conversation-id`, and optionally `x-mnemom-api-key`,
22
- // `x-mnemom-provider`, `x-mnemom-egress-key`, `x-mnemom-contract` — the exact header set
22
+ // `x-mnemom-provider`, `x-mnemom-egress-key`, `x-mnemom-contract` / `x-mnemom-goal-mode` — the exact header set
23
23
  // the non-RC launch sends via ANTHROPIC_CUSTOM_HEADERS. The OAuth `authorization` bearer
24
24
  // is STRIPPED on that leg (it never leaves the machine) and `oauth-*` tokens are removed
25
25
  // from `anthropic-beta`. Everything else under api.anthropic.com (OAuth refresh, the
@@ -62,6 +62,8 @@
62
62
  // MNEMOM_AGENT_RC_AGENT x-mnemom-agent value (required)
63
63
  // MNEMOM_AGENT_RC_CONVERSATION_ID x-mnemom-conversation-id value (required)
64
64
  // MNEMOM_AGENT_RC_CONTRACT_B64 base64 contract for x-mnemom-contract (optional)
65
+ // MNEMOM_AGENT_RC_GOAL_MODE x-mnemom-goal-mode value (optional; `--implicit` — no contract,
66
+ // the gateway infers the goal from the user's messages)
65
67
  // MNEMOM_AGENT_RC_PROVIDER x-mnemom-provider directive JSON (optional; router door)
66
68
  // MNEMOM_AGENT_RC_THREAD_MODE `reject` (default: answer thread-bearing requests locally,
67
69
  // see above) | `strip` (legacy: delete `thread`, forward)
@@ -98,6 +100,7 @@ const AGENT = env.MNEMOM_AGENT_RC_AGENT || die("MNEMOM_AGENT_RC_AGENT is require
98
100
  const CONV =
99
101
  env.MNEMOM_AGENT_RC_CONVERSATION_ID || die("MNEMOM_AGENT_RC_CONVERSATION_ID is required");
100
102
  const CONTRACT_B64 = env.MNEMOM_AGENT_RC_CONTRACT_B64 || "";
103
+ const GOAL_MODE = env.MNEMOM_AGENT_RC_GOAL_MODE || "";
101
104
  const PROVIDER = env.MNEMOM_AGENT_RC_PROVIDER || "";
102
105
  const THREAD_MODE = env.MNEMOM_AGENT_RC_THREAD_MODE || "reject";
103
106
  if (THREAD_MODE !== "reject" && THREAD_MODE !== "strip")
@@ -107,7 +110,9 @@ const PARENT = Number(env.MNEMOM_AGENT_RC_PARENT_PID || 0);
107
110
  const PORT = Number(env.MNEMOM_AGENT_RC_PORT || 0);
108
111
 
109
112
  const log = (line) => {
110
- const s = `${new Date().toISOString()} ${line}\n`;
113
+ // One record per line: gateway header values and error bodies are spliced into log lines, so a
114
+ // CR/LF inside them must not fabricate extra records (log injection).
115
+ const s = `${new Date().toISOString()} ${String(line).replace(/[\r\n]+/g, " ")}\n`;
111
116
  if (LOGFILE) {
112
117
  try {
113
118
  fs.appendFileSync(LOGFILE, s);
@@ -345,6 +350,7 @@ function handle(req, res, viaPlain) {
345
350
  if (PROVIDER) headers["x-mnemom-provider"] = PROVIDER;
346
351
  if (secrets.openai) headers["x-mnemom-egress-key"] = secrets.openai;
347
352
  if (CONTRACT_B64) headers["x-mnemom-contract"] = CONTRACT_B64;
353
+ if (GOAL_MODE) headers["x-mnemom-goal-mode"] = GOAL_MODE;
348
354
  if (headers["anthropic-beta"]) {
349
355
  // drop the OAuth-only beta tokens
350
356
  const b = String(headers["anthropic-beta"])
@@ -388,8 +394,12 @@ function handle(req, res, viaPlain) {
388
394
  });
389
395
  }
390
396
  ur.pipe(res);
397
+ const goalRev = ur.headers["x-mnemom-goal-revision"];
398
+ const steer = ur.headers["x-mnemom-goal-steer"];
391
399
  const ident = toGateway
392
- ? ` agent=${ur.headers["x-mnemom-agent"] || "-"} verdict=${ur.headers["x-mnemom-goal-verdict"] || "-"}`
400
+ ? ` agent=${ur.headers["x-mnemom-agent"] || "-"} verdict=${ur.headers["x-mnemom-goal-verdict"] || "-"}` +
401
+ (goalRev ? ` goal-rev=${goalRev}` : "") +
402
+ (steer ? ` steer=${steer}` : "")
393
403
  : "";
394
404
  ur.on("end", () => {
395
405
  let bodyNote = "";
@@ -488,7 +498,7 @@ proxy.on("clientError", (e, sock) => {
488
498
  proxy.listen(PORT, "127.0.0.1", () => {
489
499
  const { port } = proxy.address();
490
500
  log(
491
- `listening 127.0.0.1:${port} agent=${AGENT} conv=${CONV} door=${GATEWAY.href} contract=${CONTRACT_B64 ? "yes" : "no"} provider=${PROVIDER || "-"} thread=${THREAD_MODE}`,
501
+ `listening 127.0.0.1:${port} agent=${AGENT} conv=${CONV} door=${GATEWAY.href} contract=${CONTRACT_B64 ? "yes" : "no"} goal_mode=${GOAL_MODE || "-"} provider=${PROVIDER || "-"} thread=${THREAD_MODE}`,
492
502
  );
493
503
  process.stdout.write(`READY ${port} ${p("ca.pem")}\n`);
494
504
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.0-next.7",
3
+ "version": "0.17.0-next.9",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,10 +26,10 @@
26
26
  "devDependencies": {
27
27
  "@types/js-yaml": "^4.0.9",
28
28
  "@types/node": "^20.10.0",
29
- "@vitest/coverage-v8": "^4.1.10",
29
+ "@vitest/coverage-v8": "^4.1.11",
30
30
  "tsx": "^4.23.1",
31
31
  "typescript": "^6.0.3",
32
- "vitest": "^4.1.10"
32
+ "vitest": "^4.1.11"
33
33
  },
34
34
  "files": [
35
35
  "dist",
@@ -65,5 +65,9 @@
65
65
  },
66
66
  "engines": {
67
67
  "node": ">=18.0.0"
68
+ },
69
+ "overrides": {
70
+ "@vitest/mocker": "^4.1.11",
71
+ "nanoid": "^3.3.18"
68
72
  }
69
73
  }