@mnemom/mnemom 0.17.0-next.7 → 0.17.0-next.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/commands/agent-config.d.ts +12 -0
- package/dist/commands/agent-config.js +22 -2
- package/dist/commands/agent-session.d.ts +26 -10
- package/dist/commands/agent-session.js +59 -23
- package/dist/commands/agent.d.ts +36 -0
- package/dist/commands/agent.js +70 -12
- package/dist/index.js +17 -4
- package/dist/lib/agent.d.ts +43 -1
- package/dist/lib/agent.js +80 -1
- package/dist/lib/auth.js +21 -1
- package/dist/lib/launcher-config.d.ts +3 -1
- package/dist/lib/launcher-config.js +20 -1
- package/dist/lib/oauth.d.ts +26 -4
- package/dist/lib/oauth.js +98 -29
- package/dist/rc-proxy.mjs +14 -4
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -44,6 +44,32 @@ That's it. `mnemom init` detects your configured AI provider API keys (Anthropic
|
|
|
44
44
|
| `mnemom card publish <file>` | Publish alignment card from JSON file |
|
|
45
45
|
| `mnemom card validate <file>` | Validate card JSON locally |
|
|
46
46
|
| `mnemom usage --org <id>` | Show per-person token and request consumption |
|
|
47
|
+
| `mnemom agent [name]` | Launch a governed coding agent through the gateway |
|
|
48
|
+
|
|
49
|
+
### `mnemom agent` — goal alignment
|
|
50
|
+
|
|
51
|
+
`mnemom agent` launches your coding-agent CLI (Claude Code today) with its model traffic
|
|
52
|
+
routed through the Mnemom gateway under a governed identity. Goal alignment — the judge
|
|
53
|
+
nudging the agent back when it drifts from your goal — comes in three modes; a bare
|
|
54
|
+
`mnemom agent` in a terminal asks which one you want (Enter = off, for now):
|
|
55
|
+
|
|
56
|
+
| Mode | How | What is sent |
|
|
57
|
+
| ------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
|
|
58
|
+
| **Implicit** | `--implicit` — the gateway infers the goal from your own messages and revises it as you steer; nothing to type | `x-mnemom-goal-mode: implicit`, no contract |
|
|
59
|
+
| **Sealed** | `--goal "<statement>"` (+ `--requirement/--allow/--forbid/--goal-id`) — sealed as a contract on the first turn | `x-mnemom-contract` |
|
|
60
|
+
| **Off** | neither | neither header |
|
|
61
|
+
|
|
62
|
+
`--implicit` cannot be combined with the sealed-contract flags: a sealed contract always
|
|
63
|
+
wins on the gateway and would silently switch implicit mode off, so the launch refuses the
|
|
64
|
+
mix. The guardrail ceilings (`--budget`, `--max-turns`, `--stall`, or `guardrails.*` in
|
|
65
|
+
`~/.mnemom/launcher.toml`) apply in both modes — in implicit mode they ride on the mode
|
|
66
|
+
header. In implicit mode the goal lags your steering by one request: a response to a new
|
|
67
|
+
message carries `x-mnemom-goal-steer: queued` and the revision the agent acted under;
|
|
68
|
+
the next request reads the new revision (`x-mnemom-goal-revision`).
|
|
69
|
+
|
|
70
|
+
Make one mode the standing default with `mnemom agent config set goal_mode implicit`
|
|
71
|
+
(or `off`); `--goal` still seals for that launch and `--no-implicit` turns it off for one
|
|
72
|
+
launch. A sealed contract needs a per-launch statement, so it cannot be the default.
|
|
47
73
|
|
|
48
74
|
## How It Works
|
|
49
75
|
|
|
@@ -15,3 +15,15 @@
|
|
|
15
15
|
*/
|
|
16
16
|
/** Entry point for `mnemom agent config <verb> [args...]`. Throws on misuse. */
|
|
17
17
|
export declare function agentConfigCommand(argv: string[]): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* `$VISUAL` / `$EDITOR` is a program plus optional plain flags (`code --wait`, `emacsclient -t`),
|
|
20
|
+
* never shell syntax: the value is split on whitespace and every token must be a bare
|
|
21
|
+
* program name, path, or flag. Anything else (quotes, `;`, `$(`, backticks, redirections, …) is
|
|
22
|
+
* refused with a remediation rather than handed to spawnSync — the editor is launched WITHOUT a
|
|
23
|
+
* shell, so such a value could only ever fail confusingly or, via a lookalike binary on PATH, run
|
|
24
|
+
* something the user did not mean. Exported for the tests.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseEditorSpec(spec: string): {
|
|
27
|
+
bin: string;
|
|
28
|
+
args: string[];
|
|
29
|
+
};
|
|
@@ -98,8 +98,8 @@ function editConfig() {
|
|
|
98
98
|
// Seed a header-only file so $EDITOR opens something with guidance, not a blank.
|
|
99
99
|
if (!existsSync(LAUNCHER_CONFIG_PATH))
|
|
100
100
|
writeLauncherConfig(loadLauncherConfig());
|
|
101
|
-
const editor = process.env.VISUAL || process.env.EDITOR || fallbackEditor();
|
|
102
|
-
const res = spawnSync(editor, [LAUNCHER_CONFIG_PATH], { stdio: "inherit" });
|
|
101
|
+
const { bin: editor, args: editorArgs } = parseEditorSpec(process.env.VISUAL || process.env.EDITOR || fallbackEditor());
|
|
102
|
+
const res = spawnSync(editor, [...editorArgs, LAUNCHER_CONFIG_PATH], { stdio: "inherit" });
|
|
103
103
|
if (res.error) {
|
|
104
104
|
throw new Error(`mnemom agent config edit: could not launch editor '${editor}': ${res.error.message}\n` +
|
|
105
105
|
` Set $EDITOR, or edit ${LAUNCHER_CONFIG_PATH} directly.`);
|
|
@@ -108,6 +108,23 @@ function editConfig() {
|
|
|
108
108
|
loadLauncherConfig();
|
|
109
109
|
out(`Saved ${LAUNCHER_CONFIG_PATH}.`);
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* `$VISUAL` / `$EDITOR` is a program plus optional plain flags (`code --wait`, `emacsclient -t`),
|
|
113
|
+
* never shell syntax: the value is split on whitespace and every token must be a bare
|
|
114
|
+
* program name, path, or flag. Anything else (quotes, `;`, `$(`, backticks, redirections, …) is
|
|
115
|
+
* refused with a remediation rather than handed to spawnSync — the editor is launched WITHOUT a
|
|
116
|
+
* shell, so such a value could only ever fail confusingly or, via a lookalike binary on PATH, run
|
|
117
|
+
* something the user did not mean. Exported for the tests.
|
|
118
|
+
*/
|
|
119
|
+
export function parseEditorSpec(spec) {
|
|
120
|
+
const tokens = spec.trim().split(/\s+/).filter(Boolean);
|
|
121
|
+
const SAFE = /^[A-Za-z0-9_./+=:@,-]+$/;
|
|
122
|
+
if (tokens.length === 0 || !tokens.every((t) => SAFE.test(t))) {
|
|
123
|
+
throw new Error(`mnemom agent config edit: refusing to run $VISUAL/$EDITOR value '${spec}' — only a program name or path plus plain flags is accepted (no shell syntax).\n` +
|
|
124
|
+
` Set $EDITOR to e.g. 'code --wait', or edit ${LAUNCHER_CONFIG_PATH} directly.`);
|
|
125
|
+
}
|
|
126
|
+
return { bin: tokens[0], args: tokens.slice(1) };
|
|
127
|
+
}
|
|
111
128
|
function fallbackEditor() {
|
|
112
129
|
for (const cand of ["nano", "vim", "vi"]) {
|
|
113
130
|
const r = spawnSync("sh", ["-c", `command -v "$1" >/dev/null 2>&1`, "sh", cand], {
|
|
@@ -158,6 +175,9 @@ function deleteKey(cfg, key) {
|
|
|
158
175
|
case "effort":
|
|
159
176
|
delete cfg.effort;
|
|
160
177
|
break;
|
|
178
|
+
case "goal_mode":
|
|
179
|
+
delete cfg.goalMode;
|
|
180
|
+
break;
|
|
161
181
|
case "cli":
|
|
162
182
|
delete cfg.cli;
|
|
163
183
|
break;
|
|
@@ -7,25 +7,35 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 1. a SESSION NAME — labels the session in traces and the claude.ai session
|
|
9
9
|
* list (defaults to the current folder's name);
|
|
10
|
-
* 2.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* 2. GOAL ALIGNMENT — one of three:
|
|
11
|
+
* - INFER it from your messages (implicit mode): nothing to type; the
|
|
12
|
+
* gateway reads your own messages, revises the goal as you steer, and the
|
|
13
|
+
* judge nudges the agent back when it drifts (a little µ per new message
|
|
14
|
+
* and per judged turn). Sends `x-mnemom-goal-mode: implicit`, no contract.
|
|
15
|
+
* - TYPE a one-line goal: sealed as a contract for the session; the judge
|
|
16
|
+
* nudges against it (a little µ per turn). Sends `x-mnemom-contract`.
|
|
17
|
+
* - OFF: the session runs governed with goal alignment off — neither header
|
|
18
|
+
* is sent, so the gateway's goal hooks are simply not engaged.
|
|
15
19
|
*
|
|
16
20
|
* Non-interactive runs (pipes, CI) never prompt — they get a clear error asking
|
|
17
|
-
* for a name. `--goal` on the command line always wins over the prompt
|
|
18
|
-
*
|
|
21
|
+
* for a name. `--goal` on the command line always wins over the prompt, and a
|
|
22
|
+
* resolved implicit launch (`--implicit`, or launcher.toml `goal_mode =
|
|
23
|
+
* "implicit"` with no `--no-implicit`) skips it too. Pure apart from the
|
|
24
|
+
* injected `ask` functions, so every arm is unit-tested.
|
|
19
25
|
*/
|
|
20
26
|
export interface SessionAsk {
|
|
21
27
|
askInput(question: string): Promise<string>;
|
|
22
28
|
askYesNo(question: string, defaultYes: boolean): Promise<boolean>;
|
|
29
|
+
/** Numbered single-select; resolves the chosen label, or null on no/invalid choice. */
|
|
30
|
+
askSelect(question: string, options: string[]): Promise<string | null>;
|
|
23
31
|
}
|
|
24
32
|
export interface SessionSetupInput {
|
|
25
33
|
/** The positional `[scenario]` (session name), if given. */
|
|
26
34
|
scenario?: string;
|
|
27
35
|
/** `--goal <statement>`, if given — skips the goal prompt. */
|
|
28
36
|
goal?: string;
|
|
37
|
+
/** Resolved implicit mode (flag or config default) — skips the goal prompt; alignment on without a contract. */
|
|
38
|
+
implicit?: boolean;
|
|
29
39
|
interactive: boolean;
|
|
30
40
|
/** For the default session name (tests inject one). */
|
|
31
41
|
cwd?: string;
|
|
@@ -34,17 +44,23 @@ export interface SessionSetupInput {
|
|
|
34
44
|
}
|
|
35
45
|
export interface SessionSetup {
|
|
36
46
|
scenario: string;
|
|
37
|
-
/** The goal statement to seal, or undefined =
|
|
47
|
+
/** The goal statement to seal, or undefined = no sealed contract (implicit or off). */
|
|
38
48
|
goal?: string;
|
|
39
|
-
goalAlignment: "on" | "off";
|
|
49
|
+
goalAlignment: "on" | "implicit" | "off";
|
|
40
50
|
/** True when the terminal prompts ran. */
|
|
41
51
|
prompted: boolean;
|
|
42
52
|
}
|
|
43
53
|
export declare const NO_SESSION_NAME_MESSAGE: string;
|
|
54
|
+
/** The three goal-alignment choices offered by the interactive prompt (labels are the select options). */
|
|
55
|
+
export declare const GOAL_CHOICE_IMPLICIT = "Infer my goal from my messages \u2014 nothing to type; revised as I steer (implicit mode)";
|
|
56
|
+
export declare const GOAL_CHOICE_TYPED = "Type a one-line goal \u2014 sealed as a contract for this session";
|
|
57
|
+
export declare const GOAL_CHOICE_OFF = "Off \u2014 run governed with goal alignment off (Enter)";
|
|
58
|
+
export declare const GOAL_CHOICES: readonly string[];
|
|
59
|
+
export declare const GOAL_CHOICE_QUESTION = "Goal alignment for this session?";
|
|
44
60
|
/** Current folder name, sanitised like an agent slug; "session" when that is empty. */
|
|
45
61
|
export declare function defaultSessionName(cwd?: string): string;
|
|
46
62
|
/** The one-line status printed at launch so the customer always knows the mode. */
|
|
47
|
-
export declare function goalAlignmentLine(
|
|
63
|
+
export declare function goalAlignmentLine(mode: boolean | "on" | "implicit" | "off"): string;
|
|
48
64
|
/**
|
|
49
65
|
* Resolve the session name + goal for this launch. See the module doc for the
|
|
50
66
|
* rules. Throws NO_SESSION_NAME_MESSAGE when no name was given and we cannot ask.
|
|
@@ -7,29 +7,50 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 1. a SESSION NAME — labels the session in traces and the claude.ai session
|
|
9
9
|
* list (defaults to the current folder's name);
|
|
10
|
-
* 2.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* 2. GOAL ALIGNMENT — one of three:
|
|
11
|
+
* - INFER it from your messages (implicit mode): nothing to type; the
|
|
12
|
+
* gateway reads your own messages, revises the goal as you steer, and the
|
|
13
|
+
* judge nudges the agent back when it drifts (a little µ per new message
|
|
14
|
+
* and per judged turn). Sends `x-mnemom-goal-mode: implicit`, no contract.
|
|
15
|
+
* - TYPE a one-line goal: sealed as a contract for the session; the judge
|
|
16
|
+
* nudges against it (a little µ per turn). Sends `x-mnemom-contract`.
|
|
17
|
+
* - OFF: the session runs governed with goal alignment off — neither header
|
|
18
|
+
* is sent, so the gateway's goal hooks are simply not engaged.
|
|
15
19
|
*
|
|
16
20
|
* Non-interactive runs (pipes, CI) never prompt — they get a clear error asking
|
|
17
|
-
* for a name. `--goal` on the command line always wins over the prompt
|
|
18
|
-
*
|
|
21
|
+
* for a name. `--goal` on the command line always wins over the prompt, and a
|
|
22
|
+
* resolved implicit launch (`--implicit`, or launcher.toml `goal_mode =
|
|
23
|
+
* "implicit"` with no `--no-implicit`) skips it too. Pure apart from the
|
|
24
|
+
* injected `ask` functions, so every arm is unit-tested.
|
|
19
25
|
*/
|
|
20
26
|
import { basename } from "node:path";
|
|
21
27
|
import { sanitizeAgentName } from "../lib/agent.js";
|
|
22
28
|
export const NO_SESSION_NAME_MESSAGE = "mnemom agent: a session name is required here (e.g. `mnemom agent onboarding`). " +
|
|
23
29
|
"In a terminal, plain `mnemom agent` asks for one.";
|
|
30
|
+
/** The three goal-alignment choices offered by the interactive prompt (labels are the select options). */
|
|
31
|
+
export const GOAL_CHOICE_IMPLICIT = "Infer my goal from my messages — nothing to type; revised as I steer (implicit mode)";
|
|
32
|
+
export const GOAL_CHOICE_TYPED = "Type a one-line goal — sealed as a contract for this session";
|
|
33
|
+
export const GOAL_CHOICE_OFF = "Off — run governed with goal alignment off (Enter)";
|
|
34
|
+
export const GOAL_CHOICES = [
|
|
35
|
+
GOAL_CHOICE_IMPLICIT,
|
|
36
|
+
GOAL_CHOICE_TYPED,
|
|
37
|
+
GOAL_CHOICE_OFF,
|
|
38
|
+
];
|
|
39
|
+
export const GOAL_CHOICE_QUESTION = "Goal alignment for this session?";
|
|
24
40
|
/** Current folder name, sanitised like an agent slug; "session" when that is empty. */
|
|
25
41
|
export function defaultSessionName(cwd = process.cwd()) {
|
|
26
42
|
return sanitizeAgentName(basename(cwd)) || "session";
|
|
27
43
|
}
|
|
28
44
|
/** The one-line status printed at launch so the customer always knows the mode. */
|
|
29
|
-
export function goalAlignmentLine(
|
|
30
|
-
|
|
45
|
+
export function goalAlignmentLine(mode) {
|
|
46
|
+
const m = mode === true ? "on" : mode === false ? "off" : mode;
|
|
47
|
+
if (m === "implicit") {
|
|
48
|
+
return ("mnemom agent: goal alignment: on, implicit (no sealed contract — the gateway infers your goal " +
|
|
49
|
+
"from your own messages, revises it as you steer, and the judge nudges the agent back when it drifts)");
|
|
50
|
+
}
|
|
51
|
+
return m === "on"
|
|
31
52
|
? "mnemom agent: goal alignment: on (the judge nudges the agent back when it drifts from your goal)"
|
|
32
|
-
: "mnemom agent: goal alignment: off (no goal set — pass --goal or set one when prompted)";
|
|
53
|
+
: "mnemom agent: goal alignment: off (no goal set — pass --goal or --implicit, or set one when prompted)";
|
|
33
54
|
}
|
|
34
55
|
const defaultSay = (line = "") => console.error(line);
|
|
35
56
|
/**
|
|
@@ -39,12 +60,13 @@ const defaultSay = (line = "") => console.error(line);
|
|
|
39
60
|
export async function resolveSessionSetup(input, ask) {
|
|
40
61
|
const say = input.say ?? defaultSay;
|
|
41
62
|
const flagGoal = (input.goal ?? "").trim();
|
|
63
|
+
const implicit = input.implicit === true;
|
|
42
64
|
const given = (input.scenario ?? "").trim();
|
|
43
65
|
if (given) {
|
|
44
66
|
return {
|
|
45
67
|
scenario: given,
|
|
46
68
|
goal: flagGoal || undefined,
|
|
47
|
-
goalAlignment: flagGoal ? "on" : "off",
|
|
69
|
+
goalAlignment: flagGoal ? "on" : implicit ? "implicit" : "off",
|
|
48
70
|
prompted: false,
|
|
49
71
|
};
|
|
50
72
|
}
|
|
@@ -57,22 +79,36 @@ export async function resolveSessionSetup(input, ask) {
|
|
|
57
79
|
say(` (Next time you can pass it directly: mnemom agent <name>.)`);
|
|
58
80
|
const typed = await ask.askInput(`Session name [${fallback}]:`);
|
|
59
81
|
const scenario = sanitizeAgentName(typed.trim()) || fallback;
|
|
60
|
-
// ── 2. goal (skipped when --goal was
|
|
82
|
+
// ── 2. goal alignment (skipped when --goal or implicit mode was resolved) ──
|
|
61
83
|
if (flagGoal) {
|
|
62
84
|
return { scenario, goal: flagGoal, goalAlignment: "on", prompted: true };
|
|
63
85
|
}
|
|
86
|
+
if (implicit) {
|
|
87
|
+
return { scenario, goalAlignment: "implicit", prompted: true };
|
|
88
|
+
}
|
|
64
89
|
say();
|
|
65
|
-
say("Goal alignment —
|
|
66
|
-
say("
|
|
67
|
-
say("
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
say("Goal alignment — how Mnemom keeps this session on track. Three choices:");
|
|
91
|
+
say(" Infer: nothing to type — the gateway reads your own messages, revises the goal as you");
|
|
92
|
+
say(" steer, and the judge nudges the agent back when it drifts (a little µ per message + turn).");
|
|
93
|
+
say(" Type a goal: one line, sealed as a contract; the judge nudges against it (a little µ per turn).");
|
|
94
|
+
say(" Off: the session runs governed with goal alignment off (the default — just press Enter).");
|
|
95
|
+
say(" (Make one the default: mnemom agent config set goal_mode implicit|off.)");
|
|
96
|
+
const choice = await ask.askSelect(GOAL_CHOICE_QUESTION, [...GOAL_CHOICES]);
|
|
97
|
+
if (choice === GOAL_CHOICE_IMPLICIT) {
|
|
98
|
+
return { scenario, goalAlignment: "implicit", prompted: true };
|
|
99
|
+
}
|
|
100
|
+
if (choice === GOAL_CHOICE_TYPED) {
|
|
101
|
+
const goal = (await ask.askInput("Goal (one line):")).trim();
|
|
102
|
+
if (!goal) {
|
|
103
|
+
say(" No goal entered — continuing with goal alignment off.");
|
|
104
|
+
return { scenario, goalAlignment: "off", prompted: true };
|
|
105
|
+
}
|
|
106
|
+
return { scenario, goal, goalAlignment: "on", prompted: true };
|
|
71
107
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
108
|
+
// Enter (no pick) and any invalid pick both resolve to null → off. Off is the
|
|
109
|
+
// deliberate default for now: a µ-costing mode is never picked by a bare Enter.
|
|
110
|
+
if (choice !== GOAL_CHOICE_OFF) {
|
|
111
|
+
say(" Goal alignment off (press 1 or 2 next time to turn it on).");
|
|
76
112
|
}
|
|
77
|
-
return { scenario,
|
|
113
|
+
return { scenario, goalAlignment: "off", prompted: true };
|
|
78
114
|
}
|
package/dist/commands/agent.d.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* line, or the proxy's stdin under RC). It is never logged, printed, or passed as
|
|
26
26
|
* an argv value.
|
|
27
27
|
*/
|
|
28
|
+
import { type LaunchShape, type SealedContractEnvelope } from "../lib/agent.js";
|
|
28
29
|
export interface AgentOptions {
|
|
29
30
|
goal?: string;
|
|
30
31
|
requirement?: string[];
|
|
@@ -34,6 +35,12 @@ export interface AgentOptions {
|
|
|
34
35
|
budget?: string;
|
|
35
36
|
maxTurns?: string;
|
|
36
37
|
stall?: string;
|
|
38
|
+
/**
|
|
39
|
+
* `--implicit`: goal alignment with no sealed contract — the gateway infers the
|
|
40
|
+
* goal from the user's messages and revises it as they steer; the ceilings above
|
|
41
|
+
* ride on the `x-mnemom-goal-mode` header. Exclusive with the contract flags.
|
|
42
|
+
*/
|
|
43
|
+
implicit?: boolean;
|
|
37
44
|
/** x-mnemom-agent value (defaults to "code-agent"). */
|
|
38
45
|
agent?: string;
|
|
39
46
|
conversationId?: string;
|
|
@@ -131,3 +138,32 @@ export declare function buildCliArgs(options: AgentOptions, ctx: {
|
|
|
131
138
|
yolo: boolean;
|
|
132
139
|
allowCliFlags: boolean;
|
|
133
140
|
}): string[];
|
|
141
|
+
/**
|
|
142
|
+
* Which goal-alignment mode the launch status line names: a sealed contract →
|
|
143
|
+
* "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
|
|
144
|
+
* A sealed contract and a mode header never coexist (`assembleContract` refuses
|
|
145
|
+
* the combination), so the first arm winning is not a precedence choice.
|
|
146
|
+
*/
|
|
147
|
+
export declare function goalAlignmentMode(envelope: SealedContractEnvelope | null, goalMode: string | null): "on" | "implicit" | "off";
|
|
148
|
+
export declare function logLaunch(ctx: {
|
|
149
|
+
shape: LaunchShape;
|
|
150
|
+
door: string;
|
|
151
|
+
agent: string;
|
|
152
|
+
conversationId: string;
|
|
153
|
+
cellNote: string;
|
|
154
|
+
envelope: SealedContractEnvelope | null;
|
|
155
|
+
goalMode: string | null;
|
|
156
|
+
cli: ResolvedCli;
|
|
157
|
+
}): void;
|
|
158
|
+
export declare function printPlan(ctx: {
|
|
159
|
+
scenario: string;
|
|
160
|
+
shape: LaunchShape;
|
|
161
|
+
agent: string;
|
|
162
|
+
conversationId: string;
|
|
163
|
+
door: string;
|
|
164
|
+
cellNote: string;
|
|
165
|
+
envelope: SealedContractEnvelope | null;
|
|
166
|
+
goalMode: string | null;
|
|
167
|
+
yolo: boolean;
|
|
168
|
+
cli: ResolvedCli;
|
|
169
|
+
}): void;
|
package/dist/commands/agent.js
CHANGED
|
@@ -33,18 +33,28 @@ import { fileURLToPath } from "node:url";
|
|
|
33
33
|
import { MNEMOM_DIR } from "../lib/config.js";
|
|
34
34
|
import { configKeySpec, loadLauncherConfig, writeLauncherConfig } from "../lib/launcher-config.js";
|
|
35
35
|
import { fmt } from "../lib/format.js";
|
|
36
|
-
import { askInput, askYesNo, isInteractive } from "../lib/prompt.js";
|
|
36
|
+
import { askInput, askSelect, askYesNo, isInteractive } from "../lib/prompt.js";
|
|
37
37
|
import { goalAlignmentLine, resolveSessionSetup } from "./agent-session.js";
|
|
38
38
|
import { defaultAgentSlug, ensureGovernedAgent, ensureSessionAndBalance, isQuietPreambleExit, resolveDefaultSlug, } from "./agent-setup.js";
|
|
39
|
-
import { assembleContract, buildCustomHeaders, isProdGateway, launchPreferenceExplicit, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, sanitizeAgentName, } from "../lib/agent.js";
|
|
39
|
+
import { assembleContract, buildGoalModeHeader, buildCustomHeaders, isProdGateway, launchPreferenceExplicit, resolveAnthropicDoor, resolveConversationId, resolveGatewayHost, resolveLaunchShape, sanitizeAgentName, } from "../lib/agent.js";
|
|
40
40
|
/** The CLI's own provider-key store (zero 1Password): ~/.mnemom/launcher.json, 0600. */
|
|
41
41
|
const CODE_STORE = join(MNEMOM_DIR, "launcher.json");
|
|
42
42
|
const say = (line = "") => console.error(line);
|
|
43
43
|
/** Entry point for `mnemom agent [scenario]`. Throws on fatal misconfig. */
|
|
44
44
|
export async function agentCommand(scenarioArg, options = {}) {
|
|
45
45
|
// ── session name + goal ── (a bare `mnemom agent` in a terminal asks for both,
|
|
46
|
-
// explaining goal alignment
|
|
47
|
-
|
|
46
|
+
// explaining goal alignment: infer it from your messages (implicit), type a
|
|
47
|
+
// one-line goal (sealed), or off — no contract header and no mode header)
|
|
48
|
+
const session = await resolveSessionSetup({
|
|
49
|
+
scenario: scenarioArg,
|
|
50
|
+
goal: options.goal,
|
|
51
|
+
implicit: options.implicit,
|
|
52
|
+
interactive: isInteractive(),
|
|
53
|
+
}, {
|
|
54
|
+
askInput: (q) => askInput(q),
|
|
55
|
+
askYesNo: (q, d) => askYesNo(q, d),
|
|
56
|
+
askSelect: (q, o) => askSelect(q, o),
|
|
57
|
+
});
|
|
48
58
|
const scenario = session.scenario;
|
|
49
59
|
const env = process.env;
|
|
50
60
|
let shape = resolveLaunchShape({ remoteControl: options.remoteControl, server: options.server }, env);
|
|
@@ -68,6 +78,15 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
68
78
|
budget: options.budget,
|
|
69
79
|
maxTurns: options.maxTurns,
|
|
70
80
|
stall: options.stall,
|
|
81
|
+
implicit: options.implicit,
|
|
82
|
+
});
|
|
83
|
+
// ── implicit goal mode (no contract; the gateway infers the goal from the user's
|
|
84
|
+
// messages) — the guardrail ceilings ride on the mode header instead ──
|
|
85
|
+
const goalMode = buildGoalModeHeader({
|
|
86
|
+
implicit: options.implicit,
|
|
87
|
+
budget: options.budget,
|
|
88
|
+
maxTurns: options.maxTurns,
|
|
89
|
+
stall: options.stall,
|
|
71
90
|
});
|
|
72
91
|
// ── door ──
|
|
73
92
|
const gatewayHost = resolveGatewayHost(env);
|
|
@@ -117,6 +136,7 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
117
136
|
door,
|
|
118
137
|
cellNote,
|
|
119
138
|
envelope,
|
|
139
|
+
goalMode,
|
|
120
140
|
yolo,
|
|
121
141
|
cli: planCli,
|
|
122
142
|
});
|
|
@@ -175,8 +195,8 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
175
195
|
say(fmt.warn(`mnemom agent: agent setup did not complete (${msg}) — launching with the unclaimed identity.`));
|
|
176
196
|
}
|
|
177
197
|
}
|
|
178
|
-
logLaunch({ shape, door, agent, conversationId, cellNote, envelope, cli });
|
|
179
|
-
say(goalAlignmentLine(envelope
|
|
198
|
+
logLaunch({ shape, door, agent, conversationId, cellNote, envelope, goalMode, cli });
|
|
199
|
+
say(goalAlignmentLine(goalAlignmentMode(envelope, goalMode)));
|
|
180
200
|
if (shape === "terminal") {
|
|
181
201
|
launchTerminal({
|
|
182
202
|
cli,
|
|
@@ -185,6 +205,7 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
185
205
|
agent,
|
|
186
206
|
conversationId,
|
|
187
207
|
envelope,
|
|
208
|
+
goalMode,
|
|
188
209
|
options,
|
|
189
210
|
yolo,
|
|
190
211
|
contextHint,
|
|
@@ -200,6 +221,7 @@ export async function agentCommand(scenarioArg, options = {}) {
|
|
|
200
221
|
conversationId,
|
|
201
222
|
scenario,
|
|
202
223
|
envelope,
|
|
224
|
+
goalMode,
|
|
203
225
|
options,
|
|
204
226
|
yolo,
|
|
205
227
|
});
|
|
@@ -220,7 +242,7 @@ export async function resolveAnthropicKey(env, opts) {
|
|
|
220
242
|
const source = opts.keySource;
|
|
221
243
|
// "prompt" forces the interactive prompt (skips env + store).
|
|
222
244
|
if (source !== "prompt") {
|
|
223
|
-
const fromEnv = (env.MNEMOM_AGENT_ANTHROPIC_KEY || env.ANTHROPIC_API_KEY || "")
|
|
245
|
+
const fromEnv = stripCrlf(env.MNEMOM_AGENT_ANTHROPIC_KEY || env.ANTHROPIC_API_KEY || "");
|
|
224
246
|
if (fromEnv)
|
|
225
247
|
return fromEnv;
|
|
226
248
|
// "env" restricts to env only — no store, no prompt.
|
|
@@ -231,11 +253,17 @@ export async function resolveAnthropicKey(env, opts) {
|
|
|
231
253
|
const stored = readStoredKey();
|
|
232
254
|
if (stored)
|
|
233
255
|
return stored;
|
|
256
|
+
// "store" restricts to env → store — never the interactive prompt.
|
|
257
|
+
if (source === "store") {
|
|
258
|
+
throw new Error("mnemom agent: key_source is 'store' but no Anthropic key is set in env or ~/.mnemom/launcher.json.\n" +
|
|
259
|
+
" Set one: export MNEMOM_AGENT_ANTHROPIC_KEY=sk-ant-... (or ANTHROPIC_API_KEY)\n" +
|
|
260
|
+
" Or store it: `mnemom agent config set key_source prompt`, run `mnemom agent` once and accept the save.");
|
|
261
|
+
}
|
|
234
262
|
}
|
|
235
|
-
// (source can only be "
|
|
263
|
+
// (source can only be "prompt" or undefined here — "env" and "store" threw above.)
|
|
236
264
|
if (opts.allowPrompt && isInteractive()) {
|
|
237
265
|
say("mnemom agent: no Anthropic key in env (MNEMOM_AGENT_ANTHROPIC_KEY / ANTHROPIC_API_KEY) or ~/.mnemom/launcher.json.");
|
|
238
|
-
const entered = (await askInput("Enter your Anthropic API key (sk-ant-…):", true))
|
|
266
|
+
const entered = stripCrlf(await askInput("Enter your Anthropic API key (sk-ant-…):", true));
|
|
239
267
|
if (entered) {
|
|
240
268
|
if (await askYesNo("Save it to ~/.mnemom/launcher.json (0600) for next time?", true)) {
|
|
241
269
|
storeKey(entered);
|
|
@@ -265,7 +293,7 @@ export function anthropicKeySource(env = process.env) {
|
|
|
265
293
|
function readStoredKey() {
|
|
266
294
|
try {
|
|
267
295
|
const data = JSON.parse(readFileSync(CODE_STORE, "utf8"));
|
|
268
|
-
const key = typeof data.anthropic_key === "string" ? data.anthropic_key
|
|
296
|
+
const key = typeof data.anthropic_key === "string" ? stripCrlf(data.anthropic_key) : "";
|
|
269
297
|
return key || undefined;
|
|
270
298
|
}
|
|
271
299
|
catch {
|
|
@@ -442,6 +470,7 @@ function launchTerminal(ctx) {
|
|
|
442
470
|
agent: ctx.agent,
|
|
443
471
|
conversationId: ctx.conversationId,
|
|
444
472
|
contractB64: ctx.envelope?.base64,
|
|
473
|
+
goalMode: ctx.goalMode ?? undefined,
|
|
445
474
|
});
|
|
446
475
|
const args = buildCliArgs(ctx.options, { yolo: ctx.yolo, allowCliFlags: true });
|
|
447
476
|
// Claude Code logged in via claude.ai (OAuth) PREEMPTS ANTHROPIC_API_KEY, so
|
|
@@ -489,6 +518,7 @@ function launchRemoteControl(ctx) {
|
|
|
489
518
|
MNEMOM_AGENT_RC_AGENT: ctx.agent,
|
|
490
519
|
MNEMOM_AGENT_RC_CONVERSATION_ID: ctx.conversationId,
|
|
491
520
|
MNEMOM_AGENT_RC_CONTRACT_B64: ctx.envelope?.base64 ?? "",
|
|
521
|
+
MNEMOM_AGENT_RC_GOAL_MODE: ctx.goalMode ?? "",
|
|
492
522
|
MNEMOM_AGENT_RC_PROXY_LOG: proxyLog,
|
|
493
523
|
MNEMOM_AGENT_RC_PARENT_PID: String(process.pid),
|
|
494
524
|
},
|
|
@@ -720,14 +750,30 @@ function seedContextHintFlag(childEnv) {
|
|
|
720
750
|
// ───────────────────────────────────────────────────────────────────────────
|
|
721
751
|
// Human-facing logging (never prints the key)
|
|
722
752
|
// ───────────────────────────────────────────────────────────────────────────
|
|
723
|
-
|
|
753
|
+
/**
|
|
754
|
+
* Which goal-alignment mode the launch status line names: a sealed contract →
|
|
755
|
+
* "on"; `--implicit` (mode header, no contract) → "implicit"; neither → "off".
|
|
756
|
+
* A sealed contract and a mode header never coexist (`assembleContract` refuses
|
|
757
|
+
* the combination), so the first arm winning is not a precedence choice.
|
|
758
|
+
*/
|
|
759
|
+
export function goalAlignmentMode(envelope, goalMode) {
|
|
760
|
+
if (envelope)
|
|
761
|
+
return "on";
|
|
762
|
+
if (goalMode)
|
|
763
|
+
return "implicit";
|
|
764
|
+
return "off";
|
|
765
|
+
}
|
|
766
|
+
export function logLaunch(ctx) {
|
|
724
767
|
say(`mnemom agent: routing ${ctx.cli.name} through ${ctx.door} [${ctx.cellNote}] as '${ctx.agent}' (conversation ${ctx.conversationId}).`);
|
|
725
768
|
if (ctx.envelope) {
|
|
726
769
|
say(`mnemom agent: contract statement: ${ctx.envelope.contract.statement}`);
|
|
727
770
|
say(`mnemom agent: contract sha256[0:16]: ${ctx.envelope.sha}`);
|
|
728
771
|
}
|
|
772
|
+
else if (ctx.goalMode) {
|
|
773
|
+
say(`mnemom agent: goal mode: ${ctx.goalMode} (inferred from your messages; watch x-mnemom-goal-revision / x-mnemom-goal-steer on each response)`);
|
|
774
|
+
}
|
|
729
775
|
}
|
|
730
|
-
function printPlan(ctx) {
|
|
776
|
+
export function printPlan(ctx) {
|
|
731
777
|
const L = [];
|
|
732
778
|
L.push("mnemom agent — launch plan (dry run)");
|
|
733
779
|
L.push(` scenario: ${ctx.scenario}`);
|
|
@@ -741,6 +787,10 @@ function printPlan(ctx) {
|
|
|
741
787
|
L.push(` contract: ${ctx.envelope.canonical}`);
|
|
742
788
|
L.push(` contract sha: ${ctx.envelope.sha}`);
|
|
743
789
|
}
|
|
790
|
+
else if (ctx.goalMode) {
|
|
791
|
+
L.push(" contract: (none — implicit goal mode: the gateway infers the goal from your messages)");
|
|
792
|
+
L.push(` goal mode: ${ctx.goalMode}`);
|
|
793
|
+
}
|
|
744
794
|
else {
|
|
745
795
|
L.push(" contract: (none — no contract flag given)");
|
|
746
796
|
}
|
|
@@ -748,3 +798,11 @@ function printPlan(ctx) {
|
|
|
748
798
|
L.push(" Dry run — no key was read and nothing was launched.");
|
|
749
799
|
process.stdout.write(`${L.join("\n")}\n`);
|
|
750
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* Header-value hygiene for the Anthropic key: the key is spliced into the newline-joined
|
|
803
|
+
* ANTHROPIC_CUSTOM_HEADERS block (`x-api-key: <key>`), so an embedded CR/LF from a multi-line
|
|
804
|
+
* paste would inject extra header lines. Same rule resolveConversationId applies to its input.
|
|
805
|
+
*/
|
|
806
|
+
function stripCrlf(raw) {
|
|
807
|
+
return raw.replace(/[\r\n]+/g, "").trim();
|
|
808
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { agentDoctorCommand } from "./commands/agent-doctor.js";
|
|
|
26
26
|
import { agentSetupCommand } from "./commands/agent-setup.js";
|
|
27
27
|
import { agentInviteCommand, runAgentInvocation } from "./commands/agent-invite.js";
|
|
28
28
|
import { applyConfigToEnv, loadLauncherConfig } from "./lib/launcher-config.js";
|
|
29
|
+
import { resolveImplicit } from "./lib/agent.js";
|
|
29
30
|
import { wrapCommand } from "./commands/wrap.js";
|
|
30
31
|
import { onboardCommand } from "./commands/onboard.js";
|
|
31
32
|
import { skillsListCommand, skillsDescribeCommand } from "./commands/skills.js";
|
|
@@ -111,6 +112,8 @@ const agentCmd = program
|
|
|
111
112
|
.option("--budget <usd>", "Guardrail: spend ceiling in USD (positive number)")
|
|
112
113
|
.option("--max-turns <n>", "Guardrail: turn ceiling (positive integer)")
|
|
113
114
|
.option("--stall <n>", "Guardrail: consecutive no-write turns (positive integer)")
|
|
115
|
+
.option("--implicit", "Goal alignment without a sealed contract: the gateway infers the goal from your messages and revises it as you steer (not combinable with --goal/--requirement/--allow/--forbid/--goal-id; the guardrail ceilings still apply)")
|
|
116
|
+
.option("--no-implicit", "Goal alignment off for this launch even when launcher.toml sets goal_mode = implicit")
|
|
114
117
|
.option("--conversation-id <id>", "x-mnemom-conversation-id (default: auto-generated)")
|
|
115
118
|
.option("--cli <name|path>", "Coding-agent CLI to launch: a name or full path (default: claude)")
|
|
116
119
|
.option("--gateway <url>", "Gateway host override (default: https://gateway.mnemom.ai; or MNEMOM_AGENT_GATEWAY)")
|
|
@@ -174,10 +177,19 @@ const agentCmd = program
|
|
|
174
177
|
return;
|
|
175
178
|
}
|
|
176
179
|
const numToStr = (n) => n === undefined ? undefined : String(n);
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
const
|
|
180
|
+
// Implicit mode: --implicit / --no-implicit win; a --goal statement seals
|
|
181
|
+
// (the standing config default never overrides it); else launcher.toml
|
|
182
|
+
// `goal_mode`; else off. Pure + unit-tested in lib/agent.ts.
|
|
183
|
+
const implicit = resolveImplicit({
|
|
184
|
+
goal: opts.goal,
|
|
185
|
+
implicit: opts.implicit,
|
|
186
|
+
configGoalMode: cfg.goalMode,
|
|
187
|
+
});
|
|
188
|
+
// Config guardrails apply only when goal alignment is actually intended (a
|
|
189
|
+
// --goal is present, or implicit mode — where they ride on the mode header),
|
|
190
|
+
// so a saved ceiling never forces an unrequested contract (which would
|
|
191
|
+
// then demand --goal and fail the launch).
|
|
192
|
+
const contractIntended = Boolean(opts.goal && opts.goal.trim()) || implicit;
|
|
181
193
|
// The governed identity rides on the program-level `--agent` (which
|
|
182
194
|
// shadows a subcommand `--agent` under commander@12 — the MNE-238 class),
|
|
183
195
|
// exactly as status/logs/onboard read it.
|
|
@@ -188,6 +200,7 @@ const agentCmd = program
|
|
|
188
200
|
allow: opts.allow,
|
|
189
201
|
forbid: opts.forbid,
|
|
190
202
|
goalId: opts.goalId,
|
|
203
|
+
implicit,
|
|
191
204
|
budget: opts.budget ?? (contractIntended ? numToStr(cfg.guardrails?.budgetUsd) : undefined),
|
|
192
205
|
maxTurns: opts.maxTurns ?? (contractIntended ? numToStr(cfg.guardrails?.maxTurns) : undefined),
|
|
193
206
|
stall: opts.stall ?? (contractIntended ? numToStr(cfg.guardrails?.stallTurns) : undefined),
|
package/dist/lib/agent.d.ts
CHANGED
|
@@ -48,6 +48,14 @@ export interface ContractFlags {
|
|
|
48
48
|
maxTurns?: string | number;
|
|
49
49
|
budget?: string | number;
|
|
50
50
|
stall?: string | number;
|
|
51
|
+
/**
|
|
52
|
+
* `--implicit`: goal alignment WITHOUT a sealed contract. No `x-mnemom-contract`
|
|
53
|
+
* is sent; the gateway infers the goal from the user's own messages (revision 1
|
|
54
|
+
* after the first message) and revises it as the user steers. The guardrail
|
|
55
|
+
* ceilings still apply — they ride on the `x-mnemom-goal-mode` header instead
|
|
56
|
+
* of inside a contract. Cannot be combined with the sealed-contract flags.
|
|
57
|
+
*/
|
|
58
|
+
implicit?: boolean;
|
|
51
59
|
}
|
|
52
60
|
/** The three launch shapes the command supports (see commands/code.ts). */
|
|
53
61
|
export type LaunchShape = "terminal" | "remote-control" | "server";
|
|
@@ -116,6 +124,15 @@ export interface SealedContractEnvelope {
|
|
|
116
124
|
* here matches whatever the gateway seals for the same flags.
|
|
117
125
|
*/
|
|
118
126
|
export declare function assembleContract(flags: ContractFlags): SealedContractEnvelope | null;
|
|
127
|
+
/** The request header that opts a conversation into implicit goal mode. */
|
|
128
|
+
export declare const GOAL_MODE_HEADER = "x-mnemom-goal-mode";
|
|
129
|
+
/**
|
|
130
|
+
* The `x-mnemom-goal-mode` header value for `--implicit`, or null when the flag
|
|
131
|
+
* is absent: `implicit[; max_turns=N][; budget_usd=X][; stall_turns=N]` — the
|
|
132
|
+
* guardrail ceilings validated exactly as `assembleContract` validates them (a
|
|
133
|
+
* non-positive ceiling is refused, never silently dropped into an unguarded run).
|
|
134
|
+
*/
|
|
135
|
+
export declare function buildGoalModeHeader(flags: ContractFlags): string | null;
|
|
119
136
|
/**
|
|
120
137
|
* Resolve the launch shape from the flags and the `MNEMOM_AGENT_REMOTE_CONTROL`
|
|
121
138
|
* env var (the flags win). Default is `terminal` — a plain governed launch in
|
|
@@ -142,7 +159,8 @@ export declare function launchPreferenceExplicit(opts: {
|
|
|
142
159
|
/**
|
|
143
160
|
* Build the `ANTHROPIC_CUSTOM_HEADERS` value for the terminal-only launch: the
|
|
144
161
|
* real Anthropic key as `x-api-key`, plus the governed-identity headers and the
|
|
145
|
-
* sealed contract when present
|
|
162
|
+
* sealed contract when present (or the implicit goal-mode header when the launch
|
|
163
|
+
* is `--implicit` — never both). Newline-separated, exactly the wire shape Claude
|
|
146
164
|
* Code sends to the /anthropic door. NB: the returned string contains the key —
|
|
147
165
|
* callers must place it only in the child's env, never in a log line.
|
|
148
166
|
*/
|
|
@@ -151,4 +169,28 @@ export declare function buildCustomHeaders(opts: {
|
|
|
151
169
|
agent: string;
|
|
152
170
|
conversationId: string;
|
|
153
171
|
contractB64?: string;
|
|
172
|
+
/** `buildGoalModeHeader` output for an `--implicit` launch. */
|
|
173
|
+
goalMode?: string;
|
|
154
174
|
}): string;
|
|
175
|
+
/**
|
|
176
|
+
* The persistable goal-alignment default (`launcher.toml` `goal_mode`). Only the
|
|
177
|
+
* two statement-free modes can be a standing default: a SEALED contract needs a
|
|
178
|
+
* per-session statement, so it is always a per-launch `--goal`.
|
|
179
|
+
*/
|
|
180
|
+
export type GoalModeDefault = "implicit" | "off";
|
|
181
|
+
/**
|
|
182
|
+
* Is this launch implicit-mode? Precedence, highest wins:
|
|
183
|
+
* 1. an explicit flag — `--implicit` (true) or `--no-implicit` (false);
|
|
184
|
+
* 2. a `--goal` statement — a sealed contract is a per-launch choice that the
|
|
185
|
+
* standing config default must never silently override;
|
|
186
|
+
* 3. the config default `goal_mode = "implicit"`;
|
|
187
|
+
* 4. off.
|
|
188
|
+
* Pure so every arm is unit-tested; the caller passes the resolved value on to
|
|
189
|
+
* `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
|
|
190
|
+
* combined with a contract flag — that conflict is EXPLICIT and is reported).
|
|
191
|
+
*/
|
|
192
|
+
export declare function resolveImplicit(opts: {
|
|
193
|
+
goal?: string;
|
|
194
|
+
implicit?: boolean;
|
|
195
|
+
configGoalMode?: GoalModeDefault;
|
|
196
|
+
}): boolean;
|
package/dist/lib/agent.js
CHANGED
|
@@ -132,6 +132,12 @@ function hasText(v) {
|
|
|
132
132
|
* here matches whatever the gateway seals for the same flags.
|
|
133
133
|
*/
|
|
134
134
|
export function assembleContract(flags) {
|
|
135
|
+
// Implicit goal mode seals nothing: the ceilings ride on the mode header
|
|
136
|
+
// (`buildGoalModeHeader`), so a `--budget` alone must not demand `--goal` here.
|
|
137
|
+
if (flags.implicit) {
|
|
138
|
+
assertImplicitCompatible(flags);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
135
141
|
if (!contractFlagsPresent(flags))
|
|
136
142
|
return null;
|
|
137
143
|
const statement = (flags.goal ?? "").trim();
|
|
@@ -178,6 +184,58 @@ export function assembleContract(flags) {
|
|
|
178
184
|
sha: sha256Hex16(canonical),
|
|
179
185
|
};
|
|
180
186
|
}
|
|
187
|
+
/** The request header that opts a conversation into implicit goal mode. */
|
|
188
|
+
export const GOAL_MODE_HEADER = "x-mnemom-goal-mode";
|
|
189
|
+
const SEALED_ONLY_FLAGS = "--goal/--requirement/--allow/--forbid/--goal-id";
|
|
190
|
+
/**
|
|
191
|
+
* `--implicit` and a sealed contract are mutually exclusive: on the gateway a
|
|
192
|
+
* sealed `x-mnemom-contract` always wins and would silently switch implicit mode
|
|
193
|
+
* off, so the launch refuses the combination rather than ship a header that does
|
|
194
|
+
* nothing.
|
|
195
|
+
*/
|
|
196
|
+
function assertImplicitCompatible(flags) {
|
|
197
|
+
const sealed = hasText(flags.goal) ||
|
|
198
|
+
(flags.requirement?.length ?? 0) > 0 ||
|
|
199
|
+
(flags.allow?.length ?? 0) > 0 ||
|
|
200
|
+
(flags.forbid?.length ?? 0) > 0 ||
|
|
201
|
+
hasText(flags.goalId);
|
|
202
|
+
if (sealed) {
|
|
203
|
+
throw new Error(`--implicit cannot be combined with ${SEALED_ONLY_FLAGS}: a sealed contract always wins on ` +
|
|
204
|
+
"the gateway and would silently switch implicit mode off. Drop --implicit to seal a " +
|
|
205
|
+
"contract, or drop the contract flags to let the gateway infer the goal from your messages.");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The `x-mnemom-goal-mode` header value for `--implicit`, or null when the flag
|
|
210
|
+
* is absent: `implicit[; max_turns=N][; budget_usd=X][; stall_turns=N]` — the
|
|
211
|
+
* guardrail ceilings validated exactly as `assembleContract` validates them (a
|
|
212
|
+
* non-positive ceiling is refused, never silently dropped into an unguarded run).
|
|
213
|
+
*/
|
|
214
|
+
export function buildGoalModeHeader(flags) {
|
|
215
|
+
if (!flags.implicit)
|
|
216
|
+
return null;
|
|
217
|
+
assertImplicitCompatible(flags);
|
|
218
|
+
const parts = ["implicit"];
|
|
219
|
+
if (flags.maxTurns !== undefined) {
|
|
220
|
+
if (!isPositiveInt(flags.maxTurns)) {
|
|
221
|
+
throw new Error(`--max-turns must be a positive integer (got '${flags.maxTurns}').`);
|
|
222
|
+
}
|
|
223
|
+
parts.push(`max_turns=${Number(flags.maxTurns)}`);
|
|
224
|
+
}
|
|
225
|
+
if (flags.budget !== undefined) {
|
|
226
|
+
if (!isPositiveNumber(flags.budget)) {
|
|
227
|
+
throw new Error(`--budget must be a positive USD amount (got '${flags.budget}').`);
|
|
228
|
+
}
|
|
229
|
+
parts.push(`budget_usd=${Number(flags.budget)}`);
|
|
230
|
+
}
|
|
231
|
+
if (flags.stall !== undefined) {
|
|
232
|
+
if (!isPositiveInt(flags.stall)) {
|
|
233
|
+
throw new Error(`--stall must be a positive integer (got '${flags.stall}').`);
|
|
234
|
+
}
|
|
235
|
+
parts.push(`stall_turns=${Number(flags.stall)}`);
|
|
236
|
+
}
|
|
237
|
+
return parts.join("; ");
|
|
238
|
+
}
|
|
181
239
|
/** Trim, drop empties — matches the gateway's `stringArray` filtering. */
|
|
182
240
|
function cleanList(values) {
|
|
183
241
|
return (values ?? []).map((v) => v.trim()).filter((v) => v.length > 0);
|
|
@@ -236,7 +294,8 @@ export function launchPreferenceExplicit(opts, env = process.env) {
|
|
|
236
294
|
/**
|
|
237
295
|
* Build the `ANTHROPIC_CUSTOM_HEADERS` value for the terminal-only launch: the
|
|
238
296
|
* real Anthropic key as `x-api-key`, plus the governed-identity headers and the
|
|
239
|
-
* sealed contract when present
|
|
297
|
+
* sealed contract when present (or the implicit goal-mode header when the launch
|
|
298
|
+
* is `--implicit` — never both). Newline-separated, exactly the wire shape Claude
|
|
240
299
|
* Code sends to the /anthropic door. NB: the returned string contains the key —
|
|
241
300
|
* callers must place it only in the child's env, never in a log line.
|
|
242
301
|
*/
|
|
@@ -248,5 +307,25 @@ export function buildCustomHeaders(opts) {
|
|
|
248
307
|
];
|
|
249
308
|
if (opts.contractB64)
|
|
250
309
|
lines.push(`x-mnemom-contract: ${opts.contractB64}`);
|
|
310
|
+
if (opts.goalMode)
|
|
311
|
+
lines.push(`${GOAL_MODE_HEADER}: ${opts.goalMode}`);
|
|
251
312
|
return lines.join("\n");
|
|
252
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Is this launch implicit-mode? Precedence, highest wins:
|
|
316
|
+
* 1. an explicit flag — `--implicit` (true) or `--no-implicit` (false);
|
|
317
|
+
* 2. a `--goal` statement — a sealed contract is a per-launch choice that the
|
|
318
|
+
* standing config default must never silently override;
|
|
319
|
+
* 3. the config default `goal_mode = "implicit"`;
|
|
320
|
+
* 4. off.
|
|
321
|
+
* Pure so every arm is unit-tested; the caller passes the resolved value on to
|
|
322
|
+
* `assembleContract` / `buildGoalModeHeader` (which still refuse `--implicit`
|
|
323
|
+
* combined with a contract flag — that conflict is EXPLICIT and is reported).
|
|
324
|
+
*/
|
|
325
|
+
export function resolveImplicit(opts) {
|
|
326
|
+
if (opts.implicit !== undefined)
|
|
327
|
+
return opts.implicit;
|
|
328
|
+
if (hasText(opts.goal))
|
|
329
|
+
return false;
|
|
330
|
+
return opts.configGoalMode === "implicit";
|
|
331
|
+
}
|
package/dist/lib/auth.js
CHANGED
|
@@ -117,7 +117,14 @@ export async function getAccessToken() {
|
|
|
117
117
|
if (auth.expiresAt > now + 60) {
|
|
118
118
|
return auth.accessToken;
|
|
119
119
|
}
|
|
120
|
-
// Auto-refresh
|
|
120
|
+
// Auto-refresh. A *thrown* error from refreshStoredTokens() (transient
|
|
121
|
+
// exhaustion / non-revoke 4xx from oauthRefreshTokens(), MNE-7394) is
|
|
122
|
+
// intentionally allowed to propagate here rather than being coerced to the
|
|
123
|
+
// `null` re-login signal — a momentary AS outage must not masquerade as
|
|
124
|
+
// "credentials revoked". Only a genuine invalid_grant/invalid_client returns
|
|
125
|
+
// null (see refreshStoredTokens). Refining caller-side handling (distinguish
|
|
126
|
+
// propagate-vs-degrade) is tracked under MNE-7394's parent lane card
|
|
127
|
+
// (keith/oauth-token-lifetime).
|
|
121
128
|
const refreshed = await refreshStoredTokens(auth);
|
|
122
129
|
if (refreshed)
|
|
123
130
|
return refreshed.accessToken;
|
|
@@ -139,6 +146,9 @@ export async function forceRefreshAccessToken() {
|
|
|
139
146
|
const auth = getAuthInfo();
|
|
140
147
|
if (!auth?.refreshToken)
|
|
141
148
|
return null;
|
|
149
|
+
// As in getAccessToken, a thrown transient-exhaustion / non-revoke error from
|
|
150
|
+
// refreshStoredTokens() propagates rather than being coerced to null (MNE-7394;
|
|
151
|
+
// caller-side refinement tracked under keith/oauth-token-lifetime).
|
|
142
152
|
const refreshed = await refreshStoredTokens(auth);
|
|
143
153
|
return refreshed?.accessToken ?? null;
|
|
144
154
|
}
|
|
@@ -226,6 +236,16 @@ export async function loginWithDeviceFlow() {
|
|
|
226
236
|
* Refresh the stored tokens via the OAuth refresh_token grant. Requires both a
|
|
227
237
|
* refresh token and the client_id they were issued to; returns null (rather
|
|
228
238
|
* than throwing) when refresh isn't possible so callers degrade to re-login.
|
|
239
|
+
*
|
|
240
|
+
* Note: oauthRefreshTokens() returns null ONLY on a definitive revoke
|
|
241
|
+
* (invalid_grant/invalid_client) or missing credentials; on transient
|
|
242
|
+
* exhaustion (repeated 5xx / network faults) or any other non-revoke 4xx it
|
|
243
|
+
* *throws* (MNE-7394 / R6). That thrown error is intentionally allowed to
|
|
244
|
+
* propagate through this function to its callers rather than being coerced to
|
|
245
|
+
* null, so a momentary blip never presents as "credentials revoked". Refining
|
|
246
|
+
* the caller-side handling (propagate-vs-degrade in
|
|
247
|
+
* getAccessToken/forceRefreshAccessToken) is tracked under MNE-7394's parent
|
|
248
|
+
* lane card (keith/oauth-token-lifetime).
|
|
229
249
|
*/
|
|
230
250
|
async function refreshStoredTokens(auth) {
|
|
231
251
|
if (!auth.refreshToken || !auth.clientId)
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* resolvers in lib/code.ts keep the built-in default as the final fallback);
|
|
16
16
|
* the caller folds the plain-value settings under the CLI flags.
|
|
17
17
|
*/
|
|
18
|
-
import { type LaunchShape } from "./agent.js";
|
|
18
|
+
import { type GoalModeDefault, type LaunchShape } from "./agent.js";
|
|
19
19
|
/** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
|
|
20
20
|
export declare const LAUNCHER_CONFIG_PATH: string;
|
|
21
21
|
/** Where the CLI resolves the Anthropic key from; advisory ordering/restriction. */
|
|
@@ -29,6 +29,8 @@ export interface LauncherConfig {
|
|
|
29
29
|
cli?: string;
|
|
30
30
|
launch?: LaunchShape;
|
|
31
31
|
keySource?: KeySource;
|
|
32
|
+
/** Standing goal-alignment default when neither --goal nor --implicit is given. */
|
|
33
|
+
goalMode?: GoalModeDefault;
|
|
32
34
|
guardrails?: {
|
|
33
35
|
maxTurns?: number;
|
|
34
36
|
budgetUsd?: number;
|
|
@@ -19,12 +19,13 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
20
|
import { parse as parseToml, stringify as stringifyToml, TomlError } from "smol-toml";
|
|
21
21
|
import { MNEMOM_DIR } from "./config.js";
|
|
22
|
-
import { isPositiveInt, isPositiveNumber } from "./agent.js";
|
|
22
|
+
import { isPositiveInt, isPositiveNumber, } from "./agent.js";
|
|
23
23
|
/** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
|
|
24
24
|
export const LAUNCHER_CONFIG_PATH = join(MNEMOM_DIR, "launcher.toml");
|
|
25
25
|
const LAUNCH_SHAPES = ["terminal", "remote-control", "server"];
|
|
26
26
|
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
27
27
|
const KEY_SOURCES = ["env", "store", "prompt"];
|
|
28
|
+
const GOAL_MODES = ["implicit", "off"];
|
|
28
29
|
function coerceLaunch(raw) {
|
|
29
30
|
const v = raw.trim();
|
|
30
31
|
if (LAUNCH_SHAPES.includes(v))
|
|
@@ -43,6 +44,12 @@ function coerceKeySource(raw) {
|
|
|
43
44
|
return v;
|
|
44
45
|
throw new Error(`key_source must be one of ${KEY_SOURCES.join(", ")} (got '${raw}').`);
|
|
45
46
|
}
|
|
47
|
+
function coerceGoalMode(raw) {
|
|
48
|
+
const v = raw.trim().toLowerCase();
|
|
49
|
+
if (GOAL_MODES.includes(v))
|
|
50
|
+
return v;
|
|
51
|
+
throw new Error(`goal_mode must be one of ${GOAL_MODES.join(", ")} (got '${raw}'). A sealed contract needs a statement, so it is always a per-launch --goal.`);
|
|
52
|
+
}
|
|
46
53
|
function coerceNonEmpty(name, raw) {
|
|
47
54
|
const v = raw.trim();
|
|
48
55
|
if (!v)
|
|
@@ -112,6 +119,14 @@ export const CONFIG_KEYS = [
|
|
|
112
119
|
c.keySource = coerceKeySource(v);
|
|
113
120
|
},
|
|
114
121
|
},
|
|
122
|
+
{
|
|
123
|
+
key: "goal_mode",
|
|
124
|
+
describe: `Goal alignment when no --goal/--implicit is given (${GOAL_MODES.join("|")}); a sealed contract needs a per-launch --goal`,
|
|
125
|
+
get: (c) => c.goalMode,
|
|
126
|
+
set: (c, v) => {
|
|
127
|
+
c.goalMode = coerceGoalMode(v);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
115
130
|
{
|
|
116
131
|
key: "guardrails.max_turns",
|
|
117
132
|
describe: "Contract guardrail: turn ceiling (positive integer)",
|
|
@@ -176,6 +191,8 @@ export function parseLauncherConfig(text) {
|
|
|
176
191
|
cfg.launch = coerceLaunch(str(raw.launch) ?? "");
|
|
177
192
|
if (raw.key_source !== undefined)
|
|
178
193
|
cfg.keySource = coerceKeySource(str(raw.key_source) ?? "");
|
|
194
|
+
if (raw.goal_mode !== undefined)
|
|
195
|
+
cfg.goalMode = coerceGoalMode(str(raw.goal_mode) ?? "");
|
|
179
196
|
const g = raw.guardrails;
|
|
180
197
|
if (g !== undefined) {
|
|
181
198
|
if (typeof g !== "object" || g === null || Array.isArray(g)) {
|
|
@@ -232,6 +249,8 @@ export function serializeLauncherConfig(cfg) {
|
|
|
232
249
|
doc.launch = cfg.launch;
|
|
233
250
|
if (cfg.keySource !== undefined)
|
|
234
251
|
doc.key_source = cfg.keySource;
|
|
252
|
+
if (cfg.goalMode !== undefined)
|
|
253
|
+
doc.goal_mode = cfg.goalMode;
|
|
235
254
|
const g = cfg.guardrails;
|
|
236
255
|
if (g && (g.maxTurns !== undefined || g.budgetUsd !== undefined || g.stallTurns !== undefined)) {
|
|
237
256
|
const gr = {};
|
package/dist/lib/oauth.d.ts
CHANGED
|
@@ -105,11 +105,33 @@ export declare function loginWithDevice(opts?: {
|
|
|
105
105
|
sleep?: (ms: number) => Promise<void>;
|
|
106
106
|
}): Promise<DeviceResult>;
|
|
107
107
|
/**
|
|
108
|
-
* Exchange a refresh token for a fresh access token.
|
|
109
|
-
*
|
|
110
|
-
*
|
|
108
|
+
* Exchange a refresh token for a fresh access token.
|
|
109
|
+
*
|
|
110
|
+
* Failure classification (R6 / MNE-7394) — a transient failure must never be
|
|
111
|
+
* indistinguishable from a revoked credential:
|
|
112
|
+
* - 2xx → return the fresh tokens (carrying the prior refresh token forward
|
|
113
|
+
* per RFC 6749 §6 when the AS omits a new one).
|
|
114
|
+
* - A 4xx whose OAuth body `error` is `invalid_grant` / `invalid_client` →
|
|
115
|
+
* return `null`, the re-login signal. RFC 6749 §5.2 defines these as the
|
|
116
|
+
* errors that mean the presented grant/client credential is genuinely no
|
|
117
|
+
* longer valid, so a fresh login is the correct remedy. No retry.
|
|
118
|
+
* - Any other 4xx (e.g. `invalid_request`, `429`, an unrecognized/absent body
|
|
119
|
+
* error, a non-JSON body) → throw. Something is wrong, but it does NOT mean
|
|
120
|
+
* the credentials are revoked, so we must not discard the session.
|
|
121
|
+
* - `res.status >= 500` or a fetch/network exception → retry with bounded
|
|
122
|
+
* exponential backoff (~1s/2s/4s, REFRESH_MAX_ATTEMPTS attempts); if the
|
|
123
|
+
* retries are exhausted, throw. Exhausting transient retries must NOT
|
|
124
|
+
* masquerade as "credentials revoked" (fail-closed, MNE-442) — throwing
|
|
125
|
+
* preserves the stored session so a later invocation can succeed once the
|
|
126
|
+
* AS recovers.
|
|
127
|
+
*
|
|
128
|
+
* `null` is therefore narrowed to exactly the two re-login errors; every other
|
|
129
|
+
* outcome is a success, a retry, or a thrown error. `opts.sleep` is injectable
|
|
130
|
+
* so tests drive the backoff without real timers.
|
|
111
131
|
*/
|
|
112
|
-
export declare function refreshTokens(refreshToken: string, clientId: string
|
|
132
|
+
export declare function refreshTokens(refreshToken: string, clientId: string, opts?: {
|
|
133
|
+
sleep?: (ms: number) => Promise<void>;
|
|
134
|
+
}): Promise<OAuthTokens | null>;
|
|
113
135
|
/**
|
|
114
136
|
* Open `url` in the user's default browser WITHOUT a shell. The URL is always
|
|
115
137
|
* passed as a separate argv element (never concatenated into a command string),
|
package/dist/lib/oauth.js
CHANGED
|
@@ -39,6 +39,14 @@ const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
39
39
|
// both interactive login poll loops emit, so a multi-minute browser sign-in
|
|
40
40
|
// doesn't read as a hung CLI (matches the card-write retry tick in try-me).
|
|
41
41
|
const LOGIN_HEARTBEAT_MS = 15 * 1000;
|
|
42
|
+
// Refresh-token retry policy (R6 / MNE-7394). A transient AS 5xx or a dropped
|
|
43
|
+
// network connection during refresh must be retried with bounded exponential
|
|
44
|
+
// backoff rather than collapsed into the re-login signal — a one-second AS
|
|
45
|
+
// hiccup or a flaky Wi-Fi moment must not invalidate a valid, long-lived
|
|
46
|
+
// session. Per-attempt delay is REFRESH_BACKOFF_BASE_MS * 2**attemptIndex →
|
|
47
|
+
// ~1s / 2s / 4s across REFRESH_MAX_ATTEMPTS attempts.
|
|
48
|
+
const REFRESH_MAX_ATTEMPTS = 3;
|
|
49
|
+
const REFRESH_BACKOFF_BASE_MS = 1000;
|
|
42
50
|
let cachedMetadata = null;
|
|
43
51
|
/**
|
|
44
52
|
* Fetch (and process-cache) the AS metadata document. We resolve it relative to
|
|
@@ -359,40 +367,101 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
|
|
|
359
367
|
// Refresh (RFC 6749 §6)
|
|
360
368
|
// ============================================================================
|
|
361
369
|
/**
|
|
362
|
-
* Exchange a refresh token for a fresh access token.
|
|
363
|
-
*
|
|
364
|
-
*
|
|
370
|
+
* Exchange a refresh token for a fresh access token.
|
|
371
|
+
*
|
|
372
|
+
* Failure classification (R6 / MNE-7394) — a transient failure must never be
|
|
373
|
+
* indistinguishable from a revoked credential:
|
|
374
|
+
* - 2xx → return the fresh tokens (carrying the prior refresh token forward
|
|
375
|
+
* per RFC 6749 §6 when the AS omits a new one).
|
|
376
|
+
* - A 4xx whose OAuth body `error` is `invalid_grant` / `invalid_client` →
|
|
377
|
+
* return `null`, the re-login signal. RFC 6749 §5.2 defines these as the
|
|
378
|
+
* errors that mean the presented grant/client credential is genuinely no
|
|
379
|
+
* longer valid, so a fresh login is the correct remedy. No retry.
|
|
380
|
+
* - Any other 4xx (e.g. `invalid_request`, `429`, an unrecognized/absent body
|
|
381
|
+
* error, a non-JSON body) → throw. Something is wrong, but it does NOT mean
|
|
382
|
+
* the credentials are revoked, so we must not discard the session.
|
|
383
|
+
* - `res.status >= 500` or a fetch/network exception → retry with bounded
|
|
384
|
+
* exponential backoff (~1s/2s/4s, REFRESH_MAX_ATTEMPTS attempts); if the
|
|
385
|
+
* retries are exhausted, throw. Exhausting transient retries must NOT
|
|
386
|
+
* masquerade as "credentials revoked" (fail-closed, MNE-442) — throwing
|
|
387
|
+
* preserves the stored session so a later invocation can succeed once the
|
|
388
|
+
* AS recovers.
|
|
389
|
+
*
|
|
390
|
+
* `null` is therefore narrowed to exactly the two re-login errors; every other
|
|
391
|
+
* outcome is a success, a retry, or a thrown error. `opts.sleep` is injectable
|
|
392
|
+
* so tests drive the backoff without real timers.
|
|
365
393
|
*/
|
|
366
|
-
export async function refreshTokens(refreshToken, clientId) {
|
|
394
|
+
export async function refreshTokens(refreshToken, clientId, opts) {
|
|
367
395
|
if (!refreshToken || !clientId)
|
|
368
396
|
return null;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
397
|
+
const sleep = opts?.sleep ?? defaultSleep;
|
|
398
|
+
// Discovery is not part of the refresh retry budget; a discovery failure
|
|
399
|
+
// propagates as it does today (it is already thrown by discover()).
|
|
400
|
+
const meta = await discover();
|
|
401
|
+
let lastTransient = "";
|
|
402
|
+
for (let attempt = 0; attempt < REFRESH_MAX_ATTEMPTS; attempt++) {
|
|
403
|
+
let res;
|
|
404
|
+
try {
|
|
405
|
+
res = await fetch(meta.token_endpoint, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: {
|
|
408
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
409
|
+
Accept: "application/json",
|
|
410
|
+
},
|
|
411
|
+
body: new URLSearchParams({
|
|
412
|
+
grant_type: "refresh_token",
|
|
413
|
+
refresh_token: refreshToken,
|
|
414
|
+
client_id: clientId,
|
|
415
|
+
}),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
// Network/fetch exception (DNS failure, connection reset, timeout) — this
|
|
420
|
+
// is unrelated to the validity of the refresh token, so retry it.
|
|
421
|
+
lastTransient = err instanceof Error ? err.message : String(err);
|
|
422
|
+
if (attempt < REFRESH_MAX_ATTEMPTS - 1) {
|
|
423
|
+
await sleep(REFRESH_BACKOFF_BASE_MS * 2 ** attempt);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
if (res.ok) {
|
|
429
|
+
const data = (await res.json());
|
|
430
|
+
const tokens = tokensFromResponse(data);
|
|
431
|
+
// Per RFC 6749 §6, a refresh response MAY omit a new refresh token, in
|
|
432
|
+
// which case the old one remains valid — preserve it so the next refresh
|
|
433
|
+
// works.
|
|
434
|
+
if (!tokens.refreshToken)
|
|
435
|
+
tokens.refreshToken = refreshToken;
|
|
436
|
+
return tokens;
|
|
437
|
+
}
|
|
438
|
+
if (res.status >= 500) {
|
|
439
|
+
// Transient AS outage — retry with backoff, then fall through to the
|
|
440
|
+
// "exhausted" throw once no attempts remain.
|
|
441
|
+
lastTransient = `HTTP ${res.status}`;
|
|
442
|
+
if (attempt < REFRESH_MAX_ATTEMPTS - 1) {
|
|
443
|
+
await sleep(REFRESH_BACKOFF_BASE_MS * 2 ** attempt);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
// 4xx — a definitive client-side rejection, never retried. Only
|
|
449
|
+
// invalid_grant / invalid_client map to the null re-login signal; every
|
|
450
|
+
// other 4xx surfaces as a thrown error rather than a silent null that would
|
|
451
|
+
// wrongly discard a valid session.
|
|
452
|
+
const body = (await res.json().catch(() => ({})));
|
|
453
|
+
if (body.error === "invalid_grant" || body.error === "invalid_client") {
|
|
384
454
|
return null;
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
tokens.refreshToken = refreshToken;
|
|
391
|
-
return tokens;
|
|
392
|
-
}
|
|
393
|
-
catch {
|
|
394
|
-
return null;
|
|
455
|
+
}
|
|
456
|
+
const detail = body.error
|
|
457
|
+
? body.error + (body.error_description ? ` — ${body.error_description}` : "")
|
|
458
|
+
: `HTTP ${res.status}`;
|
|
459
|
+
throw new Error(`Token refresh failed: ${detail}`);
|
|
395
460
|
}
|
|
461
|
+
// Retries exhausted on a 5xx or a network exception (fail-closed, MNE-442):
|
|
462
|
+
// throw rather than return null so the session is preserved for a later retry.
|
|
463
|
+
throw new Error(`Token refresh failed after ${REFRESH_MAX_ATTEMPTS} attempts ` +
|
|
464
|
+
`(last error: ${lastTransient || "unknown transient failure"}).`);
|
|
396
465
|
}
|
|
397
466
|
// ============================================================================
|
|
398
467
|
// Helpers
|
package/dist/rc-proxy.mjs
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// * Inside api.anthropic.com, only `POST /v1/messages[?…]` is rewritten to the Mnemom
|
|
20
20
|
// gateway door: the real Anthropic key rides as `x-api-key` (API-key billing), plus
|
|
21
21
|
// `x-mnemom-agent`, `x-mnemom-conversation-id`, and optionally `x-mnemom-api-key`,
|
|
22
|
-
// `x-mnemom-provider`, `x-mnemom-egress-key`, `x-mnemom-contract` — the exact header set
|
|
22
|
+
// `x-mnemom-provider`, `x-mnemom-egress-key`, `x-mnemom-contract` / `x-mnemom-goal-mode` — the exact header set
|
|
23
23
|
// the non-RC launch sends via ANTHROPIC_CUSTOM_HEADERS. The OAuth `authorization` bearer
|
|
24
24
|
// is STRIPPED on that leg (it never leaves the machine) and `oauth-*` tokens are removed
|
|
25
25
|
// from `anthropic-beta`. Everything else under api.anthropic.com (OAuth refresh, the
|
|
@@ -62,6 +62,8 @@
|
|
|
62
62
|
// MNEMOM_AGENT_RC_AGENT x-mnemom-agent value (required)
|
|
63
63
|
// MNEMOM_AGENT_RC_CONVERSATION_ID x-mnemom-conversation-id value (required)
|
|
64
64
|
// MNEMOM_AGENT_RC_CONTRACT_B64 base64 contract for x-mnemom-contract (optional)
|
|
65
|
+
// MNEMOM_AGENT_RC_GOAL_MODE x-mnemom-goal-mode value (optional; `--implicit` — no contract,
|
|
66
|
+
// the gateway infers the goal from the user's messages)
|
|
65
67
|
// MNEMOM_AGENT_RC_PROVIDER x-mnemom-provider directive JSON (optional; router door)
|
|
66
68
|
// MNEMOM_AGENT_RC_THREAD_MODE `reject` (default: answer thread-bearing requests locally,
|
|
67
69
|
// see above) | `strip` (legacy: delete `thread`, forward)
|
|
@@ -98,6 +100,7 @@ const AGENT = env.MNEMOM_AGENT_RC_AGENT || die("MNEMOM_AGENT_RC_AGENT is require
|
|
|
98
100
|
const CONV =
|
|
99
101
|
env.MNEMOM_AGENT_RC_CONVERSATION_ID || die("MNEMOM_AGENT_RC_CONVERSATION_ID is required");
|
|
100
102
|
const CONTRACT_B64 = env.MNEMOM_AGENT_RC_CONTRACT_B64 || "";
|
|
103
|
+
const GOAL_MODE = env.MNEMOM_AGENT_RC_GOAL_MODE || "";
|
|
101
104
|
const PROVIDER = env.MNEMOM_AGENT_RC_PROVIDER || "";
|
|
102
105
|
const THREAD_MODE = env.MNEMOM_AGENT_RC_THREAD_MODE || "reject";
|
|
103
106
|
if (THREAD_MODE !== "reject" && THREAD_MODE !== "strip")
|
|
@@ -107,7 +110,9 @@ const PARENT = Number(env.MNEMOM_AGENT_RC_PARENT_PID || 0);
|
|
|
107
110
|
const PORT = Number(env.MNEMOM_AGENT_RC_PORT || 0);
|
|
108
111
|
|
|
109
112
|
const log = (line) => {
|
|
110
|
-
|
|
113
|
+
// One record per line: gateway header values and error bodies are spliced into log lines, so a
|
|
114
|
+
// CR/LF inside them must not fabricate extra records (log injection).
|
|
115
|
+
const s = `${new Date().toISOString()} ${String(line).replace(/[\r\n]+/g, " ")}\n`;
|
|
111
116
|
if (LOGFILE) {
|
|
112
117
|
try {
|
|
113
118
|
fs.appendFileSync(LOGFILE, s);
|
|
@@ -345,6 +350,7 @@ function handle(req, res, viaPlain) {
|
|
|
345
350
|
if (PROVIDER) headers["x-mnemom-provider"] = PROVIDER;
|
|
346
351
|
if (secrets.openai) headers["x-mnemom-egress-key"] = secrets.openai;
|
|
347
352
|
if (CONTRACT_B64) headers["x-mnemom-contract"] = CONTRACT_B64;
|
|
353
|
+
if (GOAL_MODE) headers["x-mnemom-goal-mode"] = GOAL_MODE;
|
|
348
354
|
if (headers["anthropic-beta"]) {
|
|
349
355
|
// drop the OAuth-only beta tokens
|
|
350
356
|
const b = String(headers["anthropic-beta"])
|
|
@@ -388,8 +394,12 @@ function handle(req, res, viaPlain) {
|
|
|
388
394
|
});
|
|
389
395
|
}
|
|
390
396
|
ur.pipe(res);
|
|
397
|
+
const goalRev = ur.headers["x-mnemom-goal-revision"];
|
|
398
|
+
const steer = ur.headers["x-mnemom-goal-steer"];
|
|
391
399
|
const ident = toGateway
|
|
392
|
-
? ` agent=${ur.headers["x-mnemom-agent"] || "-"} verdict=${ur.headers["x-mnemom-goal-verdict"] || "-"}`
|
|
400
|
+
? ` agent=${ur.headers["x-mnemom-agent"] || "-"} verdict=${ur.headers["x-mnemom-goal-verdict"] || "-"}` +
|
|
401
|
+
(goalRev ? ` goal-rev=${goalRev}` : "") +
|
|
402
|
+
(steer ? ` steer=${steer}` : "")
|
|
393
403
|
: "";
|
|
394
404
|
ur.on("end", () => {
|
|
395
405
|
let bodyNote = "";
|
|
@@ -488,7 +498,7 @@ proxy.on("clientError", (e, sock) => {
|
|
|
488
498
|
proxy.listen(PORT, "127.0.0.1", () => {
|
|
489
499
|
const { port } = proxy.address();
|
|
490
500
|
log(
|
|
491
|
-
`listening 127.0.0.1:${port} agent=${AGENT} conv=${CONV} door=${GATEWAY.href} contract=${CONTRACT_B64 ? "yes" : "no"} provider=${PROVIDER || "-"} thread=${THREAD_MODE}`,
|
|
501
|
+
`listening 127.0.0.1:${port} agent=${AGENT} conv=${CONV} door=${GATEWAY.href} contract=${CONTRACT_B64 ? "yes" : "no"} goal_mode=${GOAL_MODE || "-"} provider=${PROVIDER || "-"} thread=${THREAD_MODE}`,
|
|
492
502
|
);
|
|
493
503
|
process.stdout.write(`READY ${port} ${p("ca.pem")}\n`);
|
|
494
504
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemom/mnemom",
|
|
3
|
-
"version": "0.17.0-next.
|
|
3
|
+
"version": "0.17.0-next.8",
|
|
4
4
|
"description": "Transparent AI agent tracing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/js-yaml": "^4.0.9",
|
|
28
28
|
"@types/node": "^20.10.0",
|
|
29
|
-
"@vitest/coverage-v8": "^4.1.
|
|
29
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
30
30
|
"tsx": "^4.23.1",
|
|
31
31
|
"typescript": "^6.0.3",
|
|
32
|
-
"vitest": "^4.1.
|
|
32
|
+
"vitest": "^4.1.11"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|
|
@@ -65,5 +65,9 @@
|
|
|
65
65
|
},
|
|
66
66
|
"engines": {
|
|
67
67
|
"node": ">=18.0.0"
|
|
68
|
+
},
|
|
69
|
+
"overrides": {
|
|
70
|
+
"@vitest/mocker": "^4.1.11",
|
|
71
|
+
"nanoid": "^3.3.18"
|
|
68
72
|
}
|
|
69
73
|
}
|