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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@
"start-args.test.ts": "cli",
"start-ownership-publication.test.ts": "cli",
"responses-core-modules.test.ts": "responses",
"responses-devin-401-replay.test.ts": "responses",
"responses-grok-devin-preflight.test.ts": "responses",
"responses-passthrough-transient-policy.test.ts": "responses",
"responses-spend-ledger-wiring.test.ts": "responses",
Expand Down
106 changes: 102 additions & 4 deletions src/oauth/devin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DEFAULT_REGION, type WindsurfRegion } from "./devin/types";
import { registerUser } from "./devin/register-user";
import { DEVIN_DEFAULT_API_SERVER, resolveDevinApiBaseUrl, validateDevinApiBaseUrl } from "./devin/api-base";
import { readDevinCliCredentialOutcome } from "./devin/cli-import";
import { CloudAuthError, mintUserJwt } from "../adapters/devin/cloud-direct/auth";
import { getCredential, listAccounts } from "./store";
import { DEPRECATED_OAUTH_PROVIDER_ALIASES } from "./index";

Expand Down Expand Up @@ -263,14 +264,111 @@ export async function loginDevin(
return loginDevinBrowser(ctrl, DEFAULT_REGION);
}

/**
* A CLI-imported account follows the CLI: after `devin auth login` rewrites the
* credential file, the copy stored at import time is stale while the file holds
* a live key. The session JWT carries no expiry, so the upstream 401 is the only
* signal, and this re-read runs only on that forced refresh.
*
* Adoption fails closed on identity. The session token carries only a
* session_id, so the file key's account is established by minting a user_jwt
* with it (GetUserJwt answers with auth_uid and email). A key Cognition refuses
* (401/403) or whose token lacks auth_uid is refused; a mint that fails for any
* other reason throws a non-terminal error, so the account is not flagged and
* the next 401 retries. The minted identity must then
* not contradict the slot's recorded accountId or email, and no other stored
* account may own the key or that identity.
*
* A slot imported from the CLI records no identity (its token has none to
* give), so its first adoption rests on the last two rules alone: it is by
* definition "whatever the CLI is signed into", and the adopted credential
* records the minted identity, which makes every later adoption strict.
*/
/** An identity probe must not hold the per-account refresh lock for the mint's full 30s. */
const DEVIN_IDENTITY_MINT_TIMEOUT_MS = 5_000;

/** Neither a live key nor a dead one: the refresh must fail without flagging the account. */
class DevinIdentityProbeUnavailableError extends Error {
constructor() {
super("Could not confirm the Devin CLI session identity right now; retry shortly.");
this.name = "DevinIdentityProbeUnavailableError";
}
}

const normalizedEmail = (value: string | undefined): string | undefined =>
value?.trim().toLowerCase() || undefined;

async function rereadDevinCliCredential(
stored: OAuthCredentials,
signal: AbortSignal | undefined,
currentAccountId: string | undefined,
): Promise<OAuthCredentials | undefined> {
const outcome = readDevinCliCredentialOutcome();
// A file that exists but cannot be read or parsed may be mid-write by `devin auth login`
// or briefly locked; like a failed identity mint, that says nothing about the key.
if (outcome.kind === "unreadable" || outcome.kind === "incomplete") throw new DevinIdentityProbeUnavailableError();
if (outcome.kind !== "ok" || outcome.file.apiKey === stored.access) return undefined;
const apiBaseUrl = validateDevinApiBaseUrl(outcome.file.apiServerUrl);
if (apiBaseUrl === undefined) return undefined;
if (findDevinCredentialOwner("devin", outcome.file.apiKey) !== undefined) return undefined;
let minted: Record<string, unknown> | undefined;
try {
const timeout = AbortSignal.timeout(DEVIN_IDENTITY_MINT_TIMEOUT_MS);
const probeSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
minted = decodeJwtPayload((await mintUserJwt(outcome.file.apiKey, apiBaseUrl, probeSignal)).jwt);
} catch (error) {
// Only Cognition refusing the key is evidence the file holds no live session. A timeout,
// DNS failure, 5xx or 429 says nothing about the key; flagging the account on one would
// strand it, because a needsReauth slot is never refreshed again.
if (error instanceof CloudAuthError && (error.status === 401 || error.status === 403)) return undefined;
throw new DevinIdentityProbeUnavailableError();
}
const authUid = typeof minted?.auth_uid === "string" && minted.auth_uid ? minted.auth_uid : undefined;
if (authUid === undefined) return undefined;
// identityFromApiKey records `sub ?? auth_uid`, so a stored id may be either claim.
const mintedIds = new Set([authUid, ...(typeof minted?.sub === "string" && minted.sub ? [minted.sub] : [])]);
const rawEmail = typeof minted?.email === "string" ? minted.email.trim() : "";
const email = rawEmail || undefined;
if (stored.accountId && !mintedIds.has(stored.accountId)) return undefined;
const storedEmail = normalizedEmail(stored.email);
if (storedEmail?.includes("@") && storedEmail !== normalizedEmail(email)) return undefined;
if (devinIdentityOwnedElsewhere(stored, currentAccountId, mintedIds, normalizedEmail(email))) return undefined;
return { ...credentialsFromApiKey(outcome.file.apiKey, apiBaseUrl, "local-cli"), accountId: authUid, ...(email ? { email } : {}) };
}

/**
* Every stored Devin row except the one being refreshed, across the alias-linked slots
* (the active row `getCredential` reads is one of these). Rows are skipped by id; only a
* caller that cannot name the row falls back to matching its key.
*/
function devinIdentityOwnedElsewhere(
stored: OAuthCredentials,
currentAccountId: string | undefined,
mintedIds: ReadonlySet<string>,
email: string | undefined,
): boolean {
for (const slot of ["devin", ...devinAliasCredentialSlots("devin")]) {
for (const { id, credential } of listAccounts(slot)) {
if (currentAccountId !== undefined ? id === currentAccountId : credential.access === stored.access) continue;
if (credential.accountId !== undefined && mintedIds.has(credential.accountId)) return true;
if (email && normalizedEmail(credential.email) === email) return true;
}
}
return false;
}

export async function refreshDevinToken(
_refreshToken: string,
_signal?: AbortSignal,
_credential?: OAuthCredentials,
signal?: AbortSignal,
credential?: OAuthCredentials,
accountId?: string,
): Promise<OAuthCredentials> {
const reread = credential?.source === "local-cli"
? await rereadDevinCliCredential(credential, signal, accountId) : undefined;
if (reread) return reread;
// Cognition has no refresh endpoint. Extending the stored expiry here is what
// the carried implementation did, and it makes a revoked key look valid
// forever. Throwing lets the request path mark the account needsReauth the
// first time a forced refresh happens.
// forever. Throwing on the upstream-401 forced refresh is what marks the
// account needsReauth.
throw new Error("invalid_grant: Devin API keys do not refresh. Run ocx login devin again.");
}
11 changes: 9 additions & 2 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ interface OAuthProviderDef {
refreshToken: string,
signal?: AbortSignal,
credential?: OAuthCredentials,
/** Store row being refreshed; passed by the generic lock only. */
accountId?: string,
): Promise<OAuthCredentials>;
/** provider entry written into config.json on first login. */
providerConfig: OcxProviderConfig;
Expand Down Expand Up @@ -626,6 +628,7 @@ const FORCE_REFRESH_PROVIDERS = new Set([
"kiro",
"google-antigravity",
"orcarouter-oauth",
"devin",
]);

export async function forceRefreshOAuthAccessSnapshot(
Expand Down Expand Up @@ -834,7 +837,11 @@ function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OA
function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials {
return {
...fresh,
source: previous.source === "local-cli" ? "oauth" : fresh.source ?? previous.source ?? "oauth",
// Shared: a refresh function returns "local-cli" only when the credential it hands back
// still is the local CLI's (Devin re-reading the CLI file, Meta Muse echoing its durable
// CLI key). Relabelling that "oauth" would stop the next forced refresh from re-reading it.
source: fresh.source === "local-cli" ? "local-cli"
: previous.source === "local-cli" ? "oauth" : fresh.source ?? previous.source ?? "oauth",
...(fresh.projectId === undefined && previous.projectId ? { projectId: previous.projectId } : {}),
...(fresh.apiBaseUrl === undefined && previous.apiBaseUrl ? { apiBaseUrl: previous.apiBaseUrl } : {}),
...(fresh.email === undefined && previous.email ? { email: previous.email } : {}),
Expand Down Expand Up @@ -1035,7 +1042,7 @@ export async function refreshGenericAccountWithLock(
}
const generation = credentialGeneration(stored);
try {
const fresh = merged(await def.refresh(stored.refresh, deps.signal, stored), stored);
const fresh = merged(await def.refresh(stored.refresh, deps.signal, stored, accountId), stored);
const outcome = await mergeAccountCredential(provider, accountId, fresh, {
expectedGeneration: generation,
afterPrePersistRead: deps.afterPrePersistRead,
Expand Down
32 changes: 23 additions & 9 deletions src/oauth/kiro-terminal-failover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,41 @@ import type { OAuthAccessSnapshot } from "./index";
import type { OcxConfig } from "../types";
import { getValidAccessSnapshotForAccount } from "./index";
import { credentialGeneration, getAccountCredentialWithStatus, getAccountSet } from "./store";
import { eligibleFailoverAccounts, isGenericOAuthFailoverEnabled,
import { eligibleFailoverAccounts, isGenericFailoverProvider,
GENERIC_OAUTH_MAX_ACCOUNTS_PER_REQUEST } from "./generic-account-failover";

/** Only an unchanged, allowlist-classified dead credential permits this alternate. */
export async function tryKiroAlternateAfterTerminalRefresh(
config: OcxConfig, failedAccountId: string, failedGeneration: string,
/**
* Only an unchanged, allowlist-classified dead credential permits this alternate.
*
* Consent is the stored-login count, needsReauth rows included: the refused account was
* marked needsReauth a moment ago, and counting only healthy rows would make a two-account
* setup look like one exactly when the second account is needed.
*/
export async function tryAlternateAfterTerminalRefresh(
config: OcxConfig, providerName: string, failedAccountId: string, failedGeneration: string,
): Promise<OAuthAccessSnapshot | null> {
if (!isGenericOAuthFailoverEnabled(config, "kiro")) return null;
const failed = getAccountCredentialWithStatus("kiro", failedAccountId);
const provider = config.providers?.[providerName];
if (!provider || !isGenericFailoverProvider(providerName, provider)) return null;
const order = getAccountSet(providerName)?.accounts.map(row => row.id) ?? [];
if (order.length < 2) return null;
const failed = getAccountCredentialWithStatus(providerName, failedAccountId);
if (!failed?.needsReauth || credentialGeneration(failed.credential) !== failedGeneration) return null;
const order = getAccountSet("kiro")?.accounts.map(row => row.id) ?? [];
const after = order.indexOf(failedAccountId);
if (after < 0) return null;
const ring = [...order.slice(after + 1), ...order.slice(0, after)];
const eligible = new Set(eligibleFailoverAccounts("kiro"));
const eligible = new Set(eligibleFailoverAccounts(providerName));
let attempted = 0;
for (const id of ring) {
if (id === failedAccountId || !eligible.has(id)) continue;
if (++attempted >= GENERIC_OAUTH_MAX_ACCOUNTS_PER_REQUEST) break;
try { return await getValidAccessSnapshotForAccount("kiro", id, { requireUsableAccount: true }); }
try { return await getValidAccessSnapshotForAccount(providerName, id, { requireUsableAccount: true }); }
catch { /* Keep the original login-required result if every alternate is stale. */ }
}
return null;
}

export function tryKiroAlternateAfterTerminalRefresh(
config: OcxConfig, failedAccountId: string, failedGeneration: string,
): Promise<OAuthAccessSnapshot | null> {
return tryAlternateAfterTerminalRefresh(config, "kiro", failedAccountId, failedGeneration);
}
4 changes: 2 additions & 2 deletions src/server/responses/adapter-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { describeUpstreamConnectFailure } from "./upstream-error";
import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
import type { AttemptRecoveryKind } from "../../usage/log";
import type { OAuthAccessSnapshot } from "../../oauth";
import { OAuthLoginRequiredError, publicOAuthAuthenticationErrorMessage } from "../../oauth";
import { publicOAuthAuthenticationErrorMessage } from "../../oauth";
import { tryKiroAlternateAfterTerminalRefresh } from "../../oauth/kiro-terminal-failover";
import { classifyKiroRefusal } from "../../adapters/kiro-refusal";
import { normalizeFinalKiroHttpError } from "../../adapters/kiro-retry";
Expand Down Expand Up @@ -610,7 +610,7 @@ export async function prepareAdapterExchange(
refreshed = await refreshResolvedOAuthSelection(transportState.sentOAuthSnapshot);
} catch (err) {
const failed = transportState.sentOAuthSnapshot;
if (route.providerName === "kiro" && err instanceof OAuthLoginRequiredError && failed
if (route.providerName === "kiro" && failed
&& transportState.genericFailovers < transportState.genericFailoverLimit) {
const alternate = await tryKiroAlternateAfterTerminalRefresh(config, failed.accountId, failed.generation);
if (alternate) {
Expand Down
9 changes: 6 additions & 3 deletions src/server/responses/request-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ export async function prepareResponsesTransport(
|| route.providerName === "kiro"
|| route.providerName === "google-antigravity"
|| route.providerName === "orcarouter-oauth"
// runTurn transport: the replay runs on the first-event preflight in run-turn-execution.
|| route.providerName === "devin"
) && route.provider.authMode === "oauth";
let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
let replayOAuthCredentialSnapshot: Pick<OAuthAccessSnapshot, "accountId" | "generation"> | undefined;
Expand Down Expand Up @@ -193,9 +195,10 @@ export async function prepareResponsesTransport(
};
const refreshResolvedOAuthSelection = async (sent: OAuthAccessSnapshot): Promise<OAuthAccessSnapshot> => {
const current = captureOAuthAccountSelection(route.providerName);
const unchanged = current?.accountId === oauthSelection?.accountId
&& current?.revision === oauthSelection?.revision;
const candidate = unchanged ? await forceRefreshOAuthAccessSnapshot(sent) : sent;
// Keyed on the account, not the selection revision: a selection that moved away and back
// (A -> B -> A) before the 401 landed still serves the rejected credential, and skipping
// the refresh would replay it and spend the one recovery attempt.
const candidate = current?.accountId === sent.accountId ? await forceRefreshOAuthAccessSnapshot(sent) : sent;
const admitted = await commitResolvedOAuthSelection(candidate);
if (!admitted) throw new Error("OAuth selection changed during credential recovery");
if (kiroLoadEnabled && options.accountLoad?.lease?.accountId !== admitted.accountId) {
Expand Down
Loading
Loading