@mnemom/mnemom 0.17.31-next.1 → 0.17.31

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.
@@ -1,4 +1,5 @@
1
1
  import { getAuthInfo, clearAuthTokens, loginWithBrowser, loginWithDeviceFlow, resolveAuth, } from "../lib/auth.js";
2
+ import { getMe } from "../lib/api.js";
2
3
  import { fmt } from "../lib/format.js";
3
4
  export async function loginCommand(options = {}) {
4
5
  try {
@@ -7,6 +8,7 @@ export async function loginCommand(options = {}) {
7
8
  const tokens = options.noBrowser ? await loginWithDeviceFlow() : await loginWithBrowser();
8
9
  console.log();
9
10
  console.log(fmt.success("Logged in successfully!"));
11
+ await printSignedInAs();
10
12
  printGrant(tokens);
11
13
  console.log();
12
14
  }
@@ -16,6 +18,24 @@ export async function loginCommand(options = {}) {
16
18
  process.exit(1);
17
19
  }
18
20
  }
21
+ /**
22
+ * Name the account this login signed in as, so a login finished in the wrong
23
+ * account (an approval from another browser or device that was signed in as
24
+ * someone else) is visible straight away. The OAuth token carries no identity,
25
+ * so this asks the API; if that fails the login still succeeded, so it prints
26
+ * nothing rather than an error.
27
+ */
28
+ async function printSignedInAs() {
29
+ let email;
30
+ try {
31
+ email = (await getMe()).email;
32
+ }
33
+ catch {
34
+ return;
35
+ }
36
+ if (email)
37
+ console.log(`Signed in as ${email}`);
38
+ }
19
39
  /**
20
40
  * Print what the issued token actually grants. OAuth access tokens are scoped
21
41
  * and (deliberately) carry no user identity, so we show scope + expiry rather
@@ -89,10 +89,35 @@ export interface LoopbackResult {
89
89
  * scoped tokens plus the client_id that was used (so the caller can persist it
90
90
  * for refresh). `openUrl` is injectable so tests can drive the flow without
91
91
  * spawning a real browser.
92
+ *
93
+ * Cross-device completion: when the AS advertises a device authorization
94
+ * endpoint, this also starts an RFC 8628 device authorization for the same
95
+ * client and prints its code with the verification URI. The login then
96
+ * finishes on whichever comes first: the loopback redirect (same machine) or an
97
+ * approval of the device code from any browser (e.g. the phone that opened the
98
+ * sign-up confirmation email), where the person types the code from this
99
+ * terminal. The code is never put in the authorize URL or any other link, so
100
+ * nothing that opens in a browser can approve this login without the person
101
+ * typing it. Tokens only ever come back to this process: the loopback code
102
+ * needs our PKCE verifier and the device grant needs our device_code. If the
103
+ * device authorization cannot be started, the login is exactly the loopback
104
+ * flow it always was.
92
105
  */
93
106
  export declare function loginWithLoopback(openUrl?: (url: string) => void, opts?: {
94
107
  inviteToken?: string;
108
+ sleep?: (ms: number) => Promise<void>;
95
109
  }): Promise<LoopbackResult>;
110
+ /** How long the loopback listener waits while a device code is live. */
111
+ export declare function crossDeviceWaitMs(expiresInSec: number): number;
112
+ /**
113
+ * Settle with whichever login path finishes first.
114
+ * - The loopback path settles the login either way, exactly as before: a
115
+ * denial, a state mismatch or the timeout in the browser ends it.
116
+ * - The device path ends it on success, or when the person explicitly denies
117
+ * the device code. Any other device failure (expiry, a transport error)
118
+ * only drops that path and the loopback wait continues.
119
+ */
120
+ export declare function firstLogin(viaLoopback: Promise<OAuthTokens>, viaDevice: Promise<OAuthTokens> | null): Promise<OAuthTokens>;
96
121
  export interface DeviceResult {
97
122
  tokens: OAuthTokens;
98
123
  clientId: string;
@@ -169,6 +194,11 @@ export declare function browserOpenerFor(platform: NodeJS.Platform, href: string
169
194
  * Returns whether a launch was attempted.
170
195
  */
171
196
  export declare function openBrowser(url: string): boolean;
197
+ /**
198
+ * setTimeout-based sleep that resolves early when `signal` aborts, so an
199
+ * abandoned device poll does not hold the process open after login finishes.
200
+ */
201
+ export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
172
202
  /**
173
203
  * Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
174
204
  * silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
package/dist/lib/oauth.js CHANGED
@@ -35,6 +35,12 @@ const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
35
35
  // Loopback login waits at most this long for the browser round-trip before
36
36
  // giving up and freeing the port.
37
37
  const LOOPBACK_TIMEOUT_MS = 5 * 60 * 1000;
38
+ // Browser login also starts a device authorization (RFC 8628) when the AS offers
39
+ // one, so the login can be approved from ANOTHER device: someone who signs up in
40
+ // the browser and confirms the email on their phone. While that is live the
41
+ // loopback listener waits as long as the device code is valid, capped here, so
42
+ // the port is never left open indefinitely.
43
+ const CROSS_DEVICE_MAX_WAIT_MS = 15 * 60 * 1000;
38
44
  // Cadence for the "…still waiting for you to finish signing in" heartbeat that
39
45
  // both interactive login poll loops emit, so a multi-minute browser sign-in
40
46
  // doesn't read as a hung CLI (matches the card-write retry tick in try-me).
@@ -208,46 +214,120 @@ function tokensFromResponse(data) {
208
214
  * scoped tokens plus the client_id that was used (so the caller can persist it
209
215
  * for refresh). `openUrl` is injectable so tests can drive the flow without
210
216
  * spawning a real browser.
217
+ *
218
+ * Cross-device completion: when the AS advertises a device authorization
219
+ * endpoint, this also starts an RFC 8628 device authorization for the same
220
+ * client and prints its code with the verification URI. The login then
221
+ * finishes on whichever comes first: the loopback redirect (same machine) or an
222
+ * approval of the device code from any browser (e.g. the phone that opened the
223
+ * sign-up confirmation email), where the person types the code from this
224
+ * terminal. The code is never put in the authorize URL or any other link, so
225
+ * nothing that opens in a browser can approve this login without the person
226
+ * typing it. Tokens only ever come back to this process: the loopback code
227
+ * needs our PKCE verifier and the device grant needs our device_code. If the
228
+ * device authorization cannot be started, the login is exactly the loopback
229
+ * flow it always was.
211
230
  */
212
231
  export async function loginWithLoopback(openUrl = openBrowser, opts = {}) {
213
232
  const meta = await discover();
214
233
  const pkce = generatePkce();
215
234
  const state = crypto.randomBytes(16).toString("hex");
216
- const { port, codePromise, close } = await startLoopbackServer(state);
235
+ const { port, codePromise, close, setTimeoutMs } = await startLoopbackServer(state);
217
236
  const redirectUri = `http://127.0.0.1:${port}/callback`;
218
- // Register a client bound to the exact loopback redirect URI. OAuth 2.1
219
- // requires exact redirect_uri matching, and the loopback port is ephemeral,
220
- // so we register per-login (cheap, public client, no secret).
221
- const clientId = await registerClient([redirectUri]);
222
- const authUrl = new URL(meta.authorization_endpoint);
223
- authUrl.searchParams.set("response_type", "code");
224
- authUrl.searchParams.set("client_id", clientId);
225
- authUrl.searchParams.set("redirect_uri", redirectUri);
226
- authUrl.searchParams.set("scope", negotiateScopes(meta));
227
- authUrl.searchParams.set("state", state);
228
- authUrl.searchParams.set("code_challenge", pkce.challenge);
229
- authUrl.searchParams.set("code_challenge_method", pkce.method);
230
- // A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
231
- // invitee who signs up in this browser round-trip skips the confirmation
232
- // email: the AS ignores the param but bounces a signed-out browser to /login
233
- // with this exact URL as return_to, and sign-up forwards the token to the api.
234
- if (opts.inviteToken)
235
- authUrl.searchParams.set("invite_token", opts.inviteToken);
236
- console.log("Opening browser to authenticate...");
237
- console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
238
- openUrl(authUrl.toString());
239
- console.log("Waiting for authentication...");
237
+ const stopPolling = new AbortController();
240
238
  try {
241
- // Heartbeat while we await the loopback redirect — otherwise the sign-in
242
- // round-trip is dead silent and reads as a hung CLI.
243
- const code = await withHeartbeat(codePromise, LOGIN_HEARTBEAT_MS, (s) => console.log(`…still waiting for you to finish signing in (${s}s)`));
244
- const tokens = await exchangeCode(meta, clientId, code, pkce.verifier, redirectUri);
239
+ // Register a client bound to the exact loopback redirect URI. OAuth 2.1
240
+ // requires exact redirect_uri matching, and the loopback port is ephemeral,
241
+ // so we register per-login (cheap, public client, no secret). The same
242
+ // client also runs the device grant, so either path refreshes the same way.
243
+ const clientId = await registerClient([redirectUri]);
244
+ const device = await startCrossDeviceAuthorization(meta, clientId);
245
+ const authUrl = new URL(meta.authorization_endpoint);
246
+ authUrl.searchParams.set("response_type", "code");
247
+ authUrl.searchParams.set("client_id", clientId);
248
+ authUrl.searchParams.set("redirect_uri", redirectUri);
249
+ authUrl.searchParams.set("scope", negotiateScopes(meta));
250
+ authUrl.searchParams.set("state", state);
251
+ authUrl.searchParams.set("code_challenge", pkce.challenge);
252
+ authUrl.searchParams.set("code_challenge_method", pkce.method);
253
+ // A Mnemom Agent invitation (`mnemom agent --invite`) rides along so a NEW
254
+ // invitee who signs up in this browser round-trip skips the confirmation
255
+ // email: the AS ignores the param but bounces a signed-out browser to /login
256
+ // with this exact URL as return_to, and sign-up forwards the token to the api.
257
+ if (opts.inviteToken)
258
+ authUrl.searchParams.set("invite_token", opts.inviteToken);
259
+ console.log("Opening browser to authenticate...");
260
+ console.log(`If the browser doesn't open, visit:\n ${authUrl.toString()}\n`);
261
+ if (device) {
262
+ setTimeoutMs(crossDeviceWaitMs(device.expires_in));
263
+ // The plain verification_uri, never verification_uri_complete: the person
264
+ // types the code there, so a link alone can never approve this login.
265
+ console.log(`Confirming on another device? Open ${device.verification_uri} and enter: ${device.user_code}\n`);
266
+ }
267
+ openUrl(authUrl.toString());
268
+ console.log("Waiting for authentication...");
269
+ const viaLoopback = codePromise.then((code) => exchangeCode(meta, clientId, code, pkce.verifier, redirectUri));
270
+ const viaDevice = device
271
+ ? pollDeviceToken(meta, clientId, device, opts.sleep ?? ((ms) => abortableSleep(ms, stopPolling.signal)),
272
+ // The loopback wait below already prints the heartbeat.
273
+ () => { }, stopPolling.signal)
274
+ : null;
275
+ // Heartbeat while we wait — otherwise the sign-in round-trip is dead
276
+ // silent and reads as a hung CLI.
277
+ const tokens = await withHeartbeat(firstLogin(viaLoopback, viaDevice), LOGIN_HEARTBEAT_MS, (s) => console.log(`…still waiting for you to finish signing in (${s}s)`));
245
278
  return { tokens, clientId };
246
279
  }
247
280
  finally {
281
+ stopPolling.abort();
248
282
  close();
249
283
  }
250
284
  }
285
+ /**
286
+ * Start the device authorization that lets a browser login be approved from
287
+ * another device. Best-effort: an AS without the endpoint, or any failure,
288
+ * returns null and the login carries on as loopback only.
289
+ */
290
+ async function startCrossDeviceAuthorization(meta, clientId) {
291
+ if (!meta.device_authorization_endpoint)
292
+ return null;
293
+ try {
294
+ const authz = await requestDeviceAuthorization(meta.device_authorization_endpoint, meta, clientId);
295
+ if (typeof authz.device_code !== "string" ||
296
+ typeof authz.user_code !== "string" ||
297
+ typeof authz.verification_uri !== "string" ||
298
+ typeof authz.expires_in !== "number") {
299
+ return null;
300
+ }
301
+ return authz;
302
+ }
303
+ catch {
304
+ return null;
305
+ }
306
+ }
307
+ /** How long the loopback listener waits while a device code is live. */
308
+ export function crossDeviceWaitMs(expiresInSec) {
309
+ const ms = Number.isFinite(expiresInSec) ? expiresInSec * 1000 : 0;
310
+ return Math.min(Math.max(ms, LOOPBACK_TIMEOUT_MS), CROSS_DEVICE_MAX_WAIT_MS);
311
+ }
312
+ /**
313
+ * Settle with whichever login path finishes first.
314
+ * - The loopback path settles the login either way, exactly as before: a
315
+ * denial, a state mismatch or the timeout in the browser ends it.
316
+ * - The device path ends it on success, or when the person explicitly denies
317
+ * the device code. Any other device failure (expiry, a transport error)
318
+ * only drops that path and the loopback wait continues.
319
+ */
320
+ export function firstLogin(viaLoopback, viaDevice) {
321
+ if (!viaDevice)
322
+ return viaLoopback;
323
+ return new Promise((resolve, reject) => {
324
+ viaLoopback.then(resolve, reject);
325
+ viaDevice.then(resolve, (err) => {
326
+ if (err instanceof Error && err.message === DEVICE_ACCESS_DENIED)
327
+ reject(err);
328
+ });
329
+ });
330
+ }
251
331
  /**
252
332
  * Start a loopback HTTP listener that captures the OAuth redirect
253
333
  * (GET /callback?code=...&state=...). Resolves with the authorization code once
@@ -293,13 +373,18 @@ function startLoopbackServer(expectedState) {
293
373
  return new Promise((resolve) => {
294
374
  server.listen(0, "127.0.0.1", () => {
295
375
  const port = server.address().port;
296
- const timeout = setTimeout(() => {
376
+ const giveUp = () => {
297
377
  rejectCode(new Error("Login timed out. Please try again."));
298
378
  server.close();
299
- }, LOOPBACK_TIMEOUT_MS);
379
+ };
380
+ let timeout = setTimeout(giveUp, LOOPBACK_TIMEOUT_MS);
300
381
  resolve({
301
382
  port,
302
383
  codePromise,
384
+ setTimeoutMs: (ms) => {
385
+ clearTimeout(timeout);
386
+ timeout = setTimeout(giveUp, ms);
387
+ },
303
388
  close: () => {
304
389
  clearTimeout(timeout);
305
390
  server.close();
@@ -358,18 +443,7 @@ export async function loginWithDevice(opts) {
358
443
  // clients"). Register a loopback placeholder — it satisfies validation and is
359
444
  // never used, since the device grant never redirects.
360
445
  const clientId = await registerClient(["http://127.0.0.1/callback"]);
361
- const authzRes = await oauthFetch("Device authorization at", meta.device_authorization_endpoint, {
362
- method: "POST",
363
- headers: {
364
- "Content-Type": "application/x-www-form-urlencoded",
365
- Accept: "application/json",
366
- },
367
- body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
368
- });
369
- if (!authzRes.ok) {
370
- throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
371
- }
372
- const authz = (await authzRes.json());
446
+ const authz = await requestDeviceAuthorization(meta.device_authorization_endpoint, meta, clientId);
373
447
  display("");
374
448
  display("To authenticate, visit:");
375
449
  display(` ${authz.verification_uri}`);
@@ -385,7 +459,26 @@ export async function loginWithDevice(opts) {
385
459
  const tokens = await pollDeviceToken(meta, clientId, authz, sleep, display);
386
460
  return { tokens, clientId };
387
461
  }
388
- async function pollDeviceToken(meta, clientId, authz, sleep, display) {
462
+ /** POST the device authorization request (RFC 8628 §3.1) for `clientId`. */
463
+ async function requestDeviceAuthorization(endpoint, meta, clientId) {
464
+ const authzRes = await oauthFetch("Device authorization at", endpoint, {
465
+ method: "POST",
466
+ headers: {
467
+ "Content-Type": "application/x-www-form-urlencoded",
468
+ Accept: "application/json",
469
+ },
470
+ body: new URLSearchParams({ client_id: clientId, scope: negotiateScopes(meta) }),
471
+ });
472
+ if (!authzRes.ok) {
473
+ throw new Error(`Device authorization failed: ${await oauthError(authzRes)}`);
474
+ }
475
+ return (await authzRes.json());
476
+ }
477
+ /** The message a device-code denial rejects with (matched by firstLogin). */
478
+ const DEVICE_ACCESS_DENIED = "Authorization was denied.";
479
+ /** Thrown into an abandoned device poll once the other login path has won. */
480
+ const POLL_STOPPED = "Device polling stopped.";
481
+ async function pollDeviceToken(meta, clientId, authz, sleep, display, signal) {
389
482
  // RFC 8628 §3.5: default interval is 5s if the server omits it; on slow_down
390
483
  // we increase the interval by 5s and keep that as the new minimum.
391
484
  let intervalMs = (authz.interval ?? 5) * 1000;
@@ -399,6 +492,8 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
399
492
  throw new Error("Device authorization expired before approval. Please try again.");
400
493
  }
401
494
  await sleep(intervalMs);
495
+ if (signal?.aborted)
496
+ throw new Error(POLL_STOPPED);
402
497
  waitedMs += intervalMs;
403
498
  let res;
404
499
  try {
@@ -437,7 +532,7 @@ async function pollDeviceToken(meta, clientId, authz, sleep, display) {
437
532
  case "expired_token":
438
533
  throw new Error("Device authorization expired before approval. Please try again.");
439
534
  case "access_denied":
440
- throw new Error("Authorization was denied.");
535
+ throw new Error(DEVICE_ACCESS_DENIED);
441
536
  default:
442
537
  throw new Error(`Device authorization failed: ${body.error ?? `HTTP ${res.status}`}` +
443
538
  (body.error_description ? ` — ${body.error_description}` : ""));
@@ -623,6 +718,23 @@ export function openBrowser(url) {
623
718
  function defaultSleep(ms) {
624
719
  return new Promise((resolve) => setTimeout(resolve, ms));
625
720
  }
721
+ /**
722
+ * setTimeout-based sleep that resolves early when `signal` aborts, so an
723
+ * abandoned device poll does not hold the process open after login finishes.
724
+ */
725
+ export function abortableSleep(ms, signal) {
726
+ return new Promise((resolve) => {
727
+ if (signal.aborted)
728
+ return resolve();
729
+ const done = () => {
730
+ clearTimeout(timer);
731
+ signal.removeEventListener("abort", done);
732
+ resolve();
733
+ };
734
+ const timer = setTimeout(done, ms);
735
+ signal.addEventListener("abort", done, { once: true });
736
+ });
737
+ }
626
738
  /**
627
739
  * Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
628
740
  * silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.31-next.1",
3
+ "version": "0.17.31",
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.11",
29
+ "@vitest/coverage-v8": "^4.1.12",
30
30
  "tsx": "^4.23.1",
31
31
  "typescript": "^6.0.3",
32
- "vitest": "^4.1.11"
32
+ "vitest": "^4.1.12"
33
33
  },
34
34
  "files": [
35
35
  "dist",
@@ -71,7 +71,7 @@
71
71
  "node": ">=18.0.0"
72
72
  },
73
73
  "overrides": {
74
- "@vitest/mocker": "^4.1.11",
74
+ "@vitest/mocker": "^4.1.12",
75
75
  "nanoid": "^3.3.18"
76
76
  }
77
77
  }