@mnemom/mnemom 0.17.0-next.6 → 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 +29 -0
- package/dist/commands/agent-config.js +193 -0
- package/dist/commands/{code-doctor.d.ts → agent-doctor.d.ts} +2 -2
- package/dist/commands/{code-doctor.js → agent-doctor.js} +27 -22
- package/dist/commands/{code-invite.d.ts → agent-invite.d.ts} +18 -18
- package/dist/commands/{code-invite.js → agent-invite.js} +30 -30
- package/dist/commands/agent-session.d.ts +68 -0
- package/dist/commands/agent-session.js +114 -0
- package/dist/commands/{code-setup.d.ts → agent-setup.d.ts} +13 -13
- package/dist/commands/{code-setup.js → agent-setup.js} +46 -42
- package/dist/commands/{code.d.ts → agent.d.ts} +50 -14
- package/dist/commands/{code.js → agent.js} +144 -86
- package/dist/commands/agents.d.ts +2 -2
- package/dist/commands/agents.js +5 -5
- package/dist/commands/onboard.js +1 -1
- package/dist/commands/wrap.js +1 -1
- package/dist/index.js +59 -51
- package/dist/lib/{code.d.ts → agent.d.ts} +52 -10
- package/dist/lib/{code.js → agent.js} +94 -15
- package/dist/lib/api.d.ts +4 -4
- package/dist/lib/api.js +9 -9
- package/dist/lib/auth.js +21 -1
- package/dist/lib/{code-config.d.ts → launcher-config.d.ts} +25 -23
- package/dist/lib/{code-config.js → launcher-config.js} +50 -31
- package/dist/lib/oauth.d.ts +26 -4
- package/dist/lib/oauth.js +98 -29
- package/dist/lib/version-gate.js +1 -1
- package/dist/rc-proxy.mjs +309 -136
- package/package.json +7 -3
- package/dist/commands/code-config.d.ts +0 -17
- package/dist/commands/code-config.js +0 -147
- package/dist/commands/code-session.d.ts +0 -52
- package/dist/commands/code-session.js +0 -78
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)
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/code-config.ts — persistent per-customer settings for `mnemom
|
|
2
|
+
* lib/code-config.ts — persistent per-customer settings for `mnemom agent`.
|
|
3
3
|
*
|
|
4
|
-
* Settings live in a human-editable TOML file at ~/.mnemom/
|
|
4
|
+
* Settings live in a human-editable TOML file at ~/.mnemom/launcher.toml (the
|
|
5
5
|
* Cargo/Codex `config.toml` convention — comments allowed, easy to hand-edit) so
|
|
6
6
|
* a customer never has to retype their flags. The SECRET Anthropic key is
|
|
7
|
-
* deliberately NOT stored here: it stays in ~/.mnemom/
|
|
7
|
+
* deliberately NOT stored here: it stays in ~/.mnemom/launcher.json at 0600 (see
|
|
8
8
|
* commands/code.ts), so the file customers open, edit, and share never carries a
|
|
9
9
|
* credential — the same split gh (config.yml/hosts.yml), aws (config/credentials)
|
|
10
10
|
* and Codex (config.toml/auth.json) use.
|
|
11
11
|
*
|
|
12
|
-
* Precedence, highest wins: command-line FLAG > ENV >
|
|
13
|
-
* `
|
|
12
|
+
* Precedence, highest wins: command-line FLAG > ENV > launcher.toml > built-in.
|
|
13
|
+
* `loadLauncherConfig` is pure parse+validate; `applyConfigToEnv` folds the
|
|
14
14
|
* env-backed settings UNDER any real env var (so env still wins and the
|
|
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 "./
|
|
19
|
-
/** The human-editable settings file. Secret key lives elsewhere (
|
|
20
|
-
export declare const
|
|
18
|
+
import { type GoalModeDefault, type LaunchShape } from "./agent.js";
|
|
19
|
+
/** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
|
|
20
|
+
export declare const LAUNCHER_CONFIG_PATH: string;
|
|
21
21
|
/** Where the CLI resolves the Anthropic key from; advisory ordering/restriction. */
|
|
22
22
|
export type KeySource = "env" | "store" | "prompt";
|
|
23
23
|
/** The validated settings, camelCased for TS (TOML keys are snake_case). */
|
|
24
|
-
export interface
|
|
24
|
+
export interface LauncherConfig {
|
|
25
25
|
gateway?: string;
|
|
26
26
|
model?: string;
|
|
27
27
|
agent?: string;
|
|
@@ -29,6 +29,8 @@ export interface CodeConfig {
|
|
|
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;
|
|
@@ -36,43 +38,43 @@ export interface CodeConfig {
|
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
/**
|
|
39
|
-
* A settable key: its dotted config name, the getter/setter into a
|
|
41
|
+
* A settable key: its dotted config name, the getter/setter into a LauncherConfig,
|
|
40
42
|
* and a one-line description for `config list`/help. Kept as data so get/set/list
|
|
41
43
|
* stay a single source of truth.
|
|
42
44
|
*/
|
|
43
45
|
export interface ConfigKeySpec {
|
|
44
46
|
key: string;
|
|
45
47
|
describe: string;
|
|
46
|
-
get(cfg:
|
|
47
|
-
set(cfg:
|
|
48
|
+
get(cfg: LauncherConfig): string | number | undefined;
|
|
49
|
+
set(cfg: LauncherConfig, raw: string): void;
|
|
48
50
|
}
|
|
49
51
|
/** The full set of settable keys — drives get/set/unset/list and validation. */
|
|
50
52
|
export declare const CONFIG_KEYS: ConfigKeySpec[];
|
|
51
53
|
export declare function configKeySpec(key: string): ConfigKeySpec | undefined;
|
|
52
54
|
/**
|
|
53
|
-
* Parse + validate a TOML settings document into a
|
|
55
|
+
* Parse + validate a TOML settings document into a LauncherConfig. Pure (no fs), so
|
|
54
56
|
* it is unit-testable. Unknown top-level keys are ignored (forward-compatible);
|
|
55
57
|
* known keys with the wrong shape throw a human-readable error.
|
|
56
58
|
*/
|
|
57
|
-
export declare function
|
|
59
|
+
export declare function parseLauncherConfig(text: string): LauncherConfig;
|
|
58
60
|
/**
|
|
59
|
-
* Load the settings from ~/.mnemom/
|
|
61
|
+
* Load the settings from ~/.mnemom/launcher.toml (or `path`). A missing file is an
|
|
60
62
|
* empty config (never an error — first run has none). A malformed or invalid
|
|
61
63
|
* file throws with the path so the customer can fix it, rather than silently
|
|
62
64
|
* launching with the wrong settings.
|
|
63
65
|
*/
|
|
64
|
-
export declare function
|
|
65
|
-
/** Serialize a
|
|
66
|
-
export declare function
|
|
67
|
-
/** Persist a
|
|
68
|
-
export declare function
|
|
66
|
+
export declare function loadLauncherConfig(path?: string): LauncherConfig;
|
|
67
|
+
/** Serialize a LauncherConfig back to TOML (snake_case keys), with a header comment. */
|
|
68
|
+
export declare function serializeLauncherConfig(cfg: LauncherConfig): string;
|
|
69
|
+
/** Persist a LauncherConfig to `path` (default launcher.toml), atomically (temp+rename), 0600. */
|
|
70
|
+
export declare function writeLauncherConfig(cfg: LauncherConfig, path?: string): void;
|
|
69
71
|
/**
|
|
70
72
|
* Fold the ENV-backed settings from config UNDER the environment: set the
|
|
71
|
-
*
|
|
73
|
+
* MNEMOM_AGENT_* var ONLY when it is not already present, so a real env var (and
|
|
72
74
|
* therefore a flag that set one) still wins. The resolvers in lib/code.ts then
|
|
73
75
|
* apply the built-in default when neither env nor config supplied a value.
|
|
74
76
|
*
|
|
75
|
-
* launch maps to
|
|
77
|
+
* launch maps to MNEMOM_AGENT_REMOTE_CONTROL using that var's existing contract
|
|
76
78
|
* (see resolveLaunchShape): terminal→"0", remote-control→"1", server→"server".
|
|
77
79
|
*/
|
|
78
|
-
export declare function applyConfigToEnv(cfg:
|
|
80
|
+
export declare function applyConfigToEnv(cfg: LauncherConfig, env?: NodeJS.ProcessEnv): void;
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/code-config.ts — persistent per-customer settings for `mnemom
|
|
2
|
+
* lib/code-config.ts — persistent per-customer settings for `mnemom agent`.
|
|
3
3
|
*
|
|
4
|
-
* Settings live in a human-editable TOML file at ~/.mnemom/
|
|
4
|
+
* Settings live in a human-editable TOML file at ~/.mnemom/launcher.toml (the
|
|
5
5
|
* Cargo/Codex `config.toml` convention — comments allowed, easy to hand-edit) so
|
|
6
6
|
* a customer never has to retype their flags. The SECRET Anthropic key is
|
|
7
|
-
* deliberately NOT stored here: it stays in ~/.mnemom/
|
|
7
|
+
* deliberately NOT stored here: it stays in ~/.mnemom/launcher.json at 0600 (see
|
|
8
8
|
* commands/code.ts), so the file customers open, edit, and share never carries a
|
|
9
9
|
* credential — the same split gh (config.yml/hosts.yml), aws (config/credentials)
|
|
10
10
|
* and Codex (config.toml/auth.json) use.
|
|
11
11
|
*
|
|
12
|
-
* Precedence, highest wins: command-line FLAG > ENV >
|
|
13
|
-
* `
|
|
12
|
+
* Precedence, highest wins: command-line FLAG > ENV > launcher.toml > built-in.
|
|
13
|
+
* `loadLauncherConfig` is pure parse+validate; `applyConfigToEnv` folds the
|
|
14
14
|
* env-backed settings UNDER any real env var (so env still wins and the
|
|
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.
|
|
@@ -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 "./
|
|
23
|
-
/** The human-editable settings file. Secret key lives elsewhere (
|
|
24
|
-
export const
|
|
22
|
+
import { isPositiveInt, isPositiveNumber, } from "./agent.js";
|
|
23
|
+
/** The human-editable settings file. Secret key lives elsewhere (launcher.json). */
|
|
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)",
|
|
@@ -147,18 +162,18 @@ export function configKeySpec(key) {
|
|
|
147
162
|
return CONFIG_KEYS.find((k) => k.key === key);
|
|
148
163
|
}
|
|
149
164
|
/**
|
|
150
|
-
* Parse + validate a TOML settings document into a
|
|
165
|
+
* Parse + validate a TOML settings document into a LauncherConfig. Pure (no fs), so
|
|
151
166
|
* it is unit-testable. Unknown top-level keys are ignored (forward-compatible);
|
|
152
167
|
* known keys with the wrong shape throw a human-readable error.
|
|
153
168
|
*/
|
|
154
|
-
export function
|
|
169
|
+
export function parseLauncherConfig(text) {
|
|
155
170
|
let raw;
|
|
156
171
|
try {
|
|
157
172
|
raw = parseToml(text);
|
|
158
173
|
}
|
|
159
174
|
catch (err) {
|
|
160
175
|
const detail = err instanceof TomlError ? err.message : String(err);
|
|
161
|
-
throw new Error(`could not parse
|
|
176
|
+
throw new Error(`could not parse launcher.toml: ${detail}`, { cause: err });
|
|
162
177
|
}
|
|
163
178
|
const cfg = {};
|
|
164
179
|
const str = (v) => typeof v === "string" && v.trim() ? v.trim() : v === undefined ? undefined : String(v);
|
|
@@ -176,6 +191,8 @@ export function parseCodeConfig(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)) {
|
|
@@ -192,12 +209,12 @@ export function parseCodeConfig(text) {
|
|
|
192
209
|
return cfg;
|
|
193
210
|
}
|
|
194
211
|
/**
|
|
195
|
-
* Load the settings from ~/.mnemom/
|
|
212
|
+
* Load the settings from ~/.mnemom/launcher.toml (or `path`). A missing file is an
|
|
196
213
|
* empty config (never an error — first run has none). A malformed or invalid
|
|
197
214
|
* file throws with the path so the customer can fix it, rather than silently
|
|
198
215
|
* launching with the wrong settings.
|
|
199
216
|
*/
|
|
200
|
-
export function
|
|
217
|
+
export function loadLauncherConfig(path = LAUNCHER_CONFIG_PATH) {
|
|
201
218
|
if (!existsSync(path))
|
|
202
219
|
return {};
|
|
203
220
|
let text;
|
|
@@ -208,15 +225,15 @@ export function loadCodeConfig(path = CODE_CONFIG_PATH) {
|
|
|
208
225
|
return {};
|
|
209
226
|
}
|
|
210
227
|
try {
|
|
211
|
-
return
|
|
228
|
+
return parseLauncherConfig(text);
|
|
212
229
|
}
|
|
213
230
|
catch (err) {
|
|
214
231
|
const msg = err instanceof Error ? err.message : String(err);
|
|
215
|
-
throw new Error(`mnemom
|
|
232
|
+
throw new Error(`mnemom agent: ${msg}\n Fix or remove ${path}.`, { cause: err });
|
|
216
233
|
}
|
|
217
234
|
}
|
|
218
|
-
/** Serialize a
|
|
219
|
-
export function
|
|
235
|
+
/** Serialize a LauncherConfig back to TOML (snake_case keys), with a header comment. */
|
|
236
|
+
export function serializeLauncherConfig(cfg) {
|
|
220
237
|
const doc = {};
|
|
221
238
|
if (cfg.gateway !== undefined)
|
|
222
239
|
doc.gateway = cfg.gateway;
|
|
@@ -232,6 +249,8 @@ export function serializeCodeConfig(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 = {};
|
|
@@ -243,39 +262,39 @@ export function serializeCodeConfig(cfg) {
|
|
|
243
262
|
gr.stall_turns = g.stallTurns;
|
|
244
263
|
doc.guardrails = gr;
|
|
245
264
|
}
|
|
246
|
-
const header = "# ~/.mnemom/
|
|
265
|
+
const header = "# ~/.mnemom/launcher.toml — settings for `mnemom agent` (the governed coding-agent launcher).\n" +
|
|
247
266
|
"# Command-line flags override these; these override the built-in defaults.\n" +
|
|
248
|
-
"# The secret Anthropic key is NOT here — it lives in ~/.mnemom/
|
|
249
|
-
"# Edit with `mnemom
|
|
267
|
+
"# The secret Anthropic key is NOT here — it lives in ~/.mnemom/launcher.json (0600).\n" +
|
|
268
|
+
"# Edit with `mnemom agent config edit`, or set one key with `mnemom agent config set <key> <value>`.\n\n";
|
|
250
269
|
const body = Object.keys(doc).length ? stringifyToml(doc) : "";
|
|
251
270
|
return `${header}${body}${body.endsWith("\n") || !body ? "" : "\n"}`;
|
|
252
271
|
}
|
|
253
|
-
/** Persist a
|
|
254
|
-
export function
|
|
272
|
+
/** Persist a LauncherConfig to `path` (default launcher.toml), atomically (temp+rename), 0600. */
|
|
273
|
+
export function writeLauncherConfig(cfg, path = LAUNCHER_CONFIG_PATH) {
|
|
255
274
|
const dir = dirname(path);
|
|
256
275
|
mkdirSync(dir, { recursive: true });
|
|
257
276
|
const tmp = join(dir, `.code-toml-tmp-${process.pid}-${Date.now()}`);
|
|
258
|
-
writeFileSync(tmp,
|
|
277
|
+
writeFileSync(tmp, serializeLauncherConfig(cfg), { mode: 0o600 });
|
|
259
278
|
renameSync(tmp, path);
|
|
260
279
|
}
|
|
261
280
|
/**
|
|
262
281
|
* Fold the ENV-backed settings from config UNDER the environment: set the
|
|
263
|
-
*
|
|
282
|
+
* MNEMOM_AGENT_* var ONLY when it is not already present, so a real env var (and
|
|
264
283
|
* therefore a flag that set one) still wins. The resolvers in lib/code.ts then
|
|
265
284
|
* apply the built-in default when neither env nor config supplied a value.
|
|
266
285
|
*
|
|
267
|
-
* launch maps to
|
|
286
|
+
* launch maps to MNEMOM_AGENT_REMOTE_CONTROL using that var's existing contract
|
|
268
287
|
* (see resolveLaunchShape): terminal→"0", remote-control→"1", server→"server".
|
|
269
288
|
*/
|
|
270
289
|
export function applyConfigToEnv(cfg, env = process.env) {
|
|
271
|
-
if (cfg.gateway && !(env.
|
|
272
|
-
env.
|
|
290
|
+
if (cfg.gateway && !(env.MNEMOM_AGENT_GATEWAY ?? "").trim()) {
|
|
291
|
+
env.MNEMOM_AGENT_GATEWAY = cfg.gateway;
|
|
273
292
|
}
|
|
274
|
-
if (cfg.cli && !(env.
|
|
275
|
-
env.
|
|
293
|
+
if (cfg.cli && !(env.MNEMOM_AGENT_CLI ?? "").trim()) {
|
|
294
|
+
env.MNEMOM_AGENT_CLI = cfg.cli;
|
|
276
295
|
}
|
|
277
|
-
if (cfg.launch && !(env.
|
|
278
|
-
env.
|
|
296
|
+
if (cfg.launch && !(env.MNEMOM_AGENT_REMOTE_CONTROL ?? "").trim()) {
|
|
297
|
+
env.MNEMOM_AGENT_REMOTE_CONTROL =
|
|
279
298
|
cfg.launch === "server" ? "server" : cfg.launch === "remote-control" ? "1" : "0";
|
|
280
299
|
}
|
|
281
300
|
}
|
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/lib/version-gate.js
CHANGED
|
@@ -67,7 +67,7 @@ export function enforceMinCliVersion(response, exit = process.exit, write = (chu
|
|
|
67
67
|
return;
|
|
68
68
|
exited = true;
|
|
69
69
|
write(`\n✖ Your Mnemom CLI (v${CLI_VERSION}) is too old for this server (minimum v${floor}).\n` +
|
|
70
|
-
` Old CLIs can silently drop newer request fields — e.g. \`--org\` on \`mnemom
|
|
70
|
+
` Old CLIs can silently drop newer request fields — e.g. \`--org\` on \`mnemom agent claim\`.\n\n` +
|
|
71
71
|
` Upgrade: npm i -g @mnemom/mnemom@latest\n\n`);
|
|
72
72
|
exit(1);
|
|
73
73
|
}
|