@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.
- package/dist/commands/agent-invite.d.ts +3 -0
- package/dist/commands/agent-invite.js +1 -1
- package/dist/commands/agent-sessions.d.ts +15 -5
- package/dist/commands/agent-sessions.js +29 -5
- package/dist/commands/agent-setup.d.ts +1 -0
- package/dist/commands/agent-setup.js +13 -3
- package/dist/commands/agent.d.ts +13 -0
- package/dist/commands/agent.js +89 -3
- package/dist/commands/validate.d.ts +1 -1
- package/dist/commands/validate.js +7 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -2
- package/dist/lib/api.d.ts +41 -0
- package/dist/lib/auth.d.ts +3 -1
- package/dist/lib/auth.js +2 -2
- package/dist/lib/claude-plugin.d.ts +114 -0
- package/dist/lib/claude-plugin.js +148 -0
- package/dist/lib/config.d.ts +13 -2
- package/dist/lib/config.js +22 -6
- package/dist/lib/first-run/governed.js +7 -4
- package/dist/lib/oauth.d.ts +3 -1
- package/dist/lib/oauth.js +13 -1
- package/dist/lib/retired-env-guard.d.ts +7 -0
- package/dist/lib/retired-env-guard.js +46 -0
- package/dist/rc-proxy.mjs +83 -13
- package/dist/rc-state.mjs +257 -0
- package/package.json +5 -1
- package/plugins/.claude-plugin/marketplace.json +20 -0
- package/plugins/mnemom/.claude-plugin/plugin.json +47 -0
- package/plugins/mnemom/.mcp.json +12 -0
- package/plugins/mnemom/hooks/hooks.json +3 -0
- package/plugins/mnemom/hooks/register.tsx +1476 -0
- package/plugins/mnemom/skills/about-mnemom/SKILL.md +88 -0
- package/plugins/mnemom/types/index.d.ts +142 -0
|
@@ -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
|
+
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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" | "
|
|
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";
|
package/dist/lib/config.js
CHANGED
|
@@ -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 === "
|
|
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/
|
|
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:
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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"))
|
package/dist/lib/oauth.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
18
|
-
//
|
|
19
|
-
// byte-for-byte (telemetry, Datadog,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
218
|
-
|
|
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) => {
|