@mnemom/mnemom 0.14.5 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/activity.d.ts +2 -0
- package/dist/commands/{integrity.js → activity.js} +9 -3
- package/dist/commands/agents.js +44 -2
- package/dist/commands/api-key.js +20 -4
- package/dist/commands/auth.js +39 -23
- package/dist/commands/card.js +17 -2
- package/dist/commands/logs.js +13 -5
- package/dist/commands/org.js +15 -2
- package/dist/commands/protection.d.ts +2 -1
- package/dist/commands/protection.js +163 -3
- package/dist/commands/status.js +7 -1
- package/dist/commands/try-me.d.ts +52 -0
- package/dist/commands/try-me.js +361 -0
- package/dist/index.js +64 -4
- package/dist/lib/api.js +5 -6
- package/dist/lib/auth.d.ts +33 -10
- package/dist/lib/auth.js +71 -201
- package/dist/lib/oauth.d.ts +121 -0
- package/dist/lib/oauth.js +450 -0
- package/dist/lib/onboarding.d.ts +28 -0
- package/dist/lib/onboarding.js +35 -0
- package/dist/lib/prompt.d.ts +3 -1
- package/dist/lib/prompt.js +47 -1
- package/dist/lib/try-me.d.ts +181 -0
- package/dist/lib/try-me.js +245 -0
- package/package.json +3 -2
- package/dist/commands/integrity.d.ts +0 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 client for the Mnemom Authorization Server.
|
|
3
|
+
*
|
|
4
|
+
* `mnemom login` authenticates against the Mnemom OAuth AS (the same AS that
|
|
5
|
+
* fronts the MCP control plane) instead of doing a raw Supabase session login.
|
|
6
|
+
* This yields SCOPED access tokens (`mcp:read` / `mcp:write`) — not the full
|
|
7
|
+
* Supabase god-token — so a leaked CLI credential can only do what the CLI was
|
|
8
|
+
* granted, and grants are independently revocable server-side (MNE-805/806).
|
|
9
|
+
*
|
|
10
|
+
* Two interactive flows, both standards-based:
|
|
11
|
+
* - Authorization Code + PKCE with a loopback redirect (the `wrangler login`
|
|
12
|
+
* pattern): open the browser to /authorize, catch the redirect on a
|
|
13
|
+
* localhost listener, exchange code+verifier at /token.
|
|
14
|
+
* - RFC 8628 Device Authorization Grant (`--no-browser` / headless): POST
|
|
15
|
+
* /device_authorization, print the user_code + verification_uri, poll
|
|
16
|
+
* /token honoring `authorization_pending` and `slow_down`.
|
|
17
|
+
*
|
|
18
|
+
* Everything is driven off RFC 8414 discovery
|
|
19
|
+
* (/.well-known/oauth-authorization-server) — endpoint paths are NOT hardcoded.
|
|
20
|
+
* The public client_id is obtained via RFC 7591 Dynamic Client Registration and
|
|
21
|
+
* cached, so there is no client secret to embed (the AS advertises
|
|
22
|
+
* token_endpoint_auth_method "none" — public clients only).
|
|
23
|
+
*/
|
|
24
|
+
import * as http from "node:http";
|
|
25
|
+
import * as crypto from "node:crypto";
|
|
26
|
+
import { execFile } from "node:child_process";
|
|
27
|
+
import { getApiUrl } from "./config.js";
|
|
28
|
+
// The scopes the CLI requests. The AS currently advertises mcp:read + mcp:write
|
|
29
|
+
// (scopes_supported in discovery); we request both so a single login covers the
|
|
30
|
+
// full command surface. If discovery ever narrows what's available we intersect
|
|
31
|
+
// against scopes_supported before asking, so we never request an unknown scope.
|
|
32
|
+
const REQUESTED_SCOPES = ["mcp:read", "mcp:write"];
|
|
33
|
+
const CLIENT_NAME = "Mnemom CLI";
|
|
34
|
+
const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
35
|
+
// Loopback login waits at most this long for the browser round-trip before
|
|
36
|
+
// giving up and freeing the port.
|
|
37
|
+
const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
38
|
+
let cachedMetadata = null;
|
|
39
|
+
/**
|
|
40
|
+
* Fetch (and process-cache) the AS metadata document. We resolve it relative to
|
|
41
|
+
* the active API base URL so staging/local point at their own AS.
|
|
42
|
+
*/
|
|
43
|
+
export async function discover() {
|
|
44
|
+
if (cachedMetadata)
|
|
45
|
+
return cachedMetadata;
|
|
46
|
+
const url = `${getApiUrl()}/.well-known/oauth-authorization-server`;
|
|
47
|
+
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
throw new Error(`Could not load OAuth metadata from ${url} (HTTP ${res.status}). ` + `Is the API reachable?`);
|
|
50
|
+
}
|
|
51
|
+
const meta = (await res.json());
|
|
52
|
+
if (!meta.authorization_endpoint || !meta.token_endpoint) {
|
|
53
|
+
throw new Error("OAuth metadata is missing authorization_endpoint/token_endpoint.");
|
|
54
|
+
}
|
|
55
|
+
cachedMetadata = meta;
|
|
56
|
+
return meta;
|
|
57
|
+
}
|
|
58
|
+
/** Reset the process-level discovery cache (used by tests). */
|
|
59
|
+
export function _resetDiscoveryCache() {
|
|
60
|
+
cachedMetadata = null;
|
|
61
|
+
}
|
|
62
|
+
/** Intersect our requested scopes with what the AS advertises. */
|
|
63
|
+
function negotiateScopes(meta) {
|
|
64
|
+
const supported = meta.scopes_supported;
|
|
65
|
+
const scopes = supported && supported.length > 0
|
|
66
|
+
? REQUESTED_SCOPES.filter((s) => supported.includes(s))
|
|
67
|
+
: REQUESTED_SCOPES;
|
|
68
|
+
// If nothing intersects (misconfigured AS), fall back to asking for what the
|
|
69
|
+
// AS says it supports rather than sending an empty scope.
|
|
70
|
+
return (scopes.length > 0 ? scopes : (supported ?? REQUESTED_SCOPES)).join(" ");
|
|
71
|
+
}
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Dynamic Client Registration (RFC 7591)
|
|
74
|
+
// ============================================================================
|
|
75
|
+
/**
|
|
76
|
+
* Register a public client for the CLI and return its client_id. The AS issues
|
|
77
|
+
* public clients (token_endpoint_auth_method "none"), so there is no secret to
|
|
78
|
+
* persist. The caller is responsible for caching the returned id.
|
|
79
|
+
*/
|
|
80
|
+
export async function registerClient(redirectUris) {
|
|
81
|
+
const meta = await discover();
|
|
82
|
+
if (!meta.registration_endpoint) {
|
|
83
|
+
throw new Error("This Authorization Server does not support dynamic client registration; " +
|
|
84
|
+
"no public CLI client_id is available.");
|
|
85
|
+
}
|
|
86
|
+
const res = await fetch(meta.registration_endpoint, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
client_name: CLIENT_NAME,
|
|
91
|
+
redirect_uris: redirectUris,
|
|
92
|
+
grant_types: ["authorization_code", "refresh_token", DEVICE_CODE_GRANT],
|
|
93
|
+
response_types: ["code"],
|
|
94
|
+
token_endpoint_auth_method: "none",
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
throw new Error(`Client registration failed (HTTP ${res.status}): ${await safeBody(res)}`);
|
|
99
|
+
}
|
|
100
|
+
const data = (await res.json());
|
|
101
|
+
if (!data.client_id) {
|
|
102
|
+
throw new Error("Client registration response did not include a client_id.");
|
|
103
|
+
}
|
|
104
|
+
return data.client_id;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Generate a PKCE verifier/challenge pair. The verifier is a high-entropy
|
|
108
|
+
* URL-safe string (RFC 7636 §4.1: 43–128 chars from the unreserved set); the
|
|
109
|
+
* challenge is BASE64URL(SHA256(verifier)) for the S256 method (the only method
|
|
110
|
+
* the AS advertises, and the only one allowed under OAuth 2.1).
|
|
111
|
+
*/
|
|
112
|
+
export function generatePkce() {
|
|
113
|
+
const verifier = base64url(crypto.randomBytes(32));
|
|
114
|
+
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
|
|
115
|
+
return { verifier, challenge, method: "S256" };
|
|
116
|
+
}
|
|
117
|
+
function base64url(buf) {
|
|
118
|
+
return buf.toString("base64url");
|
|
119
|
+
}
|
|
120
|
+
function tokensFromResponse(data) {
|
|
121
|
+
const now = Math.floor(Date.now() / 1000);
|
|
122
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in : 3600;
|
|
123
|
+
return {
|
|
124
|
+
accessToken: data.access_token,
|
|
125
|
+
tokenType: data.token_type ?? "Bearer",
|
|
126
|
+
refreshToken: data.refresh_token,
|
|
127
|
+
scope: data.scope,
|
|
128
|
+
expiresAt: now + expiresIn,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Run the interactive authorization-code + PKCE login. Returns the issued
|
|
133
|
+
* scoped tokens plus the client_id that was used (so the caller can persist it
|
|
134
|
+
* for refresh). `openUrl` is injectable so tests can drive the flow without
|
|
135
|
+
* spawning a real browser.
|
|
136
|
+
*/
|
|
137
|
+
export async function loginWithLoopback(openUrl = openBrowser) {
|
|
138
|
+
const meta = await discover();
|
|
139
|
+
const pkce = generatePkce();
|
|
140
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
141
|
+
const { port, codePromise, close } = await startLoopbackServer(state);
|
|
142
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
143
|
+
// Register a client bound to the exact loopback redirect URI. OAuth 2.1
|
|
144
|
+
// requires exact redirect_uri matching, and the loopback port is ephemeral,
|
|
145
|
+
// so we register per-login (cheap, public client, no secret).
|
|
146
|
+
const clientId = await registerClient([redirectUri]);
|
|
147
|
+
const authUrl = new URL(meta.authorization_endpoint);
|
|
148
|
+
authUrl.searchParams.set("response_type", "code");
|
|
149
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
150
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
151
|
+
authUrl.searchParams.set("scope", negotiateScopes(meta));
|
|
152
|
+
authUrl.searchParams.set("state", state);
|
|
153
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
154
|
+
authUrl.searchParams.set("code_challenge_method", pkce.method);
|
|
155
|
+
console.log("Opening browser to authenticate...");
|
|
156
|
+
console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
|
|
157
|
+
openUrl(authUrl.toString());
|
|
158
|
+
console.log("Waiting for authentication...");
|
|
159
|
+
try {
|
|
160
|
+
const code = await codePromise;
|
|
161
|
+
const tokens = await exchangeCode(meta, clientId, code, pkce.verifier, redirectUri);
|
|
162
|
+
return { tokens, clientId };
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Start a loopback HTTP listener that captures the OAuth redirect
|
|
170
|
+
* (GET /callback?code=...&state=...). Resolves with the authorization code once
|
|
171
|
+
* a redirect with a matching state arrives; rejects on error/mismatch/timeout.
|
|
172
|
+
*/
|
|
173
|
+
function startLoopbackServer(expectedState) {
|
|
174
|
+
let resolveCode;
|
|
175
|
+
let rejectCode;
|
|
176
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
177
|
+
resolveCode = resolve;
|
|
178
|
+
rejectCode = reject;
|
|
179
|
+
});
|
|
180
|
+
const server = http.createServer((req, res) => {
|
|
181
|
+
const reqUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
182
|
+
if (req.method !== "GET" || reqUrl.pathname !== "/callback") {
|
|
183
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
184
|
+
res.end("Not found");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const error = reqUrl.searchParams.get("error");
|
|
188
|
+
const code = reqUrl.searchParams.get("code");
|
|
189
|
+
const state = reqUrl.searchParams.get("state");
|
|
190
|
+
if (error) {
|
|
191
|
+
const desc = reqUrl.searchParams.get("error_description") ?? error;
|
|
192
|
+
respondHtml(res, 400, "Authentication failed", desc);
|
|
193
|
+
rejectCode(new Error(`Authorization denied: ${desc}`));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Constant-time state comparison to avoid leaking timing on the CSRF token.
|
|
197
|
+
if (!state || !timingSafeEqual(state, expectedState)) {
|
|
198
|
+
respondHtml(res, 403, "Authentication failed", "State mismatch — possible CSRF.");
|
|
199
|
+
rejectCode(new Error("State mismatch — possible CSRF attack"));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!code) {
|
|
203
|
+
respondHtml(res, 400, "Authentication failed", "No authorization code in callback.");
|
|
204
|
+
rejectCode(new Error("No authorization code in callback"));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
respondHtml(res, 200, "Authenticated!", "You can close this tab and return to the terminal.");
|
|
208
|
+
resolveCode(code);
|
|
209
|
+
});
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
server.listen(0, "127.0.0.1", () => {
|
|
212
|
+
const port = server.address().port;
|
|
213
|
+
const timeout = setTimeout(() => {
|
|
214
|
+
rejectCode(new Error("Login timed out. Please try again."));
|
|
215
|
+
server.close();
|
|
216
|
+
}, LOOPBACK_TIMEOUT_MS);
|
|
217
|
+
resolve({
|
|
218
|
+
port,
|
|
219
|
+
codePromise,
|
|
220
|
+
close: () => {
|
|
221
|
+
clearTimeout(timeout);
|
|
222
|
+
server.close();
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async function exchangeCode(meta, clientId, code, verifier, redirectUri) {
|
|
229
|
+
const res = await fetch(meta.token_endpoint, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: {
|
|
232
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
233
|
+
Accept: "application/json",
|
|
234
|
+
},
|
|
235
|
+
body: new URLSearchParams({
|
|
236
|
+
grant_type: "authorization_code",
|
|
237
|
+
code,
|
|
238
|
+
redirect_uri: redirectUri,
|
|
239
|
+
client_id: clientId,
|
|
240
|
+
code_verifier: verifier,
|
|
241
|
+
}),
|
|
242
|
+
});
|
|
243
|
+
if (!res.ok) {
|
|
244
|
+
throw new Error(`Token exchange failed: ${await oauthError(res)}`);
|
|
245
|
+
}
|
|
246
|
+
return tokensFromResponse((await res.json()));
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Run the RFC 8628 device authorization grant. Registers a client (the device
|
|
250
|
+
* flow needs no redirect, but the AS's DCR validation requires a valid
|
|
251
|
+
* redirect_uri — HTTPS or a loopback — so we register a loopback placeholder it
|
|
252
|
+
* will never redirect to), requests a device code, prints the user_code +
|
|
253
|
+
* verification_uri for the user to approve in any browser, then polls the token
|
|
254
|
+
* endpoint until approval — honoring `authorization_pending` and `slow_down`
|
|
255
|
+
* per the spec.
|
|
256
|
+
*
|
|
257
|
+
* `display` and `sleep` are injectable so tests can drive the poll loop
|
|
258
|
+
* deterministically without real timers or stdout.
|
|
259
|
+
*/
|
|
260
|
+
export async function loginWithDevice(opts) {
|
|
261
|
+
const display = opts?.display ?? ((line) => console.log(line));
|
|
262
|
+
const sleep = opts?.sleep ?? defaultSleep;
|
|
263
|
+
const meta = await discover();
|
|
264
|
+
if (!meta.device_authorization_endpoint) {
|
|
265
|
+
throw new Error("This Authorization Server does not support the device flow.");
|
|
266
|
+
}
|
|
267
|
+
// Device flow has no redirect, but the AS's DCR validation rejects the OAuth
|
|
268
|
+
// OOB sentinel ("redirect_uri must be HTTPS or a loopback for native
|
|
269
|
+
// clients"). Register a loopback placeholder — it satisfies validation and is
|
|
270
|
+
// never used, since the device grant never redirects.
|
|
271
|
+
const clientId = await registerClient(["http://127.0.0.1/callback"]);
|
|
272
|
+
const authzRes = await fetch(meta.device_authorization_endpoint, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: {
|
|
275
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
276
|
+
Accept: "application/json",
|
|
277
|
+
},
|
|
278
|
+
body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
|
|
279
|
+
});
|
|
280
|
+
if (!authzRes.ok) {
|
|
281
|
+
throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
|
|
282
|
+
}
|
|
283
|
+
const authz = (await authzRes.json());
|
|
284
|
+
display("");
|
|
285
|
+
display("To authenticate, visit:");
|
|
286
|
+
display(` ${authz.verification_uri}`);
|
|
287
|
+
display("");
|
|
288
|
+
display(`And enter the code: ${authz.user_code}`);
|
|
289
|
+
if (authz.verification_uri_complete) {
|
|
290
|
+
display("");
|
|
291
|
+
display(`Or open this URL directly:`);
|
|
292
|
+
display(` ${authz.verification_uri_complete}`);
|
|
293
|
+
}
|
|
294
|
+
display("");
|
|
295
|
+
display("Waiting for authorization...");
|
|
296
|
+
const tokens = await pollDeviceToken(meta, clientId, authz, sleep);
|
|
297
|
+
return { tokens, clientId };
|
|
298
|
+
}
|
|
299
|
+
async function pollDeviceToken(meta, clientId, authz, sleep) {
|
|
300
|
+
// RFC 8628 §3.5: default interval is 5s if the server omits it; on slow_down
|
|
301
|
+
// we increase the interval by 5s and keep that as the new minimum.
|
|
302
|
+
let intervalMs = (authz.interval ?? 5) * 1000;
|
|
303
|
+
const deadline = Date.now() + authz.expires_in * 1000;
|
|
304
|
+
for (;;) {
|
|
305
|
+
if (Date.now() >= deadline) {
|
|
306
|
+
throw new Error("Device authorization expired before approval. Please try again.");
|
|
307
|
+
}
|
|
308
|
+
await sleep(intervalMs);
|
|
309
|
+
const res = await fetch(meta.token_endpoint, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: {
|
|
312
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
313
|
+
Accept: "application/json",
|
|
314
|
+
},
|
|
315
|
+
body: new URLSearchParams({
|
|
316
|
+
grant_type: DEVICE_CODE_GRANT,
|
|
317
|
+
device_code: authz.device_code,
|
|
318
|
+
client_id: clientId,
|
|
319
|
+
}),
|
|
320
|
+
});
|
|
321
|
+
if (res.ok) {
|
|
322
|
+
return tokensFromResponse((await res.json()));
|
|
323
|
+
}
|
|
324
|
+
const body = (await res.json().catch(() => ({})));
|
|
325
|
+
switch (body.error) {
|
|
326
|
+
case "authorization_pending":
|
|
327
|
+
continue; // keep polling at the current interval
|
|
328
|
+
case "slow_down":
|
|
329
|
+
intervalMs += 5000; // RFC 8628 §3.5
|
|
330
|
+
continue;
|
|
331
|
+
case "expired_token":
|
|
332
|
+
throw new Error("Device authorization expired before approval. Please try again.");
|
|
333
|
+
case "access_denied":
|
|
334
|
+
throw new Error("Authorization was denied.");
|
|
335
|
+
default:
|
|
336
|
+
throw new Error(`Device authorization failed: ${body.error ?? `HTTP ${res.status}`}` +
|
|
337
|
+
(body.error_description ? ` — ${body.error_description}` : ""));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// ============================================================================
|
|
342
|
+
// Refresh (RFC 6749 §6)
|
|
343
|
+
// ============================================================================
|
|
344
|
+
/**
|
|
345
|
+
* Exchange a refresh token for a fresh access token. Returns null if refresh is
|
|
346
|
+
* not possible (no refresh token, or the AS rejects it — e.g. revoked/expired),
|
|
347
|
+
* so callers can fall back to prompting for re-login rather than crashing.
|
|
348
|
+
*/
|
|
349
|
+
export async function refreshTokens(refreshToken, clientId) {
|
|
350
|
+
if (!refreshToken || !clientId)
|
|
351
|
+
return null;
|
|
352
|
+
try {
|
|
353
|
+
const meta = await discover();
|
|
354
|
+
const res = await fetch(meta.token_endpoint, {
|
|
355
|
+
method: "POST",
|
|
356
|
+
headers: {
|
|
357
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
358
|
+
Accept: "application/json",
|
|
359
|
+
},
|
|
360
|
+
body: new URLSearchParams({
|
|
361
|
+
grant_type: "refresh_token",
|
|
362
|
+
refresh_token: refreshToken,
|
|
363
|
+
client_id: clientId,
|
|
364
|
+
}),
|
|
365
|
+
});
|
|
366
|
+
if (!res.ok)
|
|
367
|
+
return null;
|
|
368
|
+
const data = (await res.json());
|
|
369
|
+
const tokens = tokensFromResponse(data);
|
|
370
|
+
// Per RFC 6749 §6, a refresh response MAY omit a new refresh token, in which
|
|
371
|
+
// case the old one remains valid — preserve it so the next refresh works.
|
|
372
|
+
if (!tokens.refreshToken)
|
|
373
|
+
tokens.refreshToken = refreshToken;
|
|
374
|
+
return tokens;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// Helpers
|
|
382
|
+
// ============================================================================
|
|
383
|
+
/**
|
|
384
|
+
* Open `url` in the user's default browser WITHOUT a shell. The URL is always
|
|
385
|
+
* passed as a separate argv element (never concatenated into a command string),
|
|
386
|
+
* so shell metacharacters — `$(...)`, backticks, `\` — in the URL can never be
|
|
387
|
+
* interpreted as a command. Fire-and-forget: any failure (no opener installed,
|
|
388
|
+
* headless host) is swallowed because callers already print a manual-URL
|
|
389
|
+
* fallback, so a missing browser must not crash login.
|
|
390
|
+
*/
|
|
391
|
+
export function openBrowser(url) {
|
|
392
|
+
// On Windows, `start` is a cmd builtin and its FIRST quoted arg is the window
|
|
393
|
+
// title; the empty "" makes the URL the target rather than the title.
|
|
394
|
+
const [command, args] = process.platform === "darwin"
|
|
395
|
+
? ["open", [url]]
|
|
396
|
+
: process.platform === "win32"
|
|
397
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
398
|
+
: ["xdg-open", [url]];
|
|
399
|
+
try {
|
|
400
|
+
const child = execFile(command, args, () => {
|
|
401
|
+
/* swallow opener errors — the manual URL fallback is already printed */
|
|
402
|
+
});
|
|
403
|
+
// Don't keep the event loop alive on the opener; login flow waits on the
|
|
404
|
+
// loopback/device promise, not on the browser process.
|
|
405
|
+
child.unref?.();
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
/* execFile threw synchronously (e.g. command not found) — ignore */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function defaultSleep(ms) {
|
|
412
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
413
|
+
}
|
|
414
|
+
/** Constant-time string compare that tolerates length differences. */
|
|
415
|
+
function timingSafeEqual(a, b) {
|
|
416
|
+
const ab = Buffer.from(a);
|
|
417
|
+
const bb = Buffer.from(b);
|
|
418
|
+
if (ab.length !== bb.length)
|
|
419
|
+
return false;
|
|
420
|
+
return crypto.timingSafeEqual(ab, bb);
|
|
421
|
+
}
|
|
422
|
+
/** Escape a string for safe interpolation into HTML text content. */
|
|
423
|
+
function escapeHtml(s) {
|
|
424
|
+
return s
|
|
425
|
+
.replace(/&/g, "&")
|
|
426
|
+
.replace(/</g, "<")
|
|
427
|
+
.replace(/>/g, ">")
|
|
428
|
+
.replace(/"/g, """)
|
|
429
|
+
.replace(/'/g, "'");
|
|
430
|
+
}
|
|
431
|
+
function respondHtml(res, status, title, body) {
|
|
432
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
433
|
+
// title/body are escaped: on the error path `body` carries the OAuth
|
|
434
|
+
// error_description straight from the callback query string (attacker-
|
|
435
|
+
// controllable on the loopback URL), so interpolating it raw is reflected XSS
|
|
436
|
+
// (CodeQL js/reflected-xss). Escaping closes it; the page is plain text anyway.
|
|
437
|
+
res.end(`<html><body style="font-family:system-ui;text-align:center;padding:60px">` +
|
|
438
|
+
`<h2>${escapeHtml(title)}</h2><p>${escapeHtml(body)}</p></body></html>`);
|
|
439
|
+
}
|
|
440
|
+
/** Format a standard OAuth error response ({error, error_description}). */
|
|
441
|
+
async function oauthError(res) {
|
|
442
|
+
const body = (await res.json().catch(() => ({})));
|
|
443
|
+
if (body.error) {
|
|
444
|
+
return body.error + (body.error_description ? ` — ${body.error_description}` : "");
|
|
445
|
+
}
|
|
446
|
+
return `HTTP ${res.status}`;
|
|
447
|
+
}
|
|
448
|
+
async function safeBody(res) {
|
|
449
|
+
return (await res.text().catch(() => "")) || `HTTP ${res.status}`;
|
|
450
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared first-run / onboarding copy for the CLI's empty states.
|
|
3
|
+
*
|
|
4
|
+
* Centralised so the gateway-integration instructions and the trace-ingestion
|
|
5
|
+
* note stay identical across `logs`, `status`, and `integrity`, and stay in
|
|
6
|
+
* lockstep with the canonical gateway quickstart
|
|
7
|
+
* (docs.mnemom.ai/quickstart/gateway).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Traces are eventually-consistent: they're processed asynchronously and take
|
|
11
|
+
* a short while to land. Surfacing this turns a bare "0 traces" — which reads
|
|
12
|
+
* as a broken pipeline right after a successful call — into an expected,
|
|
13
|
+
* temporary state (MNE-270).
|
|
14
|
+
*/
|
|
15
|
+
export declare const TRACES_PENDING_NOTE: string;
|
|
16
|
+
/**
|
|
17
|
+
* Canonical gateway integration instructions (MNE-268).
|
|
18
|
+
*
|
|
19
|
+
* Mirrors the quickstart: point the provider client at the gateway's
|
|
20
|
+
* `/anthropic` base URL and name the agent with the `x-mnemom-agent` header.
|
|
21
|
+
* The previously-printed `/v1/proxy/<id>` base-URL form is NOT a real gateway
|
|
22
|
+
* route — the gateway only serves `/anthropic/*`, `/openai/*`, `/gemini/*` and
|
|
23
|
+
* resolves the agent from the `x-mnemom-agent` header (which is a name, not an
|
|
24
|
+
* id). `agentName` is the customer-chosen agent name; we fall back to a
|
|
25
|
+
* placeholder rather than printing the resolved `mnm-` id, which would be the
|
|
26
|
+
* wrong value for that header.
|
|
27
|
+
*/
|
|
28
|
+
export declare function gatewayIntegrationHelp(gatewayUrl: string, agentName?: string): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared first-run / onboarding copy for the CLI's empty states.
|
|
3
|
+
*
|
|
4
|
+
* Centralised so the gateway-integration instructions and the trace-ingestion
|
|
5
|
+
* note stay identical across `logs`, `status`, and `integrity`, and stay in
|
|
6
|
+
* lockstep with the canonical gateway quickstart
|
|
7
|
+
* (docs.mnemom.ai/quickstart/gateway).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Traces are eventually-consistent: they're processed asynchronously and take
|
|
11
|
+
* a short while to land. Surfacing this turns a bare "0 traces" — which reads
|
|
12
|
+
* as a broken pipeline right after a successful call — into an expected,
|
|
13
|
+
* temporary state (MNE-270).
|
|
14
|
+
*/
|
|
15
|
+
export const TRACES_PENDING_NOTE = "Traces are processed asynchronously and can take a minute or two to appear.\n" +
|
|
16
|
+
"If you just made a request, wait a moment and run this again.";
|
|
17
|
+
/**
|
|
18
|
+
* Canonical gateway integration instructions (MNE-268).
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the quickstart: point the provider client at the gateway's
|
|
21
|
+
* `/anthropic` base URL and name the agent with the `x-mnemom-agent` header.
|
|
22
|
+
* The previously-printed `/v1/proxy/<id>` base-URL form is NOT a real gateway
|
|
23
|
+
* route — the gateway only serves `/anthropic/*`, `/openai/*`, `/gemini/*` and
|
|
24
|
+
* resolves the agent from the `x-mnemom-agent` header (which is a name, not an
|
|
25
|
+
* id). `agentName` is the customer-chosen agent name; we fall back to a
|
|
26
|
+
* placeholder rather than printing the resolved `mnm-` id, which would be the
|
|
27
|
+
* wrong value for that header.
|
|
28
|
+
*/
|
|
29
|
+
export function gatewayIntegrationHelp(gatewayUrl, agentName) {
|
|
30
|
+
const label = agentName ?? "<your-agent-name>";
|
|
31
|
+
return ("Route requests through the gateway and name your agent with a header:\n\n" +
|
|
32
|
+
` ANTHROPIC_BASE_URL="${gatewayUrl}/anthropic"\n` +
|
|
33
|
+
` header x-mnemom-agent: ${label}\n\n` +
|
|
34
|
+
"Quickstart: https://docs.mnemom.ai/quickstart/gateway");
|
|
35
|
+
}
|
package/dist/lib/prompt.d.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* @returns Promise<boolean> - true for yes, false for no
|
|
6
6
|
*/
|
|
7
7
|
export declare function askYesNo(question: string, defaultYes?: boolean): Promise<boolean>;
|
|
8
|
+
/** Test-only: reset the cached piped-stdin reader between cases. */
|
|
9
|
+
export declare function __resetPipedStdinForTests(): void;
|
|
8
10
|
/**
|
|
9
11
|
* Prompt for a single line of text input.
|
|
10
|
-
* If mask is true, input is hidden (for API keys).
|
|
12
|
+
* If mask is true, input is hidden (for API keys / passwords).
|
|
11
13
|
*/
|
|
12
14
|
export declare function askInput(question: string, mask?: boolean): Promise<string>;
|
|
13
15
|
/**
|
package/dist/lib/prompt.js
CHANGED
|
@@ -32,11 +32,57 @@ export async function askYesNo(question, defaultYes = true) {
|
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
|
+
// ----------------------------------------------------------------------------
|
|
36
|
+
// Non-interactive (piped / scripted / CI) stdin.
|
|
37
|
+
//
|
|
38
|
+
// When stdin is NOT a TTY, prompts must read sequential lines from a SINGLE
|
|
39
|
+
// shared reader. Creating a fresh `readline.createInterface` per prompt — as
|
|
40
|
+
// askInput did historically — silently drops buffered input on a pipe: the
|
|
41
|
+
// first interface reads all currently-available bytes into its internal
|
|
42
|
+
// buffer, emits the first line, then discards the rest on close, so the next
|
|
43
|
+
// prompt sees EOF and resolves empty. That made `mnemom login --no-browser`
|
|
44
|
+
// with a piped password silently fail (MNE-269): the email read fine, the
|
|
45
|
+
// password came back empty, and login looked like it had asked but got
|
|
46
|
+
// nothing. We read every line of stdin once, then hand them out in order. On
|
|
47
|
+
// EOF (no more lines) callers get "" and can fail loudly instead of hanging.
|
|
48
|
+
let pipedStdinLines = null;
|
|
49
|
+
let pipedStdinPromise = null;
|
|
50
|
+
function readPipedStdinLines() {
|
|
51
|
+
if (pipedStdinLines)
|
|
52
|
+
return Promise.resolve(pipedStdinLines);
|
|
53
|
+
if (!pipedStdinPromise) {
|
|
54
|
+
pipedStdinPromise = new Promise((resolve) => {
|
|
55
|
+
const lines = [];
|
|
56
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
57
|
+
rl.on("line", (line) => lines.push(line));
|
|
58
|
+
rl.on("close", () => {
|
|
59
|
+
pipedStdinLines = lines;
|
|
60
|
+
resolve(lines);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return pipedStdinPromise;
|
|
65
|
+
}
|
|
66
|
+
/** Test-only: reset the cached piped-stdin reader between cases. */
|
|
67
|
+
export function __resetPipedStdinForTests() {
|
|
68
|
+
pipedStdinLines = null;
|
|
69
|
+
pipedStdinPromise = null;
|
|
70
|
+
}
|
|
35
71
|
/**
|
|
36
72
|
* Prompt for a single line of text input.
|
|
37
|
-
* If mask is true, input is hidden (for API keys).
|
|
73
|
+
* If mask is true, input is hidden (for API keys / passwords).
|
|
38
74
|
*/
|
|
39
75
|
export async function askInput(question, mask = false) {
|
|
76
|
+
// Non-interactive stdin (pipe / CI / script): serve the next buffered line
|
|
77
|
+
// from the shared reader. Masking is meaningless on a pipe — just consume the
|
|
78
|
+
// line. On EOF this returns "" so callers can fail loudly (and never hang).
|
|
79
|
+
if (!process.stdin.isTTY) {
|
|
80
|
+
process.stdout.write(`${question} `);
|
|
81
|
+
const lines = await readPipedStdinLines();
|
|
82
|
+
const next = lines.shift();
|
|
83
|
+
process.stdout.write("\n");
|
|
84
|
+
return (next ?? "").trim();
|
|
85
|
+
}
|
|
40
86
|
const rl = readline.createInterface({
|
|
41
87
|
input: process.stdin,
|
|
42
88
|
output: process.stdout,
|