Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 48 additions & 6 deletions dist/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 };
Expand All @@ -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") {
Expand All @@ -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) {
Expand Down
53 changes: 45 additions & 8 deletions src/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -106,6 +106,21 @@ async function readLimited(res: Response, max: number): Promise<string | null> {
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<string, string>): Promise<LoginPostResult> {
try {
Expand All @@ -117,14 +132,15 @@ export async function postForm(url: string, body: Record<string, string>): 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" };
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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.");
Expand All @@ -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) {
Expand Down
95 changes: 93 additions & 2 deletions test/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)) {
if (typeof value === "string") res.setHeader(name, value);
}
}
res.end(JSON.stringify(body));
}

function deviceBody(over: Record<string, unknown> = {}): Record<string, unknown> {
Expand Down Expand Up @@ -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(); }
});
Loading