From bdd742cd433f685569a8a06fd6160c5ec0be8a8f Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 11:04:23 -0700 Subject: [PATCH 1/2] fix: accept a provider id in `auth login`, and diagnose plan/account on a Codex 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two auth-flow defects, both hit by a user tonight. 1. `auth login ` misread the provider name as a URL. The login positional was declared `[url]` and passed straight to `fetch(`${url}/.well-known/opencode`)`, so `auth login openai` — the natural thing to type, given `auth list` prints provider ids and `auth logout [provider]` takes one — died with "Failed to load auth provider metadata from openai: fetch() URL is invalid". The positional is now `[target]` and routes on its shape: an http(s) URL keeps the well-known behavior unchanged, anything else resolves as a provider id through the same path `--provider` and the interactive picker use. Routing lives in `resolveLoginTarget()` so the `instance` predicate and the handler cannot disagree about which path an invocation takes. An unmatched id now names the alternatives instead of just rejecting. 2. A wrong-account / insufficient-plan login was undiagnosable. A ChatGPT OAuth credential on a free-plan account fails every Codex model request with `{"detail":"The '' model is not supported when using Codex with a ChatGPT account."}` — which blames the model and never says the real problem is which account was signed in with. The Codex fetch wrapper now recognises that one 400 shape and rewrites it into a message naming the plan and the truncated account id the request actually used, plus the commands to switch accounts. `auth list` gained the same annotation for OAuth credentials, so a wrong account is visible before it costs a debugging session. The claims come from `auth/oauth-claims.ts`, which decodes only the non-secret `chatgpt_plan_type` / `chatgpt_account_id` claims (unverified — diagnostics only, never an authorization decision). Nothing there returns, logs, or embeds token material, the account id is truncated to eight characters, and tests assert the rendered strings contain no segment of the token. `parseJwtClaims` in the Codex plugin now delegates to the same decoder so there is one implementation. Model allowlists are untouched; originator/User-Agent headers are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- .../opencode/src/altimate/plugin/altimate.ts | 2 +- packages/opencode/src/auth/oauth-claims.ts | 150 +++++++++++++++ packages/opencode/src/cli/cmd/providers.ts | 81 ++++++-- packages/opencode/src/plugin/codex.ts | 45 ++++- .../opencode/test/auth/oauth-claims.test.ts | 180 ++++++++++++++++++ .../test/cli/auth-login-target.test.ts | 83 ++++++++ .../__snapshots__/help-snapshots.test.ts.snap | 6 +- .../test/plugin/codex-plan-mismatch.test.ts | 130 +++++++++++++ 8 files changed, 653 insertions(+), 24 deletions(-) create mode 100644 packages/opencode/src/auth/oauth-claims.ts create mode 100644 packages/opencode/test/auth/oauth-claims.test.ts create mode 100644 packages/opencode/test/cli/auth-login-target.test.ts create mode 100644 packages/opencode/test/plugin/codex-plan-mismatch.test.ts diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index 6b61d01d32..e67ff13311 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -91,7 +91,7 @@ export async function buildCliContext(machineIdPath?: string): Promise { // Instance.provide() (AsyncLocalStorage-propagated across awaits), so // Config.get() resolves during an ordinary browser authorize(). The known // exception is `altimate auth login `, which deliberately skips - // instance bootstrap (ProvidersLoginCommand `instance: (args) => !args.url`); + // instance bootstrap (ProvidersLoginCommand `instance: (args) => !isAuthProviderUrl(args.target)`); // on that path this fires. Fail CLOSED — omit the durable machine_id rather // than transmit it for a user who may have opted out via config; a missed // correlation beats leaking a stable identifier. Log so a low correlation diff --git a/packages/opencode/src/auth/oauth-claims.ts b/packages/opencode/src/auth/oauth-claims.ts new file mode 100644 index 0000000000..2bf1be8487 --- /dev/null +++ b/packages/opencode/src/auth/oauth-claims.ts @@ -0,0 +1,150 @@ +/** + * Non-secret identity claims carried by OAuth access tokens, plus the + * diagnostics built on top of them. + * + * Why this exists: a ChatGPT OAuth credential that belongs to a free-plan + * account fails every Codex model request with a 400 whose body only says the + * *model* is unsupported — it never says the real problem is WHICH account you + * signed in with. These helpers pull the two non-secret claims that answer + * that question (plan type + account id) so the CLI can say it out loud. + * + * SECURITY: nothing here may return, log, or embed the token, the refresh + * token, or any other credential material. Only `plan` and a TRUNCATED account + * id ever leave this module. Callers render the returned strings directly to + * the terminal, so treat every return value as user-visible output. + */ + +/** Account ids are opaque identifiers, not secrets, but there is no reason to + * print one in full — the first segment is enough to tell two accounts apart. */ +const ACCOUNT_ID_VISIBLE_CHARS = 8 + +/** The OpenAI-namespaced claim bag inside ChatGPT id/access tokens. */ +const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth" + +export interface OAuthIdentity { + /** ChatGPT plan type claim, e.g. "free", "plus", "pro". */ + plan?: string + /** Raw account id — mask with `maskAccountId` before displaying. */ + accountId?: string +} + +/** + * Decode the payload of a JWT-shaped token WITHOUT verifying its signature. + * + * Verification is deliberately skipped: these claims are used for human-facing + * diagnostics only, never for an authorization decision. Returns undefined for + * anything that is not a three-segment token with a JSON object payload. + */ +export function decodeJwtClaims(token: string): Record | undefined { + const parts = token.split(".") + if (parts.length !== 3) return undefined + try { + const decoded = JSON.parse(Buffer.from(parts[1], "base64url").toString()) + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return undefined + return decoded as Record + } catch { + return undefined + } +} + +function stringClaim(source: Record | undefined, key: string): string | undefined { + const value = source?.[key] + return typeof value === "string" && value.length > 0 ? value : undefined +} + +/** + * Pull the plan type and account id out of an OAuth access token. Both claims + * appear either at the top level or inside the OpenAI-namespaced claim bag, + * depending on which flow minted the token, so check both. + */ +export function extractOAuthIdentity(accessToken: string | undefined): OAuthIdentity { + if (!accessToken) return {} + const claims = decodeJwtClaims(accessToken) + if (!claims) return {} + const namespaced = claims[OPENAI_AUTH_CLAIM] + const nested = + namespaced && typeof namespaced === "object" && !Array.isArray(namespaced) + ? (namespaced as Record) + : undefined + return { + plan: stringClaim(claims, "chatgpt_plan_type") ?? stringClaim(nested, "chatgpt_plan_type"), + accountId: stringClaim(claims, "chatgpt_account_id") ?? stringClaim(nested, "chatgpt_account_id"), + } +} + +/** Truncate an account id for display. */ +export function maskAccountId(accountId: string | undefined): string | undefined { + if (!accountId) return undefined + if (accountId.length <= ACCOUNT_ID_VISIBLE_CHARS) return accountId + return accountId.slice(0, ACCOUNT_ID_VISIBLE_CHARS) + "…" +} + +/** + * One-line summary of an OAuth credential for `auth list`, e.g. + * `free plan, account 4f3a1b2c…`. Returns undefined when the token carries + * neither claim (nothing useful to show, so show nothing). + */ +export function describeOAuthIdentity(accessToken: string | undefined): string | undefined { + const { plan, accountId } = extractOAuthIdentity(accessToken) + const masked = maskAccountId(accountId) + const parts = [plan ? `${plan} plan` : undefined, masked ? `account ${masked}` : undefined].filter((x): x is string => + Boolean(x), + ) + return parts.length > 0 ? parts.join(", ") : undefined +} + +/** + * The 400 body OpenAI returns when a Codex request is made with a ChatGPT + * credential whose plan has no Codex entitlement: + * + * {"detail":"The 'gpt-5.6' model is not supported when using Codex with a + * ChatGPT account."} + * + * Matching on the stable phrase rather than the whole sentence keeps this + * working as the quoted model id changes. + */ +const CODEX_PLAN_MISMATCH_PATTERN = /not supported when using Codex with a ChatGPT account/i + +export function isCodexPlanMismatchBody(body: string | undefined): boolean { + return Boolean(body) && CODEX_PLAN_MISMATCH_PATTERN.test(body!) +} + +/** + * Build the actionable replacement for that 400. Names the account and plan the + * request actually used, then gives the exact commands to switch accounts. + */ +export function codexPlanMismatchMessage(identity: OAuthIdentity, originalDetail?: string): string { + const masked = maskAccountId(identity.accountId) + const who = masked ? `Signed in as ChatGPT account ${masked}` : "Signed in with a ChatGPT account" + const plan = identity.plan + ? ` on the \`${identity.plan}\` plan` + : " whose plan could not be read from the stored credential" + return ( + `${who}${plan}. Codex models require a ChatGPT Plus or Pro plan, ` + + `so this account cannot run them.\n` + + `If you have a Plus/Pro account, switch to it:\n` + + ` altimate-code auth logout openai\n` + + ` altimate-code auth login openai\n` + + `Otherwise use an OpenAI API key instead of the ChatGPT subscription, ` + + `or pick a model from another provider.` + + (originalDetail ? `\nProvider said: ${originalDetail}` : "") + ) +} + +/** + * Given a raw 400 response body and the access token the request used, return + * an enriched `detail` string — or undefined when the body is not this failure + * (leave every other error untouched). + */ +export function enrichCodexPlanMismatchBody(body: string | undefined, accessToken: string | undefined) { + if (!isCodexPlanMismatchBody(body)) return undefined + let detail: string | undefined + try { + const parsed = JSON.parse(body!) + if (parsed && typeof parsed.detail === "string") detail = parsed.detail + } catch { + // Body was not JSON — the phrase still matched, so still enrich, just + // without quoting a `detail` field we could not parse out. + } + return codexPlanMismatchMessage(extractOAuthIdentity(accessToken), detail) +} diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 87c30f6d5c..8ce6e9bea9 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,5 +1,8 @@ import type { Argv } from "yargs" import { Auth } from "../../auth" +// altimate_change start — surface OAuth plan/account in `auth list` +import { describeOAuthIdentity } from "../../auth/oauth-claims" +// altimate_change end import { cmd } from "./cmd" import { CliError, effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" @@ -209,6 +212,43 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( return false }) +// altimate_change start — `auth login ` used to be fed straight into a +// fetch() for well-known auth metadata, so the natural `auth login openai` died on +// "fetch() URL is invalid". Only an actual http(s) URL takes the well-known path; +// anything else is treated as a provider id, exactly like `--provider`. +export function isAuthProviderUrl(value: string | undefined): value is string { + if (!value) return false + try { + const { protocol } = new URL(value) + return protocol === "http:" || protocol === "https:" + } catch { + return false + } +} + +export type LoginTarget = + /** Self-hosted auth provider: fetch its `.well-known/opencode` metadata. */ + | { kind: "url"; url: string } + /** A provider id/name — same path the interactive picker resolves to. */ + | { kind: "provider"; provider: string } + /** Nothing specified: prompt with the provider list. */ + | { kind: "picker" } + +/** + * Decide which login path an invocation takes. A URL positional keeps the + * well-known behavior it always had; anything else is a provider id, so + * `auth login openai` works like `auth login --provider openai` rather than + * being handed to fetch(). An explicit `--provider` flag takes precedence over + * the positional, following the usual flag-beats-positional convention. + */ +export function resolveLoginTarget(args: { target?: string; provider?: string }): LoginTarget { + if (isAuthProviderUrl(args.target)) return { kind: "url", url: args.target.replace(/\/+$/, "") } + const provider = args.provider ?? args.target + if (provider) return { kind: "provider", provider } + return { kind: "picker" } +} +// altimate_change end + export function resolvePluginProviders(input: { hooks: Hooks[] existingProviders: Record @@ -265,7 +305,13 @@ export const ProvidersListCommand = effectCmd({ for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID - yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + // altimate_change start — show which account/plan an OAuth credential belongs to, + // so a wrong-account login is visible here instead of surfacing later as an + // unexplained model error. Only non-secret claims are decoded, and the account id + // is truncated; the token itself is never rendered. + const identity = result.type === "oauth" ? describeOAuthIdentity(result.access) : undefined + yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}${identity ? ` (${identity})` : ""}`) + // altimate_change end } yield* Prompt.outro(`${results.length} credentials`) @@ -297,18 +343,19 @@ export const ProvidersListCommand = effectCmd({ }) export const ProvidersLoginCommand = effectCmd({ - command: "login [url]", + // altimate_change start — the positional accepts a provider id as well as a URL + command: "login [target]", describe: "log in to a provider", // URL login skips instance bootstrap, which would load remote config with the stale token and crash before re-auth. - instance: (args) => !args.url, + // A provider id goes down the picker path instead, which needs the instance. + instance: (args) => resolveLoginTarget(args).kind !== "url", builder: (yargs: Argv) => yargs - .positional("url", { - // altimate_change start — branding - describe: "altimate auth provider", - // altimate_change end + .positional("target", { + describe: "provider id (e.g. openai) or altimate auth provider URL", type: "string", }) + // altimate_change end .option("provider", { alias: ["p"], describe: "provider id or name to log in to (skips provider selection)", @@ -324,8 +371,11 @@ export const ProvidersLoginCommand = effectCmd({ UI.empty() yield* Prompt.intro("Add credential") - if (args.url) { - const url = args.url.replace(/\/+$/, "") + // altimate_change start — only real http(s) URLs take the well-known path + const target = resolveLoginTarget(args) + if (target.kind === "url") { + const url = target.url + // altimate_change end const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () => fetch(`${url}/.well-known/opencode`).then((x) => x.json()), )) as { @@ -417,13 +467,20 @@ export const ProvidersLoginCommand = effectCmd({ ] let provider: string - if (args.provider) { - const input = args.provider + // altimate_change start — a non-URL positional is a provider id, same as --provider + if (target.kind === "provider") { + const input = target.provider + // altimate_change end const byID = options.find((x) => x.value === input) const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) const match = byID ?? byName if (!match) { - return yield* fail(`Unknown provider "${input}"`) + // altimate_change start — say what to do next instead of just rejecting + return yield* fail( + `Unknown provider "${input}". Run \`altimate-code auth login\` with no arguments to pick from the list, ` + + `or pass a full auth provider URL including https://.`, + ) + // altimate_change end } provider = match.value } else { diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index ac89dab06a..dae432cdfa 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -2,6 +2,9 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Log } from "../util/log" import { Installation } from "../installation" import { Auth, OAUTH_DUMMY_KEY } from "../auth" +// altimate_change start — plan/account diagnostics for wrong-account logins +import { decodeJwtClaims, enrichCodexPlanMismatchBody, extractOAuthIdentity, maskAccountId } from "../auth/oauth-claims" +// altimate_change end import os from "os" import { ProviderTransform } from "@/provider/transform" import { ModelID, ProviderID } from "@/provider/schema" @@ -88,13 +91,10 @@ export interface IdTokenClaims { } export function parseJwtClaims(token: string): IdTokenClaims | undefined { - const parts = token.split(".") - if (parts.length !== 3) return undefined - try { - return JSON.parse(Buffer.from(parts[1], "base64url").toString()) - } catch { - return undefined - } + // altimate_change start — one JWT decoder, shared with auth/oauth-claims which + // reads the same tokens for the plan/account diagnostics + return decodeJwtClaims(token) as IdTokenClaims | undefined + // altimate_change end } export function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined { @@ -532,10 +532,39 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { ? new URL(CODEX_API_ENDPOINT) : parsed - return fetch(url, { + const response = await fetch(url, { ...init, headers, }) + + // altimate_change start — a wrong-account login is otherwise undiagnosable. + // A free-plan ChatGPT credential fails every Codex request with a 400 that + // blames the MODEL ("...is not supported when using Codex with a ChatGPT + // account"), never the account. Replace that body with one naming the plan + // and account the request actually used, plus the commands to switch. + // Only this one 400 shape is touched; every other response passes through. + if (response.status === 400) { + const body = await response + .clone() + .text() + .catch(() => "") + const enriched = enrichCodexPlanMismatchBody(body, currentAuth.access) + if (enriched) { + const identity = extractOAuthIdentity(currentAuth.access) + log.warn("codex request rejected for this account's plan", { + plan: identity.plan, + account: maskAccountId(identity.accountId), + }) + return new Response(JSON.stringify({ detail: enriched }), { + status: response.status, + statusText: response.statusText, + headers: { "content-type": "application/json" }, + }) + } + } + // altimate_change end + + return response }, } }, diff --git a/packages/opencode/test/auth/oauth-claims.test.ts b/packages/opencode/test/auth/oauth-claims.test.ts new file mode 100644 index 0000000000..de863d5e4c --- /dev/null +++ b/packages/opencode/test/auth/oauth-claims.test.ts @@ -0,0 +1,180 @@ +// Coverage for the non-secret OAuth claim helpers behind two UX fixes: +// +// 1. `auth list` showing which ChatGPT account/plan a credential belongs to. +// 2. The enriched 400 shown when a Codex request is made with a credential +// whose plan has no Codex entitlement — previously the user only saw +// `{"detail":"The 'gpt-5.6' model is not supported when using Codex with +// a ChatGPT account."}`, which blames the model, not the account. +// +// Tokens here are synthetic JWT-shaped strings with `alg: none`; nothing in +// this file talks to a network or to a real credential. +import { describe, expect, test } from "bun:test" +import { + decodeJwtClaims, + describeOAuthIdentity, + enrichCodexPlanMismatchBody, + extractOAuthIdentity, + isCodexPlanMismatchBody, + maskAccountId, +} from "../../src/auth/oauth-claims" + +function fakeJwt(payload: object): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url") + const body = Buffer.from(JSON.stringify(payload)).toString("base64url") + return `${header}.${body}.not-a-real-signature` +} + +const FREE_ACCOUNT_ID = "4f3a1b2c-9d8e-4a7b-8c6d-5e4f3a2b1c0d" + +const FREE_TOKEN = fakeJwt({ + chatgpt_plan_type: "free", + chatgpt_account_id: FREE_ACCOUNT_ID, +}) + +const NESTED_PRO_TOKEN = fakeJwt({ + "https://api.openai.com/auth": { + chatgpt_plan_type: "pro", + chatgpt_account_id: "aaaabbbb-cccc-dddd-eeee-ffff00001111", + }, +}) + +const MISMATCH_BODY = JSON.stringify({ + detail: "The 'gpt-5.6' model is not supported when using Codex with a ChatGPT account.", +}) + +describe("decodeJwtClaims", () => { + test("decodes a three-segment token payload", () => { + expect(decodeJwtClaims(fakeJwt({ a: 1 }))).toEqual({ a: 1 }) + }) + + test("returns undefined for non-JWT input", () => { + expect(decodeJwtClaims("sk-not-a-jwt")).toBeUndefined() + expect(decodeJwtClaims("only.two")).toBeUndefined() + expect(decodeJwtClaims("")).toBeUndefined() + }) + + test("returns undefined when the payload is not a JSON object", () => { + const header = Buffer.from("{}").toString("base64url") + expect(decodeJwtClaims(`${header}.${Buffer.from("[1,2]").toString("base64url")}.sig`)).toBeUndefined() + expect(decodeJwtClaims(`${header}.${Buffer.from("nonsense").toString("base64url")}.sig`)).toBeUndefined() + }) +}) + +describe("extractOAuthIdentity", () => { + test("reads top-level plan + account claims", () => { + expect(extractOAuthIdentity(FREE_TOKEN)).toEqual({ + plan: "free", + accountId: FREE_ACCOUNT_ID, + }) + }) + + test("reads the OpenAI-namespaced claim bag", () => { + expect(extractOAuthIdentity(NESTED_PRO_TOKEN)).toEqual({ + plan: "pro", + accountId: "aaaabbbb-cccc-dddd-eeee-ffff00001111", + }) + }) + + test("returns empty for missing or undecodable tokens", () => { + expect(extractOAuthIdentity(undefined)).toEqual({}) + expect(extractOAuthIdentity("opaque-token")).toEqual({}) + expect(extractOAuthIdentity(fakeJwt({ sub: "user" }))).toEqual({}) + }) +}) + +describe("maskAccountId", () => { + test("truncates long ids", () => { + expect(maskAccountId(FREE_ACCOUNT_ID)).toBe("4f3a1b2c…") + }) + + test("leaves short ids alone and passes undefined through", () => { + expect(maskAccountId("abc")).toBe("abc") + expect(maskAccountId(undefined)).toBeUndefined() + }) +}) + +describe("describeOAuthIdentity — the `auth list` annotation", () => { + test("names plan and truncated account", () => { + expect(describeOAuthIdentity(FREE_TOKEN)).toBe("free plan, account 4f3a1b2c…") + }) + + test("degrades to whichever claim is present", () => { + expect(describeOAuthIdentity(fakeJwt({ chatgpt_plan_type: "plus" }))).toBe("plus plan") + expect(describeOAuthIdentity(fakeJwt({ chatgpt_account_id: "acct-1234567890" }))).toBe("account acct-123…") + }) + + test("returns undefined when there is nothing to show", () => { + expect(describeOAuthIdentity(fakeJwt({ sub: "user" }))).toBeUndefined() + expect(describeOAuthIdentity(undefined)).toBeUndefined() + }) + + test("never leaks the token itself", () => { + const described = describeOAuthIdentity(FREE_TOKEN)! + expect(FREE_TOKEN.includes(described)).toBe(false) + for (const segment of FREE_TOKEN.split(".")) { + expect(described).not.toContain(segment) + } + }) +}) + +describe("isCodexPlanMismatchBody", () => { + test("matches the plan-entitlement 400 regardless of the quoted model", () => { + expect(isCodexPlanMismatchBody(MISMATCH_BODY)).toBe(true) + expect( + isCodexPlanMismatchBody( + JSON.stringify({ + detail: "The 'gpt-9.9-codex' model is not supported when using Codex with a ChatGPT account.", + }), + ), + ).toBe(true) + }) + + test("does not match unrelated failures", () => { + expect(isCodexPlanMismatchBody(JSON.stringify({ detail: "rate limit exceeded" }))).toBe(false) + expect(isCodexPlanMismatchBody("")).toBe(false) + expect(isCodexPlanMismatchBody(undefined)).toBe(false) + }) +}) + +describe("enrichCodexPlanMismatchBody", () => { + test("free-plan token produces an actionable message", () => { + const message = enrichCodexPlanMismatchBody(MISMATCH_BODY, FREE_TOKEN)! + expect(message).toContain("4f3a1b2c…") + expect(message).toContain("`free` plan") + expect(message).toContain("Plus or Pro") + expect(message).toContain("altimate-code auth logout openai") + expect(message).toContain("altimate-code auth login openai") + // The provider's own wording is preserved for context. + expect(message).toContain("not supported when using Codex with a ChatGPT account") + }) + + test("never includes the token, its payload segment, or the full account id", () => { + const message = enrichCodexPlanMismatchBody(MISMATCH_BODY, FREE_TOKEN)! + for (const segment of FREE_TOKEN.split(".")) { + expect(message).not.toContain(segment) + } + expect(message).not.toContain(FREE_ACCOUNT_ID) + }) + + test("still actionable when the token carries no readable claims", () => { + const message = enrichCodexPlanMismatchBody(MISMATCH_BODY, "opaque-token")! + expect(message).toContain("altimate-code auth login openai") + expect(message).toContain("plan could not be read") + }) + + test("handles a non-JSON body that still carries the phrase", () => { + const message = enrichCodexPlanMismatchBody( + "model is not supported when using Codex with a ChatGPT account", + FREE_TOKEN, + )! + expect(message).toContain("`free` plan") + expect(message).not.toContain("Provider said:") + }) + + test("leaves every other error alone", () => { + expect( + enrichCodexPlanMismatchBody(JSON.stringify({ detail: "context length exceeded" }), FREE_TOKEN), + ).toBeUndefined() + expect(enrichCodexPlanMismatchBody(undefined, FREE_TOKEN)).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/cli/auth-login-target.test.ts b/packages/opencode/test/cli/auth-login-target.test.ts new file mode 100644 index 0000000000..f8cb193854 --- /dev/null +++ b/packages/opencode/test/cli/auth-login-target.test.ts @@ -0,0 +1,83 @@ +// Regression coverage for `altimate-code auth login `. +// +// The login positional used to be declared as `[url]` and fed straight into +// `fetch(`${url}/.well-known/opencode`)`, so the natural thing to type — +// `auth login openai`, a provider id the CLI itself prints in `auth list` and +// accepts in `auth logout` — failed with: +// +// Error: Failed to load auth provider metadata from openai: fetch() URL is invalid +// +// The positional is now `[target]`: an http(s) URL still takes the well-known +// path, anything else is resolved as a provider id (the same path `--provider` +// and the interactive picker use). +import { describe, expect, test } from "bun:test" +import { ProvidersLoginCommand, isAuthProviderUrl, resolveLoginTarget } from "../../src/cli/cmd/providers" + +describe("isAuthProviderUrl", () => { + test("accepts http(s) URLs", () => { + for (const value of ["https://auth.example.com", "http://localhost:4000", "https://example.com/auth/"]) { + expect(isAuthProviderUrl(value)).toBe(true) + } + }) + + test("rejects provider ids and other non-URLs", () => { + for (const value of ["openai", "anthropic", "github-copilot", "amazon-bedrock", "example.com", "", undefined]) { + expect(isAuthProviderUrl(value)).toBe(false) + } + }) + + test("rejects non-http schemes", () => { + for (const value of ["file:///etc/passwd", "ftp://example.com", "mailto:a@b.c"]) { + expect(isAuthProviderUrl(value)).toBe(false) + } + }) +}) + +describe("resolveLoginTarget", () => { + test("a provider id goes to the provider path, not a fetch (the reported bug)", () => { + expect(resolveLoginTarget({ target: "openai" })).toEqual({ kind: "provider", provider: "openai" }) + }) + + test("real URLs still take the well-known path", () => { + expect(resolveLoginTarget({ target: "https://auth.example.com" })).toEqual({ + kind: "url", + url: "https://auth.example.com", + }) + }) + + test("trailing slashes are stripped from URLs (unchanged behavior)", () => { + expect(resolveLoginTarget({ target: "https://auth.example.com///" })).toEqual({ + kind: "url", + url: "https://auth.example.com", + }) + }) + + test("no argument prompts with the picker", () => { + expect(resolveLoginTarget({})).toEqual({ kind: "picker" }) + expect(resolveLoginTarget({ target: undefined, provider: undefined })).toEqual({ kind: "picker" }) + }) + + test("--provider still works on its own", () => { + expect(resolveLoginTarget({ provider: "anthropic" })).toEqual({ kind: "provider", provider: "anthropic" }) + }) + + test("an explicit --provider flag beats the positional", () => { + expect(resolveLoginTarget({ target: "openai", provider: "anthropic" })).toEqual({ + kind: "provider", + provider: "anthropic", + }) + }) + + test("a URL positional keeps the well-known path even alongside --provider", () => { + expect(resolveLoginTarget({ target: "https://auth.example.com", provider: "anthropic" })).toEqual({ + kind: "url", + url: "https://auth.example.com", + }) + }) +}) + +describe("ProvidersLoginCommand", () => { + test("help no longer advertises the positional as a URL", () => { + expect(ProvidersLoginCommand.command).toBe("login [target]") + }) +}) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index e71b7cff4e..b05a46c773 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -161,7 +161,7 @@ manage AI providers and credentials Commands: altimate-code providers list list providers and credentials [aliases: ls] - altimate-code providers login [url] log in to a provider + altimate-code providers login [target] log in to a provider altimate-code providers logout [provider] log out from a configured provider Options: @@ -545,12 +545,12 @@ Options: `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers login --help 1`] = ` -"altimate-code providers login [url] +"altimate-code providers login [target] log in to a provider Positionals: - url altimate auth provider [string] + target provider id (e.g. openai) or altimate auth provider URL [string] Options: -h, --help show help [boolean] diff --git a/packages/opencode/test/plugin/codex-plan-mismatch.test.ts b/packages/opencode/test/plugin/codex-plan-mismatch.test.ts new file mode 100644 index 0000000000..f025a522f4 --- /dev/null +++ b/packages/opencode/test/plugin/codex-plan-mismatch.test.ts @@ -0,0 +1,130 @@ +// Regression coverage for the wrong-account / insufficient-plan diagnosis in +// the Codex OAuth fetch wrapper (packages/opencode/src/plugin/codex.ts — the +// ACTIVE plugin, wired via plugin/index.ts). +// +// A ChatGPT credential belonging to a free-plan account fails every Codex model +// request with: +// +// 400 {"detail":"The 'gpt-5.6' model is not supported when using Codex with a +// ChatGPT account."} +// +// which blames the model and never mentions the account, so the user has no way +// to tell that the real problem is which account they signed in with. The +// wrapper now rewrites exactly that 400 into a message naming the plan and the +// (truncated) account id, plus the commands to switch accounts. +// +// No network: `globalThis.fetch` is stubbed for the duration of each test, and +// the tokens are synthetic JWT-shaped strings. +import { afterEach, describe, expect, test } from "bun:test" +import { CodexAuthPlugin } from "../../src/plugin/codex" + +function fakeJwt(payload: object): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url") + const body = Buffer.from(JSON.stringify(payload)).toString("base64url") + return `${header}.${body}.not-a-real-signature` +} + +const FREE_TOKEN = fakeJwt({ + chatgpt_plan_type: "free", + chatgpt_account_id: "4f3a1b2c-9d8e-4a7b-8c6d-5e4f3a2b1c0d", +}) + +const PLAN_MISMATCH_BODY = JSON.stringify({ + detail: "The 'gpt-5.6' model is not supported when using Codex with a ChatGPT account.", +}) + +const realFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = realFetch +}) + +/** Build the provider fetch wrapper the AI SDK would call, with an OAuth + * credential that is valid and unexpired (so no refresh round-trip happens). */ +async function makeCodexFetch(accessToken: string) { + const plugin = await CodexAuthPlugin({ client: {} } as any) + const auth = { + type: "oauth" as const, + access: accessToken, + refresh: "refresh-token", + expires: Date.now() + 60 * 60 * 1000, + } + const loaded = await plugin.auth!.loader!((async () => auth) as any, { models: { "gpt-5.6": {} } } as any) + return (loaded as { fetch: typeof fetch }).fetch +} + +function stubFetch(response: Response) { + const calls: Array<{ url: string }> = [] + globalThis.fetch = (async (input: any) => { + calls.push({ url: String(input) }) + return response + }) as any + return calls +} + +describe("codex fetch wrapper — plan/account diagnosis", () => { + test("free-plan 400 is rewritten into an actionable message", async () => { + stubFetch(new Response(PLAN_MISMATCH_BODY, { status: 400, statusText: "Bad Request" })) + const codexFetch = await makeCodexFetch(FREE_TOKEN) + + const response = await codexFetch("https://api.openai.com/v1/responses", { method: "POST", body: "{}" }) + expect(response.status).toBe(400) + + const detail = ((await response.json()) as { detail: string }).detail + expect(detail).toContain("4f3a1b2c…") + expect(detail).toContain("`free` plan") + expect(detail).toContain("Plus or Pro") + expect(detail).toContain("altimate-code auth logout openai") + expect(detail).toContain("altimate-code auth login openai") + }) + + test("the rewritten body never contains the access token or the full account id", async () => { + stubFetch(new Response(PLAN_MISMATCH_BODY, { status: 400 })) + const codexFetch = await makeCodexFetch(FREE_TOKEN) + + const body = await (await codexFetch("https://api.openai.com/v1/responses", { method: "POST" })).text() + expect(body).not.toContain(FREE_TOKEN) + for (const segment of FREE_TOKEN.split(".")) { + expect(body).not.toContain(segment) + } + expect(body).not.toContain("4f3a1b2c-9d8e-4a7b-8c6d-5e4f3a2b1c0d") + expect(body).not.toContain("refresh-token") + }) + + test("still actionable when the token carries no readable claims", async () => { + stubFetch(new Response(PLAN_MISMATCH_BODY, { status: 400 })) + const codexFetch = await makeCodexFetch("opaque-access-token") + + const detail = ((await (await codexFetch("https://api.openai.com/v1/responses", {})).json()) as { detail: string }) + .detail + expect(detail).toContain("plan could not be read") + expect(detail).toContain("altimate-code auth login openai") + }) + + test("other 400s pass through untouched", async () => { + const original = JSON.stringify({ detail: "context length exceeded" }) + stubFetch(new Response(original, { status: 400 })) + const codexFetch = await makeCodexFetch(FREE_TOKEN) + + const response = await codexFetch("https://api.openai.com/v1/responses", {}) + expect(response.status).toBe(400) + expect(await response.text()).toBe(original) + }) + + test("successful responses pass through untouched", async () => { + stubFetch(new Response('{"ok":true}', { status: 200 })) + const codexFetch = await makeCodexFetch(FREE_TOKEN) + + const response = await codexFetch("https://api.openai.com/v1/responses", {}) + expect(response.status).toBe(200) + expect(await response.text()).toBe('{"ok":true}') + }) + + test("requests still go to the Codex endpoint (rewrite is unchanged)", async () => { + const calls = stubFetch(new Response("{}", { status: 200 })) + const codexFetch = await makeCodexFetch(FREE_TOKEN) + + await codexFetch("https://api.openai.com/v1/responses", {}) + expect(calls[0].url).toBe("https://chatgpt.com/backend-api/codex/responses") + }) +}) From 7d53631b737b6cbebccc753311819cd3f5b7d86c Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Sat, 29 Aug 2026 11:21:32 -0700 Subject: [PATCH 2/2] fix: harden the OAuth claim diagnostics after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the claim-based diagnostics: - Claims come from a token we deliberately do not verify and are rendered straight into a terminal, so sanitize them at the extraction boundary: printable ASCII only (no escape or control sequences) and capped at 64 characters. A claim that sanitizes to nothing is dropped. - An entitled account (plus/pro/team/business/enterprise) can hit the same 400 for a model that simply is not offered to ChatGPT-subscription accounts. Telling that user their plan is too low contradicts what they are paying for, so the message now branches: unentitled plans get the switch-accounts instructions, entitled ones are told the model — not the plan — is the problem, with a pointer to `auth list` in case the stored account is not the intended one. - Clarify in `resolveLoginTarget`'s doc comment that a URL positional always wins (it is the only route to the well-known path) and `--provider` beats the positional only when the positional is not a URL. Behavior unchanged; the previous wording read as if the flag always won. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VqnuBDGkh1ZT65Ti7e6DHZ --- packages/opencode/src/auth/oauth-claims.ts | 35 ++++++++++++++++-- packages/opencode/src/cli/cmd/providers.ts | 6 ++-- .../opencode/test/auth/oauth-claims.test.ts | 36 +++++++++++++++++++ 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/auth/oauth-claims.ts b/packages/opencode/src/auth/oauth-claims.ts index 2bf1be8487..a64e5a40c5 100644 --- a/packages/opencode/src/auth/oauth-claims.ts +++ b/packages/opencode/src/auth/oauth-claims.ts @@ -47,9 +47,20 @@ export function decodeJwtClaims(token: string): Record | undefi } } +/** Claim values are rendered straight into a terminal and into error text, and + * the token is never signature-verified, so treat every claim as untrusted + * input: keep printable ASCII only (no control or escape sequences) and cap the + * length so a hostile claim cannot wallpaper the output. */ +const MAX_CLAIM_LENGTH = 64 + +function sanitizeClaim(value: string): string | undefined { + const cleaned = value.replace(/[^\x20-\x7e]/g, "").slice(0, MAX_CLAIM_LENGTH) + return cleaned.length > 0 ? cleaned : undefined +} + function stringClaim(source: Record | undefined, key: string): string | undefined { const value = source?.[key] - return typeof value === "string" && value.length > 0 ? value : undefined + return typeof value === "string" && value.length > 0 ? sanitizeClaim(value) : undefined } /** @@ -109,13 +120,31 @@ export function isCodexPlanMismatchBody(body: string | undefined): boolean { return Boolean(body) && CODEX_PLAN_MISMATCH_PATTERN.test(body!) } +/** Plans that do carry Codex access. On one of these the 400 is NOT an + * entitlement problem, so the message must not claim the plan is too low — + * it would contradict what the user is paying for. */ +const CODEX_ENTITLED_PLANS = new Set(["plus", "pro", "team", "business", "enterprise"]) + /** * Build the actionable replacement for that 400. Names the account and plan the - * request actually used, then gives the exact commands to switch accounts. + * request actually used; on an unentitled plan it says to switch accounts, and + * on an entitled one it says the model, not the plan, is the problem. */ export function codexPlanMismatchMessage(identity: OAuthIdentity, originalDetail?: string): string { const masked = maskAccountId(identity.accountId) const who = masked ? `Signed in as ChatGPT account ${masked}` : "Signed in with a ChatGPT account" + const provider = originalDetail ? `\nProvider said: ${originalDetail}` : "" + + if (identity.plan && CODEX_ENTITLED_PLANS.has(identity.plan.toLowerCase())) { + return ( + `${who} on the \`${identity.plan}\` plan, which does include Codex — so this is the model, ` + + `not the plan. This model is not offered to ChatGPT-subscription accounts; pick a different ` + + `model, or use an OpenAI API key.\n` + + `If that account is not the one you meant to use, \`altimate-code auth list\` shows which is stored.` + + provider + ) + } + const plan = identity.plan ? ` on the \`${identity.plan}\` plan` : " whose plan could not be read from the stored credential" @@ -127,7 +156,7 @@ export function codexPlanMismatchMessage(identity: OAuthIdentity, originalDetail ` altimate-code auth login openai\n` + `Otherwise use an OpenAI API key instead of the ChatGPT subscription, ` + `or pick a model from another provider.` + - (originalDetail ? `\nProvider said: ${originalDetail}` : "") + provider ) } diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 8ce6e9bea9..b542ee6267 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -238,8 +238,10 @@ export type LoginTarget = * Decide which login path an invocation takes. A URL positional keeps the * well-known behavior it always had; anything else is a provider id, so * `auth login openai` works like `auth login --provider openai` rather than - * being handed to fetch(). An explicit `--provider` flag takes precedence over - * the positional, following the usual flag-beats-positional convention. + * being handed to fetch(). A URL positional always wins — it is the only way to + * reach the well-known path, and it is what pre-existing invocations pass. When + * the positional is not a URL, an explicit `--provider` flag takes precedence + * over it, following the usual flag-beats-positional convention. */ export function resolveLoginTarget(args: { target?: string; provider?: string }): LoginTarget { if (isAuthProviderUrl(args.target)) return { kind: "url", url: args.target.replace(/\/+$/, "") } diff --git a/packages/opencode/test/auth/oauth-claims.test.ts b/packages/opencode/test/auth/oauth-claims.test.ts index de863d5e4c..91a233c738 100644 --- a/packages/opencode/test/auth/oauth-claims.test.ts +++ b/packages/opencode/test/auth/oauth-claims.test.ts @@ -82,6 +82,30 @@ describe("extractOAuthIdentity", () => { }) }) +describe("claim sanitization — claims are untrusted, unverified input", () => { + test("control characters and escape sequences are stripped before display", () => { + const hostile = fakeJwt({ + chatgpt_plan_type: "free\u001b[2Jwiped", + chatgpt_account_id: "acct\n\r\u0007-1234", + }) + const identity = extractOAuthIdentity(hostile) + expect(identity.plan).toBe("free[2Jwiped") + expect(identity.accountId).toBe("acct-1234") + for (const value of [identity.plan!, identity.accountId!]) { + expect(/[\x00-\x1f\x7f]/.test(value)).toBe(false) + } + }) + + test("absurdly long claims are capped", () => { + const identity = extractOAuthIdentity(fakeJwt({ chatgpt_plan_type: "x".repeat(5000) })) + expect(identity.plan!.length).toBe(64) + }) + + test("a claim of only control characters is dropped entirely", () => { + expect(extractOAuthIdentity(fakeJwt({ chatgpt_plan_type: "\u0000\u0007" }))).toEqual({}) + }) +}) + describe("maskAccountId", () => { test("truncates long ids", () => { expect(maskAccountId(FREE_ACCOUNT_ID)).toBe("4f3a1b2c…") @@ -171,6 +195,18 @@ describe("enrichCodexPlanMismatchBody", () => { expect(message).not.toContain("Provider said:") }) + test("an entitled plan is told the model is the problem, not the plan", () => { + // A Plus/Pro account can hit the same 400 for a model that simply is not + // offered to subscription accounts. Telling that user their plan is too low + // would contradict what they are paying for. + const proToken = fakeJwt({ chatgpt_plan_type: "pro", chatgpt_account_id: FREE_ACCOUNT_ID }) + const message = enrichCodexPlanMismatchBody(MISMATCH_BODY, proToken)! + expect(message).toContain("`pro` plan, which does include Codex") + expect(message).toContain("this is the model, not the plan") + expect(message).not.toContain("cannot run them") + expect(message).not.toContain("auth logout openai") + }) + test("leaves every other error alone", () => { expect( enrichCodexPlanMismatchBody(JSON.stringify({ detail: "context length exceeded" }), FREE_TOKEN),