diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6e3ef..b57af2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- **`agentmbx login` keeps polling when the token endpoint answers 429 or 503 (T707).** A rate-limit body is not an OAuth error, so the client printed "unexpected token response" and dropped an approval that was still in progress. It now waits `Retry-After` or `X-Retry-After` when that value is a positive number of seconds that still fits in the code's life, and otherwise waits the current interval plus 5 seconds, the same step as RFC 8628 `slow_down`. A wait that would outlive the code stops with the existing expiry message. The access token is still never logged. + - **One liveness answer: a released lease whose session still runs is idle, not live and not offline (T699).** `agentmbx agents` read the session binding while `mbx_agents` and the send receipt read the identity lease, so an OpenCode MCP eviction released the lease and the surfaces disagreed for hours: the CLI said `live`, the receipt said `offline (released)`. A single `agentLiveness` answer (src/liveness.ts) now composes the lease, the live session row and the serve kind, and every surface uses it. A released or expired lease whose session still runs reads as `idle` with its reason (claimable, and wakeable when the T688 shared service or a registered T659 poller can start its next turn, which re-claims the mailbox). `agentmbx agents` prints that state plus a trailing reason column and keeps shell senders visible as `live`; `mbx_agents` and `mbx_identity list` follow; a send reports `live-next-prompt`/`live-wake` instead of `offline`. The 30-minute lease idle TTL and the receipt's 3-minute heartbeat rule are unchanged. - **An OpenCode v2 resume note reaches the model instead of being silently dropped (T700).** OpenCode 2.x drives the plugin's default `setup(ctx)` path. It marks a turn at `session.execution.started`, before the first tool, so the busy-turn guard treated every tool as mid-turn and the revisit notice could only fall back; at the turn's end the plugin admitted it with `resume:false`, whose `msg_` receipt can arrive without any `session_message` (T544), and then discarded the pending copy. The first tool of a turn now places the note even after the v2 start event, and a kept note is only dropped after it actually lands in a tool result: a stop no longer acknowledges a receipt as delivery, and `resume:true` is not used because starting a turn on an idle TUI is not a resume note's job. A fresh conversation's start note keeps its queued admission, which the host materialises as the conversation begins. diff --git a/dist/login.js b/dist/login.js index f2af85f..7468e84 100644 --- a/dist/login.js +++ b/dist/login.js @@ -77,6 +77,20 @@ async function readLimited(res, max) { } return Buffer.concat(chunks).toString("utf8"); } +/** Prefer Retry-After, then X-Retry-After. Anything too long to be a delay is ignored. */ +function retryAfterHeader(res) { + for (const name of ["retry-after", "x-retry-after"]) { + const value = res.headers.get(name); + if (value && value.length <= 64) + return value; + } + return undefined; +} +function jsonResult(status, body, retryAfter) { + return retryAfter === undefined + ? { kind: "json", status, body } + : { kind: "json", status, body, retryAfter }; +} /** RFC 8628 form POST. Redirects are not followed. The body is capped so a token response cannot be huge. */ export async function postForm(url, body) { try { @@ -88,18 +102,19 @@ export async function postForm(url, body) { cache: "no-store", signal: AbortSignal.timeout(15_000), }); + const retryAfter = retryAfterHeader(res); if (res.status >= 300 && res.status < 400) { await res.body?.cancel(); - return { kind: "json", status: res.status, body: null }; + return jsonResult(res.status, null, retryAfter); } const text = await readLimited(res, 65_536); if (text === null || !text) - return { kind: "json", status: res.status, body: null }; + return jsonResult(res.status, null, retryAfter); try { - return { kind: "json", status: res.status, body: JSON.parse(text) }; + return jsonResult(res.status, JSON.parse(text), retryAfter); } catch { - return { kind: "json", status: res.status, body: null }; + return jsonResult(res.status, null, retryAfter); } } catch (e) { @@ -195,6 +210,17 @@ function expired(io) { io.log("This sign-in code expired."); io.log("Run `agentmbx login` again to get a new code."); } +/** + * Seconds to wait after 429 or 503. A positive delay that still fits in the code's life is used; + * anything else (missing, non-numeric, huge) waits interval+5, the same step as slow_down. + */ +function rateLimitSeconds(header, remainingMs, interval) { + const parsed = header === undefined ? Number.NaN : Number(header.trim()); + const remainingSec = remainingMs / 1000; + if (Number.isFinite(parsed) && parsed > 0 && parsed <= remainingSec) + return parsed; + return interval + 5; +} /** * Run the device flow. Prints the user code, the host-key fingerprint, and the verification URL. * Opens the page when a browser is available. Polls until approval, denial, expiry, or a network failure. @@ -232,10 +258,12 @@ export async function runLogin(opts, io) { else io.log("Could not open a browser. Open the URL above and enter the user code."); // RFC 8628 §3.5: slow_down adds 5 seconds to the interval. The token is not stored. + // 429/503 use one pause (the retry header, or interval+5), then the same +5 for later polls. let interval = device.interval; + let pause = interval; const deadline = io.now() + device.expires_in * 1000; for (;;) { - await io.sleep(interval * 1000); + await io.sleep(pause * 1000); if (io.now() >= deadline) { expired(io); return { ok: false }; @@ -251,10 +279,13 @@ export async function runLogin(opts, io) { return { ok: false }; } const error = oauthError(polled.body); - if (error === "authorization_pending") + if (error === "authorization_pending") { + pause = interval; continue; + } if (error === "slow_down") { interval += 5; + pause = interval; continue; } if (error === "access_denied") { @@ -271,6 +302,17 @@ export async function runLogin(opts, io) { io.log("Run `agentmbx login` again."); return { ok: false }; } + if (polled.status === 429 || polled.status === 503) { + const remaining = deadline - io.now(); + const wait = rateLimitSeconds(polled.retryAfter, remaining, interval); + if (wait * 1000 > remaining) { + expired(io); + return { ok: false }; + } + pause = wait; + interval += 5; + continue; + } if (polled.status === 200 && accessToken(polled.body)) { const token = readAccessToken(polled.body); if (opts.home && opts.hostKey && token) { diff --git a/src/login.ts b/src/login.ts index a25a5ba..b521424 100644 --- a/src/login.ts +++ b/src/login.ts @@ -18,7 +18,7 @@ export const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; const USAGE = "USAGE_ERROR"; export type LoginPostResult = - | { kind: "json"; status: number; body: unknown } + | { kind: "json"; status: number; body: unknown; retryAfter?: string } | { kind: "network"; message: string }; export interface LoginIO { @@ -106,6 +106,21 @@ async function readLimited(res: Response, max: number): Promise { return Buffer.concat(chunks).toString("utf8"); } +/** Prefer Retry-After, then X-Retry-After. Anything too long to be a delay is ignored. */ +function retryAfterHeader(res: Response): string | undefined { + for (const name of ["retry-after", "x-retry-after"]) { + const value = res.headers.get(name); + if (value && value.length <= 64) return value; + } + return undefined; +} + +function jsonResult(status: number, body: unknown, retryAfter: string | undefined): LoginPostResult { + return retryAfter === undefined + ? { kind: "json", status, body } + : { kind: "json", status, body, retryAfter }; +} + /** RFC 8628 form POST. Redirects are not followed. The body is capped so a token response cannot be huge. */ export async function postForm(url: string, body: Record): Promise { try { @@ -117,14 +132,15 @@ export async function postForm(url: string, body: Record): Promi cache: "no-store", signal: AbortSignal.timeout(15_000), }); + const retryAfter = retryAfterHeader(res); if (res.status >= 300 && res.status < 400) { await res.body?.cancel(); - return { kind: "json", status: res.status, body: null }; + return jsonResult(res.status, null, retryAfter); } const text = await readLimited(res, 65_536); - if (text === null || !text) return { kind: "json", status: res.status, body: null }; - try { return { kind: "json", status: res.status, body: JSON.parse(text) as unknown }; } - catch { return { kind: "json", status: res.status, body: null }; } + if (text === null || !text) return jsonResult(res.status, null, retryAfter); + try { return jsonResult(res.status, JSON.parse(text) as unknown, retryAfter); } + catch { return jsonResult(res.status, null, retryAfter); } } catch (e) { return { kind: "network", message: e instanceof Error ? e.message : "network error" }; } @@ -211,6 +227,17 @@ function expired(io: LoginIO): void { io.log("Run `agentmbx login` again to get a new code."); } +/** + * Seconds to wait after 429 or 503. A positive delay that still fits in the code's life is used; + * anything else (missing, non-numeric, huge) waits interval+5, the same step as slow_down. + */ +function rateLimitSeconds(header: string | undefined, remainingMs: number, interval: number): number { + const parsed = header === undefined ? Number.NaN : Number(header.trim()); + const remainingSec = remainingMs / 1000; + if (Number.isFinite(parsed) && parsed > 0 && parsed <= remainingSec) return parsed; + return interval + 5; +} + /** * Run the device flow. Prints the user code, the host-key fingerprint, and the verification URL. * Opens the page when a browser is available. Polls until approval, denial, expiry, or a network failure. @@ -243,10 +270,12 @@ export async function runLogin(opts: LoginOptions, io: LoginIO): Promise<{ ok: b else io.log("Could not open a browser. Open the URL above and enter the user code."); // RFC 8628 §3.5: slow_down adds 5 seconds to the interval. The token is not stored. + // 429/503 use one pause (the retry header, or interval+5), then the same +5 for later polls. let interval = device.interval; + let pause = interval; const deadline = io.now() + device.expires_in * 1000; for (;;) { - await io.sleep(interval * 1000); + await io.sleep(pause * 1000); if (io.now() >= deadline) { expired(io); return { ok: false }; } const polled = await io.post(`${base}${DEVICE_TOKEN_PATH}`, { grant_type: DEVICE_GRANT_TYPE, @@ -259,8 +288,8 @@ export async function runLogin(opts: LoginOptions, io: LoginIO): Promise<{ ok: b return { ok: false }; } const error = oauthError(polled.body); - if (error === "authorization_pending") continue; - if (error === "slow_down") { interval += 5; continue; } + if (error === "authorization_pending") { pause = interval; continue; } + if (error === "slow_down") { interval += 5; pause = interval; continue; } if (error === "access_denied") { io.log("Sign-in was denied."); io.log("Run `agentmbx login` again if you still want to sign this host in."); @@ -272,6 +301,14 @@ export async function runLogin(opts: LoginOptions, io: LoginIO): Promise<{ ok: b io.log("Run `agentmbx login` again."); return { ok: false }; } + if (polled.status === 429 || polled.status === 503) { + const remaining = deadline - io.now(); + const wait = rateLimitSeconds(polled.retryAfter, remaining, interval); + if (wait * 1000 > remaining) { expired(io); return { ok: false }; } + pause = wait; + interval += 5; + continue; + } if (polled.status === 200 && accessToken(polled.body)) { const token = readAccessToken(polled.body); if (opts.home && opts.hostKey && token) { diff --git a/test/login.test.ts b/test/login.test.ts index da766b3..7bbc0a2 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -64,9 +64,19 @@ async function fakeAuth(script: { function answer(res: ServerResponse, step: Step | undefined): void { if (step === undefined) { res.statusCode = 404; res.end(); return; } if (step === "drop") { res.destroy(); return; } - res.statusCode = typeof step.error === "string" ? 400 : 200; + const status = typeof step.status === "number" ? step.status : typeof step.error === "string" ? 400 : 200; + const headers = step.headers; + const body = { ...step }; + delete body.status; + delete body.headers; + res.statusCode = status; res.setHeader("content-type", "application/json"); - res.end(JSON.stringify(step)); + if (headers && typeof headers === "object") { + for (const [name, value] of Object.entries(headers as Record)) { + if (typeof value === "string") res.setHeader(name, value); + } + } + res.end(JSON.stringify(body)); } function deviceBody(over: Record = {}): Record { @@ -401,3 +411,84 @@ test("agentmbx login exchanges the device session and fails closed", async () => assert.match(help.out, /accounts\.agentmbx\.com\/api\/auth/); assert.equal(existsSync(fresh), false); }); + +const LIMITED = { message: "Too many requests" }; +const APPROVED = { access_token: TOKEN, token_type: "Bearer" }; + +test("429 and 503 wait out a retry header, then keep polling", async () => { + const auth = await fakeAuth({ + polls: [ + { status: 429, headers: { "X-Retry-After": "8" }, ...LIMITED }, + { status: 503, headers: { "Retry-After": "3", "X-Retry-After": "30" }, ...LIMITED }, + APPROVED, + ], + }); + try { + const run = harness(); + const outcome = await runLogin({ baseUrl: auth.base, host: "macbook", fingerprint: FP, openBrowser: false }, run.io); + assert.equal(outcome.ok, true); + // interval 5, then X-Retry-After 8, then Retry-After 3 (it wins over the paired X-Retry-After). + assert.deepEqual(run.sleeps, [5000, 8000, 3000]); + assert.equal(run.lines.join("\n").includes(TOKEN), false); + assert.equal(run.lines.join("\n").includes("unexpected token response"), false); + assert.equal(auth.requests().filter((r) => r.url === DEVICE_TOKEN_PATH).length, 3); + } finally { await auth.close(); } +}); + +test("a 429 without a retry header waits interval plus 5 seconds", async () => { + const auth = await fakeAuth({ polls: [{ status: 429, ...LIMITED }, APPROVED] }); + try { + const run = harness(); + const outcome = await runLogin({ baseUrl: auth.base, host: "macbook", fingerprint: FP, openBrowser: false }, run.io); + assert.equal(outcome.ok, true); + assert.deepEqual(run.sleeps, [5000, 10_000]); + assert.equal(run.lines.join("\n").includes(TOKEN), false); + } finally { await auth.close(); } +}); + +test("an absurd retry header falls back to interval plus 5 seconds", async () => { + const auth = await fakeAuth({ + polls: [{ status: 429, headers: { "X-Retry-After": "1.8e14" }, ...LIMITED }, APPROVED], + }); + try { + const run = harness(); + const outcome = await runLogin({ baseUrl: auth.base, host: "macbook", fingerprint: FP, openBrowser: false }, run.io); + assert.equal(outcome.ok, true); + assert.deepEqual(run.sleeps, [5000, 10_000]); + assert.equal(run.sleeps.some((ms) => ms > 60_000), false); + assert.equal(run.lines.join("\n").includes(TOKEN), false); + } finally { await auth.close(); } +}); + +test("a rate-limit wait that would outlive the code expires instead of polling again", async () => { + let t = 0; + const auth = await fakeAuth({ + device: deviceBody({ expires_in: 12, interval: 5 }), + polls: [{ status: 429, headers: { "Retry-After": "1.8e14" }, ...LIMITED }, APPROVED], + }); + try { + const run = harness({ + now: () => t, + sleep: async (ms) => { run.sleeps.push(ms); t += ms; }, + }); + const outcome = await runLogin({ baseUrl: auth.base, host: "macbook", fingerprint: FP, openBrowser: false }, run.io); + assert.equal(outcome.ok, false); + assert.deepEqual(run.sleeps, [5000]); + assert.equal(auth.requests().filter((r) => r.url === DEVICE_TOKEN_PATH).length, 1); + assert.ok(run.lines.includes("This sign-in code expired.")); + assert.ok(run.lines.includes("Run `agentmbx login` again to get a new code.")); + assert.equal(run.lines.join("\n").includes("unexpected token response"), false); + assert.equal(run.lines.join("\n").includes(TOKEN), false); + } finally { await auth.close(); } +}); + +test("a non-OAuth token answer still stops", async () => { + const auth = await fakeAuth({ polls: [{ status: 400, message: "nope" }] }); + try { + const run = harness(); + const outcome = await runLogin({ baseUrl: auth.base, host: "macbook", fingerprint: FP, openBrowser: false }, run.io); + assert.equal(outcome.ok, false); + assert.ok(run.lines.includes(`The sign-in server at ${auth.base} sent an unexpected token response.`)); + assert.equal(auth.requests().filter((r) => r.url === DEVICE_TOKEN_PATH).length, 1); + } finally { await auth.close(); } +});