@mnemom/mnemom 0.15.0 → 0.15.1-next.1
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/try-me.js +183 -50
- package/dist/lib/oauth.d.ts +11 -0
- package/dist/lib/oauth.js +45 -5
- package/dist/lib/prompt.js +19 -7
- package/package.json +1 -1
package/dist/commands/try-me.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* host-connector authorization in the MCP flow, not a forged credential.
|
|
25
25
|
*/
|
|
26
26
|
import { resolveBriefing, birthAgent, fetchAgentClaimed, buildClaimUrl, buildGrantUrl, buildDojoDeepLink, claimProof, isTokenMode, looksLikeTryMeToken, sleep, } from "../lib/try-me.js";
|
|
27
|
-
import { putAlignmentCard, putProtectionCard, MnemomApiError } from "../lib/api.js";
|
|
27
|
+
import { putAlignmentCard, putProtectionCard, listOrgAgents, MnemomApiError } from "../lib/api.js";
|
|
28
28
|
import { resolveAuth, loginWithBrowser, loginWithDeviceFlow } from "../lib/auth.js";
|
|
29
29
|
import { openBrowser } from "../lib/oauth.js";
|
|
30
30
|
import { askSelect, askInput, askYesNo, isInteractive } from "../lib/prompt.js";
|
|
@@ -40,6 +40,13 @@ export async function tryMeCommand(token, options = {}) {
|
|
|
40
40
|
const json = !!options.json;
|
|
41
41
|
// JSON output is only coherent non-interactively — no prompts can be shown.
|
|
42
42
|
const nonInteractive = !!options.yes || json || !isInteractive();
|
|
43
|
+
// The name pick is a deliberate human-handoff checkpoint (the manifest marks
|
|
44
|
+
// it human_handoff:true). It must fire on EVERY real run — agent-driven,
|
|
45
|
+
// piped, and inherited-stdin runs all report isTTY=false yet can still answer
|
|
46
|
+
// a prompt — so it is auto-skipped ONLY on an explicit non-interactive
|
|
47
|
+
// request: --yes, --json, or --name (the last handled inside pickName). It is
|
|
48
|
+
// intentionally NOT gated on isInteractive(), unlike the open/login prompts.
|
|
49
|
+
const skipNamePrompt = !!options.yes || json;
|
|
43
50
|
const autoOpen = options.open !== false; // --no-open → false
|
|
44
51
|
const result = { token, version: "", dry_run: !!options.dryRun, steps: [] };
|
|
45
52
|
// A human-facing log that is silenced in --json mode (the JSON is the output).
|
|
@@ -69,7 +76,7 @@ export async function tryMeCommand(token, options = {}) {
|
|
|
69
76
|
}
|
|
70
77
|
const apiBase = (options.api ?? getApiUrl()).replace(/\/$/, "");
|
|
71
78
|
// ── State: name (human checkpoint) ──────────────────────────────────────────
|
|
72
|
-
const name = await pickName(manifest, options,
|
|
79
|
+
const name = await pickName(manifest, options, skipNamePrompt, say);
|
|
73
80
|
result.steps.push({ step: "name", status: "ok", detail: name });
|
|
74
81
|
// ── State: birth ────────────────────────────────────────────────────────────
|
|
75
82
|
let agentId;
|
|
@@ -107,12 +114,15 @@ export async function tryMeCommand(token, options = {}) {
|
|
|
107
114
|
result.claimed = true;
|
|
108
115
|
result.steps.push({ step: "claim", status: "ok", detail: agentId });
|
|
109
116
|
say(fmt.success("Claimed — you now own this agent."));
|
|
117
|
+
// The session context the card writes share: ensureSession + writeCardWithRetry
|
|
118
|
+
// both need it (the latter re-triggers ensureSession on a persistent auth 403).
|
|
119
|
+
const sessionCtx = { nonInteractive, autoOpen, agentId, say };
|
|
110
120
|
// ── Ensure a CLI session for the card writes (the human's own login) ────────
|
|
111
|
-
await ensureSession(
|
|
121
|
+
await ensureSession(sessionCtx);
|
|
112
122
|
// ── State: alignment (set directly with the post-claim session) ─────────────
|
|
113
123
|
say();
|
|
114
124
|
say(`${fmt.badge("alignment", "cyan")} Publishing your alignment card — your signed, public statement of intent…`);
|
|
115
|
-
await writeCardWithRetry("alignment",
|
|
125
|
+
await writeCardWithRetry("alignment", manifest.declare.alignment_card, options.pollTimeout ?? 600, sessionCtx);
|
|
116
126
|
result.alignment = "set";
|
|
117
127
|
result.steps.push({ step: "alignment", status: "ok" });
|
|
118
128
|
say(fmt.success("Alignment card set."));
|
|
@@ -126,7 +136,7 @@ export async function tryMeCommand(token, options = {}) {
|
|
|
126
136
|
say,
|
|
127
137
|
});
|
|
128
138
|
say(fmt.dim("Waiting for your approval, then setting the protection card…"));
|
|
129
|
-
await writeCardWithRetry("protection",
|
|
139
|
+
await writeCardWithRetry("protection", manifest.declare.protection_card, options.pollTimeout ?? 600, sessionCtx);
|
|
130
140
|
result.protection = "set";
|
|
131
141
|
result.steps.push({ step: "protection", status: "ok" });
|
|
132
142
|
say(fmt.success("Protection card set."));
|
|
@@ -143,12 +153,12 @@ export async function tryMeCommand(token, options = {}) {
|
|
|
143
153
|
console.log(JSON.stringify(result, null, 2));
|
|
144
154
|
}
|
|
145
155
|
// ── name (human checkpoint) ──────────────────────────────────────────────────
|
|
146
|
-
async function pickName(manifest, options,
|
|
156
|
+
async function pickName(manifest, options, skipPrompt, say) {
|
|
147
157
|
if (options.name && options.name.trim())
|
|
148
158
|
return options.name.trim();
|
|
149
159
|
const opts = manifest.handoff.name_options ?? [];
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
const fallback = opts[0] ?? "mnemom-dojo-agent";
|
|
161
|
+
if (skipPrompt) {
|
|
152
162
|
say(fmt.dim(`Non-interactive: naming the agent "${fallback}" (override with --name).`));
|
|
153
163
|
return fallback;
|
|
154
164
|
}
|
|
@@ -157,13 +167,19 @@ async function pickName(manifest, options, nonInteractive, say) {
|
|
|
157
167
|
const choice = await askSelect(manifest.handoff.name_question, [...opts, TYPE_MY_OWN]);
|
|
158
168
|
if (choice && choice !== TYPE_MY_OWN)
|
|
159
169
|
return choice;
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
typed = (await askInput("Enter a name for your agent:")).trim();
|
|
163
|
-
if (
|
|
164
|
-
|
|
170
|
+
// "Type my own" — or no valid selection — falls through to free-form entry.
|
|
171
|
+
for (;;) {
|
|
172
|
+
const typed = (await askInput("Enter a name for your agent:")).trim();
|
|
173
|
+
if (typed)
|
|
174
|
+
return typed;
|
|
175
|
+
// A non-responsive stream (EOF on a pipe) returns "" on every read; don't
|
|
176
|
+
// spin forever — fall back to the default so an unattended run completes.
|
|
177
|
+
if (!process.stdin.isTTY) {
|
|
178
|
+
say(fmt.warn(`No name entered on a non-interactive stream — using "${fallback}".`));
|
|
179
|
+
return fallback;
|
|
180
|
+
}
|
|
181
|
+
say(fmt.warn("A name is required (it's permanent) — please enter one."));
|
|
165
182
|
}
|
|
166
|
-
return typed;
|
|
167
183
|
}
|
|
168
184
|
/** Token mode is the shipped reality; legacy provider-key claims aren't supported by this runner. */
|
|
169
185
|
function missingLegacyProof() {
|
|
@@ -210,28 +226,99 @@ async function pollUntilClaimed(apiBase, agentId, timeoutSeconds, say) {
|
|
|
210
226
|
say(fmt.dim(` …still waiting for the claim (${waited / 1000}s).`));
|
|
211
227
|
}
|
|
212
228
|
}
|
|
213
|
-
// ── CLI session for the card writes ──────────────────────────────────────────
|
|
214
229
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
230
|
+
* Validate that a JWT session can actually act for the just-claimed agent —
|
|
231
|
+
* NOT merely that some credential exists on disk.
|
|
232
|
+
*
|
|
233
|
+
* The card-write PUTs authorize on ORG MEMBERSHIP (ADR-062): the caller may set
|
|
234
|
+
* the cards only if they belong to the org the agent was claimed into. Listing
|
|
235
|
+
* the caller's org fleets is the cheapest authoritative probe of exactly that —
|
|
236
|
+
* it both proves the token is live (a stale/revoked token throws "Not
|
|
237
|
+
* authenticated" / 401) AND that this session governs the agent (it appears in
|
|
238
|
+
* the fleet). A locally-unexpired token signed in as a DIFFERENT account passes
|
|
239
|
+
* the old `type !== "none"` check yet fails here (the agent isn't in its fleet),
|
|
240
|
+
* which is the silent-403 hang this guards against.
|
|
241
|
+
*
|
|
242
|
+
* A brief read lag right after the claim is absorbed with a couple of bounded
|
|
243
|
+
* retries so the happy path doesn't bounce a valid owner into a needless
|
|
244
|
+
* re-login. A non-auth error (network blip / 5xx) is inconclusive → we return
|
|
245
|
+
* `true` and let the card-write path's grace+escalation be the authority, so a
|
|
246
|
+
* transient hiccup can't strand the run.
|
|
219
247
|
*/
|
|
220
|
-
async function
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
248
|
+
async function sessionCanActForAgent(agentId) {
|
|
249
|
+
const ATTEMPTS = 3;
|
|
250
|
+
for (let i = 0; i < ATTEMPTS; i++) {
|
|
251
|
+
try {
|
|
252
|
+
const fleet = await listOrgAgents();
|
|
253
|
+
if (fleet.some((a) => a.id === agentId))
|
|
254
|
+
return true;
|
|
255
|
+
// Authenticated, but the agent isn't (yet) in any of the caller's orgs.
|
|
256
|
+
// Retry a couple of times to ride out post-claim propagation before
|
|
257
|
+
// concluding the session is for the wrong account.
|
|
258
|
+
if (i < ATTEMPTS - 1)
|
|
259
|
+
await sleep(POLL_INTERVAL_MS);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
const status = err instanceof MnemomApiError ? err.effectiveStatus : undefined;
|
|
263
|
+
const authFailure = status === 401 || (err instanceof Error && /not authenticated/i.test(err.message));
|
|
264
|
+
// A definitive auth failure ⇒ the session is unusable ⇒ force re-login.
|
|
265
|
+
if (authFailure)
|
|
266
|
+
return false;
|
|
267
|
+
// Anything else is inconclusive — don't force a re-login on it.
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Ensure the CLI holds a session that can set THIS agent's cards before writing
|
|
275
|
+
* them. The card-write PUTs authorize on org membership and need the human's
|
|
276
|
+
* JWT/API-key — the headless twin of the MCP host-connector authorization.
|
|
277
|
+
*
|
|
278
|
+
* This does NOT trust a bare existence check: a stale/expired/wrong-account
|
|
279
|
+
* session passes `type !== "none"` but can't write the cards, which previously
|
|
280
|
+
* turned into a silent multi-minute 403 retry hang. So for a JWT we VALIDATE it
|
|
281
|
+
* can act for the agent's owner and, if not, fall through to a fresh login
|
|
282
|
+
* instead of returning early.
|
|
283
|
+
*
|
|
284
|
+
* `force` (set by the card-write escalation) skips the existence/validation
|
|
285
|
+
* check and re-authenticates unconditionally — the session on disk has already
|
|
286
|
+
* proven it can't write this agent's cards.
|
|
287
|
+
*/
|
|
288
|
+
async function ensureSession(ctx, opts = {}) {
|
|
289
|
+
if (!opts.force) {
|
|
290
|
+
const cred = await resolveAuth();
|
|
291
|
+
// An API key is a long-lived credential with no expiry/identity to validate
|
|
292
|
+
// (and the org-scoped probe rejects API keys) — trust it as before.
|
|
293
|
+
if (cred.type === "api-key")
|
|
294
|
+
return;
|
|
295
|
+
if (cred.type === "jwt") {
|
|
296
|
+
if (await sessionCanActForAgent(ctx.agentId))
|
|
297
|
+
return;
|
|
298
|
+
ctx.say();
|
|
299
|
+
ctx.say(fmt.warn("Your CLI session looks stale — it's expired or signed in as a different account, so " +
|
|
300
|
+
"it can't set this agent's cards. Re-authenticating as the agent's owner…"));
|
|
301
|
+
// fall through to the fresh-login path
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!opts.force) {
|
|
305
|
+
ctx.say();
|
|
306
|
+
ctx.say(fmt.dim("To set your cards as you, the CLI needs your Mnemom session — this is the headless twin of " +
|
|
307
|
+
"the host-connector authorization (your own login, not a forged credential)."));
|
|
308
|
+
}
|
|
227
309
|
if (ctx.nonInteractive) {
|
|
228
|
-
throw new Error("
|
|
229
|
-
`
|
|
310
|
+
throw new Error("No usable Mnemom session for this agent. Run `mnemom login` (or set MNEMOM_TOKEN / " +
|
|
311
|
+
`MNEMOM_API_KEY) as the account that owns ${ctx.agentId}, then re-run with ` +
|
|
312
|
+
`--resume ${ctx.agentId} to resume after the claim.`);
|
|
230
313
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
314
|
+
// On a forced re-auth the user is mid-flow and we've already explained why; go
|
|
315
|
+
// straight to the login rather than prompting again.
|
|
316
|
+
if (!opts.force) {
|
|
317
|
+
const ok = await askYesNo("Sign in now? (one-click — you just signed in to claim)", true);
|
|
318
|
+
if (!ok) {
|
|
319
|
+
throw new Error("A Mnemom session is required to set the cards. Run `mnemom login`, then re-run with " +
|
|
320
|
+
`--resume ${ctx.agentId} to resume.`);
|
|
321
|
+
}
|
|
235
322
|
}
|
|
236
323
|
// No local browser (e.g. SSH) → device flow; otherwise the loopback OAuth flow.
|
|
237
324
|
if (ctx.autoOpen) {
|
|
@@ -242,18 +329,52 @@ async function ensureSession(ctx) {
|
|
|
242
329
|
}
|
|
243
330
|
ctx.say(fmt.success("Signed in."));
|
|
244
331
|
}
|
|
245
|
-
// ── card writes (retry on
|
|
332
|
+
// ── card writes (retry on grant propagation; escalate on stale auth) ─────────
|
|
246
333
|
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* (the manifest's 403 insufficient_scope → keep polling; first 200 → done). Any
|
|
251
|
-
* other status is a real error and is surfaced immediately.
|
|
334
|
+
* Short window to absorb the legit org-membership propagation that can briefly
|
|
335
|
+
* 401/403 the alignment write right after a claim, before we treat a persistent
|
|
336
|
+
* unauthorized response as a stale-session auth problem worth re-authenticating.
|
|
252
337
|
*/
|
|
253
|
-
|
|
338
|
+
const AUTH_PROPAGATION_GRACE_MS = 20_000;
|
|
339
|
+
/**
|
|
340
|
+
* Classify a retriable (401/403) card-write failure.
|
|
341
|
+
*
|
|
342
|
+
* - `grant-pending`: a PROTECTION 403 — the one-time CISO grant hasn't landed in
|
|
343
|
+
* the browser yet (the manifest's `insufficient_scope`). This is expected and
|
|
344
|
+
* is polled for the full window; it must NOT trigger a re-login. (Alignment is
|
|
345
|
+
* written first and has no grant, so a stale/wrong-account session is caught
|
|
346
|
+
* there before protection runs — making a protection 403 reliably the grant.)
|
|
347
|
+
* - `auth`: a 401 anywhere, or an alignment 403 — the caller isn't authorized.
|
|
348
|
+
* After a short propagation grace this means the session is stale/mismatched.
|
|
349
|
+
*/
|
|
350
|
+
function classifyAuthError(kind, status) {
|
|
351
|
+
if (status === 403 && kind === "protection")
|
|
352
|
+
return "grant-pending";
|
|
353
|
+
return "auth";
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Write a card, tolerating the two legitimate transient unauthorized states and
|
|
357
|
+
* escalating a persistent one instead of hanging:
|
|
358
|
+
*
|
|
359
|
+
* - PROTECTION grant landing (403): poll the full window for the human to
|
|
360
|
+
* approve the one-time grant in the browser.
|
|
361
|
+
* - post-claim org-membership propagation (alignment 401/403): a SHORT grace.
|
|
362
|
+
*
|
|
363
|
+
* Beyond the grace a persistent 401/403 is a stale/wrong-account session, NOT a
|
|
364
|
+
* propagation delay — so we re-authenticate as the agent's owner (forced
|
|
365
|
+
* `ensureSession`) and retry the write ONCE. If it still 401/403s after that the
|
|
366
|
+
* account genuinely can't act for this agent → a clear error, never a 10-minute
|
|
367
|
+
* silent retry. Any non-401/403 status is a real error surfaced immediately.
|
|
368
|
+
*/
|
|
369
|
+
async function writeCardWithRetry(kind, card, timeoutSeconds, ctx) {
|
|
370
|
+
const { agentId, say } = ctx;
|
|
254
371
|
const body = JSON.stringify(card);
|
|
255
372
|
const put = kind === "alignment" ? putAlignmentCard : putProtectionCard;
|
|
256
373
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
374
|
+
// The auth-escalation grace is capped by the overall timeout so a 0-timeout
|
|
375
|
+
// (tests / explicit --poll-timeout 0) escalates immediately rather than never.
|
|
376
|
+
const authGraceDeadline = Date.now() + Math.min(AUTH_PROPAGATION_GRACE_MS, timeoutSeconds * 1000);
|
|
377
|
+
let reauthed = false;
|
|
257
378
|
let waited = 0;
|
|
258
379
|
for (;;) {
|
|
259
380
|
try {
|
|
@@ -262,23 +383,35 @@ async function writeCardWithRetry(kind, agentId, card, timeoutSeconds, say) {
|
|
|
262
383
|
}
|
|
263
384
|
catch (err) {
|
|
264
385
|
const status = err instanceof MnemomApiError ? err.effectiveStatus : undefined;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
386
|
+
if (status !== 401 && status !== 403)
|
|
387
|
+
throw err; // a real error — surface it
|
|
388
|
+
const kindOfFailure = classifyAuthError(kind, status);
|
|
389
|
+
// A stale/mismatched session: once the short propagation grace is spent,
|
|
390
|
+
// re-authenticate as the agent's owner and retry the write exactly once.
|
|
391
|
+
if (kindOfFailure === "auth" && Date.now() >= authGraceDeadline) {
|
|
392
|
+
if (!reauthed) {
|
|
393
|
+
reauthed = true;
|
|
394
|
+
await ensureSession(ctx, { force: true });
|
|
395
|
+
continue; // retry immediately with the fresh session
|
|
274
396
|
}
|
|
275
|
-
throw
|
|
397
|
+
throw new Error(`Couldn't set the ${kind} card — this account isn't authorized for ${agentId} ` +
|
|
398
|
+
`(last status ${status}) even after re-authenticating. Sign in as the account that ` +
|
|
399
|
+
`owns ${agentId}, then re-run with --resume ${agentId}.`, { cause: err });
|
|
400
|
+
}
|
|
401
|
+
// Overall timeout (covers the long human wait for the protection grant).
|
|
402
|
+
if (Date.now() >= deadline) {
|
|
403
|
+
throw new Error(`Timed out waiting to set the ${kind} card (last status ${status}). ` +
|
|
404
|
+
(kindOfFailure === "grant-pending"
|
|
405
|
+
? "Make sure you approved the one-time grant, then re-run with --resume " +
|
|
406
|
+
agentId +
|
|
407
|
+
"."
|
|
408
|
+
: "Re-run with --resume " + agentId + " to resume."), { cause: err });
|
|
276
409
|
}
|
|
277
410
|
await sleep(POLL_INTERVAL_MS);
|
|
278
411
|
waited += POLL_INTERVAL_MS;
|
|
279
412
|
if (waited % 15000 === 0) {
|
|
280
413
|
say(fmt.dim(` …waiting to set the ${kind} card (${waited / 1000}s)` +
|
|
281
|
-
(
|
|
414
|
+
(kindOfFailure === "grant-pending"
|
|
282
415
|
? " — approve the grant in your browser if you haven't."
|
|
283
416
|
: ".")));
|
|
284
417
|
}
|
package/dist/lib/oauth.d.ts
CHANGED
|
@@ -119,3 +119,14 @@ export declare function refreshTokens(refreshToken: string, clientId: string): P
|
|
|
119
119
|
* fallback, so a missing browser must not crash login.
|
|
120
120
|
*/
|
|
121
121
|
export declare function openBrowser(url: string): void;
|
|
122
|
+
/**
|
|
123
|
+
* Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
|
|
124
|
+
* silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
|
|
125
|
+
* called with the elapsed whole-seconds count. The timer is always cleared when
|
|
126
|
+
* the promise settles, and is unref'd so it never keeps the event loop alive on
|
|
127
|
+
* its own. `timers` is injectable so tests drive the ticks without real clocks.
|
|
128
|
+
*/
|
|
129
|
+
export declare function withHeartbeat<T>(promise: Promise<T>, intervalMs: number, onTick: (elapsedSeconds: number) => void, timers?: {
|
|
130
|
+
set?: (cb: () => void, ms: number) => unknown;
|
|
131
|
+
clear?: (handle: unknown) => void;
|
|
132
|
+
}): Promise<T>;
|
package/dist/lib/oauth.js
CHANGED
|
@@ -35,6 +35,10 @@ 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
|
+
// Cadence for the "…still waiting for you to finish signing in" heartbeat that
|
|
39
|
+
// both interactive login poll loops emit, so a multi-minute browser sign-in
|
|
40
|
+
// doesn't read as a hung CLI (matches the card-write retry tick in try-me).
|
|
41
|
+
const LOGIN_HEARTBEAT_MS = 15 * 1000;
|
|
38
42
|
let cachedMetadata = null;
|
|
39
43
|
/**
|
|
40
44
|
* Fetch (and process-cache) the AS metadata document. We resolve it relative to
|
|
@@ -157,7 +161,9 @@ export async function loginWithLoopback(openUrl = openBrowser) {
|
|
|
157
161
|
openUrl(authUrl.toString());
|
|
158
162
|
console.log("Waiting for authentication...");
|
|
159
163
|
try {
|
|
160
|
-
|
|
164
|
+
// Heartbeat while we await the loopback redirect — otherwise the sign-in
|
|
165
|
+
// round-trip is dead silent and reads as a hung CLI.
|
|
166
|
+
const code = await withHeartbeat(codePromise, LOGIN_HEARTBEAT_MS, (s) => console.log(`…still waiting for you to finish signing in (${s}s)`));
|
|
161
167
|
const tokens = await exchangeCode(meta, clientId, code, pkce.verifier, redirectUri);
|
|
162
168
|
return { tokens, clientId };
|
|
163
169
|
}
|
|
@@ -293,19 +299,24 @@ export async function loginWithDevice(opts) {
|
|
|
293
299
|
}
|
|
294
300
|
display("");
|
|
295
301
|
display("Waiting for authorization...");
|
|
296
|
-
const tokens = await pollDeviceToken(meta, clientId, authz, sleep);
|
|
302
|
+
const tokens = await pollDeviceToken(meta, clientId, authz, sleep, display);
|
|
297
303
|
return { tokens, clientId };
|
|
298
304
|
}
|
|
299
|
-
async function pollDeviceToken(meta, clientId, authz, sleep) {
|
|
305
|
+
async function pollDeviceToken(meta, clientId, authz, sleep, display) {
|
|
300
306
|
// RFC 8628 §3.5: default interval is 5s if the server omits it; on slow_down
|
|
301
307
|
// we increase the interval by 5s and keep that as the new minimum.
|
|
302
308
|
let intervalMs = (authz.interval ?? 5) * 1000;
|
|
303
309
|
const deadline = Date.now() + authz.expires_in * 1000;
|
|
310
|
+
// Heartbeat off accumulated poll time (not wall-clock) so it stays correct
|
|
311
|
+
// even when sleep is stubbed in tests; ticks every LOGIN_HEARTBEAT_MS.
|
|
312
|
+
let waitedMs = 0;
|
|
313
|
+
let nextHeartbeatMs = LOGIN_HEARTBEAT_MS;
|
|
304
314
|
for (;;) {
|
|
305
315
|
if (Date.now() >= deadline) {
|
|
306
316
|
throw new Error("Device authorization expired before approval. Please try again.");
|
|
307
317
|
}
|
|
308
318
|
await sleep(intervalMs);
|
|
319
|
+
waitedMs += intervalMs;
|
|
309
320
|
const res = await fetch(meta.token_endpoint, {
|
|
310
321
|
method: "POST",
|
|
311
322
|
headers: {
|
|
@@ -324,10 +335,10 @@ async function pollDeviceToken(meta, clientId, authz, sleep) {
|
|
|
324
335
|
const body = (await res.json().catch(() => ({})));
|
|
325
336
|
switch (body.error) {
|
|
326
337
|
case "authorization_pending":
|
|
327
|
-
|
|
338
|
+
break; // keep polling at the current interval
|
|
328
339
|
case "slow_down":
|
|
329
340
|
intervalMs += 5000; // RFC 8628 §3.5
|
|
330
|
-
|
|
341
|
+
break;
|
|
331
342
|
case "expired_token":
|
|
332
343
|
throw new Error("Device authorization expired before approval. Please try again.");
|
|
333
344
|
case "access_denied":
|
|
@@ -336,6 +347,12 @@ async function pollDeviceToken(meta, clientId, authz, sleep) {
|
|
|
336
347
|
throw new Error(`Device authorization failed: ${body.error ?? `HTTP ${res.status}`}` +
|
|
337
348
|
(body.error_description ? ` — ${body.error_description}` : ""));
|
|
338
349
|
}
|
|
350
|
+
// Still pending (authorization_pending / slow_down) — emit a heartbeat
|
|
351
|
+
// every ~15s so a multi-minute approval doesn't look hung.
|
|
352
|
+
if (waitedMs >= nextHeartbeatMs) {
|
|
353
|
+
display(`…still waiting for you to finish signing in (${Math.round(waitedMs / 1000)}s)`);
|
|
354
|
+
nextHeartbeatMs += LOGIN_HEARTBEAT_MS;
|
|
355
|
+
}
|
|
339
356
|
}
|
|
340
357
|
}
|
|
341
358
|
// ============================================================================
|
|
@@ -411,6 +428,29 @@ export function openBrowser(url) {
|
|
|
411
428
|
function defaultSleep(ms) {
|
|
412
429
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
413
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Await `promise` while emitting a heartbeat tick every `intervalMs`, so a long
|
|
433
|
+
* silent wait (a browser sign-in round-trip) doesn't look hung. `onTick` is
|
|
434
|
+
* called with the elapsed whole-seconds count. The timer is always cleared when
|
|
435
|
+
* the promise settles, and is unref'd so it never keeps the event loop alive on
|
|
436
|
+
* its own. `timers` is injectable so tests drive the ticks without real clocks.
|
|
437
|
+
*/
|
|
438
|
+
export async function withHeartbeat(promise, intervalMs, onTick, timers = {}) {
|
|
439
|
+
const set = timers.set ?? ((cb, ms) => setInterval(cb, ms));
|
|
440
|
+
const clear = timers.clear ?? ((h) => clearInterval(h));
|
|
441
|
+
let elapsedMs = 0;
|
|
442
|
+
const handle = set(() => {
|
|
443
|
+
elapsedMs += intervalMs;
|
|
444
|
+
onTick(Math.round(elapsedMs / 1000));
|
|
445
|
+
}, intervalMs);
|
|
446
|
+
handle?.unref?.();
|
|
447
|
+
try {
|
|
448
|
+
return await promise;
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
clear(handle);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
414
454
|
/** Constant-time string compare that tolerates length differences. */
|
|
415
455
|
function timingSafeEqual(a, b) {
|
|
416
456
|
const ab = Buffer.from(a);
|
package/dist/lib/prompt.js
CHANGED
|
@@ -162,6 +162,11 @@ export async function askMultiSelect(question, options) {
|
|
|
162
162
|
});
|
|
163
163
|
});
|
|
164
164
|
}
|
|
165
|
+
/** Map a typed answer ("1".."n") to its option label, or null if out of range. */
|
|
166
|
+
function resolveSelection(answer, options) {
|
|
167
|
+
const idx = parseInt(answer.trim(), 10) - 1;
|
|
168
|
+
return idx >= 0 && idx < options.length ? options[idx] : null;
|
|
169
|
+
}
|
|
165
170
|
/**
|
|
166
171
|
* Single-select prompt. Displays numbered options, user enters a number.
|
|
167
172
|
* Returns selected label or null if invalid.
|
|
@@ -171,6 +176,19 @@ export async function askSelect(question, options) {
|
|
|
171
176
|
for (let i = 0; i < options.length; i++) {
|
|
172
177
|
console.log(` ${i + 1}) ${options[i]}`);
|
|
173
178
|
}
|
|
179
|
+
// Non-interactive stdin (pipe / CI / agent-driven): serve the next buffered
|
|
180
|
+
// line from the SAME shared reader askInput uses. A fresh per-prompt readline
|
|
181
|
+
// interface drops buffered lines on a pipe (MNE-269), and `rl.question` never
|
|
182
|
+
// resolves on EOF — so a non-TTY run would silently hang here. Read the
|
|
183
|
+
// shared buffer instead; on EOF there's nothing to pick, so return null and
|
|
184
|
+
// let the caller fall back.
|
|
185
|
+
if (!process.stdin.isTTY) {
|
|
186
|
+
process.stdout.write("Select: ");
|
|
187
|
+
const lines = await readPipedStdinLines();
|
|
188
|
+
const next = lines.shift();
|
|
189
|
+
process.stdout.write("\n");
|
|
190
|
+
return resolveSelection(next ?? "", options);
|
|
191
|
+
}
|
|
174
192
|
const rl = readline.createInterface({
|
|
175
193
|
input: process.stdin,
|
|
176
194
|
output: process.stdout,
|
|
@@ -178,13 +196,7 @@ export async function askSelect(question, options) {
|
|
|
178
196
|
return new Promise((resolve) => {
|
|
179
197
|
rl.question("Select: ", (answer) => {
|
|
180
198
|
rl.close();
|
|
181
|
-
|
|
182
|
-
if (idx >= 0 && idx < options.length) {
|
|
183
|
-
resolve(options[idx]);
|
|
184
|
-
}
|
|
185
|
-
else {
|
|
186
|
-
resolve(null);
|
|
187
|
-
}
|
|
199
|
+
resolve(resolveSelection(answer, options));
|
|
188
200
|
});
|
|
189
201
|
});
|
|
190
202
|
}
|