@mnemom/mnemom 0.17.32-next.4 → 0.17.33

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.
Files changed (49) hide show
  1. package/dist/commands/agent-fuel.d.ts +9 -0
  2. package/dist/commands/agent-fuel.js +36 -2
  3. package/dist/commands/agent-run.d.ts +50 -0
  4. package/dist/commands/agent-run.js +221 -0
  5. package/dist/commands/agent-setup.d.ts +8 -3
  6. package/dist/commands/agent-setup.js +32 -9
  7. package/dist/commands/agent-share-fixture.d.ts +3 -1
  8. package/dist/commands/agent-share-fixture.js +11 -7
  9. package/dist/commands/agent-share-render.d.ts +23 -0
  10. package/dist/commands/agent-share-render.js +72 -0
  11. package/dist/commands/agent-share.d.ts +15 -2
  12. package/dist/commands/agent-share.js +30 -9
  13. package/dist/commands/agent.d.ts +19 -4
  14. package/dist/commands/agent.js +33 -13
  15. package/dist/index.js +20 -0
  16. package/dist/lib/api.js +17 -1
  17. package/dist/lib/auth.d.ts +6 -0
  18. package/dist/lib/auth.js +161 -14
  19. package/dist/lib/experimental.d.ts +1 -1
  20. package/dist/lib/experimental.js +13 -0
  21. package/dist/lib/oauth.d.ts +7 -0
  22. package/dist/lib/oauth.js +13 -0
  23. package/dist/lib/runner/bundle.d.ts +129 -0
  24. package/dist/lib/runner/bundle.js +319 -0
  25. package/dist/lib/runner/contract-header.d.ts +46 -0
  26. package/dist/lib/runner/contract-header.js +85 -0
  27. package/dist/lib/runner/executor-client.d.ts +71 -0
  28. package/dist/lib/runner/executor-client.js +97 -0
  29. package/dist/lib/runner/job.d.ts +70 -0
  30. package/dist/lib/runner/job.js +463 -0
  31. package/dist/lib/runner/loop.d.ts +45 -0
  32. package/dist/lib/runner/loop.js +104 -0
  33. package/dist/lib/runner/oidc.d.ts +38 -0
  34. package/dist/lib/runner/oidc.js +89 -0
  35. package/dist/lib/runner/options.d.ts +41 -0
  36. package/dist/lib/runner/options.js +47 -0
  37. package/dist/lib/runner/outcome.d.ts +47 -0
  38. package/dist/lib/runner/outcome.js +89 -0
  39. package/dist/lib/runner/phases.d.ts +49 -0
  40. package/dist/lib/runner/phases.js +99 -0
  41. package/dist/lib/runner/proxy.d.ts +45 -0
  42. package/dist/lib/runner/proxy.js +153 -0
  43. package/dist/lib/runner/settings.d.ts +31 -0
  44. package/dist/lib/runner/settings.js +44 -0
  45. package/dist/lib/statusline-feed.js +10 -11
  46. package/dist/lib/statusline.d.ts +6 -0
  47. package/dist/lib/statusline.js +19 -9
  48. package/dist/rc-proxy.mjs +69 -5
  49. package/package.json +2 -1
@@ -43,9 +43,15 @@ export interface RenderedLinePreview {
43
43
  coverage?: string;
44
44
  kind: string;
45
45
  }
46
- /** The aip `renderLines` contract, injected (not yet published). */
46
+ /**
47
+ * The aip `renderLines` contract, injected. Resolves to the lines the card may show; REJECTS
48
+ * (with the renderer's reasons) when the card is not verifiable or breaks claim-lint, in which
49
+ * case no link is printed. `period` asks for the period-card surface (reverts always shown).
50
+ */
47
51
  export interface LineRenderer {
48
- renderLines(card: unknown): RenderedLinePreview[];
52
+ renderLines(card: unknown, opts?: {
53
+ period?: boolean;
54
+ }): RenderedLinePreview[] | Promise<RenderedLinePreview[]>;
49
55
  }
50
56
  export interface StatementSource {
51
57
  listLines(): Promise<CandidateLine[]>;
@@ -56,6 +62,11 @@ export interface CardIssuer {
56
62
  }
57
63
  export interface AgentShareDeps {
58
64
  source: StatementSource;
65
+ /**
66
+ * Optional claim-lint screen (aip `lintRendering`) run on each candidate before it is
67
+ * offered; returns a reason when the line must not be shared. Word lists live in aip only.
68
+ */
69
+ screen?: (line: SharedCardLine) => Promise<string | null> | string | null;
59
70
  issuer: CardIssuer;
60
71
  renderer: LineRenderer;
61
72
  /** Interactive picker: returns the chosen line ids. */
@@ -66,6 +77,8 @@ export interface AgentShareDeps {
66
77
  err: (line: string) => void;
67
78
  }
68
79
  export interface AgentShareFlags {
80
+ /** Period card: every offered revert line is included, and a card without one is refused. */
81
+ period?: boolean;
69
82
  lines?: string;
70
83
  list?: boolean;
71
84
  json?: boolean;
@@ -17,6 +17,7 @@
17
17
  import { createInterface } from "node:readline/promises";
18
18
  import { getWebsiteUrl } from "../lib/config.js";
19
19
  import { fmt } from "../lib/format.js";
20
+ import { aipLineRenderer, aipLineScreen } from "./agent-share-render.js";
20
21
  export const AGENT_SHARE_ENV = "MNEMOM_AGENT_SHARE";
21
22
  export function agentShareEnabled(env = process.env) {
22
23
  return env[AGENT_SHARE_ENV] === "1";
@@ -132,10 +133,16 @@ export async function agentShareCommand(flags, deps, env = process.env) {
132
133
  const refused = [];
133
134
  for (const c of candidates) {
134
135
  const r = refuseReason(c);
135
- if (r)
136
+ if (r) {
136
137
  refused.push(r);
138
+ continue;
139
+ }
140
+ const shared = toSharedLine(c);
141
+ const lint = deps.screen ? await deps.screen(shared) : null;
142
+ if (lint)
143
+ refused.push({ id: shared.id, reason: "estimate", detail: lint });
137
144
  else
138
- offered.push(toSharedLine(c));
145
+ offered.push(shared);
139
146
  }
140
147
  if (flags.list) {
141
148
  if (flags.json) {
@@ -175,6 +182,15 @@ export async function agentShareCommand(flags, deps, env = process.env) {
175
182
  deps.err(fmt.error("No lines picked."));
176
183
  return 1;
177
184
  }
185
+ if (flags.period) {
186
+ for (const l of offered)
187
+ if (l.kind === "revert" && !ids.includes(l.id))
188
+ ids.push(l.id);
189
+ if (!offered.some((l) => l.kind === "revert")) {
190
+ deps.err(fmt.error("A period card always shows reverts, and none are available to share."));
191
+ return 1;
192
+ }
193
+ }
178
194
  const chosen = [];
179
195
  for (const id of ids) {
180
196
  const r = refused.find((x) => x.id === id);
@@ -202,13 +218,22 @@ export async function agentShareCommand(flags, deps, env = process.env) {
202
218
  deps.err(fmt.error(`Refusing to share: ${problem}.`));
203
219
  return 1;
204
220
  }
221
+ // The renderer is the gate: nothing is linked unless aip renders the card (verified, lint clean).
222
+ let rendered;
223
+ try {
224
+ rendered = await deps.renderer.renderLines(card, { period: flags.period === true });
225
+ }
226
+ catch (error) {
227
+ deps.err(fmt.error(`Refusing to share: ${error.message}.`));
228
+ return 1;
229
+ }
205
230
  const link = buildShareLink(card, deps.websiteBase());
206
231
  if (flags.json) {
207
232
  deps.out(JSON.stringify({ link, lines: chosen }, null, 2));
208
233
  return 0;
209
234
  }
210
235
  deps.out("Your card:");
211
- for (const r of deps.renderer.renderLines(card)) {
236
+ for (const r of rendered) {
212
237
  deps.out(` ${r.text}${r.coverage ? ` (${r.coverage})` : ""}`);
213
238
  }
214
239
  deps.out("");
@@ -224,12 +249,8 @@ const notConnected = () => {
224
249
  export const liveAgentShareDeps = {
225
250
  source: { listLines: async () => notConnected() },
226
251
  issuer: { issue: async () => notConnected() },
227
- renderer: {
228
- renderLines: (card) => (card.lines ?? []).map((l) => ({
229
- text: `${l.label}: ${l.value}`,
230
- kind: l.kind,
231
- })),
232
- },
252
+ renderer: aipLineRenderer(undefined),
253
+ screen: aipLineScreen,
233
254
  pick: async (lines) => {
234
255
  const rl = createInterface({ input: process.stdin, output: process.stderr });
235
256
  try {
@@ -417,15 +417,30 @@ export interface BaseUrlProxyOptions {
417
417
  */
418
418
  export declare function startBaseUrlProxy(door: string, done: (baseUrl: string | null, recordStatePath?: string) => void, opts?: BaseUrlProxyOptions): void;
419
419
  /**
420
- * The three secret lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai). The
421
- * Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy forwards it as
422
- * x-mnemom-api-key either way; a subscription launch always has one, an api-key launch has one
423
- * when this machine stores one or the launch pins. Never logged.
420
+ * The four lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai, proxy
421
+ * credential). The Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy
422
+ * forwards it as x-mnemom-api-key either way; a subscription launch always has one, an api-key
423
+ * launch has one when this machine stores one or the launch pins. Line 4 is this session's proxy
424
+ * credential ({@link newRcProxyCredential}). Never logged.
424
425
  */
425
426
  export declare function rcProxyStdin(ctx: {
426
427
  anthropicKey?: string;
427
428
  mnemomKey?: string;
429
+ proxyCredential: string;
428
430
  }): string;
431
+ /**
432
+ * MNE-9233: a fresh per-session credential for the Remote Control proxy. The proxy injects the
433
+ * owner's keys and its loopback port is reachable by every local process, so it serves only a
434
+ * caller that presents this value as `Proxy-Authorization: Basic` (rc-proxy.mjs). 32 random bytes,
435
+ * hex: URL-safe in the HTTPS_PROXY userinfo without escaping.
436
+ */
437
+ export declare function newRcProxyCredential(): string;
438
+ /**
439
+ * The HTTPS_PROXY value for the Remote Control child: the proxy's loopback port, with this
440
+ * session's credential as the userinfo. Claude Code sends it as `Proxy-Authorization` on CONNECT
441
+ * and on absolute-form requests. The user name must match rc-proxy.mjs's PROXY_USER.
442
+ */
443
+ export declare function rcProxyUrl(port: string, proxyCredential: string): string;
429
444
  /**
430
445
  * The agent id a launch pins, or undefined for a name launch: `--agent` when it is a full
431
446
  * `mnm-<uuid>` (any case), else launcher.toml `agent_id` (only when no `--agent` was given —
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { spawn, spawnSync } from "node:child_process";
29
29
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, renameSync, } from "node:fs";
30
- import { randomUUID } from "node:crypto";
30
+ import { randomBytes, randomUUID } from "node:crypto";
31
31
  import { tmpdir, homedir, hostname } from "node:os";
32
32
  import { basename, join, dirname } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
@@ -1620,13 +1620,31 @@ export function startBaseUrlProxy(door, done, opts = {}) {
1620
1620
  // Remote Control launch (terminal RC + headless server) — behind the bundled proxy
1621
1621
  // ───────────────────────────────────────────────────────────────────────────
1622
1622
  /**
1623
- * The three secret lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai). The
1624
- * Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy forwards it as
1625
- * x-mnemom-api-key either way; a subscription launch always has one, an api-key launch has one
1626
- * when this machine stores one or the launch pins. Never logged.
1623
+ * The four lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai, proxy
1624
+ * credential). The Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy
1625
+ * forwards it as x-mnemom-api-key either way; a subscription launch always has one, an api-key
1626
+ * launch has one when this machine stores one or the launch pins. Line 4 is this session's proxy
1627
+ * credential ({@link newRcProxyCredential}). Never logged.
1627
1628
  */
1628
1629
  export function rcProxyStdin(ctx) {
1629
- return `${ctx.anthropicKey ?? ""}\n${ctx.mnemomKey ?? ""}\n\n`;
1630
+ return `${ctx.anthropicKey ?? ""}\n${ctx.mnemomKey ?? ""}\n\n${ctx.proxyCredential}\n`;
1631
+ }
1632
+ /**
1633
+ * MNE-9233: a fresh per-session credential for the Remote Control proxy. The proxy injects the
1634
+ * owner's keys and its loopback port is reachable by every local process, so it serves only a
1635
+ * caller that presents this value as `Proxy-Authorization: Basic` (rc-proxy.mjs). 32 random bytes,
1636
+ * hex: URL-safe in the HTTPS_PROXY userinfo without escaping.
1637
+ */
1638
+ export function newRcProxyCredential() {
1639
+ return randomBytes(32).toString("hex");
1640
+ }
1641
+ /**
1642
+ * The HTTPS_PROXY value for the Remote Control child: the proxy's loopback port, with this
1643
+ * session's credential as the userinfo. Claude Code sends it as `Proxy-Authorization` on CONNECT
1644
+ * and on absolute-form requests. The user name must match rc-proxy.mjs's PROXY_USER.
1645
+ */
1646
+ export function rcProxyUrl(port, proxyCredential) {
1647
+ return `http://mnemom:${proxyCredential}@127.0.0.1:${port}`;
1630
1648
  }
1631
1649
  /**
1632
1650
  * The agent id a launch pins, or undefined for a name launch: `--agent` when it is a full
@@ -1652,6 +1670,8 @@ function launchRemoteControl(ctx) {
1652
1670
  }
1653
1671
  }
1654
1672
  const proxyScript = resolveProxyScript();
1673
+ // MNE-9233: only the child launched below may use this session's proxy (stdin line 4 + HTTPS_PROXY).
1674
+ const proxyCredential = newRcProxyCredential();
1655
1675
  const rcDir = mkdtempSync(join(rcStateRoot(), "mnemom-code-rc."));
1656
1676
  writeFileSync(join(rcDir, "owner.pid"), `${process.pid}\n`, { mode: 0o600 }); // rc-state.mjs sweep
1657
1677
  const proxyLog = process.env.MNEMOM_AGENT_RC_PROXY_LOG || join(rcDir, "proxy.log");
@@ -1718,9 +1738,9 @@ function launchRemoteControl(ctx) {
1718
1738
  stopped = true;
1719
1739
  stopProxyAndTempDir();
1720
1740
  });
1721
- // Secrets on stdin — three lines (anthropic, mnemom, openai). An api-key launch sends the
1722
- // Anthropic key, plus this machine's Mnemom key when it has one (the key principal; always
1723
- // with a pin); a subscription launch sends the Mnemom key only (the identity anchor — the
1741
+ // Secrets on stdin — four lines (anthropic, mnemom, openai, proxy credential). An api-key launch
1742
+ // sends the Anthropic key, plus this machine's Mnemom key when it has one (the key principal;
1743
+ // always with a pin); a subscription launch sends the Mnemom key only (the identity anchor — the
1724
1744
  // user's OAuth bearer is what pays). openai is always empty on this Anthropic-only door.
1725
1745
  // A proxy that exits before reading them turns this write into an EPIPE; that is
1726
1746
  // not a failure of its own — the exited-before-READY check below refuses the
@@ -1731,7 +1751,7 @@ function launchRemoteControl(ctx) {
1731
1751
  say(`mnemom agent: could not hand the secrets to the Remote Control proxy: ${err.message}`);
1732
1752
  void onExit().finally(() => process.exit(1));
1733
1753
  });
1734
- proxy.stdin?.end(rcProxyStdin(ctx));
1754
+ proxy.stdin?.end(rcProxyStdin({ ...ctx, proxyCredential }));
1735
1755
  let stdoutBuf = "";
1736
1756
  const start = Date.now();
1737
1757
  const tryStart = () => {
@@ -1748,7 +1768,7 @@ function launchRemoteControl(ctx) {
1748
1768
  void onExit().finally(() => process.exit(1));
1749
1769
  return;
1750
1770
  }
1751
- spawnRcCli({ ...ctx, port, ca, onExit });
1771
+ spawnRcCli({ ...ctx, port, ca, proxyCredential, onExit });
1752
1772
  return;
1753
1773
  }
1754
1774
  if (proxy.exitCode !== null) {
@@ -1805,8 +1825,8 @@ function spawnRcCli(ctx) {
1805
1825
  // env. The RC gate forwards the Mnemom identity via the proxy's own auth plane, never from
1806
1826
  // this child's env, so there is no legitimate use for these vars here either.
1807
1827
  stripOwnerCredentialEnv(childEnv);
1808
- childEnv.HTTPS_PROXY = `http://127.0.0.1:${ctx.port}`;
1809
- childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
1828
+ childEnv.HTTPS_PROXY = rcProxyUrl(ctx.port, ctx.proxyCredential);
1829
+ childEnv.https_proxy = childEnv.HTTPS_PROXY;
1810
1830
  childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
1811
1831
  childEnv[SESSION_NAME_ENV] = rcName;
1812
1832
  // Autocompact guard (lib/autocompact-guard.ts). The interactive shape gets the --settings layer
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { agentShareFixtureEnabled, fixtureAgentShareParts, } from "./commands/ag
19
19
  import { agentSessionsCommand, agentShowCommand, liveSessionDeps, } from "./commands/agent-sessions.js";
20
20
  import { agentGoalPinCommand, agentGoalResetCommand, agentGoalShowCommand, liveGoalDeps, } from "./commands/agent-goal.js";
21
21
  import { agentFuelCommand, liveFuelDeps } from "./commands/agent-fuel.js";
22
+ import { runAgentRun } from "./commands/agent-run.js";
22
23
  import { orgListCommand, orgShowCommand, orgUseCommand } from "./commands/org.js";
23
24
  import { teamListCommand, teamShowCommand, teamTemplateCommand, teamPreviewComposeCommand, teamAdminGrantCommand, teamAdminRevokeCommand, teamAdminListCommand, teamCoverageCommand, } from "./commands/team.js";
24
25
  import { advisoriesListCommand, advisoriesShowCommand } from "./commands/advisories.js";
@@ -339,6 +340,8 @@ const agentCmd = program
339
340
  configAgentId: cfg.agentId,
340
341
  }),
341
342
  governance,
343
+ // Setup provisions for the launch, so it authenticates the way the launch will.
344
+ auth: opts.auth,
342
345
  });
343
346
  return;
344
347
  }
@@ -919,6 +922,7 @@ agentCmd
919
922
  .command("share", { hidden: true })
920
923
  .description("Build a shareable card from lines of your signed statements and print the share-page link (card in the URL fragment; nothing is posted). Needs MNEMOM_AGENT_SHARE=1")
921
924
  .option("--lines <ids>", "Comma-separated line ids to include (skips the picker)")
925
+ .option("--period", "Period card: always include reverts (refused if none are available)")
922
926
  .option("--list", "List the lines available to share and exit")
923
927
  .option("--json", "Print JSON instead of text")
924
928
  .action(async (opts) => {
@@ -1019,6 +1023,22 @@ agentCmd
1019
1023
  process.exit(1);
1020
1024
  }
1021
1025
  });
1026
+ // `mnemom agent run --watch|--once` — the thin runner (ruling 34.12; Doors W-L13): claim served-phase
1027
+ // jobs from the org's ready pool and run their signed phases. Exit 0 done/no_job, 1 failed,
1028
+ // 2 usage, 3 refused.
1029
+ agentCmd
1030
+ .command("run", { hidden: hiddenUnless("dispatch") })
1031
+ .description("EXPERIMENTAL (`mnemom experimental enable dispatch`): run served-phase jobs from your org's pool. " +
1032
+ "The executor key comes from MNEMOM_EXECUTOR_KEY (or --oidc in GitHub Actions); each job prints one outcome line")
1033
+ .option("--watch", "Keep claiming jobs until SIGTERM or SIGINT (the container form)")
1034
+ .option("--once", "Run at most one job, then exit (the CI form)")
1035
+ .option("--fail-mode <mode>", "closed (default) or open; also MNEMOM_RUNNER_FAIL_MODE")
1036
+ .option("--json", "Print only outcome lines on stdout; logs go to stderr")
1037
+ .option("--oidc", "With --once: exchange the GitHub Actions ID token for a one-job key")
1038
+ .hook("preAction", () => requireExperimental("`mnemom agent run`", "dispatch"))
1039
+ .action(async (opts) => {
1040
+ process.exitCode = await runAgentRun(opts);
1041
+ });
1022
1042
  // `mnemom agent fuel <file>` — the fuel step (Doors W3.2): one confirmation seals a batch of job
1023
1043
  // contracts through the batch-seal endpoint and commits them to the repo.
1024
1044
  agentCmd
package/dist/lib/api.js CHANGED
@@ -212,7 +212,23 @@ export async function getAgent(id) {
212
212
  */
213
213
  export async function listAgents() {
214
214
  const data = await gFetch("/v1/agents?limit=100", {}, { fallback: "Failed to list agents" });
215
- return data.agents ?? [];
215
+ return (data.agents ?? []).map(toListItem);
216
+ }
217
+ /**
218
+ * An org-scoped API key gets its org's fleet from `GET /v1/agents` (`get_org_agent_fleet`), whose
219
+ * rows carry `agent_id`/`agent_name`; a personal key gets `id`/`name`. Read both as one shape.
220
+ */
221
+ function toListItem(a) {
222
+ if (!("agent_id" in a))
223
+ return a;
224
+ return {
225
+ id: a.agent_id,
226
+ name: a.agent_name ?? null,
227
+ email: a.owner_email ?? null,
228
+ created_at: a.created_at,
229
+ last_seen: a.last_seen ?? null,
230
+ containment_status: a.containment_status ?? null,
231
+ };
216
232
  }
217
233
  /**
218
234
  * Every agent the caller OWNS, across all their orgs — `GET /v1/agents?scope=all`: rows whose
@@ -67,6 +67,12 @@ export declare function getAccessToken(): Promise<string | null>;
67
67
  * actual exp, or server-side revocation). Force a refresh and retry once.
68
68
  */
69
69
  export declare function forceRefreshAccessToken(): Promise<string | null>;
70
+ /**
71
+ * True when auth.json holds a session whose access token has expired. After
72
+ * getAccessToken() returned null this means the refresh was rejected (or was
73
+ * impossible), so the remedy is `mnemom login`, not creating an account.
74
+ */
75
+ export declare function hasExpiredStoredSession(): boolean;
70
76
  /**
71
77
  * Get a valid access token or exit with a helpful message.
72
78
  */
package/dist/lib/auth.js CHANGED
@@ -15,11 +15,24 @@
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import { MNEMOM_DIR } from "./config.js";
18
- import { loginWithLoopback, loginWithDevice, refreshTokens as oauthRefreshTokens, } from "./oauth.js";
18
+ import { loginWithLoopback, loginWithDevice, refreshTokens as oauthRefreshTokens, refreshWorstCaseMs, } from "./oauth.js";
19
19
  // ============================================================================
20
20
  // Auth Store (persisted to ~/.mnemom/auth.json)
21
21
  // ============================================================================
22
22
  const AUTH_FILE = path.join(MNEMOM_DIR, "auth.json");
23
+ /**
24
+ * Held while one process refreshes the stored tokens (MNE-9232). The AS rotates
25
+ * refresh tokens, so two processes refreshing with the same one race: the first
26
+ * wins and the second gets invalid_grant. The lock serialises them, and the
27
+ * waiter re-reads auth.json under it and picks up the winner's tokens.
28
+ */
29
+ const AUTH_LOCK_FILE = `${AUTH_FILE}.lock`;
30
+ const AUTH_LOCK_POLL_MS = 100;
31
+ /** A stored access token this close to expiry (seconds) is treated as expired. */
32
+ const EXPIRY_BUFFER_S = 60;
33
+ function nowSeconds() {
34
+ return Math.floor(Date.now() / 1000);
35
+ }
23
36
  function loadAuthStore() {
24
37
  try {
25
38
  if (!fs.existsSync(AUTH_FILE))
@@ -112,9 +125,7 @@ export async function getAccessToken() {
112
125
  const auth = getAuthInfo();
113
126
  if (!auth)
114
127
  return null;
115
- // Check expiry (with 60s buffer)
116
- const now = Math.floor(Date.now() / 1000);
117
- if (auth.expiresAt > now + 60) {
128
+ if (auth.expiresAt > nowSeconds() + EXPIRY_BUFFER_S) {
118
129
  return auth.accessToken;
119
130
  }
120
131
  // Auto-refresh. A *thrown* error from refreshStoredTokens() (transient
@@ -152,6 +163,15 @@ export async function forceRefreshAccessToken() {
152
163
  const refreshed = await refreshStoredTokens(auth);
153
164
  return refreshed?.accessToken ?? null;
154
165
  }
166
+ /**
167
+ * True when auth.json holds a session whose access token has expired. After
168
+ * getAccessToken() returned null this means the refresh was rejected (or was
169
+ * impossible), so the remedy is `mnemom login`, not creating an account.
170
+ */
171
+ export function hasExpiredStoredSession() {
172
+ const auth = getAuthInfo();
173
+ return auth !== null && auth.expiresAt <= nowSeconds() + EXPIRY_BUFFER_S;
174
+ }
155
175
  /**
156
176
  * Get a valid access token or exit with a helpful message.
157
177
  */
@@ -232,11 +252,113 @@ export async function loginWithDeviceFlow() {
232
252
  // ============================================================================
233
253
  // Token refresh (OAuth refresh_token grant)
234
254
  // ============================================================================
255
+ /** True when `pid` names a running process (EPERM: it runs as another user). */
256
+ function pidAlive(pid) {
257
+ try {
258
+ process.kill(pid, 0);
259
+ return true;
260
+ }
261
+ catch (err) {
262
+ return err.code === "EPERM";
263
+ }
264
+ }
265
+ /**
266
+ * A lock is stale when its holder has exited or it has been held longer than a
267
+ * refresh can take. Age and holder are read through one descriptor so both
268
+ * describe the same lock file. A lock that vanished first is not stale: the
269
+ * next create attempt decides.
270
+ */
271
+ function authLockIsStale(staleMs) {
272
+ let mtimeMs;
273
+ let holder;
274
+ let fd;
275
+ try {
276
+ fd = fs.openSync(AUTH_LOCK_FILE, "r");
277
+ }
278
+ catch {
279
+ return false;
280
+ }
281
+ try {
282
+ mtimeMs = fs.fstatSync(fd).mtimeMs;
283
+ holder = fs.readFileSync(fd, "utf-8");
284
+ }
285
+ catch {
286
+ return false;
287
+ }
288
+ finally {
289
+ fs.closeSync(fd);
290
+ }
291
+ if (Date.now() - mtimeMs >= staleMs)
292
+ return true;
293
+ const pid = Number(holder.trim());
294
+ return Number.isInteger(pid) && pid > 0 && !pidAlive(pid);
295
+ }
296
+ /**
297
+ * Take the refresh lock with O_EXCL (the update.lock pattern in
298
+ * lib/update/state.ts), waiting while another live process holds it. A stale
299
+ * lock is taken over. Waiting past the longest a refresh can take throws, which
300
+ * callers treat as a transient failure, never as revoked credentials.
301
+ *
302
+ * Two waiters can both judge one lock stale and both take it over. That only
303
+ * puts back the pre-lock race, which the invalid_grant re-read in
304
+ * refreshStoredTokens() still recovers from.
305
+ */
306
+ async function acquireAuthLock() {
307
+ const staleMs = refreshWorstCaseMs();
308
+ const deadline = Date.now() + staleMs;
309
+ const owner = String(process.pid);
310
+ for (;;) {
311
+ try {
312
+ fs.mkdirSync(MNEMOM_DIR, { recursive: true, mode: 0o700 });
313
+ fs.writeFileSync(AUTH_LOCK_FILE, owner, { flag: "wx", mode: 0o600 });
314
+ break;
315
+ }
316
+ catch (err) {
317
+ if (err.code !== "EEXIST")
318
+ throw err;
319
+ }
320
+ if (authLockIsStale(staleMs)) {
321
+ fs.rmSync(AUTH_LOCK_FILE, { force: true });
322
+ continue;
323
+ }
324
+ if (Date.now() >= deadline) {
325
+ throw new Error(`Timed out waiting for another mnemom process to refresh the Mnemom session ` +
326
+ `(${AUTH_LOCK_FILE}). Re-run the command.`);
327
+ }
328
+ await new Promise((resolve) => setTimeout(resolve, AUTH_LOCK_POLL_MS));
329
+ }
330
+ return () => {
331
+ // Remove the lock only while it is still ours: if this process stalled past
332
+ // staleMs another process may have taken it over.
333
+ try {
334
+ if (fs.readFileSync(AUTH_LOCK_FILE, "utf-8") === owner) {
335
+ fs.rmSync(AUTH_LOCK_FILE, { force: true });
336
+ }
337
+ }
338
+ catch {
339
+ /* already gone */
340
+ }
341
+ };
342
+ }
343
+ /** True when `candidate` holds a different, unexpired access token than `seen`. */
344
+ function isNewerSession(candidate, seen) {
345
+ return (candidate.accessToken !== seen.accessToken &&
346
+ candidate.expiresAt > nowSeconds() + EXPIRY_BUFFER_S);
347
+ }
235
348
  /**
236
349
  * Refresh the stored tokens via the OAuth refresh_token grant. Requires both a
237
350
  * refresh token and the client_id they were issued to; returns null (rather
238
351
  * than throwing) when refresh isn't possible so callers degrade to re-login.
239
352
  *
353
+ * Runs under AUTH_LOCK_FILE and works from auth.json as it is once the lock is
354
+ * held, not from the caller's earlier read (MNE-9232):
355
+ * - another process already refreshed (a different, unexpired access token
356
+ * on disk) → return its tokens, no network call;
357
+ * - the session was removed (`mnemom logout`) → null, never resurrected;
358
+ * - invalid_grant/invalid_client → re-read once; if a writer that does not
359
+ * take this lock (an older CLI) stored a different refresh token meanwhile,
360
+ * use its session or refresh once with its refresh token.
361
+ *
240
362
  * Note: oauthRefreshTokens() returns null ONLY on a definitive revoke
241
363
  * (invalid_grant/invalid_client) or missing credentials; on transient
242
364
  * exhaustion (repeated 5xx / network faults) or any other non-revoke 4xx it
@@ -250,14 +372,39 @@ export async function loginWithDeviceFlow() {
250
372
  async function refreshStoredTokens(auth) {
251
373
  if (!auth.refreshToken || !auth.clientId)
252
374
  return null;
253
- const refreshed = await oauthRefreshTokens(auth.refreshToken, auth.clientId);
254
- if (!refreshed)
255
- return null;
256
- const stored = fromOAuthTokens(refreshed, auth.clientId);
257
- // Preserve any legacy identity fields so whoami/status keep working across a
258
- // refresh of a token set that still carries them.
259
- stored.userId = auth.userId;
260
- stored.email = auth.email;
261
- saveAuthTokens(stored);
262
- return stored;
375
+ const release = await acquireAuthLock();
376
+ try {
377
+ const current = getAuthInfo();
378
+ if (current && isNewerSession(current, auth))
379
+ return current;
380
+ if (!current?.refreshToken || !current.clientId)
381
+ return null;
382
+ let base = current;
383
+ let clientId = current.clientId;
384
+ let refreshed = await oauthRefreshTokens(current.refreshToken, current.clientId);
385
+ if (!refreshed) {
386
+ const latest = getAuthInfo();
387
+ if (!latest?.refreshToken || !latest.clientId)
388
+ return null;
389
+ if (latest.refreshToken === current.refreshToken)
390
+ return null;
391
+ if (isNewerSession(latest, current))
392
+ return latest;
393
+ base = latest;
394
+ clientId = latest.clientId;
395
+ refreshed = await oauthRefreshTokens(latest.refreshToken, latest.clientId);
396
+ if (!refreshed)
397
+ return null;
398
+ }
399
+ const stored = fromOAuthTokens(refreshed, clientId);
400
+ // Preserve any legacy identity fields so whoami/status keep working across a
401
+ // refresh of a token set that still carries them.
402
+ stored.userId = base.userId;
403
+ stored.email = base.email;
404
+ saveAuthTokens(stored);
405
+ return stored;
406
+ }
407
+ finally {
408
+ release();
409
+ }
263
410
  }
@@ -12,7 +12,7 @@
12
12
  * Everything here is pure or dependency-injected; index.ts and the command
13
13
  * modules are the thin drivers.
14
14
  */
15
- export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "record" | "fuel" | "durable";
15
+ export type ExperimentalId = "context-handoff" | "slack" | "implicit" | "measured-savings" | "record" | "fuel" | "durable" | "dispatch";
16
16
  /**
17
17
  * Features that used to be experimental and are now on by default for everyone. Their ids are
18
18
  * no longer in the registry, but `mnemom experimental enable|disable <id>` still accepts them
@@ -120,6 +120,19 @@ export const EXPERIMENTAL_FEATURES = [
120
120
  env: [],
121
121
  },
122
122
  },
123
+ {
124
+ // The thin runner (Doors W-L13, ruling 34.12 (4)). Off: `agent run` exits 2 with the gate
125
+ // pointer before reading any credential.
126
+ id: "dispatch",
127
+ summary: "The thin runner: `mnemom agent run --watch|--once` claims served-phase jobs and runs them",
128
+ since: "0.17.32",
129
+ gates: {
130
+ flags: [],
131
+ configKeys: [],
132
+ commands: ["mnemom agent run"],
133
+ env: [],
134
+ },
135
+ },
123
136
  ];
124
137
  export const EXPERIMENTAL_IDS = EXPERIMENTAL_FEATURES.map((f) => f.id);
125
138
  export const EXPERIMENTAL_ENV = "MNEMOM_EXPERIMENTAL";
@@ -26,6 +26,13 @@
26
26
  * positive integer (capped at 5 min), else the default.
27
27
  */
28
28
  export declare function oauthHttpTimeoutMs(env?: NodeJS.ProcessEnv): number;
29
+ /**
30
+ * The longest one refreshTokens() call can legitimately take (ms): discovery
31
+ * plus REFRESH_MAX_ATTEMPTS token requests, each at the per-request deadline,
32
+ * plus the backoff sleeps between attempts. lib/auth.ts uses it to bound how
33
+ * long a process waits on another process's refresh lock (MNE-9232).
34
+ */
35
+ export declare function refreshWorstCaseMs(env?: NodeJS.ProcessEnv): number;
29
36
  /** The name carried by an error thrown when an AS round-trip hit its deadline. */
30
37
  export declare const OAUTH_TIMEOUT_ERROR = "OAuthTimeoutError";
31
38
  export interface AuthServerMetadata {
package/dist/lib/oauth.js CHANGED
@@ -86,6 +86,19 @@ export function oauthHttpTimeoutMs(env = process.env) {
86
86
  }
87
87
  return OAUTH_HTTP_TIMEOUT_MS;
88
88
  }
89
+ /**
90
+ * The longest one refreshTokens() call can legitimately take (ms): discovery
91
+ * plus REFRESH_MAX_ATTEMPTS token requests, each at the per-request deadline,
92
+ * plus the backoff sleeps between attempts. lib/auth.ts uses it to bound how
93
+ * long a process waits on another process's refresh lock (MNE-9232).
94
+ */
95
+ export function refreshWorstCaseMs(env = process.env) {
96
+ let backoff = 0;
97
+ for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS - 1; attempt++) {
98
+ backoff += REFRESH_BACKOFF_BASE_MS * 2 ** attempt;
99
+ }
100
+ return oauthHttpTimeoutMs(env) * (1 + REFRESH_MAX_ATTEMPTS) + backoff;
101
+ }
89
102
  /** The name carried by an error thrown when an AS round-trip hit its deadline. */
90
103
  export const OAUTH_TIMEOUT_ERROR = "OAuthTimeoutError";
91
104
  function isTimeout(err) {