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..a64e5a40c5 --- /dev/null +++ b/packages/opencode/src/auth/oauth-claims.ts @@ -0,0 +1,179 @@ +/** + * 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 + } +} + +/** 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 ? sanitizeClaim(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!) +} + +/** 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; 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" + 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.` + + provider + ) +} + +/** + * 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..b542ee6267 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,45 @@ 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(). 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(/\/+$/, "") } + 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 +307,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 +345,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 +373,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 +469,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..91a233c738 --- /dev/null +++ b/packages/opencode/test/auth/oauth-claims.test.ts @@ -0,0 +1,216 @@ +// 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("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…") + }) + + 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("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), + ).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") + }) +})