Skip to content
Open
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
48 changes: 42 additions & 6 deletions src/oauth/meta-muse-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/
import type { OAuthController, OAuthCredentials } from "./types";
import { sanitizeApiKeyValue } from "../providers/api-keys";
import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";

/** Meta's own Muse Code client. Public in its device-approval URL; not a secret. */
const CLIENT_ID = "1031625952748946";
Expand Down Expand Up @@ -173,12 +174,45 @@ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}

async function readMuseJson(
response: Response,
signal: AbortSignal,
kind: "device-authorization" | "device-token" | "mint-invalid",
): Promise<Record<string, unknown> | undefined> {
const declaredLength = response.headers.get("content-length");
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > BOUNDED_BODY_MAX_BYTES) {
void response.body?.cancel().catch(() => undefined);
throw new MuseDeviceLoginError(
kind,
`Muse Code response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
{ status: response.status },
);
}
const { bytes, oversized } = await readBoundedResponseBytes(response, {
maxBytes: BOUNDED_BODY_MAX_BYTES,
signal,
});
if (oversized) {
throw new MuseDeviceLoginError(
kind,
`Muse Code response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
{ status: response.status },
);
}
try {
return record(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)));
} catch {
return undefined;
}
}

/** Step 1: ask Meta for a user code. */
export async function requestMuseDeviceAuthorization(
deps: MuseDeviceDeps = {},
signal?: AbortSignal,
): Promise<MuseDeviceAuthorization> {
const now = deps.now ?? Date.now;
const request = requestSignal(signal);
const response = await (deps.fetchImpl ?? fetch)(DEVICE_AUTHORIZATION_URL, {
method: "POST",
headers: {
Expand All @@ -188,7 +222,7 @@ export async function requestMuseDeviceAuthorization(
},
body: new URLSearchParams({ client_id: CLIENT_ID }).toString(),
redirect: "error",
signal: requestSignal(signal),
signal: request,
});
if (!response.ok) {
throw new MuseDeviceLoginError(
Expand All @@ -197,7 +231,7 @@ export async function requestMuseDeviceAuthorization(
{ status: response.status },
);
}
const payload = record(await response.json().catch(() => undefined));
const payload = await readMuseJson(response, request, "device-authorization");
const deviceCode = text(payload?.device_code);
const userCode = text(payload?.user_code);
if (!deviceCode || !userCode) {
Expand Down Expand Up @@ -241,6 +275,7 @@ export async function pollMuseDeviceToken(
// shape checked the deadline at the top, so a sleep ending exactly at the deadline
// skipped the final poll and discarded an approval the user had already completed
// inside that window.
const request = requestSignal(signal);
const response = await (deps.fetchImpl ?? fetch)(DEVICE_TOKEN_URL, {
method: "POST",
headers: {
Expand All @@ -254,9 +289,9 @@ export async function pollMuseDeviceToken(
grant_type: DEVICE_GRANT_TYPE,
}).toString(),
redirect: "error",
signal: requestSignal(signal),
signal: request,
});
const payload = record(await response.json().catch(() => undefined));
const payload = await readMuseJson(response, request, "device-token");
if (response.ok) {
// [W3] No deadline re-check here. If Meta answered 200 with a token, Meta accepted
// the device code; its clock is authoritative and ours is not. Discarding an issued
Expand Down Expand Up @@ -322,6 +357,7 @@ export async function mintMuseApiKey(
signal?: AbortSignal,
): Promise<MuseKeyPayload> {
const now = deps.now ?? Date.now;
const request = requestSignal(signal);
const response = await (deps.fetchImpl ?? fetch)(MUSE_KEY_URL, {
method: "POST",
headers: {
Expand All @@ -332,7 +368,7 @@ export async function mintMuseApiKey(
},
body: JSON.stringify(options.onboard ? { onboard: true } : {}),
redirect: "error",
signal: requestSignal(signal),
signal: request,
});
if (response.status === 429) {
const wait = retryAfterMs(response.headers.get("retry-after"), now());
Expand All @@ -352,7 +388,7 @@ export async function mintMuseApiKey(
{ status: response.status },
);
}
const payload = record(await response.json().catch(() => undefined));
const payload = await readMuseJson(response, request, "mint-invalid");
if (!payload) {
throw new MuseDeviceLoginError("mint-invalid", "Muse Code key exchange returned an unreadable response", {
status: response.status,
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an
| --- | --- |
| `src/providers/registry.ts` | Canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata. |
| `src/providers/derive.ts` | Enrichment from provider presets into user config. |
| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. |
| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update every mapped OAuth structure document

This change updates only runtime.md, but structure/INDEX.md maps src/oauth/ to three owning documents: runtime.md, transports/inventory.md, and providers/xai-grok.md. Because the new OAuth response-body contract leaves the latter two owners untouched, the repository's mandatory source-to-doc synchronization is incomplete; update both mapped documents in this commit, or narrow their manifest ownership if they no longer describe this area.

AGENTS.md reference: structure/AGENTS.md:L44-L50

Useful? React with 👍 / 👎.

| `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. |
| `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. |
| `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. |
Expand Down
28 changes: 28 additions & 0 deletions tests/providers/meta-muse-device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const KEY = `LLM|${"1".repeat(16)}|${"c".repeat(27)}`;
const ACCOUNT_TOKEN = "meta-account-" + "z".repeat(48);
/** If this string ever reaches an error message, a response body leaked into one. */
const BODY_CANARY = "canary-body-must-never-appear-in-an-error";
const OVERSIZED_JSON = JSON.stringify({ value: "x".repeat(65_536) });

interface Reply { status?: number; body?: unknown; text?: string; headers?: Record<string, string> }
interface Scenario {
Expand Down Expand Up @@ -132,6 +133,13 @@ describe("muse device authorization", () => {
expect(error.message).toContain("500");
expect(error.message).not.toContain(BODY_CANARY);
});

test("rejects an oversized streamed authorization response", async () => {
const h = harness({ auth: { text: OVERSIZED_JSON } });
const error = await caught(() => requestMuseDeviceAuthorization(h.deps));
expect(error.kind).toBe("device-authorization");
expect(error.message).toContain("65536-byte limit");
});
});

describe("muse device poll", () => {
Expand Down Expand Up @@ -217,6 +225,14 @@ describe("muse device poll", () => {
expect(error.kind).toBe("device-token");
});

test("rejects an oversized token response instead of polling again", async () => {
const h = harness({ tokens: [{ text: OVERSIZED_JSON }] });
const auth = await requestMuseDeviceAuthorization(h.deps);
const error = await caught(() => pollMuseDeviceToken(auth, h.deps));
expect(error.kind).toBe("device-token");
expect(h.calls.token).toBe(1);
});

// W4 and W3 together: the last seconds of a grant must still be polled, and a token
// the server issued in that window must not be thrown away by a local clock.
test("polls once more inside the final seconds and accepts a late token", async () => {
Expand Down Expand Up @@ -284,6 +300,18 @@ describe("muse key mint", () => {
expect(error.message).not.toContain(BODY_CANARY);
});

test("rejects an oversized declared mint response before consuming it", async () => {
let cancelled = false;
const fetchImpl = (async () => new Response(new ReadableStream({
pull() {},
cancel() { cancelled = true; },
}), { headers: { "content-length": "65537" } })) as typeof fetch;
const error = await caught(() => mintMuseApiKey(ACCOUNT_TOKEN, {}, { fetchImpl }));
expect(error.kind).toBe("mint-invalid");
expect(error.message).toContain("65536-byte limit");
expect(cancelled).toBeTrue();
});

test("lowercases the email and keeps the usage object", async () => {
const h = harness({ mint: { body: { ...MINT_OK, subs_usage: { weekly: { used_percent: 4 } } } } });
const payload = await mintMuseApiKey(ACCOUNT_TOKEN, {}, h.deps);
Expand Down
Loading