@mnemom/mnemom 0.17.20-next.0 → 0.17.21

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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The bundled "Mnemom for Claude Code" plugin (`cli/plugins/mnemom`): a Claude Code
3
+ * mod + skill + MCP config that `mnemom agent` loads into the Claude Code it launches, so the
4
+ * session SHOWS it is governed — the Mnemom band above the prompt, `/mnemom` commands, the ◆ sign
5
+ * on the spinner, Mnemom trailers in commit attribution, and a guard that keeps the routing in
6
+ * place.
7
+ *
8
+ * The launcher tells the mod about the launch through NON-SECRET env vars. They carry a distinct
9
+ * `MNEMOM_CC_*` prefix so they can never be confused with the launcher's own `MNEMOM_AGENT_*`
10
+ * inputs, and none of them is a key, a header value or anything derived from one: the mod reads
11
+ * them by literal name (`claude plugin validate` lists every name), and a stray `env` dump of the
12
+ * child shows nothing it should not.
13
+ *
14
+ * Only Claude Code proper (`cli.name === "claude"`) understands `--plugin-dir`; every other
15
+ * adapter launches without the plugin.
16
+ *
17
+ * OFF BY DEFAULT. The folder ships in the npm tarball (`package.json` `files`, minus its tests
18
+ * and checkout-only files) but no launch loads it unless asked: `--plugin` or
19
+ * MNEMOM_AGENT_PLUGIN=1|true|yes|on opts in (`--no-plugin` or 0|false|off|no wins over the env),
20
+ * and the flags stay out of `--help`. An install missing the folder says so and launches without
21
+ * it. Turning it on for everyone is one edit: `pluginEnabled`'s default to true. It never blocks
22
+ * the launch.
23
+ */
24
+ /** The plugin's `name` in its plugin.json, and its folder under `cli/plugins/`. */
25
+ export declare const CLAUDE_PLUGIN_NAME = "mnemom";
26
+ /** The env Claude Code reads a path list of extra plugin folders from (where no flag can be given). */
27
+ export declare const CLAUDE_PLUGIN_DIRS_ENV = "CLAUDE_CODE_PLUGIN_DIRS";
28
+ /** The env vars the launcher exports for the mod. Every value is non-secret. */
29
+ export declare const PLUGIN_ENV: {
30
+ /** `https://gateway.mnemom.ai` (no path): presence = the session is governed. */
31
+ readonly gatewayHost: "MNEMOM_CC_GATEWAY_HOST";
32
+ /** The public API origin the Trust Rating is read from (`https://api.mnemom.ai`). */
33
+ readonly apiUrl: "MNEMOM_CC_API_URL";
34
+ /** The governed agent's name (the `x-mnemom-agent` header's value). */
35
+ readonly agent: "MNEMOM_CC_AGENT";
36
+ readonly conversationId: "MNEMOM_CC_CONVERSATION_ID";
37
+ /** The cell note the launch log prints (`us-2/prod`, or `custom (<host>)`). */
38
+ readonly cell: "MNEMOM_CC_CELL";
39
+ /** `terminal` | `remote-control` | `server`. */
40
+ readonly shape: "MNEMOM_CC_SHAPE";
41
+ /** `sealed` (a contract rides in the header) | `implicit` | `off`. */
42
+ readonly goalMode: "MNEMOM_CC_GOAL_MODE";
43
+ /** The sealed contract's sha256[0:16]; empty unless sealed. */
44
+ readonly contractSha: "MNEMOM_CC_CONTRACT_SHA";
45
+ /**
46
+ * The sealed contract itself — the canonical JSON the gateway hashes (statement, requirements,
47
+ * allowed/forbidden paths, ceilings). Empty unless sealed. It is what `--goal`/`--requirement`/…
48
+ * said on the command line: the person's own words, no secret in it.
49
+ */
50
+ readonly contract: "MNEMOM_CC_CONTRACT";
51
+ /**
52
+ * The implicit mode's guardrail ceilings, `max_turns=N; budget_usd=X; stall_turns=N` (the
53
+ * `x-mnemom-goal-mode` header minus its leading `implicit`). Empty unless implicit with ceilings.
54
+ */
55
+ readonly guardrails: "MNEMOM_CC_GUARDRAILS";
56
+ /**
57
+ * How to run this very `mnemom` again, as a JSON argv (`[node, ...execArgv, script]`): the mod
58
+ * runs `mnemom agent show <conversation> --json` / `mnemom agent goal show …` through it to read
59
+ * the live goal state (the sealed verdict, the inferred goal, guardrail usage). The CLI
60
+ * authenticates with the launch key from its own ledger; the mod never holds the key.
61
+ */
62
+ readonly launcherExec: "MNEMOM_CC_LAUNCHER_EXEC";
63
+ /** The `mnemom agent <session>` scenario name. */
64
+ readonly session: "MNEMOM_CC_SESSION";
65
+ readonly launcherVersion: "MNEMOM_CC_LAUNCHER_VERSION";
66
+ };
67
+ export type PluginEnvName = (typeof PLUGIN_ENV)[keyof typeof PLUGIN_ENV];
68
+ /**
69
+ * Whether this launch loads the plugin: `--plugin` / `--no-plugin` (an explicit boolean) wins,
70
+ * then MNEMOM_AGENT_PLUGIN (1/true/yes/on ⇒ on), else OFF: the plugin lands dark.
71
+ */
72
+ export declare function pluginEnabled(optionPlugin: boolean | undefined, env: NodeJS.ProcessEnv): boolean;
73
+ /**
74
+ * The bundled plugin's folder (the one holding `.claude-plugin/plugin.json`), or null when it is
75
+ * missing. Lives at `<package>/plugins/mnemom`, next to `dist/` and `src/`, so it is
76
+ * found from a built `dist/lib/` and from `src/lib/` under tsx alike.
77
+ */
78
+ export declare function resolvePluginDir(baseDirOverride?: string): string | null;
79
+ /** What the launcher knows about a launch that the mod shows. */
80
+ export interface PluginLaunch {
81
+ gatewayHost: string;
82
+ apiUrl: string;
83
+ agent: string;
84
+ conversationId: string;
85
+ cellNote: string;
86
+ shape: "terminal" | "remote-control" | "server";
87
+ /** The sealed contract's sha (envelope.sha) when one rides along. */
88
+ contractSha?: string;
89
+ /** The sealed contract's canonical JSON (envelope.canonical) when one rides along. */
90
+ contract?: string;
91
+ /**
92
+ * The launcher's goal-mode header when no contract is sealed (`implicit[; max_turns=N; …]`),
93
+ * or null.
94
+ */
95
+ goalMode?: string | null;
96
+ /** The argv that runs this `mnemom` again (`[process.execPath, ...process.execArgv, process.argv[1]]`). */
97
+ launcherExec?: readonly string[];
98
+ scenario: string;
99
+ launcherVersion: string;
100
+ }
101
+ /**
102
+ * The `MNEMOM_CC_*` block for the child env. Pure: no key, no header, nothing the gateway
103
+ * authenticates with — just what the launch log already prints to stderr.
104
+ */
105
+ export declare function buildPluginEnv(launch: PluginLaunch): Record<PluginEnvName, string>;
106
+ /** Writes the block onto a child env in place (the launch shapes all build their env first). */
107
+ export declare function applyPluginEnv(childEnv: NodeJS.ProcessEnv, launch: PluginLaunch): void;
108
+ /**
109
+ * Appends the plugin folder to CLAUDE_CODE_PLUGIN_DIRS (for the headless `remote-control` server,
110
+ * which takes no `--plugin-dir`): a user's own list is kept, the folder is added once.
111
+ */
112
+ export declare function appendPluginDirsEnv(childEnv: NodeJS.ProcessEnv, pluginDir: string): void;
113
+ /** The line the launch log and the dry-run plan print for the plugin. */
114
+ export declare function pluginPlanLine(pluginDir: string | null | undefined, enabled: boolean): string;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The bundled "Mnemom for Claude Code" plugin (`cli/plugins/mnemom`): a Claude Code
3
+ * mod + skill + MCP config that `mnemom agent` loads into the Claude Code it launches, so the
4
+ * session SHOWS it is governed — the Mnemom band above the prompt, `/mnemom` commands, the ◆ sign
5
+ * on the spinner, Mnemom trailers in commit attribution, and a guard that keeps the routing in
6
+ * place.
7
+ *
8
+ * The launcher tells the mod about the launch through NON-SECRET env vars. They carry a distinct
9
+ * `MNEMOM_CC_*` prefix so they can never be confused with the launcher's own `MNEMOM_AGENT_*`
10
+ * inputs, and none of them is a key, a header value or anything derived from one: the mod reads
11
+ * them by literal name (`claude plugin validate` lists every name), and a stray `env` dump of the
12
+ * child shows nothing it should not.
13
+ *
14
+ * Only Claude Code proper (`cli.name === "claude"`) understands `--plugin-dir`; every other
15
+ * adapter launches without the plugin.
16
+ *
17
+ * OFF BY DEFAULT. The folder ships in the npm tarball (`package.json` `files`, minus its tests
18
+ * and checkout-only files) but no launch loads it unless asked: `--plugin` or
19
+ * MNEMOM_AGENT_PLUGIN=1|true|yes|on opts in (`--no-plugin` or 0|false|off|no wins over the env),
20
+ * and the flags stay out of `--help`. An install missing the folder says so and launches without
21
+ * it. Turning it on for everyone is one edit: `pluginEnabled`'s default to true. It never blocks
22
+ * the launch.
23
+ */
24
+ import { existsSync } from "node:fs";
25
+ import { dirname, join, delimiter } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ /** The plugin's `name` in its plugin.json, and its folder under `cli/plugins/`. */
28
+ export const CLAUDE_PLUGIN_NAME = "mnemom";
29
+ /** The env Claude Code reads a path list of extra plugin folders from (where no flag can be given). */
30
+ export const CLAUDE_PLUGIN_DIRS_ENV = "CLAUDE_CODE_PLUGIN_DIRS";
31
+ /** The env vars the launcher exports for the mod. Every value is non-secret. */
32
+ export const PLUGIN_ENV = {
33
+ /** `https://gateway.mnemom.ai` (no path): presence = the session is governed. */
34
+ gatewayHost: "MNEMOM_CC_GATEWAY_HOST",
35
+ /** The public API origin the Trust Rating is read from (`https://api.mnemom.ai`). */
36
+ apiUrl: "MNEMOM_CC_API_URL",
37
+ /** The governed agent's name (the `x-mnemom-agent` header's value). */
38
+ agent: "MNEMOM_CC_AGENT",
39
+ conversationId: "MNEMOM_CC_CONVERSATION_ID",
40
+ /** The cell note the launch log prints (`us-2/prod`, or `custom (<host>)`). */
41
+ cell: "MNEMOM_CC_CELL",
42
+ /** `terminal` | `remote-control` | `server`. */
43
+ shape: "MNEMOM_CC_SHAPE",
44
+ /** `sealed` (a contract rides in the header) | `implicit` | `off`. */
45
+ goalMode: "MNEMOM_CC_GOAL_MODE",
46
+ /** The sealed contract's sha256[0:16]; empty unless sealed. */
47
+ contractSha: "MNEMOM_CC_CONTRACT_SHA",
48
+ /**
49
+ * The sealed contract itself — the canonical JSON the gateway hashes (statement, requirements,
50
+ * allowed/forbidden paths, ceilings). Empty unless sealed. It is what `--goal`/`--requirement`/…
51
+ * said on the command line: the person's own words, no secret in it.
52
+ */
53
+ contract: "MNEMOM_CC_CONTRACT",
54
+ /**
55
+ * The implicit mode's guardrail ceilings, `max_turns=N; budget_usd=X; stall_turns=N` (the
56
+ * `x-mnemom-goal-mode` header minus its leading `implicit`). Empty unless implicit with ceilings.
57
+ */
58
+ guardrails: "MNEMOM_CC_GUARDRAILS",
59
+ /**
60
+ * How to run this very `mnemom` again, as a JSON argv (`[node, ...execArgv, script]`): the mod
61
+ * runs `mnemom agent show <conversation> --json` / `mnemom agent goal show …` through it to read
62
+ * the live goal state (the sealed verdict, the inferred goal, guardrail usage). The CLI
63
+ * authenticates with the launch key from its own ledger; the mod never holds the key.
64
+ */
65
+ launcherExec: "MNEMOM_CC_LAUNCHER_EXEC",
66
+ /** The `mnemom agent <session>` scenario name. */
67
+ session: "MNEMOM_CC_SESSION",
68
+ launcherVersion: "MNEMOM_CC_LAUNCHER_VERSION",
69
+ };
70
+ const TRUTHY = new Set(["1", "true", "yes", "on"]);
71
+ /**
72
+ * Whether this launch loads the plugin: `--plugin` / `--no-plugin` (an explicit boolean) wins,
73
+ * then MNEMOM_AGENT_PLUGIN (1/true/yes/on ⇒ on), else OFF: the plugin lands dark.
74
+ */
75
+ export function pluginEnabled(optionPlugin, env) {
76
+ if (optionPlugin !== undefined)
77
+ return optionPlugin;
78
+ const raw = (env.MNEMOM_AGENT_PLUGIN ?? "").trim().toLowerCase();
79
+ return TRUTHY.has(raw);
80
+ }
81
+ /**
82
+ * The bundled plugin's folder (the one holding `.claude-plugin/plugin.json`), or null when it is
83
+ * missing. Lives at `<package>/plugins/mnemom`, next to `dist/` and `src/`, so it is
84
+ * found from a built `dist/lib/` and from `src/lib/` under tsx alike.
85
+ */
86
+ export function resolvePluginDir(baseDirOverride) {
87
+ const here = baseDirOverride ?? dirname(fileURLToPath(import.meta.url));
88
+ const dir = join(here, "..", "..", "plugins", CLAUDE_PLUGIN_NAME);
89
+ return existsSync(join(dir, ".claude-plugin", "plugin.json")) ? dir : null;
90
+ }
91
+ /** Header-free, newline-free: a value that ends up in a child env and, later, on a band. */
92
+ function clean(v) {
93
+ return v.replace(/[\r\n]+/g, " ").trim();
94
+ }
95
+ /**
96
+ * The `MNEMOM_CC_*` block for the child env. Pure: no key, no header, nothing the gateway
97
+ * authenticates with — just what the launch log already prints to stderr.
98
+ */
99
+ export function buildPluginEnv(launch) {
100
+ const sealed = Boolean(launch.contractSha);
101
+ const implicit = !sealed && /^implicit(\s*;|$)/.test((launch.goalMode ?? "").trim());
102
+ const goalMode = sealed ? "sealed" : implicit ? "implicit" : "off";
103
+ // `implicit; max_turns=40; budget_usd=5` → `max_turns=40; budget_usd=5`.
104
+ const guardrails = implicit
105
+ ? clean(launch.goalMode ?? "")
106
+ .replace(/^implicit\s*;?\s*/, "")
107
+ .trim()
108
+ : "";
109
+ const exec = (launch.launcherExec ?? []).map(clean).filter(Boolean);
110
+ return {
111
+ [PLUGIN_ENV.gatewayHost]: clean(launch.gatewayHost).replace(/\/+$/, ""),
112
+ [PLUGIN_ENV.apiUrl]: clean(launch.apiUrl).replace(/\/+$/, ""),
113
+ [PLUGIN_ENV.agent]: clean(launch.agent),
114
+ [PLUGIN_ENV.conversationId]: clean(launch.conversationId),
115
+ [PLUGIN_ENV.cell]: clean(launch.cellNote),
116
+ [PLUGIN_ENV.shape]: launch.shape,
117
+ [PLUGIN_ENV.goalMode]: goalMode,
118
+ [PLUGIN_ENV.contractSha]: clean(launch.contractSha ?? ""),
119
+ [PLUGIN_ENV.contract]: sealed ? clean(launch.contract ?? "") : "",
120
+ [PLUGIN_ENV.guardrails]: guardrails,
121
+ [PLUGIN_ENV.launcherExec]: exec.length > 0 ? JSON.stringify(exec) : "",
122
+ [PLUGIN_ENV.session]: clean(launch.scenario),
123
+ [PLUGIN_ENV.launcherVersion]: clean(launch.launcherVersion),
124
+ };
125
+ }
126
+ /** Writes the block onto a child env in place (the launch shapes all build their env first). */
127
+ export function applyPluginEnv(childEnv, launch) {
128
+ for (const [k, v] of Object.entries(buildPluginEnv(launch)))
129
+ childEnv[k] = v;
130
+ }
131
+ /**
132
+ * Appends the plugin folder to CLAUDE_CODE_PLUGIN_DIRS (for the headless `remote-control` server,
133
+ * which takes no `--plugin-dir`): a user's own list is kept, the folder is added once.
134
+ */
135
+ export function appendPluginDirsEnv(childEnv, pluginDir) {
136
+ const have = (childEnv[CLAUDE_PLUGIN_DIRS_ENV] ?? "").split(delimiter).filter(Boolean);
137
+ if (have.includes(pluginDir))
138
+ return;
139
+ childEnv[CLAUDE_PLUGIN_DIRS_ENV] = [...have, pluginDir].join(delimiter);
140
+ }
141
+ /** The line the launch log and the dry-run plan print for the plugin. */
142
+ export function pluginPlanLine(pluginDir, enabled) {
143
+ if (!enabled)
144
+ return "off";
145
+ if (!pluginDir)
146
+ return "off (the plugin folder is not in this install)";
147
+ return `${CLAUDE_PLUGIN_NAME} (${pluginDir})`;
148
+ }
@@ -7,10 +7,21 @@
7
7
  */
8
8
  /** Base directory for mnemom CLI state (auth tokens, caches). */
9
9
  export declare const MNEMOM_DIR: string;
10
- export type Environment = "production" | "staging" | "local";
10
+ export type Environment = "production" | "local";
11
+ /**
12
+ * Thrown for `MNEMOM_ENV=staging`: the value used to be accepted, and the ring
13
+ * it named is gone (MNE-7968). Failing loud is deliberate — the two silent
14
+ * alternatives are both wrong: resolving to the dead staging hostnames makes
15
+ * every call fail with a DNS/connection error that does not say why, and
16
+ * falling through to production would send a caller who asked for a non-prod
17
+ * ring at real customer data.
18
+ */
19
+ export declare class RetiredEnvironmentError extends Error {
20
+ constructor();
21
+ }
11
22
  /**
12
23
  * Resolve the active environment from MNEMOM_ENV.
13
- * Defaults to production.
24
+ * Defaults to production. `staging` throws (see RetiredEnvironmentError).
14
25
  */
15
26
  export declare function getEnvironment(): Environment;
16
27
  export type CellRing = "us-1";
@@ -11,27 +11,43 @@ import * as os from "node:os";
11
11
  export const MNEMOM_DIR = path.join(os.homedir(), ".mnemom");
12
12
  const API_URLS = {
13
13
  production: "https://api.mnemom.ai",
14
- staging: "https://api-staging.mnemom.ai",
15
14
  local: "http://localhost:8787",
16
15
  };
17
16
  const GATEWAY_URLS = {
18
17
  production: "https://gateway.mnemom.ai",
19
- staging: "https://gateway-staging.mnemom.ai",
20
18
  local: "http://localhost:8787",
21
19
  };
22
20
  const WEBSITE_URLS = {
23
21
  production: "https://www.mnemom.ai",
24
- staging: "https://staging.mnemom.ai",
25
22
  local: "http://localhost:5173",
26
23
  };
24
+ /**
25
+ * Thrown for `MNEMOM_ENV=staging`: the value used to be accepted, and the ring
26
+ * it named is gone (MNE-7968). Failing loud is deliberate — the two silent
27
+ * alternatives are both wrong: resolving to the dead staging hostnames makes
28
+ * every call fail with a DNS/connection error that does not say why, and
29
+ * falling through to production would send a caller who asked for a non-prod
30
+ * ring at real customer data.
31
+ */
32
+ export class RetiredEnvironmentError extends Error {
33
+ constructor() {
34
+ super("MNEMOM_ENV=staging is no longer supported: the staging ring was retired (MNE-7968) " +
35
+ "and nothing serves api-staging / gateway-staging.mnemom.ai. " +
36
+ "Use MNEMOM_RING=us-1 for the preview cell, MNEMOM_ENV=local for a local stack, " +
37
+ "or set MNEMOM_API_URL / MNEMOM_GATEWAY_URL / MNEMOM_WEBSITE_URL to target another cell.");
38
+ this.name = "RetiredEnvironmentError";
39
+ }
40
+ }
27
41
  /**
28
42
  * Resolve the active environment from MNEMOM_ENV.
29
- * Defaults to production.
43
+ * Defaults to production. `staging` throws (see RetiredEnvironmentError).
30
44
  */
31
45
  export function getEnvironment() {
32
46
  const env = process.env.MNEMOM_ENV;
33
- if (env === "staging" || env === "local")
47
+ if (env === "local")
34
48
  return env;
49
+ if (env === "staging")
50
+ throw new RetiredEnvironmentError();
35
51
  return "production";
36
52
  }
37
53
  const RING_URLS = {
@@ -61,7 +77,7 @@ export function getRing() {
61
77
  const env = process.env.MNEMOM_RING?.trim();
62
78
  return env && isCellRing(env) ? env : undefined;
63
79
  }
64
- // Process-level base overrides. MNEMOM_ENV only selects prod/staging/local;
80
+ // Process-level base overrides. MNEMOM_ENV only selects prod/local;
65
81
  // to target an arbitrary ring (preview/us-1, test) a command can set an
66
82
  // explicit override (e.g. `try-me --api <ring>`) or the operator can export
67
83
  // MNEMOM_API_URL / MNEMOM_GATEWAY_URL / MNEMOM_WEBSITE_URL. Crucially these
@@ -123,11 +123,14 @@ export class Governance {
123
123
  if (!r.cc_session_id && t !== null)
124
124
  this.windows.push(["registry_window", t]);
125
125
  }
126
- // Remote Control proxy logs: $TMPDIR/mnemom-code-rc.*/proxy.log, ~/.mnemom/*rc-proxy*.log
126
+ // Remote Control proxy logs: ~/.mnemom/rc/mnemom-code-rc.*/proxy.log (the launcher's dir),
127
+ // $TMPDIR/mnemom-code-rc.*/proxy.log (where older CLIs put it), ~/.mnemom/*rc-proxy*.log
127
128
  const logs = [];
128
- for (const d of safeList(tmp)) {
129
- if (d.startsWith("mnemom-code-rc."))
130
- logs.push(join(tmp, d, "proxy.log"));
129
+ for (const base of [join(mnemomDir, "rc"), tmp]) {
130
+ for (const d of safeList(base)) {
131
+ if (d.startsWith("mnemom-code-rc."))
132
+ logs.push(join(base, d, "proxy.log"));
133
+ }
131
134
  }
132
135
  for (const f of safeList(mnemomDir)) {
133
136
  if (f.includes("rc-proxy") && f.endsWith(".log"))
@@ -83,7 +83,9 @@ export interface LoopbackResult {
83
83
  * for refresh). `openUrl` is injectable so tests can drive the flow without
84
84
  * spawning a real browser.
85
85
  */
86
- export declare function loginWithLoopback(openUrl?: (url: string) => void): Promise<LoopbackResult>;
86
+ export declare function loginWithLoopback(openUrl?: (url: string) => void, opts?: {
87
+ inviteToken?: string;
88
+ }): Promise<LoopbackResult>;
87
89
  export interface DeviceResult {
88
90
  tokens: OAuthTokens;
89
91
  clientId: string;
package/dist/lib/oauth.js CHANGED
@@ -146,7 +146,7 @@ function tokensFromResponse(data) {
146
146
  * for refresh). `openUrl` is injectable so tests can drive the flow without
147
147
  * spawning a real browser.
148
148
  */
149
- export async function loginWithLoopback(openUrl = openBrowser) {
149
+ export async function loginWithLoopback(openUrl = openBrowser, opts = {}) {
150
150
  const meta = await discover();
151
151
  const pkce = generatePkce();
152
152
  const state = crypto.randomBytes(16).toString("hex");
@@ -164,6 +164,12 @@ export async function loginWithLoopback(openUrl = openBrowser) {
164
164
  authUrl.searchParams.set("state", state);
165
165
  authUrl.searchParams.set("code_challenge", pkce.challenge);
166
166
  authUrl.searchParams.set("code_challenge_method", pkce.method);
167
+ // A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
168
+ // invitee who signs up in this browser round-trip skips the confirmation
169
+ // email: the AS ignores the param but bounces a signed-out browser to /login
170
+ // with this exact URL as return_to, and sign-up forwards the token to the api.
171
+ if (opts.inviteToken)
172
+ authUrl.searchParams.set("invite_token", opts.inviteToken);
167
173
  console.log("Opening browser to authenticate...");
168
174
  console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
169
175
  openUrl(authUrl.toString());
@@ -234,6 +240,12 @@ function startLoopbackServer(expectedState) {
234
240
  close: () => {
235
241
  clearTimeout(timeout);
236
242
  server.close();
243
+ // The browser keeps idle keep-alive sockets (incl. Chrome's spare
244
+ // preconnect) open while the tab stays up; server.close() alone waits
245
+ // on them, so `mnemom login` printed success and then sat there until
246
+ // the tab was closed. Drop them — the callback response is already sent.
247
+ // Optional call: closeAllConnections() landed in Node 18.2 (engines allow 18.0).
248
+ server.closeAllConnections?.();
237
249
  },
238
250
  });
239
251
  });
@@ -0,0 +1,7 @@
1
+ export declare const RETIRED_ENV_EXIT_CODE = 2;
2
+ /**
3
+ * The startup error line when any CLI base URL would resolve via the retired
4
+ * `MNEMOM_ENV=staging`, else undefined. Exported for the unit test; the module
5
+ * body below is what the CLI relies on.
6
+ */
7
+ export declare function retiredEnvMessage(): string | undefined;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Startup guard for `MNEMOM_ENV=staging` (MNE-7968).
3
+ *
4
+ * `getEnvironment()` throws `RetiredEnvironmentError` for that value, and three
5
+ * modules resolve the API base at MODULE-EVAL time (`lib/api.ts`,
6
+ * `lib/webhooks-api.ts`, `commands/listen.ts` — see the note at the top of
7
+ * `__tests__/cli-api-integration.test.ts`). Those modules are statically
8
+ * imported by `index.ts`, so without this guard the throw surfaces during ESM
9
+ * linking — before `runEntrypoint()`, before commander, before any try/catch —
10
+ * and every invocation, `mnemom --help` included, dies with a raw Node stack
11
+ * trace. This module is the FIRST import of `index.ts`, so it evaluates before
12
+ * any of them: it prints the same message on one line and exits 2 (the CLI's
13
+ * usage-error code).
14
+ *
15
+ * The guard decides by running config.ts's own resolvers rather than reading
16
+ * MNEMOM_ENV, so it follows their precedence exactly (raw MNEMOM_*_URL > ring >
17
+ * MNEMOM_ENV): when a valid MNEMOM_RING, or all three MNEMOM_*_URL overrides,
18
+ * answer every base URL, MNEMOM_ENV is never consulted and a stale
19
+ * `MNEMOM_ENV=staging` beside them is harmless. Importing this module has no
20
+ * effect unless some base URL would resolve via the retired environment.
21
+ */
22
+ import { getApiUrl, getGatewayUrl, getWebsiteUrl, RetiredEnvironmentError } from "./config.js";
23
+ export const RETIRED_ENV_EXIT_CODE = 2;
24
+ /**
25
+ * The startup error line when any CLI base URL would resolve via the retired
26
+ * `MNEMOM_ENV=staging`, else undefined. Exported for the unit test; the module
27
+ * body below is what the CLI relies on.
28
+ */
29
+ export function retiredEnvMessage() {
30
+ try {
31
+ getApiUrl();
32
+ getGatewayUrl();
33
+ getWebsiteUrl();
34
+ return undefined;
35
+ }
36
+ catch (err) {
37
+ if (err instanceof RetiredEnvironmentError)
38
+ return `error: ${err.message}`;
39
+ throw err;
40
+ }
41
+ }
42
+ const message = retiredEnvMessage();
43
+ if (message !== undefined) {
44
+ console.error(message);
45
+ process.exit(RETIRED_ENV_EXIT_CODE);
46
+ }
package/dist/rc-proxy.mjs CHANGED
@@ -14,9 +14,10 @@
14
14
  // WHAT IT DOES
15
15
  // * Listens on 127.0.0.1 (ephemeral port) as an HTTP CONNECT proxy.
16
16
  // * Terminates TLS for api.anthropic.com ONLY, with an ephemeral CA (397 days, key held in
17
- // memory only) minted at startup into a private 0700 tmpdir (removed on exit), and a 7-day
18
- // leaf re-minted every 24 h and hot-swapped (see below). Every other host is tunnelled
19
- // byte-for-byte (telemetry, Datadog, MCP servers…).
17
+ // memory only) minted at startup into a private 0700 dir under ~/.mnemom/rc (removed on
18
+ // exit; see rc-state.mjs for why not the OS temp dir), and a 7-day leaf re-minted every 24 h
19
+ // and hot-swapped (see below). Every other host is tunnelled byte-for-byte (telemetry, Datadog,
20
+ // MCP servers…).
20
21
  // * Inside api.anthropic.com, only `POST /v1/messages[?…]` is rewritten to the Mnemom
21
22
  // gateway door: the real Anthropic key rides as `x-api-key` (API-key billing), plus
22
23
  // `x-mnemom-agent`, `x-mnemom-conversation-id`, and optionally `x-mnemom-api-key`,
@@ -90,6 +91,7 @@ import path from "node:path";
90
91
  import crypto from "node:crypto";
91
92
  import { execFileSync } from "node:child_process";
92
93
  import { TURN_HEADER, TurnOrigins, turnHeaderEnabled, turnHeaderFor } from "./turn-origin.mjs";
94
+ import { OWNER_FILE, ensureRcStateRoot, keepPrivateDir, sweepRcDirs } from "./rc-state.mjs";
93
95
 
94
96
  const MITM_HOST = "api.anthropic.com";
95
97
  const env = process.env;
@@ -125,7 +127,19 @@ const PORT = Number(env.MNEMOM_AGENT_RC_PORT || 0);
125
127
  // transcript. Implicit goal mode only — a sealed contract does not infer a goal from the user.
126
128
  const TURNS = GOAL_MODE && turnHeaderEnabled(env) ? new TurnOrigins() : null;
127
129
 
128
- const log = (line) => {
130
+ // If something deleted the log's dir, recreate it (once per line) rather than spill log lines onto
131
+ // the terminal Claude Code is drawing on (the launcher's stderr). True when the retry may go ahead.
132
+ const remakeLogDir = (e) => {
133
+ if (e.code !== "ENOENT") return false;
134
+ try {
135
+ fs.mkdirSync(path.dirname(LOGFILE), { recursive: true, mode: 0o700 });
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ };
141
+
142
+ const log = (line, retry = true) => {
129
143
  // One record per line: gateway header values and error bodies are spliced into log lines, so a
130
144
  // CR/LF inside them must not fabricate extra records (log injection).
131
145
  const s = `${new Date().toISOString()} ${String(line).replace(/[\r\n]+/g, " ")}\n`;
@@ -133,8 +147,9 @@ const log = (line) => {
133
147
  try {
134
148
  fs.appendFileSync(LOGFILE, s);
135
149
  return;
136
- } catch {
137
- /* fall through */
150
+ } catch (e) {
151
+ // The retry goes through the one append above: there is a single write sink for log lines.
152
+ if (retry && remakeLogDir(e)) return log(line, false);
138
153
  }
139
154
  }
140
155
  process.stderr.write(s);
@@ -157,7 +172,7 @@ if (SUBSCRIPTION) {
157
172
  } else if (!secrets.anthropic) die("the Anthropic key (stdin line 1) is empty");
158
173
 
159
174
  // --- ephemeral CA + rotating leaf for api.anthropic.com ---------------------------------
160
- // Private 0700 dir under the OS tmpdir, removed on exit. Claude Code reads NODE_EXTRA_CA_CERTS
175
+ // Private 0700 dir under ~/.mnemom/rc (rc-state.mjs), removed on exit. Claude Code reads NODE_EXTRA_CA_CERTS
161
176
  // once at process start, so the CA it trusts can never change mid-session: the CA is valid for
162
177
  // CA_DAYS and its private key lives in memory only (read into a Buffer, then deleted from disk).
163
178
  // The leaf is short-lived and re-minted from that key every LEAF_ROTATE_MS — and on demand when
@@ -166,17 +181,42 @@ if (SUBSCRIPTION) {
166
181
  // startup broke every session older than 48 h with "SSL certificate has expired".) openssl only
167
182
  // takes the CA key from a file, so each signing writes it 0600 into caDir and deletes it right
168
183
  // after. Works with macOS's system LibreSSL (3.3+) and OpenSSL 1.1.1+ (`-addext`).
169
- // MNEMOM_RC_LEAF_ROTATE_MS / MNEMOM_RC_LEAF_DAYS exist for tests only.
184
+ //
185
+ // Nothing may take the CA files mid-session. In the OS temp dir, macOS's dirhelper deleted
186
+ // ca.pem / leaf.ext from sessions older than ~3-4 days (files not accessed for 3 days; openssl
187
+ // reading them at each rotation does not move their atime), every later rotation failed, and
188
+ // Claude Code reported UNABLE_TO_VERIFY_LEAF_SIGNATURE until relaunch (2026-10-03). The dir now
189
+ // lives under ~/.mnemom/rc, which no cleaner touches (the OS temp dir only if home is not
190
+ // writable). Belt and braces: the CA PEM, the leaf.ext text and the owner pid live in memory too,
191
+ // and keepCaDir() — every CA_KEEPALIVE_MS and before every mint — recreates caDir if it is gone,
192
+ // restores any of the three that is missing or differs (ca.pem byte-identical at the same path:
193
+ // NODE_EXTRA_CA_CERTS points at it), and refreshes the atime/mtime of the dir and everything in
194
+ // it. At startup, sibling dirs left by dead sessions are swept (rc-state.mjs).
195
+ // MNEMOM_RC_LEAF_ROTATE_MS / MNEMOM_RC_LEAF_DAYS / MNEMOM_RC_CA_KEEPALIVE_MS exist for tests only.
170
196
  const CA_DAYS = 397;
171
197
  const LEAF_DAYS = Number(env.MNEMOM_RC_LEAF_DAYS || 7);
172
198
  const LEAF_ROTATE_MS = Number(env.MNEMOM_RC_LEAF_ROTATE_MS || 24 * 60 * 60 * 1000);
173
199
  const LEAF_MIN_LEFT_MS = 24 * 60 * 60 * 1000;
174
- const caDir = fs.mkdtempSync(path.join(os.tmpdir(), "mnemom-code-rc-ca-"));
200
+ const CA_KEEPALIVE_MS = Number(env.MNEMOM_RC_CA_KEEPALIVE_MS || 6 * 60 * 60 * 1000);
201
+ let rcRoot;
202
+ try {
203
+ rcRoot = ensureRcStateRoot();
204
+ const swept = sweepRcDirs(rcRoot, { maxAgeMs: CA_DAYS * 24 * 60 * 60 * 1000 });
205
+ if (swept.length) log(`swept ${swept.length} dir(s) left by dead sessions under ${rcRoot}`);
206
+ } catch (e) {
207
+ rcRoot = os.tmpdir();
208
+ log(`cannot use ~/.mnemom/rc (${e.code || e.message}); CA dir falls back to ${rcRoot}`);
209
+ }
210
+ const caDir = fs.mkdtempSync(path.join(rcRoot, "mnemom-code-rc-ca-"));
175
211
  fs.chmodSync(caDir, 0o700);
176
212
  const p = (f) => path.join(caDir, f);
213
+ const OWNER = `${process.pid}\n`;
214
+ fs.writeFileSync(p(OWNER_FILE), OWNER, { mode: 0o600 });
177
215
  let rotateTimer = null;
216
+ let keepAliveTimer = null;
178
217
  const cleanup = () => {
179
218
  clearInterval(rotateTimer); // a no-op while still null
219
+ clearInterval(keepAliveTimer);
180
220
  try {
181
221
  fs.rmSync(caDir, { recursive: true, force: true });
182
222
  } catch {
@@ -190,6 +230,8 @@ const ossl = (...a) => execFileSync("openssl", a, { stdio: ["ignore", "ignore",
190
230
  const osslErr = (e) => (e.stderr && e.stderr.length ? e.stderr.toString().trim() : e.message);
191
231
 
192
232
  let caKey; // Buffer — never on disk outside mintLeaf()'s signing call
233
+ let caPem; // Buffer — the exact bytes of ca.pem, restored from here if the file goes missing
234
+ const LEAF_EXT = `subjectAltName=DNS:${MITM_HOST}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`;
193
235
  try {
194
236
  ossl(
195
237
  "req",
@@ -214,17 +256,43 @@ try {
214
256
  );
215
257
  caKey = fs.readFileSync(p("ca.key"));
216
258
  fs.rmSync(p("ca.key"));
217
- fs.writeFileSync(
218
- p("leaf.ext"),
219
- `subjectAltName=DNS:${MITM_HOST}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n`,
220
- );
259
+ caPem = fs.readFileSync(p("ca.pem"));
260
+ fs.writeFileSync(p("leaf.ext"), LEAF_EXT);
221
261
  } catch (e) {
222
262
  fs.rmSync(p("ca.key"), { force: true });
223
263
  die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
224
264
  }
225
265
 
266
+ // Recreate caDir and its long-lived files from memory if something deleted them, and refresh
267
+ // every atime/mtime so the cleaner does not try again (see the block comment above).
268
+ // keepPrivateDir (rc-state.mjs) only ever reuses a dir that is ours: a real 0700 directory owned by
269
+ // this uid, checked on an fd opened without following a symlink. Anything else could hand Claude
270
+ // Code a CA someone else controls. Throws when the dir cannot be made safe (mintLeaf then fails
271
+ // and the current leaf stays); otherwise returns the names it restored.
272
+ const keepCaDir = () =>
273
+ keepPrivateDir(
274
+ caDir,
275
+ [
276
+ ["ca.pem", caPem, 0o644],
277
+ ["leaf.ext", Buffer.from(LEAF_EXT), 0o644],
278
+ [OWNER_FILE, Buffer.from(OWNER), 0o600],
279
+ ],
280
+ { logfile: LOGFILE },
281
+ );
282
+
283
+ function keepAlive(why) {
284
+ try {
285
+ const restored = keepCaDir();
286
+ if (restored.length) log(`ca keep-alive (${why}): restored ${restored.join(", ")}`);
287
+ } catch (e) {
288
+ log(`ca keep-alive FAILED (${why}): ${e.message}`);
289
+ }
290
+ }
291
+
226
292
  // Mint a fresh leaf key + cert signed by the in-memory CA key. Returns { key, cert, notAfter }.
227
293
  function mintLeaf() {
294
+ const restored = keepCaDir(); // a rotation must never fail on a deleted file
295
+ if (restored.length) log(`ca keep-alive (mint): restored ${restored.join(", ")}`);
228
296
  ossl(
229
297
  "req",
230
298
  "-newkey",
@@ -563,6 +631,8 @@ function rotateLeaf(why) {
563
631
  }
564
632
  rotateTimer = setInterval(() => rotateLeaf("interval"), LEAF_ROTATE_MS);
565
633
  rotateTimer.unref();
634
+ keepAliveTimer = setInterval(() => keepAlive("interval"), CA_KEEPALIVE_MS);
635
+ keepAliveTimer.unref();
566
636
 
567
637
  const proxy = http.createServer((req, res) => handle(req, res, true));
568
638
  proxy.on("connect", (req, sock, head) => {