Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ as OrcaRouter are reused until the provider revokes them); API-key login provide
key, validate it when possible, and save the resulting provider config. The command prints the
currently accepted OAuth and API-key provider ids when the name is missing or unknown.

Failed OAuth logins exit with status 1 and print a fixed diagnostic: the HTTP status and standard
OAuth error code (such as `invalid_grant`) when the provider returned one, `network_error` or
`timeout` when the provider could not be reached, `configuration_error` with the reason when a
local setting such as a Codex account namespace blocks the login, and `oauth_authentication_failed`
otherwise. Pool 2 (`anthropic2`) also reports `configuration_error` before prompting when a custom
provider owns that name or its config file cannot be read or parsed.

Use the same command to **reauthenticate** after `ocx status` / `ocx doctor` reports
reauthentication required or a terminal refresh failure (or use Reauthenticate in the dashboard).
Codex pool accounts are not one of those OAuth or API-key providers, but `ocx login codex` reaches
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,7 @@
"oauth-health.test.ts": "oauth",
"oauth-log.test.ts": "oauth",
"oauth-login-cli-browser-launch.test.ts": "oauth",
"oauth-login-cli-errors.test.ts": "oauth",
"oauth-login-cli-live-update.test.ts": "oauth",
"oauth-login-open-browser.test.ts": "oauth",
"oauth-login-summary.test.ts": "oauth",
Expand Down
13 changes: 11 additions & 2 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,14 @@ export class OAuthProviderPublicationError extends Error {
}
}

/** A local configuration conflict found before any credential is written; its text is fixed. */
export class OAuthProviderNamespaceCollisionError extends Error {
constructor(message: string) {
super(message);
this.name = "OAuthProviderNamespaceCollisionError";
}
}

export class OAuthReauthIdentityMismatchError extends Error {
constructor() {
super("Signed-in account does not match the selected account. Sign in with the same account.");
Expand Down Expand Up @@ -485,6 +493,7 @@ export function publicOAuthAuthenticationErrorMessage(error: unknown): string {
(error instanceof OAuthLoginRequiredError && isOAuthProvider(error.provider))
|| error instanceof OAuthAccountPausedError
|| error instanceof OAuthProviderPublicationError
|| error instanceof OAuthProviderNamespaceCollisionError
// Reauth identity outcomes carry fixed, account-free remediation text. Dropping them to the
// generic message hides WHICH failure the user must fix (sign in with the selected account).
|| error instanceof OAuthReauthIdentityMismatchError
Expand Down Expand Up @@ -1506,7 +1515,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
if (!def) return;
if (provider === GOOGLE_ANTIGRAVITY_PROVIDER) migrateLegacyAntigravityStaticCatalog(config);
const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
if (namespaceCollision) throw new Error(namespaceCollision);
if (namespaceCollision) throw new OAuthProviderNamespaceCollisionError(namespaceCollision);
const existing = config.providers[provider];
const providerConfig = def.resolveProviderConfig?.(config) ?? def.providerConfig;
// Clone operator state, including xAI wire choices and their migration version.
Expand Down Expand Up @@ -1603,7 +1612,7 @@ export async function runLogin(
preflightConfig.codexAccountNamespaces,
provider,
);
if (namespaceCollision) throw new Error(namespaceCollision);
if (namespaceCollision) throw new OAuthProviderNamespaceCollisionError(namespaceCollision);
}
if (provider === "anthropic2" && observeInitialConfigState() !== "exists") {
throw new OAuthProviderPublicationError(provider, false);
Expand Down
44 changes: 44 additions & 0 deletions src/oauth/login-cli-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { AnthropicTokenError } from "./anthropic";
import { OAuthProviderNamespaceCollisionError, OAuthProviderPublicationError, publicOAuthAuthenticationErrorMessage } from "./index";
import { XaiTokenRequestError } from "./xai";
import { AnthropicInstanceCollisionError, AnthropicInstanceConfigReadError } from "./store-anthropic-instance";

// Standard RFC 6749 / RFC 6750 / RFC 8628 error codes. A provider's `error` field is printed only
// when it is one of these, so a body that echoes request parameters there still prints nothing.
const STANDARD_OAUTH_ERROR_CODES = new Set([
"invalid_request", "invalid_client", "invalid_grant", "unauthorized_client",
"unsupported_grant_type", "invalid_scope", "access_denied", "unsupported_response_type",
"server_error", "temporarily_unavailable", "invalid_token", "insufficient_scope",
"authorization_pending", "slow_down", "expired_token",
]);

/** Fixed, machine-readable reason for a failed login; never derived from free-form text. */
export function oauthLoginFailureDiagnostic(error: unknown): string {
if (error instanceof OAuthProviderNamespaceCollisionError || error instanceof OAuthProviderPublicationError
|| error instanceof AnthropicInstanceCollisionError || error instanceof AnthropicInstanceConfigReadError) {
return "configuration_error";
}
const status = error instanceof AnthropicTokenError ? error.httpStatus
: error instanceof XaiTokenRequestError ? error.status : undefined;
const oauthError = error instanceof AnthropicTokenError || error instanceof XaiTokenRequestError
? error.oauthError : undefined;
const parts: string[] = [];
if (typeof status === "number" && Number.isInteger(status) && status >= 400 && status <= 599) parts.push(`HTTP ${status}`);
if (typeof oauthError === "string" && STANDARD_OAUTH_ERROR_CODES.has(oauthError)) parts.push(oauthError);
if (parts.length > 0) return parts.join(" ");
const name = error instanceof Error ? error.name : "";
if (name === "TimeoutError" || name === "AbortError") return "timeout";
// fetch() reports refused connections and DNS failures as a TypeError carrying a string code; a
// programming TypeError carries none and stays in the generic bucket.
const code = error instanceof TypeError ? (error as { code?: unknown }).code : undefined;
if ((error instanceof AnthropicTokenError && error.requestNotSent)
|| (error instanceof XaiTokenRequestError && error.status === undefined)
|| (typeof code === "string" && code !== "")) return "network_error";
Comment thread
lidge-jun marked this conversation as resolved.
return "oauth_authentication_failed";
}

/** Keep terminal login failures within the public OAuth error vocabulary. */
export function reportOAuthLoginError(error: unknown): never {
console.error(`\n❌ OAuth login failed (${oauthLoginFailureDiagnostic(error)}): ${publicOAuthAuthenticationErrorMessage(error)}`);
process.exit(1);
}
9 changes: 8 additions & 1 deletion src/oauth/login-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { OcxConfig, OcxProviderConfig } from "../types";
import { configuredAdminToken } from "../lib/admin-secrets";
import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
import { assertAnthropicInstanceLoginConfig } from "./store-anthropic-instance";
import { reportOAuthLoginError } from "./login-cli-errors";

/**
* Seams a test drives in place of a browser, a terminal and a real provider. Production passes
Expand Down Expand Up @@ -122,6 +123,12 @@ export function loginUsageMessage(): string {

export async function handleLogin(provider?: string, deps: LoginCliDeps = {}): Promise<void> {
const name = (provider ?? "").trim().toLowerCase();
try {
// Check raw ownership before tolerant loading can print diagnostics or fall back to defaults.
if (name === "anthropic2") assertAnthropicInstanceLoginConfig({ providers: {} }, name);
} catch (error) {
reportOAuthLoginError(error);
}
// A removed provider id reached through its alias still logs in — the merged
// successor owns the flow. Warn rather than silently reroute so scripts and
// docs that still say `devin-cli` surface the rename to whoever runs them.
Expand Down Expand Up @@ -159,7 +166,7 @@ export async function handleOAuthLogin(name: string, deps: LoginCliDeps = {}): P
return await ask("Paste redirect URL or code (or wait for browser): ");
},
});
});
}).catch(reportOAuthLoginError);
Comment thread
lidge-jun marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// A device or polling provider never prompts, so nothing above waited on the launcher. It is
// still owed an answer before this claims the login worked.
await browser.settled();
Expand Down
11 changes: 9 additions & 2 deletions src/oauth/store-anthropic-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ export class AnthropicInstanceCollisionError extends Error {
}
}

export class AnthropicInstanceConfigReadError extends Error {
constructor() {
super("Cannot verify Anthropic Pool 2 ownership in the existing config file.");
this.name = "AnthropicInstanceConfigReadError";
}
}

export function assertAnthropicInstanceLoginConfig(config: Pick<OcxConfig, "providers">, provider: string, credentialWritten = false): void {
if (provider !== "anthropic2") return;
const row = config.providers.anthropic2;
Expand All @@ -44,11 +51,11 @@ export function assertAnthropicInstanceLoginConfig(config: Pick<OcxConfig, "prov
try { raw = readFileSync(getConfigPath(), "utf8"); }
catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
throw new Error("Cannot verify Anthropic Pool 2 ownership in the existing config file.");
throw new AnthropicInstanceConfigReadError();
}
let parsed: unknown;
try { parsed = JSON.parse(raw.replace(/^\uFEFF/, "")); }
catch { throw new Error("Cannot verify Anthropic Pool 2 ownership in the existing config file."); }
catch { throw new AnthropicInstanceConfigReadError(); }
const providers = parsed && typeof parsed === "object" && "providers" in parsed ? parsed.providers : undefined;
if (providers && typeof providers === "object" && Object.hasOwn(providers, provider)) {
const persisted = (providers as Record<string, unknown>)[provider];
Expand Down
4 changes: 3 additions & 1 deletion structure/providers/anthropic-account-pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ existing auth-store write lock. Display identities do not establish a duplicate.
Refresh intent paths and credential-owner checks retain the actual instance.
Pool 2 config publication rechecks the latest provider row under the config mutation
lock; a collision preserves the custom row and reports any already-written orphan
credential. No default-provider change accompanies that publication.
credential. No default-provider change accompanies that publication. CLI preflight checks raw
config ownership before tolerant loading and reports a custom-row collision or unreadable/malformed
config through the fixed `configuration_error` reporter before prompting.

Regression coverage: `tests/providers/provider-anthropic-instance.test.ts`,
`tests/config/config-anthropic-instance-pool.test.ts`,
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ this wire projection does not change the usage ledger.

## CLI readiness diagnostics

The [CLI management contract](cli-management.md#cli-readiness-diagnostics) owns terminal-safe human diagnostics, structured-output fidelity and capability argument failures. Its [data-key and observation contract](cli-management.md#data-key-probes-and-observation-streams) also owns explicit stdin cancellation, credential separation and bounded follow lifetimes.
The [CLI management contract](cli-management.md#cli-readiness-diagnostics) owns terminal-safe human diagnostics, structured-output fidelity and capability argument failures. Its [data-key and observation contract](cli-management.md#data-key-probes-and-observation-streams) also owns explicit stdin cancellation, credential separation and bounded follow lifetimes. `src/oauth/login-cli.ts` delegates failed provider logins to `src/oauth/login-cli-errors.ts`: the terminal prints the public OAuth message with a fixed reason (a validated HTTP status plus an allowlisted RFC 6749/8628 error code, `network_error`, `timeout`, `configuration_error`, or `oauth_authentication_failed`) and exits 1, so provider response text never reaches the terminal (`tests/oauth/oauth-login-cli-errors.test.ts`). A status-less `XaiTokenRequestError` represents a connection failure after retries and reports `network_error` without exposing its cause. Pool 2 checks raw config ownership before tolerant config loading or prompting; custom-row collisions and unreadable or malformed config are fixed `configuration_error` diagnostics.

## CLI resolve and stop contracts for embedding shells

Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@
"oauth-callback-server.test.ts": "oauth", "oauth-device-code-contract.test.ts": "oauth",
"oauth-first-add-hint.test.ts": "gui", "oauth-health.test.ts": "oauth",
"oauth-log.test.ts": "oauth", "oauth-login-cli-browser-launch.test.ts": "oauth",
"oauth-login-cli-errors.test.ts": "oauth",
"oauth-login-cli-live-update.test.ts": "oauth", "oauth-login-open-browser.test.ts": "oauth",
"oauth-login-summary.test.ts": "oauth", "oauth-manual-code.test.ts": "oauth",
"oauth-open-browser-choice.test.ts": "oauth", "oauth-open-url-result.test.ts": "oauth",
Expand Down
Loading
Loading