diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index d5d77c70a..82c2980b9 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -645,7 +645,7 @@ ade --role cto actions run ai.piLoginCancel --input-json '{"providerId":"anthrop ade cursor cloud agents list --text ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fix flaky test" --auto-pr ade --role cto github app-auth login # device-flow authorize the machine ADE GitHub App (headless/brain) -ade github app-auth status --text # show whether a GitHub App user token is stored (login, expiry) +ade github app-auth status --text # show the GitHub App credential state, login, expiry, and any renewal failure ade --role cto github app-auth clear # remove the stored GitHub App authorization ade actions run github.getStatus --input-json '{"forceRefresh":true}' --text # show active read/write sources and cooldowns ade open ade://lane/ @@ -663,6 +663,13 @@ ade skill list --text ade skill show ade-browser --text ``` +`github app-auth status` answers "re-authorize, or wait?" from `credentialState` +alone — never from `expiresAt`. An access token lives 8 hours and renews on use, +so a lapsed `expiresAt` with `credentialState: "authorized"` is healthy. +`"blocked"` means ADE paused its own refresh retries until `refreshBlockedUntil` +after a transient failure (`lastRefreshError` carries the reason): wait, do not +re-authorize. Only `"needs_reauth"` and `"missing"` call for `app-auth login`. + GitHub reads try credentials in environment → ADE GitHub App → GitHub CLI → stored PAT order. Writes skip the read-only GitHub App. `github.getStatus` reports the active read/write sources, per-credential failure/cooldown state, diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index fc902144f..92a924a80 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -174,6 +174,7 @@ import { import { createLaneWorktreeLockService, type LaneWorktreeLockService } from "../../desktop/src/main/services/lanes/laneWorktreeLockService"; import { createHeadlessLinearServices } from "./headlessLinearServices"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { watchCredentialsForRelayRepair } from "./services/credentials/credentialChangeRelayRepair"; import { getSignedInAccountAccessToken, type AccountAuthService, @@ -846,6 +847,9 @@ export async function createAdeRuntime(args: { processRegistry.start(); let runtimeCreated = false; let staleSessionReconcileTimer: ReturnType | null = null; + // Declared out here so the failure path below can release it: the watcher is + // installed long before `runtime` exists, and only `runtime.dispose` stops it. + let stopCredentialWatch: (() => void) | null = null; try { const reconcileStaleRunningSessions = (reason: "startup" | "fresh-activity-grace-expired") => { const reconciledSessions = sessionService.reconcileStaleRunningSessions({ @@ -1590,6 +1594,20 @@ export async function createAdeRuntime(args: { error: error instanceof Error ? error.message : String(error), }); }); + // A repaired or removed GitHub App credential ends the relay's auth-pending + // cooldown at once, the way the desktop app's `onAppUserAuthChanged` does. + // The brain has no such callback — the credential is written by whichever + // process ran the device flow — so it watches the shared machine file + // instead. Best-effort: a store with no watcher leaves the behaviour as it + // was, and the cooldown expires on its own after five minutes. + // + // Installed AFTER `start()`, which marks the service started synchronously: a + // credential change during startup would otherwise poll the relay through a + // service that has not started, and the poll `start()` runs supersedes it. + stopCredentialWatch = watchCredentialsForRelayRepair({ + logger, + pollNow: () => automationIngressService.pollNow(), + }); // Brain → Cloudflare push relay publisher. Owns push registration (from the // paired phone via `push.*` sync commands) and fans agent/PR state transitions @@ -2047,6 +2065,7 @@ export async function createAdeRuntime(args: { // lease subscription, or a disposed scope could later stop the shared // tunnel on a lease transition it no longer has any business observing. swallow(() => relayTunnelGate.dispose()); + swallow(() => stopCredentialWatch?.()); swallow(() => automationIngressService?.dispose()); swallow(() => linearIngressService?.stop()); swallow(() => cursorCloudIngressService.stop()); @@ -2101,6 +2120,14 @@ export async function createAdeRuntime(args: { if (staleSessionReconcileTimer) { clearTimeout(staleSessionReconcileTimer); } + try { + // Only `runtime.dispose` stops this watcher, and there is no runtime. + // Left running it polls the credential file for the life of the + // process and pins the ingress service through its `pollNow` closure. + stopCredentialWatch?.(); + } catch { + // Preserve the original startup failure. + } try { processRegistry.stop(); } catch { diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 6092ae64a..1568a82e6 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -41,6 +41,7 @@ import { startHeadlessRpcTcpServer, shouldAutoRegisterProjectForPlan, formatBrainStatus, + formatGithubAppUserAuth, shouldBlockManualMachineRuntimeSpawn, shouldProbeBrainStartupState, shouldEnforceMachineRuntimeBuildCompatibility, @@ -58,6 +59,7 @@ import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { generateRpcAuthToken } from "./rpcAuth"; import { JsonRpcClient } from "./tuiClient/jsonRpcClient"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { readBrainHeartbeat } from "./services/runtime/brainHeartbeat"; import { localIpcListenOptions } from "./services/runtime/localIpcListenOptions"; type ResolveRootsOptions = Parameters[0]; @@ -67,6 +69,53 @@ process.env.ADE_ENABLE_MACOS_VM = "1"; const crdtHostIt = process.platform === "darwin" ? it : it.skip; +/** + * Waits for a DETACHED test brain to leave, and kills it if it will not. + * + * A brain owns its ADE_HOME for as long as it runs: it writes the credential + * store, that store's lock file, and the relay configuration under + * `secrets/`, plus its heartbeat under `runtime/`. `ade runtime stop` returns + * when the shutdown request is answered, not when the process is gone, and it + * reports rather than throws when it never reached a brain at all. A teardown + * that removes ADE_HOME on that signal alone races those writes: one file + * created between `rmSync`'s readdir and its rmdir fails the whole teardown + * with ENOTEMPTY. + */ +async function waitForDetachedProcessExit( + pid: number | null, + timeoutMs = 10_000, +): Promise { + if (pid === null || !Number.isInteger(pid) || pid <= 0) return; + // Same rule as the brain's "self" heartbeat verdict: never signal your own + // process. A stale heartbeat naming this pid would SIGKILL the test runner. + if (pid === process.pid) return; + const isRunning = (): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + // EPERM means the process exists and belongs to somebody else, which is + // still "running" for this purpose. Only ESRCH means it is gone. + return (error as NodeJS.ErrnoException | null)?.code !== "ESRCH"; + } + }; + const waitUntilGone = async (deadline: number): Promise => { + while (Date.now() < deadline) { + if (!isRunning()) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return !isRunning(); + }; + if (await waitUntilGone(Date.now() + timeoutMs)) return; + try { + process.kill(pid, "SIGKILL"); + } catch { + // Already gone, or not ours to signal. + } + // SIGKILL is not synchronous either: the process still has to be reaped. + await waitUntilGone(Date.now() + 2_000); +} + function withEnv(updates: Record, run: () => T): T { const previous = new Map(); for (const key of Object.keys(updates)) { @@ -1307,6 +1356,79 @@ describe("ADE CLI", () => { .not.toContain("nothing to repair"); }); + it("reports the GitHub App credential state, not the access-token expiry", () => { + // A lapsed 8-hour access token behind a live refresh token is healthy. An + // agent that reads `expiresAt` re-authorizes a working credential, so the + // printed verdict has to come from `credentialState`. + const authorized = formatGithubAppUserAuth({ + configured: true, + tokenStored: true, + userLogin: "octocat", + expiresAt: "2026-08-20T01:00:00.000Z", + refreshTokenExpiresAt: "2026-11-18T01:00:00.000Z", + credentialState: "authorized", + refreshBlockedUntil: null, + lastRefreshError: null, + checkedAt: "2026-08-20T12:00:00.000Z", + error: null, + }); + expect(authorized).toContain("Authorized as octocat"); + expect(authorized).not.toContain("app-auth login"); + + // "blocked" must never read as a request to re-authorize, and the reason + // must survive whole — the generic record renderer truncates it as JSON. + const blocked = formatGithubAppUserAuth({ + configured: true, + tokenStored: true, + userLogin: "octocat", + expiresAt: null, + refreshTokenExpiresAt: "2026-11-18T01:00:00.000Z", + credentialState: "blocked", + refreshBlockedUntil: "2026-08-20T12:05:00.000Z", + lastRefreshError: { + kind: "rate_limited", + message: "GitHub is rate-limiting ADE's sign-in requests right now. Try again in a few minutes.", + status: 429, + at: "2026-08-20T12:00:00.000Z", + }, + checkedAt: "2026-08-20T12:00:00.000Z", + error: null, + }); + expect(blocked).toContain("do not re-authorize"); + expect(blocked).toContain("2026-08-20T12:05:00.000Z"); + expect(blocked).toContain("rate_limited (HTTP 429)"); + expect(blocked).toContain("Try again in a few minutes."); + + // Only a dead refresh token may ask for a login. + expect(formatGithubAppUserAuth({ + configured: true, + tokenStored: true, + userLogin: "octocat", + expiresAt: null, + refreshTokenExpiresAt: null, + credentialState: "needs_reauth", + refreshBlockedUntil: null, + lastRefreshError: null, + checkedAt: "2026-08-20T12:00:00.000Z", + error: null, + })).toContain("ade --role cto github app-auth login"); + + // An older host sends no credentialState at all. The shared derivation + // judges by the refresh token, so a lapsed 8-hour access token next to a + // live refresh token still reads as authorized — never as "log in again". + expect(formatGithubAppUserAuth({ + configured: true, + tokenStored: true, + userLogin: "octocat", + expiresAt: "2026-08-20T04:00:00.000Z", + refreshTokenExpiresAt: "2099-01-01T00:00:00.000Z", + refreshBlockedUntil: null, + lastRefreshError: null, + checkedAt: "2026-08-20T12:00:00.000Z", + error: null, + })).toContain("ADE renews this credential on its own"); + }); + it("skips the brain-starting probe inside supervisor and handover probe children", () => { // Those children run `ade runtime status` with the install lock set. On // Windows the probe would ask the service manager, which spawns another @@ -5517,11 +5639,17 @@ describe("ADE CLI", () => { expect(codeRequests).toBe(1); expect(tokenRequests).toBe(1); } finally { + // Read the pid BEFORE the stop: a clean shutdown removes the heartbeat + // file on its way out. + const brainPid = readBrainHeartbeat(path.join(adeHome, "runtime"))?.pid ?? null; try { await runCli(["--socket", socketPath, "runtime", "stop", "--text"]); } catch { // Best-effort cleanup if the detached test runtime never became available. } + // The brain writes into `adeHome` for as long as it lives. Removing the + // directory under a live one is what fails this teardown with ENOTEMPTY. + await waitForDetachedProcessExit(brainPid); stderrWrite.mockRestore(); process.argv[1] = previousArgvEntry; for (const [key, value] of previousEnv) { @@ -5531,7 +5659,9 @@ describe("ADE CLI", () => { await new Promise((resolve) => directory.close(() => resolve())); fs.rmSync(adeHome, { recursive: true, force: true }); } - }, 30_000); + // The teardown above waits for a detached brain to leave, which costs up to + // 12 seconds on its own before the login flow's own budget is counted. + }, 45_000); posixIt("accepts current-session deadline success but rejects a stale signed-in account", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-account-deadline-sock-")); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index acef7ecd7..c71a94c95 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -72,6 +72,8 @@ import { sessionCanonicalUiState, sessionStatusDisplay, } from "../../desktop/src/renderer/lib/terminalAttention"; +import { deriveGithubAccountAuthState } from "../../desktop/src/renderer/lib/githubIntegrationStatus"; +import type { GitHubAppUserAuthStatus } from "../../desktop/src/shared/types"; import { ADE_USAGE_RANGE_PRESETS, ADE_USAGE_SCOPES, @@ -324,6 +326,7 @@ type FormatterId = | "history-show" | "actions-list" | "action-result" + | "github-app-auth" | "automation-run-detail" | "automation-ingress" | "automation-linear-ingress" @@ -1366,7 +1369,7 @@ const HELP_BY_COMMAND: Record = { store. The token itself is never printed. $ ade --role cto github app-auth login Start device flow and wait for approval - $ ade github app-auth status --text Show whether a token is stored (login, expiry) + $ ade github app-auth status --text Show the credential state, login, and expiry $ ade --role cto github app-auth clear Remove the stored authorization $ ade github actions --text List raw github service actions $ ade actions run github.getStatus --input-json '{"forceRefresh":true}' --text @@ -1381,6 +1384,11 @@ const HELP_BY_COMMAND: Record = { GitHub CLI, then a stored PAT. Writes skip the read-only GitHub App. Authentication failures and rate limits can fall through to the next healthy credential while the failed source is in cooldown. + - Read "state" from app-auth status, not "access token expires". An access + token lives 8 hours and renews on use, so a lapsed expiry with state + "authorized" is healthy. State "blocked" means ADE paused its own retries + until "retry after" — wait, do not re-authorize. Only "needs_reauth" and + "missing" call for login. Flags (login): --max-wait Give up waiting after N seconds (default: GitHub's @@ -12852,6 +12860,7 @@ function buildGithubPlan(args: string[]): CliPlan { return { kind: "execute", label: "github app-auth status", + formatter: "github-app-auth", steps: [actionStep("result", "github", "getAppUserAuthStatus")], }; } @@ -12866,6 +12875,7 @@ function buildGithubPlan(args: string[]): CliPlan { return { kind: "execute", label: "github app-auth clear", + formatter: "github-app-auth", steps: [actionStep("result", "github", "clearAppUserAuth")], }; } @@ -20958,6 +20968,68 @@ export function formatBrainStatus(value: unknown): string { ]); } +/** + * `ade github app-auth status | clear | login` in --text mode. + * + * An agent reads this to answer one question: re-authorize now, or wait? Only + * `credentialState` answers it. A stored token whose 8-hour access token has + * lapsed is still `authorized` — it renews on use — so `expiresAt` alone reads + * as broken when nothing is. `blocked` means ADE has paused its own retries + * until `refreshBlockedUntil` and re-authorizing cannot help; `needs_reauth` is + * the only state that asks for a login. The generic record renderer prints + * `lastRefreshError` as JSON truncated at 96 columns, which cuts the reason in + * half, so the reason gets its own block here. + */ +export function formatGithubAppUserAuth(value: unknown): string { + if (!isRecord(value)) return "The GitHub App authorization status is not available."; + const credentialState = asString(value.credentialState); + const userLogin = asString(value.userLogin); + const refreshBlockedUntil = asString(value.refreshBlockedUntil); + const lastRefreshError = isRecord(value.lastRefreshError) ? value.lastRefreshError : null; + // One module answers "how is this credential judged" for every surface. It + // also carries the legacy fallback: an older host sends no credentialState, + // and the refresh token — never the 8-hour access token — decides the truth. + const accountState = deriveGithubAccountAuthState(value as unknown as GitHubAppUserAuthStatus); + const headline = ((): string => { + if (value.configured !== true) { + return "The ADE GitHub App is not configured on this machine."; + } + if (accountState === "valid") { + return `Authorized${userLogin ? ` as ${userLogin}` : ""}. ADE renews this credential on its own.`; + } + if (accountState === "blocked") { + return "Authorized, but renewal is paused after a transient failure. ADE retries on its own — do not re-authorize."; + } + if (accountState === "needs_reauth") { + return "Re-authorization is needed. Run `ade --role cto github app-auth login`."; + } + return "Not authorized. Run `ade --role cto github app-auth login`."; + })(); + const rows: Array<[string, unknown]> = [ + ["state", credentialState], + ["account", userLogin], + ["token", value.tokenStored === true ? "stored" : "not stored"], + ["access token expires", value.expiresAt], + ["refresh token expires", value.refreshTokenExpiresAt], + ["retry after", refreshBlockedUntil], + ["checked", value.checkedAt], + ["error", value.error], + ]; + const sections = [headline, "", renderKeyValues("GitHub App authorization", rows)]; + if (lastRefreshError) { + const kind = asString(lastRefreshError.kind) ?? "unknown"; + const status = typeof lastRefreshError.status === "number" ? ` (HTTP ${lastRefreshError.status})` : ""; + const at = asString(lastRefreshError.at); + sections.push( + "", + "Last renewal failure", + ` ${kind}${status}${at ? ` at ${at}` : ""}`, + ` ${asString(lastRefreshError.message) ?? "No detail was reported."}`, + ); + } + return sections.join("\n"); +} + function formatTextOutput( value: unknown, formatter: FormatterId | undefined, @@ -21280,6 +21352,8 @@ function formatTextOutput( return formatStorageMaintenance(value); case "update-status": return formatUpdateStatus(value); + case "github-app-auth": + return formatGithubAppUserAuth(value); case "action-result": default: if (isRecord(value)) @@ -22032,6 +22106,7 @@ async function runGithubAppLogin( output: formatOutput( { ...status, status: "expired", error: "timed_out" }, options, + "github-app-auth", ), exitCode: 1, }; @@ -22044,10 +22119,15 @@ async function runGithubAppLogin( ); const poll = await runGithubAction("pollAppUserDeviceAuth", { sessionId }); const status = asString(poll.status); - const authStatus = isRecord(poll.authStatus) ? poll.authStatus : poll; + // The typed printer reads an auth status. When the host returned none, + // `authStatus` is the poll envelope instead, and printing that as an auth + // status would report "not configured" for a machine that is configured. + const polledAuthStatus = isRecord(poll.authStatus) ? poll.authStatus : null; + const authStatus = polledAuthStatus ?? poll; + const authFormatter: FormatterId | undefined = polledAuthStatus ? "github-app-auth" : undefined; if (status === "authorized") { process.stderr.write("GitHub App authorized.\n"); - return { output: formatOutput(authStatus, options), exitCode: 0 }; + return { output: formatOutput(authStatus, options, authFormatter), exitCode: 0 }; } if (status === "pending" || status === "slow_down") { if (typeof poll.intervalSec === "number" && poll.intervalSec > 0) { @@ -22060,7 +22140,7 @@ async function runGithubAppLogin( asString(poll.message) ?? `GitHub device authorization ${status ?? "failed"}.`; process.stderr.write(`${message}\n`); - return { output: formatOutput(authStatus, options), exitCode: 1 }; + return { output: formatOutput(authStatus, options, authFormatter), exitCode: 1 }; } } finally { await connection.close(); diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 9f33d0c39..f66ed7217 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -35,6 +35,7 @@ vi.mock("../../desktop/src/main/services/automations/automationSecretService", ( })); import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { makeStoredAppUserToken } from "../../desktop/src/main/services/github/githubAppUserAuth.testFixtures"; import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { createHeadlessGitHubService, createHeadlessLinearServices } from "./headlessLinearServices"; import { resetGitHubServiceHealthCache } from "../../desktop/src/main/services/github/githubStatusPage"; @@ -1644,21 +1645,20 @@ describe("headlessLinearServices", () => { const environment = isolateHeadlessGithubAuth("ade-headless-github-app-refresh-", { emptyGhConfig: true, }); - new EncryptedFileCredentialStore().setSync("github.appUserToken.v1", JSON.stringify({ + new EncryptedFileCredentialStore().setSync("github.appUserToken.v1", makeStoredAppUserToken({ accessToken: "ghu_expiring_app_token", - tokenType: "bearer", - scope: null, expiresAt: new Date(Date.now() + 10_000).toISOString(), refreshToken: "ghr_refresh_token", refreshTokenExpiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), userLogin: "alice", - updatedAt: new Date().toISOString(), })); + // GitHub's real answer for a rejected refresh token: HTTP 200 with an error + // body. Only a definitive code like this one may write the credential off. globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ - error: "bad_verification_code", + error: "bad_refresh_token", error_description: "Bad credentials", }), { - status: 400, + status: 200, headers: { "content-type": "application/json" }, })) as unknown as typeof fetch; const githubService = createHeadlessGitHubService( @@ -1693,22 +1693,19 @@ describe("headlessLinearServices", () => { const environment = isolateHeadlessGithubAuth("ade-headless-github-app-refresh-fallback-", { emptyGhConfig: true, }); - new EncryptedFileCredentialStore().setSync("github.appUserToken.v1", JSON.stringify({ + new EncryptedFileCredentialStore().setSync("github.appUserToken.v1", makeStoredAppUserToken({ accessToken: "ghu_expiring_app_token", - tokenType: "bearer", - scope: null, expiresAt: new Date(Date.now() + 10_000).toISOString(), refreshToken: "ghr_refresh_token", refreshTokenExpiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), userLogin: "alice", - updatedAt: new Date().toISOString(), })); globalThis.fetch = vi.fn() .mockResolvedValueOnce(new Response(JSON.stringify({ - error: "bad_verification_code", + error: "bad_refresh_token", error_description: "Bad credentials", }), { - status: 400, + status: 200, headers: { "content-type": "application/json" }, })) .mockResolvedValueOnce(new Response(JSON.stringify({ login: "bob" }), { @@ -1775,6 +1772,68 @@ describe("headlessLinearServices", () => { } }); + it("resolves the GitHub App credential once per window instead of per request", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-app-window-", { + emptyGhConfig: true, + }); + // An access token past its life, so every resolution that is not cached + // costs a refresh POST — the traffic that rate-limited GitHub for the user. + new EncryptedFileCredentialStore().setSync( + "github.appUserToken.v1", + makeStoredAppUserToken(), + ); + const requestedUrls: string[] = []; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + requestedUrls.push(String(input)); + if (String(input) === "https://github.com/login/oauth/access_token") { + // The refresh must SUCCEED, or the failure writes a backoff that blocks + // every later resolution on its own — and the assertion below would + // hold with the cache deleted. The rotated access token is stale on + // arrival for the same reason: a long-lived one would make the next + // resolution skip the POST because the token is fresh, not because the + // cache answered. With this response, only the cache can hold the count + // down (2 with it, 3 without). + return new Response( + JSON.stringify({ + access_token: "ghu_fresh", + token_type: "bearer", + expires_in: 1, + refresh_token: "ghr_rotated", + refresh_token_expires_in: 15_811_200, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { "content-type": "application/json", "x-oauth-scopes": "" }, + }); + }) as unknown as typeof fetch; + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ token: null, ghCliPath: null, ghAuthError: null }), + }, + ); + + try { + await githubService.getStatus(); + await githubService.getAppInstallationStatus({ owner: "acme", name: "repo" }); + await githubService.getStatus(); + + const refreshPosts = requestedUrls + .filter((url) => url === "https://github.com/login/oauth/access_token"); + // Two, not three: both status reads share ONE resolution through the + // window, and the installation check resolves the App credential on its + // own path, which this cache does not cover. Without the cache the two + // status reads refresh separately and this is three. + expect(refreshPosts).toHaveLength(2); + } finally { + environment.restore(); + } + }); + it("drops a cached headless writer when that credential disappears", async () => { const environment = isolateHeadlessGithubAuth("ade-headless-github-cache-"); const machineCredentialStore = new EncryptedFileCredentialStore(); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 2e4cef6e4..df97aa681 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -50,10 +50,15 @@ import type { GitHubPullRequestReview, } from "../../desktop/src/main/services/github/githubService"; import { - fetchGitHubAppInstallationStatus, + fetchAppInstallationStatusForRepo, type GitHubRelaySecretReader, } from "../../desktop/src/main/services/github/githubRelayConfig"; import { createGitHubAppUserAuthService } from "../../desktop/src/main/services/github/githubAppUserAuthService"; +import { + appCredentialFailureEntry, + resolveStoredAppUserTokenForRelay, + type AppUserAuthFailure, +} from "../../desktop/src/main/services/github/githubAppUserAuthFailure"; import { requestGithubRawWithCredentialFallback, type GithubRawRequestArgs, @@ -74,7 +79,9 @@ import { createFileService as createFileServiceImpl } from "../../desktop/src/ma import { createPrService as createPrServiceImpl } from "../../desktop/src/main/services/prs/prService"; import { createAutomationSecretService as createAutomationSecretServiceImpl } from "../../desktop/src/main/services/automations/automationSecretService"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { createExpiringPromiseCache } from "../../desktop/src/shared/expiringPromiseCache"; import { + GITHUB_CREDENTIAL_CACHE_TTL_MS, evaluateGithubCredentialCapabilities, githubOperationCredentialCandidates, githubOperationCredentialPrecedence, @@ -747,11 +754,35 @@ export function createHeadlessGitHubService( promise: Promise; } | null = null; + type AppCredentialLookup = { + token: string | null; + failure: AppUserAuthFailure | null; + status: GitHubAppUserAuthStatus; + }; + /** + * The App credential is resolved at most once per TTL window, matching the + * desktop service's inventory cache. + * + * Every status read, PR call and relay poll builds an inventory, and building + * one asks for a GitHub App token — which on a stale access token is a refresh + * POST. The headless twin had no such window, which is why the brain, not the + * desktop app, drove most of the refresh traffic. Only the App lookup is + * cached: the `gh` CLI and PAT reads keep answering live, so signing out of + * `gh` still demotes the write credential immediately. + */ + const appCredentialCache = createExpiringPromiseCache({ + ttlMs: GITHUB_CREDENTIAL_CACHE_TTL_MS, + build: buildAppCredentialAsync, + }); + const invalidateStatusCache = (): void => { cachedStatus = null; cachedAt = 0; cachedStatusBinding = null; statusLookupGeneration += 1; + // A re-authorization or a sign-out just replaced the App credential this + // cache holds, so it cannot outlive the status it fed. + appCredentialCache.clear(); }; const noteCredentialStoreReadState = (unreadable: boolean): boolean => { @@ -802,6 +833,22 @@ export function createHeadlessGitHubService( return read.value; }; + // A hoisted declaration, so the cache that names it can be declared next to + // the invalidator that owns it rather than after every function it calls. + async function buildAppCredentialAsync(): Promise { + const status = appUserAuth.getAuthStatus(); + const resolved = await resolveStoredAppUserTokenForRelay({ + status, + appUserAuth, + logger, + event: "github.app_user_token_unavailable", + }); + return { ...resolved, status }; + } + + const readAppCredentialAsync = async (): Promise => + await appCredentialCache.read(); + const readCredentialInventoryAsync = async (): Promise => { const patToken = await readStoredPatTokenAsync(); const patTokenStored = Boolean(patToken); @@ -811,24 +858,11 @@ export function createHeadlessGitHubService( // below would otherwise hand this inventory somebody else's outcome. const storeUnreadableForThisRead = credentialStoreUnreadable; const environmentToken = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); - const appStatus = appUserAuth.getAuthStatus(); const [appResult, gh] = await Promise.all([ - appStatus.tokenStored - ? appUserAuth.getValidTokenForRelay() - .then((token) => ({ token, failure: null })) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - const failure = classifyGitHubAuthFailure({ message }); - logger.warn("github.app_user_token_refresh_failed", { - error: message, - kind: failure.authFailure.kind, - retryAt: failure.authFailure.retryAt, - }); - return { token: null, failure }; - }) - : Promise.resolve({ token: null, failure: null }), + readAppCredentialAsync(), Promise.resolve(options.ghAuthTokenProvider?.() ?? ghAuthTokenAsync()), ]); + const appStatus = appResult.status; const appToken = appResult.token; const candidates: HeadlessGitHubTokenCandidate[] = []; if (environmentToken) { @@ -875,9 +909,7 @@ export function createHeadlessGitHubService( return { candidates, availableSources: new Set(candidates.map((candidate) => candidate.source)), - failures: appResult.failure - ? [{ source: "app", ...appResult.failure }] - : [], + failures: appCredentialFailureEntry(appResult.failure), appTokenStored: appToken != null || appStatus.tokenStored, patTokenStored, ghCliPath: gh.ghCliPath, @@ -2037,17 +2069,13 @@ export function createHeadlessGitHubService( const owner = args.owner?.trim(); const name = args.name?.trim(); const repo = owner && name ? { owner, name } : await detectGitHubRepoAsync(projectRoot); - const githubAppUserToken = await appUserAuth.getValidTokenForRelay().catch(() => null); - const accountAccessToken = options.getAccountAccessToken - ? await options.getAccountAccessToken().catch(() => null) - : null; - return fetchGitHubAppInstallationStatus({ + return await fetchAppInstallationStatusForRepo({ repo, + appUserAuth, + logger, secretReader: options.githubRelaySecretReader, - forceRefresh: args.forceRefresh === true, - githubAppUserToken, - accountAccessToken, - auditLog: appUserAuth.auditLog, + forceRefresh: args.forceRefresh, + getAccountAccessToken: options.getAccountAccessToken, }); }, getAppUserAuthStatus(): GitHubAppUserAuthStatus { diff --git a/apps/ade-cli/src/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts index be2e841a1..700a3a928 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -2857,9 +2857,14 @@ describe("AccountAuthService refresh and sign-out", () => { expect(JSON.parse(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)!)).toMatchObject({ refreshToken: "refresh-rotated-by-desktop", }); - // A live-pid journal left behind after a non-invalid_grant failure would - // make every peer wait on a refresh nobody is running. - expect(store.getSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY)).toBeNull(); + // The journal SURVIVES a failed exchange. A POST that answered 503 — or + // timed out, or never returned at all — may still have spent the grant at + // Clerk, which is the exact window the journal exists for. It names the + // generation this exchange started on, and it ages out of peer_in_flight on + // its own after ROTATION_JOURNAL_PEER_MAX_AGE_MS. + expect(JSON.parse(store.getSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY)!)).toMatchObject({ + oldRefreshTokenHash: accountTokenGeneration("refresh-old"), + }); }); it("preserves a newer session written by another process while refresh succeeds", async () => { @@ -3061,3 +3066,320 @@ describe("AccountAuthService refresh and sign-out", () => { }); }); }); + +/** + * The shape the routed desktop store has: it can update ONE key atomically, but + * it cannot answer a whole-map updater, because its keys live in two files. + */ +function createRoutedShapeStore(): SyncCredentialStore { + const backing = new MemoryCredentialStore(); + return { + get: (key) => backing.get(key), + set: (key, value) => backing.set(key, value), + delete: (key) => backing.delete(key), + getSync: (key) => backing.getSync(key), + setSync: (key, value) => backing.setSync(key, value), + deleteSync: (key) => backing.deleteSync(key), + getLastReadState: () => backing.getLastReadState(), + updateKeySync: (key, mutator) => { + const next = mutator(backing.getSync(key)); + if (next === undefined) return; + if (next === null) backing.deleteSync(key); + else backing.setSync(key, next); + }, + }; +} + +describe("AccountAuthService over a store with only updateKeySync", () => { + it("persists a refreshed session through the per-key compare-and-swap", async () => { + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const store = createRoutedShapeStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession({ + accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs - 60_000) / 1000) }), + }))); + const refreshedAccessToken = jwt({ + sub: "user_old", + exp: Math.floor((nowMs + 3_600_000) / 1000), + }); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl: vi.fn(async (input: string) => { + if (input.endsWith("/oauth/userinfo")) return jsonResponse({}); + return jsonResponse({ + access_token: refreshedAccessToken, + refresh_token: "refresh-rotated", + expires_in: 3_600, + }); + }), + now: () => nowMs, + }); + activeServices.push(service); + + await expect(service.getAccessToken()).resolves.toBe(refreshedAccessToken); + expect(JSON.parse(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)!)).toMatchObject({ + accessToken: refreshedAccessToken, + refreshToken: "refresh-rotated", + }); + // The rotation journal takes the same per-key path, so it is spent, not + // stranded for the next refresh to wait on. + expect(store.getSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY)).toBeNull(); + }); + + it("writes the needs-re-auth marker instead of rejecting the grant locally", async () => { + // Without the per-key path this store fell through to "rejected locally": + // the marker never reached disk, so every other process kept serving a + // grant the provider had condemned. + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const store = createRoutedShapeStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession({ + accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs - 60_000) / 1000) }), + }))); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl: vi.fn(async () => jsonResponse({ + error: "invalid_grant", + error_description: "refresh token is invalid", + }, 400)), + refreshRotationWaitMs: 0, + now: () => nowMs, + }); + activeServices.push(service); + + await expect(service.getAccessToken()).rejects.toThrow(/invalid/i); + expect(JSON.parse(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)!)).toMatchObject({ + needsReauth: true, + rejectedReason: "invalid_grant", + rejectedAt: "2026-07-14T12:00:00.000Z", + }); + }); + + it("journals the rotation before the exchange the way an atomic store does", async () => { + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const store = createRoutedShapeStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession({ + accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs - 60_000) / 1000) }), + }))); + const journalDuringExchange: Array = []; + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl: vi.fn(async (input: string) => { + if (input.endsWith("/oauth/userinfo")) return jsonResponse({}); + journalDuringExchange.push(store.getSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY)); + return jsonResponse({ + access_token: jwt({ sub: "user_old", exp: Math.floor((nowMs + 3_600_000) / 1000) }), + refresh_token: "refresh-rotated", + expires_in: 3_600, + }); + }), + now: () => nowMs, + pid: 909, + sessionMutationSource: "brain", + }); + activeServices.push(service); + + await service.getAccessToken(); + expect(JSON.parse(journalDuringExchange[0]!)).toMatchObject({ + version: 1, + oldRefreshTokenHash: accountTokenGeneration("refresh-old"), + pid: 909, + source: "brain", + userId: "user_old", + }); + }); +}); + +/** + * A store that reads fine but whose atomic write always throws — a keychain + * that locked, a file that went read-only, a peer holding the lock past the + * timeout. + */ +function createFailingWriteStore(): SyncCredentialStore { + const backing = new MemoryCredentialStore(); + return { + get: (key) => backing.get(key), + set: (key, value) => backing.set(key, value), + delete: (key) => backing.delete(key), + getSync: (key) => backing.getSync(key), + setSync: (key, value) => backing.setSync(key, value), + deleteSync: (key) => backing.deleteSync(key), + getLastReadState: () => backing.getLastReadState(), + updateKeySync: () => { + throw new Error("credential store is locked"); + }, + }; +} + +describe("AccountAuthService when the credential store cannot be written", () => { + it("keeps the provider's error and rejects the grant locally when the marker cannot be written", async () => { + // A throwing store must not replace the `invalid_grant` the caller has to + // see, and must not cost this process the local rejection either: the dead + // grant still has to stop being served here. + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const store = createFailingWriteStore(); + const raw = JSON.stringify(storedSession({ + accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs - 60_000) / 1000) }), + })); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, raw); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl: vi.fn(async () => jsonResponse({ + error: "invalid_grant", + error_description: "refresh token is invalid", + }, 400)), + refreshRotationWaitMs: 0, + now: () => nowMs, + }); + activeServices.push(service); + + const error = await service.getAccessToken().then(() => null, (raised: unknown) => raised as Error); + expect(error?.message).toMatch(/invalid/i); + expect(error?.message).not.toMatch(/locked/i); + // The record is untouched, and the rejection lives in this process only. + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe(raw); + expect(service.getStatus()).toMatchObject({ signedIn: false, sessionState: "expired" }); + }); + + it("reports a refreshed session as not persisted instead of failing the refresh", async () => { + // The exchange succeeded. Raising the store's error here would turn a good + // refresh into a failed one and sign the user out of a live session. + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const store = createFailingWriteStore(); + const raw = JSON.stringify(storedSession({ + accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs - 60_000) / 1000) }), + })); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, raw); + const refreshedAccessToken = jwt({ + sub: "user_old", + exp: Math.floor((nowMs + 3_600_000) / 1000), + }); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl: vi.fn(async (input: string) => { + if (input.endsWith("/oauth/userinfo")) return jsonResponse({}); + return jsonResponse({ + access_token: refreshedAccessToken, + refresh_token: "refresh-rotated", + expires_in: 3_600, + }); + }), + now: () => nowMs, + }); + activeServices.push(service); + + await expect(service.getAccessToken()).resolves.toBe(refreshedAccessToken); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe(raw); + }); + + /** + * A store that refuses to write the SESSION but still takes the journal + * beside it — one locked key, a lock timeout hit by one write, a peer holding + * that row. A store that refuses everything writes no journal at all, so it + * cannot show what the journal is for. + */ + function createStoreRefusingTheSessionWrite(): SyncCredentialStore { + const backing = new MemoryCredentialStore(); + return { + get: (key) => backing.get(key), + set: (key, value) => backing.set(key, value), + delete: (key) => backing.delete(key), + getSync: (key) => backing.getSync(key), + setSync: (key, value) => backing.setSync(key, value), + deleteSync: (key) => backing.deleteSync(key), + getLastReadState: () => backing.getLastReadState(), + updateKeySync: (key, mutator) => { + if (key === ACCOUNT_SESSION_CREDENTIAL_KEY) { + throw new Error("credential store is locked"); + } + const next = mutator(backing.getSync(key)); + if (next === undefined) return; + if (next === null) backing.deleteSync(key); + else backing.setSync(key, next); + }, + }; + } + + /** + * The exchange spent the grant at Clerk and the store still holds the OLD + * session bytes, so the next refresh POSTs a token Clerk has already + * consumed. The journal entry is the only thing that makes the `invalid_grant` + * that follows non-definitive — clearing it signed the machine out of a + * session nobody revoked. + */ + it("keeps the rotation journal when the rotated session cannot be written", async () => { + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const store = createStoreRefusingTheSessionWrite(); + const session = storedSession({ + accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs - 60_000) / 1000) }), + }); + const raw = JSON.stringify(session); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, raw); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl: vi.fn(async (input: string) => { + if (input.endsWith("/oauth/userinfo")) return jsonResponse({}); + return jsonResponse({ + access_token: jwt({ sub: "user_old", exp: Math.floor((nowMs + 3_600_000) / 1000) }), + refresh_token: "refresh-rotated", + expires_in: 3_600, + }); + }), + refreshRotationWaitMs: 0, + now: () => nowMs, + }); + activeServices.push(service); + + await service.getAccessToken(); + + const journal = store.getSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY); + expect(journal).not.toBeNull(); + expect(JSON.parse(journal!)).toMatchObject({ userId: session.userId }); + }); + + it("does not condemn the session on the invalid_grant that follows a failed write", async () => { + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const store = createStoreRefusingTheSessionWrite(); + const raw = JSON.stringify(storedSession({ + accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs - 60_000) / 1000) }), + })); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, raw); + let exchanges = 0; + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl: vi.fn(async (input: string) => { + if (input.endsWith("/oauth/userinfo")) return jsonResponse({}); + exchanges += 1; + // The first exchange succeeds and cannot be written. The second POSTs + // the grant the first one already spent, which Clerk refuses. + if (exchanges === 1) { + return jsonResponse({ + access_token: jwt({ sub: "user_old", exp: Math.floor((nowMs + 3_600_000) / 1000) }), + refresh_token: "refresh-rotated", + expires_in: 3_600, + }); + } + return jsonResponse({ + error: "invalid_grant", + error_description: "refresh token is invalid", + }, 400); + }), + refreshRotationWaitMs: 0, + now: () => nowMs, + }); + activeServices.push(service); + + await service.getAccessToken(); + await expect(service.getAccessToken()).rejects.toThrow(/invalid/i); + + // Not "expired": the rejection is ambiguous, so the session survives and a + // later attempt decides it. + expect(service.getStatus()).not.toMatchObject({ sessionState: "expired" }); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe(raw); + }); +}); diff --git a/apps/ade-cli/src/services/account/accountAuthService.ts b/apps/ade-cli/src/services/account/accountAuthService.ts index 62294d40d..694845f48 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -15,6 +15,10 @@ import { type CredentialStoreReadFailureReason, type SyncCredentialStore, } from "../credentials/credentialStore"; +import { + supportsAtomicCredentialUpdate, + updateCredentialKeySync, +} from "../credentials/updateCredentialKey"; import { runWithAbortSignal } from "../sync/abortSignal"; import { createRotationJournal } from "./accountSessionRotationJournal"; @@ -1034,6 +1038,23 @@ export function createAccountAuthService(args: { else logger.info("account.session_mutation", meta); }; + /** + * Reports a credential write that threw, and swallows it. + * + * Every caller below is already handling something else — a rejected grant, a + * development credential, a completed refresh — and the store failing is not + * a reason to lose that outcome or to replace the error the caller is about + * to raise. Each of them has a documented degraded path for "not written". + */ + const warnSessionWriteFailed = (reason: string, error: unknown): void => { + logger.warn("account.session_write_failed", { + reason, + pid: mutationPid, + source: mutationSource, + error: error instanceof Error ? error.message : String(error), + }); + }; + const readRawEnvCredential = (): string | null => readNonEmptyString(env[ACCOUNT_TOKEN_ENV_KEY]); const resolveOAuthConfig = async (): Promise => @@ -1141,20 +1162,32 @@ export function createAccountAuthService(args: { }; const invalidateStoredSessionIfCurrent = (raw: string): void => { - const updateSync = args.credentialStore.updateSync; - if (updateSync) { - // Atomic compare-and-delete: remove only the development session we - // observed, so a production credential a peer wrote after our read is - // never clobbered. - updateSync.call(args.credentialStore, (values) => { - if (values[ACCOUNT_SESSION_CREDENTIAL_KEY] !== raw) return false; - delete values[ACCOUNT_SESSION_CREDENTIAL_KEY]; - return true; - }); - } + let erased = false; + // Atomic compare-and-delete: remove only the development session we + // observed, so a production credential a peer wrote after our read is + // never clobbered. + // // Without atomic compare-and-delete we do NOT get-then-delete — that races a // peer-written production replacement. The development session is simply // rejected on every read instead of being erased. + if (supportsAtomicCredentialUpdate(args.credentialStore)) { + try { + updateCredentialKeySync( + args.credentialStore, + ACCOUNT_SESSION_CREDENTIAL_KEY, + (current) => { + if (current !== raw) return undefined; + erased = true; + return null; + }, + ); + } catch (error) { + // A write that threw is a write that did not happen. The local + // rejection below still stops this process serving the session. + erased = false; + warnSessionWriteFailed("development_material_rejected", error); + } + } authEpoch += 1; lastObservedSignedIn = false; setSessionReadState("missing"); @@ -1162,7 +1195,9 @@ export function createAccountAuthService(args: { action: "delete", reason: "development_material_rejected", level: "warn", - outcome: args.credentialStore.updateSync ? "erased" : "rejected_locally", + // The real outcome of the write, not merely whether the store claimed to + // support one: the mutator declines when a peer replaced the record. + outcome: erased ? "erased" : "rejected_locally", }); warnDevelopmentClerkIgnored(); }; @@ -1279,21 +1314,29 @@ export function createAccountAuthService(args: { needsReauth: true, ...(oauthErrorCode ? { rejectedReason: oauthErrorCode } : {}), }; - const updateSync = args.credentialStore.updateSync; let marked = false; - if (updateSync) { - // Compare-and-swap on the exact bytes we were rejected for: a replacement - // a peer persisted after our read must never be condemned. - updateSync.call(args.credentialStore, (values) => { - if (values[ACCOUNT_SESSION_CREDENTIAL_KEY] !== raw) return false; - values[ACCOUNT_SESSION_CREDENTIAL_KEY] = JSON.stringify(rejected); - marked = true; - return true; - }); - } + // Compare-and-swap on the exact bytes we were rejected for: a replacement + // a peer persisted after our read must never be condemned. + // // Without compare-and-swap the marker cannot be written safely, but this // process must still stop serving the dead grant. It is rejected on every - // local read instead. + // local read instead — which is also what a failed write leaves behind. + if (supportsAtomicCredentialUpdate(args.credentialStore)) { + try { + updateCredentialKeySync( + args.credentialStore, + ACCOUNT_SESSION_CREDENTIAL_KEY, + (current) => { + if (current !== raw) return undefined; + marked = true; + return JSON.stringify(rejected); + }, + ); + } catch (error) { + marked = false; + warnSessionWriteFailed("refresh_grant_rejected", error); + } + } authEpoch += 1; lastObservedSignedIn = false; setSessionReadState("missing"); @@ -1366,15 +1409,25 @@ export function createAccountAuthService(args: { } }; + /** + * Why a rotated session did not reach the store. + * + * The two failures need opposite handling and must never be collapsed into + * one boolean. `superseded_by_peer` means the store now holds something + * NEWER, so the caller starts over and picks it up. `write_failed` means the + * store holds something OLDER and always will, so starting over is an + * infinite loop — the caller serves the credential it just obtained instead. + */ + type PersistRefreshedOutcome = "persisted" | "superseded_by_peer" | "write_failed"; + const persistRefreshedSessionIfCurrent = ( refreshed: AccountSessionRecord, expectedRaw: string, reason: string, /** Generation this exchange journaled, so only our own entry is cleared. */ journaledTokenGeneration?: string | null, - ): boolean => { - const updateSync = args.credentialStore.updateSync; - if (!updateSync) { + ): PersistRefreshedOutcome => { + if (!supportsAtomicCredentialUpdate(args.credentialStore)) { if (args.credentialStore.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY) !== expectedRaw) { // A peer persisted first, but our exchange is still over — the // generation we journaled is spent either way. Scoped, so a peer's @@ -1385,29 +1438,61 @@ export function createAccountAuthService(args: { if (journaledTokenGeneration) { rotationJournal.clear("rotation_superseded", journaledTokenGeneration); } - return false; + return "superseded_by_peer"; } - // persistSession clears the journal on the way through. - persistSession(refreshed, reason); - return true; + try { + // persistSession clears the journal on the way through. + persistSession(refreshed, reason); + } catch (error) { + // Same reasoning as the atomic branch below: the exchange already spent + // the grant, so a failed write must NOT raise and must NOT clear the + // journal. + warnSessionWriteFailed(reason, error); + return "write_failed"; + } + return "persisted"; } let persisted = false; - updateSync.call(args.credentialStore, (values) => { - if (values[ACCOUNT_SESSION_CREDENTIAL_KEY] !== expectedRaw) return false; - values[ACCOUNT_SESSION_CREDENTIAL_KEY] = JSON.stringify(refreshed); - persisted = true; - return true; - }); + let writeFailed = false; + try { + updateCredentialKeySync( + args.credentialStore, + ACCOUNT_SESSION_CREDENTIAL_KEY, + (current) => { + if (current !== expectedRaw) return undefined; + persisted = true; + return JSON.stringify(refreshed); + }, + ); + } catch (error) { + // The exchange itself succeeded. Raising here would turn a SUCCESSFUL + // refresh into a failed one and sign the user out of a live session. + persisted = false; + writeFailed = true; + warnSessionWriteFailed(reason, error); + } if (persisted) { lastObservedSignedIn = true; locallyRejectedSessionRaw = null; locallyRejectedSessionState = "signed_out"; storedSessionRejected = false; } - // The exchange completed either way: our journaled generation is spent, so - // its entry has nothing left to protect. A peer's newer entry is untouched. - if (journaledTokenGeneration) { + const outcome: PersistRefreshedOutcome = persisted + ? "persisted" + : writeFailed + ? "write_failed" + : "superseded_by_peer"; + // The replacement is durable, or the store already holds something newer: + // either way our journaled generation has nothing left to protect, and a + // peer's newer entry is untouched. + // + // NOT on `write_failed`. There the exchange spent the grant and the store + // still holds the OLD session bytes, so the next refresh POSTs a token + // Clerk has already consumed. The surviving entry is the ONLY thing that + // makes the `invalid_grant` that follows non-definitive; clearing it here + // signed the machine out of a session that was never revoked. + if (journaledTokenGeneration && outcome !== "write_failed") { rotationJournal.clear( persisted ? "rotation_persisted" : "rotation_superseded", journaledTokenGeneration, @@ -1417,9 +1502,9 @@ export function createAccountAuthService(args: { action: "rotate", reason, tokenGeneration: accountTokenGeneration(refreshed.refreshToken), - outcome: persisted ? "persisted" : "superseded_by_peer", + outcome, }); - return persisted; + return outcome; }; const finishPendingSession = ( @@ -2281,71 +2366,81 @@ export function createAccountAuthService(args: { }; let token: TokenResponse | null = null; let config: AccountOAuthConfig | null = null; - // Generation we journaled for this exchange. Cleared in `finally` so a - // network/timeout/early-return path cannot leave a live-pid journal that - // peers wait on forever (the sticky mutex that looks like daily logout). - let journaledGeneration: string | null = null; const accessTokenStillFresh = (session: AccountSessionRecord): boolean => { const expiresAtMs = Date.parse( accessTokenExpiresAt(session.accessToken) ?? session.expiresAt, ); return Number.isFinite(expiresAtMs) && expiresAtMs > now() + ACCESS_TOKEN_REFRESH_SKEW_MS; }; - try { - for (let attempt = 0; attempt < 2; attempt += 1) { - const refreshRecord = refreshSnapshot.session; - config = refreshRecord.oauthConfig - ? normalizeOAuthConfig(refreshRecord.oauthConfig) - : await resolveOAuthConfig(); - const tokenGeneration = accountTokenGeneration(refreshRecord.refreshToken) ?? ""; - // Compare-and-swap the journal before talking to Clerk. A live peer - // already exchanging this grant must be waited out: Clerk refresh - // tokens are single-use, and a second POST is what produced the - // daily `invalid_grant` / mark_dead sign-outs. A dead peer's journal - // is taken over and treated as an interrupted rotation, so the - // `invalid_grant` that follows is not definitive. - const begin = rotationJournal.tryBegin({ - oldRefreshTokenHash: tokenGeneration, - userId: refreshRecord.userId, + for (let attempt = 0; attempt < 2; attempt += 1) { + const refreshRecord = refreshSnapshot.session; + config = refreshRecord.oauthConfig + ? normalizeOAuthConfig(refreshRecord.oauthConfig) + : await resolveOAuthConfig(); + const tokenGeneration = accountTokenGeneration(refreshRecord.refreshToken) ?? ""; + // Compare-and-swap the journal before talking to Clerk. A live peer + // already exchanging this grant must be waited out: Clerk refresh + // tokens are single-use, and a second POST is what produced the + // daily `invalid_grant` / mark_dead sign-outs. A dead peer's journal + // is taken over and treated as an interrupted rotation, so the + // `invalid_grant` that follows is not definitive. + const begin = rotationJournal.tryBegin({ + oldRefreshTokenHash: tokenGeneration, + userId: refreshRecord.userId, + }); + if (begin.kind === "peer_in_flight") { + const rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); + if (rotation.kind === "rotated") return rotation.snapshot.session; + if (rotation.kind !== "unchanged") return null; + throw new Error( + "ADE is refreshing your account sign-in in another process. Retry in a moment.", + ); + } + const interruptedRotation = begin.takeover; + try { + token = await postTokenForm({ + fetchImpl, + tokenUrl: `${config.issuer}/oauth/token`, + signal: sharedSignal, + body: { + grant_type: "refresh_token", + refresh_token: refreshRecord.refreshToken!, + client_id: config.clientId, + }, }); - if (begin.kind === "peer_in_flight") { - const rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); - if (rotation.kind === "rotated") return rotation.snapshot.session; - if (rotation.kind !== "unchanged") return null; - throw new Error( - "ADE is refreshing your account sign-in in another process. Retry in a moment.", - ); + break; + } catch (error) { + if ( + !(error instanceof AccountTokenRequestError) + || error.oauthErrorCode !== "invalid_grant" + ) { + throw error; } - journaledGeneration = tokenGeneration; - const interruptedRotation = begin.takeover; - try { - token = await postTokenForm({ - fetchImpl, - tokenUrl: `${config.issuer}/oauth/token`, - signal: sharedSignal, - body: { - grant_type: "refresh_token", - refresh_token: refreshRecord.refreshToken!, - client_id: config.clientId, - }, - }); - break; - } catch (error) { - if ( - !(error instanceof AccountTokenRequestError) - || error.oauthErrorCode !== "invalid_grant" - ) { - throw error; + // The desktop and brain share this credential. A peer that won a + // rotating refresh exchange may not have persisted its replacement + // by the time Clerk rejects our old token, so poll before declaring + // the grant dead. The window out-waits the credential store's lock + // timeout, so a winner still queued for the lock cannot lose. + let rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); + if (rotation.kind === "rotated" && attempt === 0) { + // A peer already persisted a usable pair. Serving it avoids a + // second Clerk POST that would burn their new rotating grant. + if (accessTokenStillFresh(rotation.snapshot.session)) { + return rotation.snapshot.session; } - // The desktop and brain share this credential. A peer that won a - // rotating refresh exchange may not have persisted its replacement - // by the time Clerk rejects our old token, so poll before declaring - // the grant dead. The window out-waits the credential store's lock - // timeout, so a winner still queued for the lock cannot lose. - let rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); + refreshSnapshot = rotation.snapshot; + continue; + } + if (rotation.kind !== "unchanged") return null; + if (interruptedRotation) { + // An interrupted journal makes this rejection ambiguous: the + // stored token may already have been spent by the process that + // died. Spend one more rotation-wait cycle, then give up for this + // attempt WITHOUT condemning the session. Clearing the journal + // makes the next refresh definitive, so an actually-dead grant + // still reaches the needs-re-auth state one attempt later. + rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); if (rotation.kind === "rotated" && attempt === 0) { - // A peer already persisted a usable pair. Serving it avoids a - // second Clerk POST that would burn their new rotating grant. if (accessTokenStillFresh(rotation.snapshot.session)) { return rotation.snapshot.session; } @@ -2353,104 +2448,97 @@ export function createAccountAuthService(args: { continue; } if (rotation.kind !== "unchanged") return null; - if (interruptedRotation) { - // An interrupted journal makes this rejection ambiguous: the - // stored token may already have been spent by the process that - // died. Spend one more rotation-wait cycle, then give up for this - // attempt WITHOUT condemning the session. Clearing the journal - // makes the next refresh definitive, so an actually-dead grant - // still reaches the needs-re-auth state one attempt later. - rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); - if (rotation.kind === "rotated" && attempt === 0) { - if (accessTokenStillFresh(rotation.snapshot.session)) { - return rotation.snapshot.session; - } - refreshSnapshot = rotation.snapshot; - continue; - } - if (rotation.kind !== "unchanged") return null; - rotationJournal.clear("interrupted_rotation_inconclusive", tokenGeneration); - journaledGeneration = null; - logSessionMutation({ - action: "rotation_journal_interrupted", - reason: "invalid_grant_not_definitive", - level: "warn", - oauthErrorCode: error.oauthErrorCode, - tokenGeneration, - outcome: "session_preserved", - }); - throw error; - } - const marked = markStoredSessionRejectedIfExact( - refreshSnapshot.raw, - refreshSnapshot.session, - error.oauthErrorCode, - ); - if (!marked && readSessionSnapshot().raw !== refreshSnapshot.raw) { - return null; - } + rotationJournal.clear("interrupted_rotation_inconclusive", tokenGeneration); + logSessionMutation({ + action: "rotation_journal_interrupted", + reason: "invalid_grant_not_definitive", + level: "warn", + oauthErrorCode: error.oauthErrorCode, + tokenGeneration, + outcome: "session_preserved", + }); throw error; } + const marked = markStoredSessionRejectedIfExact( + refreshSnapshot.raw, + refreshSnapshot.session, + error.oauthErrorCode, + ); + if (!marked && readSessionSnapshot().raw !== refreshSnapshot.raw) { + return null; + } + throw error; } - if (!token || !config) { - throw new Error("ADE account session expired. Run `ade login` again."); - } - if (authEpoch !== epochAtJoin) return null; - const obtainedAtMs = now(); - const refreshed = await buildSessionRecord( + } + if (!token || !config) { + throw new Error("ADE account session expired. Run `ade login` again."); + } + if (authEpoch !== epochAtJoin) return null; + const obtainedAtMs = now(); + const refreshed = await buildSessionRecord( + token, + refreshSnapshot.session, + undefined, + config, + { fetchUserinfo: false, obtainedAtMs, signal: sharedSignal }, + ); + if (authEpoch !== epochAtJoin) return null; + const rotationOutcome = persistRefreshedSessionIfCurrent( + refreshed, + refreshSnapshot.raw, + "refresh_token_rotated", + accountTokenGeneration(refreshSnapshot.session.refreshToken), + ); + // `persistRefreshedSessionIfCurrent` owns the journal from here: it + // clears the entry on `persisted` and on `superseded_by_peer`, and + // deliberately KEEPS it on `write_failed` — the replacement never became + // durable, so the entry must survive to say the stored grant may already + // have been spent. No other path clears it either: a POST that threw may + // still have spent the grant at Clerk, which is the crash window the + // journal exists for. The journal self-heals — the next cycle reads it + // as `already_ours`, so the `invalid_grant` that follows is not + // definitive and the two-rotation wait reaches needs_reauth one attempt + // later. A request that never left costs one extra non-definitive retry. + if (rotationOutcome === "write_failed") { + // The store will keep holding the OLD record, so starting over + // would refresh again against a grant this exchange already spent. + // Serve the pair we just obtained and let the next process retry. + return refreshed; + } + if (rotationOutcome !== "persisted") return null; + + // The rotated access/refresh pair is durable before optional profile + // enrichment. Identity is carried from the previously verified subject, + // so avoidable userinfo latency cannot expose a stale refresh token to a + // second process. + let enriched: AccountSessionRecord; + try { + enriched = await buildSessionRecord( token, refreshSnapshot.session, undefined, config, - { fetchUserinfo: false, obtainedAtMs, signal: sharedSignal }, + { obtainedAtMs, signal: sharedSignal }, ); - if (authEpoch !== epochAtJoin) return null; - if (!persistRefreshedSessionIfCurrent( - refreshed, - refreshSnapshot.raw, - "refresh_token_rotated", - accountTokenGeneration(refreshSnapshot.session.refreshToken), - )) { - // Persist path clears our journaled generation; skip the finally clear. - journaledGeneration = null; - return null; - } - journaledGeneration = null; - - // The rotated access/refresh pair is durable before optional profile - // enrichment. Identity is carried from the previously verified subject, - // so avoidable userinfo latency cannot expose a stale refresh token to a - // second process. - let enriched: AccountSessionRecord; - try { - enriched = await buildSessionRecord( - token, - refreshSnapshot.session, - undefined, - config, - { obtainedAtMs, signal: sharedSignal }, - ); - } catch (error) { - if (sharedSignal.aborted) throw error; - // The rotated credential and verified prior subject are already - // durable. Optional profile enrichment must not make that successful - // refresh unusable. - return readSession() ?? refreshed; - } - if (authEpoch !== epochAtJoin) return null; - const refreshedRaw = JSON.stringify(refreshed); - return persistRefreshedSessionIfCurrent( - enriched, - refreshedRaw, - "refresh_profile_enriched", - ) - ? enriched - : readSession(); - } finally { - if (journaledGeneration) { - rotationJournal.clear("refresh_finished", journaledGeneration); - } + } catch (error) { + if (sharedSignal.aborted) throw error; + // The rotated credential and verified prior subject are already + // durable. Optional profile enrichment must not make that successful + // refresh unusable. + return readSession() ?? refreshed; } + if (authEpoch !== epochAtJoin) return null; + const refreshedRaw = JSON.stringify(refreshed); + // Only a peer's newer record is worth re-reading for. The rotated + // pair is already durable, so a failed enrichment write costs nothing + // but the profile fields, which this record carries anyway. + return persistRefreshedSessionIfCurrent( + enriched, + refreshedRaw, + "refresh_profile_enriched", + ) === "superseded_by_peer" + ? readSession() + : enriched; })().finally(() => { clearTimeout(sharedRefreshTimer); refreshInFlight = null; diff --git a/apps/ade-cli/src/services/account/accountSessionRotationJournal.test.ts b/apps/ade-cli/src/services/account/accountSessionRotationJournal.test.ts new file mode 100644 index 000000000..79d1d9264 --- /dev/null +++ b/apps/ade-cli/src/services/account/accountSessionRotationJournal.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SyncCredentialStore } from "../credentials/credentialStore"; +import { + ACCOUNT_SESSION_ROTATION_JOURNAL_KEY, + ROTATION_JOURNAL_PEER_MAX_AGE_MS, + createRotationJournal, + parseRotationJournal, + type RotationJournalEntry, +} from "./accountSessionRotationJournal"; + +/** + * The journal decides, in one place, whether this process may POST a rotating + * grant right now. Every branch of that decision is a real incident: a wrongly + * granted begin burns a live peer's grant, and a wrongly refused one wedges + * every process behind a rotation nobody is running. + * + * These tests drive the store directly, which is the only way to state "the + * journal already holds THIS entry" without running a whole refresh. + */ + +const OUR_PID = 4242; +const PEER_PID = 999; +const NOW_MS = Date.parse("2026-08-20T12:00:00.000Z"); + +function createStore(): SyncCredentialStore { + const values = new Map(); + return { + get: async (key) => values.get(key) ?? null, + set: async (key, value) => void values.set(key, value), + delete: async (key) => void values.delete(key), + getSync: (key) => values.get(key) ?? null, + setSync: (key, value) => void values.set(key, value), + deleteSync: (key) => void values.delete(key), + updateKeySync: (key, mutator) => { + const next = mutator(values.get(key) ?? null); + if (next === undefined) return; + if (next === null) values.delete(key); + else values.set(key, next); + }, + }; +} + +function writeEntry( + store: SyncCredentialStore, + patch: Partial = {}, +): void { + store.setSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY, JSON.stringify({ + version: 1, + oldRefreshTokenHash: "gen-old", + startedAt: new Date(NOW_MS - 1_000).toISOString(), + pid: PEER_PID, + source: "desktop", + userId: "user_1", + ...patch, + })); +} + +function readEntry(store: SyncCredentialStore): RotationJournalEntry | null { + return parseRotationJournal(store.getSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY)); +} + +function buildJournal( + store: SyncCredentialStore, + options: { pidAlive?: (pid: number) => boolean; nowMs?: number } = {}, +) { + const log = vi.fn(); + const journal = createRotationJournal({ + credentialStore: store, + now: () => options.nowMs ?? NOW_MS, + pid: OUR_PID, + source: "cli", + log, + pidAlive: options.pidAlive ?? (() => false), + }); + const actions = (): string[] => log.mock.calls.map(([entry]) => String(entry.action)); + return { journal, log, actions }; +} + +const BEGIN = { oldRefreshTokenHash: "gen-old", userId: "user_1" }; + +describe("account session rotation journal", () => { + it("writes an owned entry when no journal exists", () => { + const store = createStore(); + const { journal, actions } = buildJournal(store); + + expect(journal.tryBegin(BEGIN)).toEqual({ kind: "acquired", takeover: false }); + expect(readEntry(store)).toMatchObject({ oldRefreshTokenHash: "gen-old", pid: OUR_PID }); + expect(actions()).toEqual(["rotation_journal_begin"]); + }); + + /** + * A live peer is waited out, never raced: Clerk refresh tokens are single-use, + * so a second POST against one generation makes the loser look signed out. + */ + it("refuses to begin behind a live, fresh peer", () => { + const store = createStore(); + writeEntry(store); + const { journal, actions } = buildJournal(store, { pidAlive: (pid) => pid === PEER_PID }); + + expect(journal.tryBegin(BEGIN).kind).toBe("peer_in_flight"); + // The peer's entry is left exactly as it was, and the refusal is reported. + expect(readEntry(store)).toMatchObject({ pid: PEER_PID, source: "desktop" }); + expect(actions()).toEqual(["rotation_journal_interrupted"]); + }); + + it("takes over a dead peer's entry as an interrupted rotation", () => { + const store = createStore(); + writeEntry(store); + const { journal, actions } = buildJournal(store); + + expect(journal.tryBegin(BEGIN)).toEqual({ kind: "acquired", takeover: true }); + expect(readEntry(store)).toMatchObject({ pid: OUR_PID }); + expect(actions()).toEqual(["rotation_journal_interrupted", "rotation_journal_begin"]); + }); + + /** + * A wedged owner or a reused pid must not block every peer forever, so an + * entry older than the peer window is taken over even while its pid is live. + */ + it("takes over a live peer's entry once it has aged out", () => { + const store = createStore(); + writeEntry(store, { + startedAt: new Date(NOW_MS - ROTATION_JOURNAL_PEER_MAX_AGE_MS - 1).toISOString(), + }); + const { journal } = buildJournal(store, { pidAlive: () => true }); + + expect(journal.tryBegin(BEGIN)).toEqual({ kind: "acquired", takeover: true }); + expect(readEntry(store)).toMatchObject({ pid: OUR_PID }); + }); + + /** + * Our OWN surviving entry means an exchange ran and never made its replacement + * durable. That is an interrupted rotation exactly like a dead peer's — and + * the entry must NOT be rewritten, because restamping `startedAt` keeps a + * process that fails the same write forever young in every peer's eyes. + */ + it("reports our own surviving entry as a takeover without restamping it", () => { + const store = createStore(); + const startedAt = new Date(NOW_MS - 30_000).toISOString(); + writeEntry(store, { pid: OUR_PID, startedAt, source: "cli" }); + const { journal, actions } = buildJournal(store); + + expect(journal.tryBegin(BEGIN)).toEqual({ kind: "acquired", takeover: true }); + expect(readEntry(store)).toMatchObject({ startedAt, pid: OUR_PID }); + // One uniform acquired tail: the interruption AND the begin are both logged. + expect(actions()).toEqual(["rotation_journal_interrupted", "rotation_journal_begin"]); + }); + + it("reports our own surviving entry the same way without an atomic store", () => { + const store = createStore(); + const startedAt = new Date(NOW_MS - 30_000).toISOString(); + writeEntry(store, { pid: OUR_PID, startedAt, source: "cli" }); + delete (store as { updateKeySync?: unknown }).updateKeySync; + const { journal, actions } = buildJournal(store); + + expect(journal.tryBegin(BEGIN)).toEqual({ kind: "acquired", takeover: true }); + expect(readEntry(store)).toMatchObject({ startedAt }); + expect(actions()).toEqual(["rotation_journal_interrupted", "rotation_journal_begin"]); + }); + + /** + * A peer may have started its own rotation against a NEWER generation while + * ours was in flight. Erasing that entry strips the crash protection from an + * exchange still running elsewhere. + */ + it("clears only the generation the caller names", () => { + const store = createStore(); + writeEntry(store, { oldRefreshTokenHash: "gen-peer" }); + const { journal, actions } = buildJournal(store); + + journal.clear("rotation_persisted", "gen-old"); + expect(readEntry(store)).toMatchObject({ oldRefreshTokenHash: "gen-peer" }); + expect(actions()).toEqual([]); + + journal.clear("rotation_persisted", "gen-peer"); + expect(readEntry(store)).toBeNull(); + expect(actions()).toEqual(["rotation_journal_clear"]); + }); + + it("clears any entry when the caller names no generation", () => { + const store = createStore(); + writeEntry(store, { oldRefreshTokenHash: "gen-peer" }); + const { journal } = buildJournal(store); + + journal.clear("sign_out"); + + expect(readEntry(store)).toBeNull(); + }); +}); diff --git a/apps/ade-cli/src/services/account/accountSessionRotationJournal.ts b/apps/ade-cli/src/services/account/accountSessionRotationJournal.ts index f1c51aa18..a85eb91cb 100644 --- a/apps/ade-cli/src/services/account/accountSessionRotationJournal.ts +++ b/apps/ade-cli/src/services/account/accountSessionRotationJournal.ts @@ -18,6 +18,10 @@ */ import type { SyncCredentialStore } from "../credentials/credentialStore"; +import { + supportsAtomicCredentialUpdate, + updateCredentialKeySync, +} from "../credentials/updateCredentialKey"; import type { AccountSessionMutationAction, AccountSessionMutationSource, @@ -223,10 +227,21 @@ export function createRotationJournal(args: RotationJournalArgs): RotationJourna }); return decision; } - if (decision.kind === "already_ours") { - return { kind: "acquired", takeover: false }; - } - if (decision.takeover) { + const ourOwnEntry = decision.kind === "already_ours"; + if (ourOwnEntry) { + // Our OWN entry for this same generation survived a previous exchange. + // The only way that happens is an exchange that ran and never made its + // replacement durable — a failed store write, most of all. That is an + // interrupted rotation exactly like a dead peer's, so report it as one: + // otherwise the `invalid_grant` on the grant we already spent reads as + // definitive and signs the machine out. + args.log({ + action: "rotation_journal_interrupted", + reason: "own_journal_survived_previous_exchange", + tokenGeneration: entry.oldRefreshTokenHash, + level: "warn", + }); + } else if (decision.takeover) { args.log({ action: "rotation_journal_interrupted", reason: "dead_peer_journal_taken_over", @@ -234,7 +249,11 @@ export function createRotationJournal(args: RotationJournalArgs): RotationJourna level: "warn", }); } - if (!options.alreadyPersisted) { + // Our surviving entry IS the journal for this exchange. Rewriting it would + // restamp `startedAt`, which is what ages an abandoned entry out of + // peer_in_flight — a process that keeps failing the same write would keep + // its own journal young forever. + if (!options.alreadyPersisted && !ourOwnEntry) { persistBegin(entry); } args.log({ @@ -242,7 +261,7 @@ export function createRotationJournal(args: RotationJournalArgs): RotationJourna reason: "refresh_exchange_started", tokenGeneration: entry.oldRefreshTokenHash, }); - return { kind: "acquired", takeover: decision.takeover }; + return { kind: "acquired", takeover: ourOwnEntry || decision.takeover }; }; const tryBegin = (entry: { @@ -250,21 +269,24 @@ export function createRotationJournal(args: RotationJournalArgs): RotationJourna userId: string | null; }): RotationJournalBeginResult => { try { - const updateSync = args.credentialStore.updateSync; - if (!updateSync) { + // A store with no atomic update cannot compare-and-swap the journal, so + // it decides against a plain read instead. + if (!supportsAtomicCredentialUpdate(args.credentialStore)) { return finalizeBegin(decide(read(), entry), entry); } let decision: BeginDecision | undefined; - updateSync.call(args.credentialStore, (values) => { - const existing = parseRotationJournal(values[ACCOUNT_SESSION_ROTATION_JOURNAL_KEY]); - decision = decide(existing, entry); - if (decision.kind === "peer_in_flight" || decision.kind === "already_ours") { - return false; - } - values[ACCOUNT_SESSION_ROTATION_JOURNAL_KEY] = serialize(entry); - return true; - }); + updateCredentialKeySync( + args.credentialStore, + ACCOUNT_SESSION_ROTATION_JOURNAL_KEY, + (current) => { + decision = decide(parseRotationJournal(current), entry); + if (decision.kind === "peer_in_flight" || decision.kind === "already_ours") { + return undefined; + } + return serialize(entry); + }, + ); if (!decision) { // The store declined to run the updater. Proceed without a journal // rather than blocking the exchange. diff --git a/apps/ade-cli/src/services/credentials/credentialChangeRelayRepair.test.ts b/apps/ade-cli/src/services/credentials/credentialChangeRelayRepair.test.ts new file mode 100644 index 000000000..cd8dcb9a8 --- /dev/null +++ b/apps/ade-cli/src/services/credentials/credentialChangeRelayRepair.test.ts @@ -0,0 +1,375 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + CREDENTIAL_CHANGE_POLL_COALESCE_MS, + watchCredentialsForRelayRepair, +} from "./credentialChangeRelayRepair"; + +const APP_TOKEN_KEY = "github.appUserToken.v1"; + +function createLogger() { + return { warn: vi.fn() }; +} + +/** + * A store that hands its change listener straight back to the test, backed by + * a values map so tests can write one key and fire the watcher. + */ +function createWatchableStore( + options: { identity?: string; values?: Record } = {}, +) { + const listeners = new Set<() => void>(); + const values: Record = { ...(options.values ?? {}) }; + let reads = 0; + let readsFail = false; + const fire = (): void => { + for (const listener of [...listeners]) listener(); + }; + return { + fire, + /** How many times the credential was actually decrypted. */ + readCount: () => reads, + /** Stands in for a locked or corrupt store: every read throws. */ + setReadsFail: (fail: boolean) => { + readsFail = fail; + }, + /** Write one key and notify, the way a real credential write does. */ + write: (key: string, value: string) => { + values[key] = value; + fire(); + }, + writeAppToken: (value: string) => { + values[APP_TOKEN_KEY] = value; + fire(); + }, + listenerCount: () => listeners.size, + store: { + getSync: (key: string) => { + reads += 1; + if (readsFail) throw new Error("credential store unreadable"); + return values[key] ?? null; + }, + ...(options.identity === undefined + ? {} + : { credentialStoreIdentity: () => options.identity as string }), + onDidChange: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }; +} + +describe("watchCredentialsForRelayRepair", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("forces a relay poll when the shared credential file changes", () => { + const watchable = createWatchableStore(); + const pollNow = vi.fn(async () => undefined); + + watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow, + credentialStore: watchable.store, + now: () => 0, + }); + watchable.writeAppToken("token-1"); + + expect(pollNow).toHaveBeenCalledTimes(1); + }); + + it("polls once more after the last write of a burst", () => { + // A single sign-in rewrites the file several times, and every process on + // the machine sees each write. The burst must still end in a poll that + // sees the FINAL credential, not be dropped on the floor. + const watchable = createWatchableStore(); + const pollNow = vi.fn(async () => undefined); + let nowMs = 1_000; + + watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow, + credentialStore: watchable.store, + now: () => nowMs, + }); + watchable.writeAppToken("token-1"); + expect(pollNow).toHaveBeenCalledTimes(1); + + nowMs += 100; + watchable.writeAppToken("token-2"); + nowMs += 100; + watchable.writeAppToken("token-3"); + // Still inside the window: the burst has been coalesced, not polled again. + expect(pollNow).toHaveBeenCalledTimes(1); + + nowMs = 1_000 + CREDENTIAL_CHANGE_POLL_COALESCE_MS; + vi.advanceTimersByTime(CREDENTIAL_CHANGE_POLL_COALESCE_MS); + // Exactly one extra poll for the whole burst, and it ran after the last + // write. + expect(pollNow).toHaveBeenCalledTimes(2); + }); + + it("does not force a poll when another credential key changes", () => { + // Account sessions rotate constantly and share the file. Whole-file + // re-encryption makes the raw bytes change every time, so only the + // decrypted App token says whether the relay has anything to re-check. + const watchable = createWatchableStore({ + values: { [APP_TOKEN_KEY]: "token-1" }, + }); + const pollNow = vi.fn(async () => undefined); + let nowMs = 1_000; + + watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow, + credentialStore: watchable.store, + now: () => nowMs, + }); + watchable.write("account.session.v1", "session-2"); + nowMs += CREDENTIAL_CHANGE_POLL_COALESCE_MS * 2; + watchable.write("account.session.v1", "session-3"); + vi.advanceTimersByTime(CREDENTIAL_CHANGE_POLL_COALESCE_MS * 2); + + expect(pollNow).not.toHaveBeenCalled(); + }); + + it("forces a poll when the App credential value changes", () => { + const watchable = createWatchableStore({ + values: { [APP_TOKEN_KEY]: "token-1" }, + }); + const pollNow = vi.fn(async () => undefined); + + watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow, + credentialStore: watchable.store, + now: () => 1_000, + }); + watchable.writeAppToken("token-2"); + + expect(pollNow).toHaveBeenCalledTimes(1); + }); + + it("forces a poll when the App credential cannot be read", () => { + // A read failure says nothing about whether the App credential moved, so + // it must fail OPEN. Treating it as "unchanged" is how a repair the user + // already finished sat out the full five-minute cooldown. + const watchable = createWatchableStore({ + values: { [APP_TOKEN_KEY]: "token-1" }, + }); + watchable.setReadsFail(true); + const pollNow = vi.fn(async () => undefined); + let nowMs = 1_000; + + watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow, + credentialStore: watchable.store, + now: () => nowMs, + }); + watchable.fire(); + expect(pollNow).toHaveBeenCalledTimes(1); + + nowMs += CREDENTIAL_CHANGE_POLL_COALESCE_MS; + watchable.fire(); + expect(pollNow).toHaveBeenCalledTimes(2); + }); + + it("recovers from an unreadable baseline instead of suppressing every later poll", () => { + // The baseline is read at install time. If the store was locked then, the + // watcher must not compare later reads against that non-answer forever. + const watchable = createWatchableStore({ + values: { [APP_TOKEN_KEY]: "token-1" }, + }); + watchable.setReadsFail(true); + const pollNow = vi.fn(async () => undefined); + let nowMs = 1_000; + + watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow, + credentialStore: watchable.store, + now: () => nowMs, + }); + + watchable.setReadsFail(false); + nowMs += CREDENTIAL_CHANGE_POLL_COALESCE_MS * 2; + watchable.fire(); + expect(pollNow).toHaveBeenCalledTimes(1); + + // Readable again and unchanged: the scoped comparison is back on. + nowMs += CREDENTIAL_CHANGE_POLL_COALESCE_MS * 2; + watchable.fire(); + expect(pollNow).toHaveBeenCalledTimes(1); + }); + + it("decrypts the credential once per change however many watchers share the file", () => { + // Reading this key takes the file lock and decrypts the whole store. Ten + // open projects must not pay for that ten times to learn the same fact. + const watchable = createWatchableStore({ identity: "shared-file-read-count" }); + const now = () => 0; + + const stopFirst = watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow: vi.fn(async () => undefined), + credentialStore: watchable.store, + now, + }); + const stopSecond = watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow: vi.fn(async () => undefined), + credentialStore: watchable.store, + now, + }); + expect(watchable.readCount()).toBe(1); + + watchable.writeAppToken("token-1"); + expect(watchable.readCount()).toBe(2); + + stopFirst(); + stopSecond(); + }); + + it("keeps notifying the other watchers when one of them throws", () => { + // The shared watch is an aggregate over independent subscribers, so it owes + // them the same per-subscriber isolation the store's own watcher documents. + const watchable = createWatchableStore({ identity: "shared-file-isolation" }); + const survivingPoll = vi.fn(async () => undefined); + + const stopThrowing = watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow: vi.fn(async () => undefined), + credentialStore: watchable.store, + // Throws from inside the listener itself, before the poll is reached. + now: () => { + throw new Error("clock unavailable"); + }, + }); + const stopSurviving = watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow: survivingPoll, + credentialStore: watchable.store, + now: () => 0, + }); + + expect(() => watchable.writeAppToken("token-1")).not.toThrow(); + expect(survivingPoll).toHaveBeenCalledTimes(1); + + stopThrowing(); + stopSurviving(); + }); + + it("shares one underlying watcher across stores over the same file", () => { + // Every project runtime in the process installs a repair watcher, and they + // all read the same machine credential file. + const watchable = createWatchableStore({ identity: "machine-credentials" }); + const firstPoll = vi.fn(async () => undefined); + const secondPoll = vi.fn(async () => undefined); + let nowMs = 1_000; + + const stopFirst = watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow: firstPoll, + credentialStore: watchable.store, + now: () => nowMs, + }); + const stopSecond = watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow: secondPoll, + credentialStore: watchable.store, + now: () => nowMs, + }); + expect(watchable.listenerCount()).toBe(1); + + watchable.writeAppToken("token-1"); + expect(firstPoll).toHaveBeenCalledTimes(1); + expect(secondPoll).toHaveBeenCalledTimes(1); + + // Dropping one keeps the other alive. + stopFirst(); + expect(watchable.listenerCount()).toBe(1); + nowMs += CREDENTIAL_CHANGE_POLL_COALESCE_MS * 2; + watchable.writeAppToken("token-2"); + expect(firstPoll).toHaveBeenCalledTimes(1); + expect(secondPoll).toHaveBeenCalledTimes(2); + + // The last stop disposes the underlying watcher. + stopSecond(); + expect(watchable.listenerCount()).toBe(0); + }); + + it("leaves behaviour unchanged when the store cannot be watched", () => { + const stop = watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow: vi.fn(), + credentialStore: {}, + }); + + expect(() => stop()).not.toThrow(); + }); + + it("keeps the watch alive when a forced poll rejects", async () => { + const watchable = createWatchableStore(); + const logger = createLogger(); + + watchCredentialsForRelayRepair({ + logger, + pollNow: async () => { + throw new Error("relay unreachable"); + }, + credentialStore: watchable.store, + now: () => 0, + }); + watchable.writeAppToken("token-1"); + await Promise.resolve(); + await Promise.resolve(); + + expect(logger.warn).toHaveBeenCalledWith( + "automations.github_relay_credential_repoll_failed", + { error: "relay unreachable" }, + ); + expect(watchable.listenerCount()).toBe(1); + }); + + it("stops forcing polls once the subscription is dropped", () => { + const watchable = createWatchableStore(); + const pollNow = vi.fn(async () => undefined); + let nowMs = 0; + + const stop = watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow, + credentialStore: watchable.store, + now: () => nowMs, + }); + stop(); + nowMs += CREDENTIAL_CHANGE_POLL_COALESCE_MS * 2; + watchable.writeAppToken("token-1"); + + expect(pollNow).not.toHaveBeenCalled(); + }); + + it("drops a coalesced poll that was still pending when the watch stopped", () => { + const watchable = createWatchableStore(); + const pollNow = vi.fn(async () => undefined); + let nowMs = 1_000; + + const stop = watchCredentialsForRelayRepair({ + logger: createLogger(), + pollNow, + credentialStore: watchable.store, + now: () => nowMs, + }); + watchable.writeAppToken("token-1"); + nowMs += 100; + watchable.writeAppToken("token-2"); + stop(); + vi.advanceTimersByTime(CREDENTIAL_CHANGE_POLL_COALESCE_MS * 2); + + expect(pollNow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/ade-cli/src/services/credentials/credentialChangeRelayRepair.ts b/apps/ade-cli/src/services/credentials/credentialChangeRelayRepair.ts new file mode 100644 index 000000000..03828477e --- /dev/null +++ b/apps/ade-cli/src/services/credentials/credentialChangeRelayRepair.ts @@ -0,0 +1,269 @@ +import { EncryptedFileCredentialStore } from "./credentialStore"; + +/** + * Ends the GitHub relay's auth-pending cooldown as soon as the credential it is + * waiting on changes on disk. + * + * The desktop app gets this for free: it owns both the auth service and the + * ingress, so it calls `pollNow()` from `onAppUserAuthChanged`. The ADE brain + * owns neither — the credential is written by whichever process ran the device + * flow — so it watches the shared machine file that every process reads from. + * Without this the brain sits out the full five-minute cooldown after a repair + * the user already finished. + * + * Best-effort throughout: a store with no watcher, or a watcher that cannot be + * installed, leaves the behaviour exactly as it was. + */ + +/** The credential this repair exists for. Other keys share the same file. */ +const GITHUB_APP_USER_TOKEN_KEY = "github.appUserToken.v1"; + +/** At most one forced poll per this window, however noisy the file is. */ +export const CREDENTIAL_CHANGE_POLL_COALESCE_MS = 5_000; + +/** + * How often the watcher this module installs stats the credential file. + * + * Deliberately slower than the store's own 250 ms default. What this replaces + * is a FIVE-MINUTE cooldown, so reacting two seconds after the write instead of + * a quarter of a second is not a difference anyone can perceive, and the slower + * interval costs the file eight times fewer stats. + */ +const CREDENTIAL_CHANGE_WATCH_INTERVAL_MS = 2_000; + +type WatchableCredentialStore = { + onDidChange?(listener: () => void): () => void; + getSync?(key: string): string | null; + credentialStoreIdentity?(): string; +}; + +export type CredentialChangeRelayRepairArgs = { + logger: { warn(message: string, meta?: Record): void }; + pollNow: () => Promise | void; + /** + * The store to watch. Defaults to the shared machine credential file, which + * is where every ADE process reads the GitHub App credential from. + */ + credentialStore?: WatchableCredentialStore; + now?: () => number; +}; + +/** + * What one read of the App credential produced. + * + * "The read failed" is a separate answer from "the value is null", because the + * two must lead to opposite decisions: an absent credential is a fact worth + * comparing against, an unreadable store is no information at all. + */ +type AppUserTokenRead = + | { kind: "value"; value: string | null } + | { kind: "read_failed" }; + +type AppUserTokenListener = (read: AppUserTokenRead) => void; + +/** + * Reads the App credential, or reports that it could not be read. + * + * A store with no `getSync` is reported the same way a throwing one is: there + * is nothing to scope the repair to, so every change has to force a poll. + */ +function readAppUserToken(store: WatchableCredentialStore): AppUserTokenRead { + const getSync = store.getSync; + if (typeof getSync !== "function") return { kind: "read_failed" }; + try { + return { kind: "value", value: getSync.call(store, GITHUB_APP_USER_TOKEN_KEY) ?? null }; + } catch { + return { kind: "read_failed" }; + } +} + +type SharedWatch = { + listeners: Set; + /** + * The most recent read over this file, and the baseline a listener that joins + * later starts from. Kept here so N runtimes cost ONE locked decrypt per + * change instead of one each. + */ + lastRead: AppUserTokenRead; + dispose: () => void; +}; + +/** + * One underlying file watcher per credential file per process. + * + * Every project runtime in this process installs a repair watcher, and they all + * read the same machine credential file. Without this registry a machine with + * ten open projects stats that file ten times per interval to learn the same + * fact. + */ +const sharedWatches = new Map(); + +function createWatch( + store: WatchableCredentialStore, + onDidChange: (listener: () => void) => () => void, +): SharedWatch { + const listeners = new Set(); + const watch: SharedWatch = { + listeners, + lastRead: readAppUserToken(store), + dispose: () => undefined, + }; + watch.dispose = onDidChange(() => { + const read = readAppUserToken(store); + watch.lastRead = read; + for (const each of [...listeners]) { + try { + each(read); + } catch { + // Per-subscriber isolation, the same guarantee the store's own change + // watcher documents: one runtime's repair throwing must not stop the + // other runtimes on this file from hearing about the change. + } + } + }); + return watch; +} + +function subscribeShared( + store: WatchableCredentialStore, + onDidChange: (listener: () => void) => () => void, + listener: AppUserTokenListener, +): { baseline: AppUserTokenRead; unsubscribe: () => void } { + let identity: string | null = null; + try { + identity = store.credentialStoreIdentity?.() ?? null; + } catch { + identity = null; + } + if (identity === null) { + const watch = createWatch(store, onDidChange); + watch.listeners.add(listener); + return { + baseline: watch.lastRead, + unsubscribe: () => { + if (!watch.listeners.delete(listener)) return; + watch.dispose(); + }, + }; + } + + const key = identity; + let shared = sharedWatches.get(key); + if (!shared) { + shared = createWatch(store, onDidChange); + sharedWatches.set(key, shared); + } + shared.listeners.add(listener); + return { + baseline: shared.lastRead, + unsubscribe: () => { + const current = sharedWatches.get(key); + if (!current?.listeners.delete(listener)) return; + if (current.listeners.size > 0) return; + sharedWatches.delete(key); + current.dispose(); + }, + }; +} + +export function watchCredentialsForRelayRepair( + args: CredentialChangeRelayRepairArgs, +): () => void { + const now = args.now ?? (() => Date.now()); + let store = args.credentialStore; + if (store === undefined) { + try { + store = new EncryptedFileCredentialStore({ + credentialChangePollIntervalMs: CREDENTIAL_CHANGE_WATCH_INTERVAL_MS, + }); + } catch { + return () => undefined; + } + } + const onDidChange = store?.onDidChange; + if (!store || typeof onDidChange !== "function") return () => undefined; + const watched = store; + + const warnRepollFailed = (error: unknown): void => { + args.logger.warn("automations.github_relay_credential_repoll_failed", { + error: error instanceof Error ? error.message : String(error), + }); + }; + + /** + * The last read this watcher acted on. Whole-file re-encryption changes every + * byte on disk on every write, so only the decrypted value says whether THIS + * key moved. + */ + let lastSeenAppUserToken: AppUserTokenRead = { kind: "read_failed" }; + let lastForcedPollMs = Number.NEGATIVE_INFINITY; + let pendingPoll: ReturnType | null = null; + let stopped = false; + + const forcePoll = (): void => { + lastForcedPollMs = now(); + try { + void Promise.resolve(args.pollNow()).catch(warnRepollFailed); + } catch (error) { + warnRepollFailed(error); + } + }; + + const onChange = (read: AppUserTokenRead): void => { + if (stopped) return; + const previous = lastSeenAppUserToken; + lastSeenAppUserToken = read; + // Every ADE process sees every write to this file, and account sessions + // rotate far more often than the App credential does. Only the credential + // the relay is waiting on is worth a forced poll. + // + // Fail OPEN when the read did not produce a value: a store that cannot be + // decrypted right now tells us nothing about whether the App credential + // moved, and the cost of a poll nobody needed is one HTTP call, while the + // cost of skipping one is the full five-minute cooldown. The same rule run + // against the baseline is what keeps an unreadable store at install time + // from suppressing every later poll. + if ( + read.kind === "value" + && previous.kind === "value" + && read.value === previous.value + ) { + return; + } + + const nowMs = now(); + const sinceLastPoll = nowMs - lastForcedPollMs; + if (sinceLastPoll >= CREDENTIAL_CHANGE_POLL_COALESCE_MS) { + forcePoll(); + return; + } + // A single sign-in rewrites the file several times. Coalesce on the + // trailing edge: one timer for the whole burst, and it fires after the + // last write, so the poll always sees the final credential. + if (pendingPoll) return; + pendingPoll = setTimeout(() => { + pendingPoll = null; + if (stopped) return; + forcePoll(); + }, CREDENTIAL_CHANGE_POLL_COALESCE_MS - sinceLastPoll); + pendingPoll.unref?.(); + }; + + let unsubscribe: () => void; + try { + const subscription = subscribeShared(watched, onDidChange.bind(watched), onChange); + lastSeenAppUserToken = subscription.baseline; + unsubscribe = subscription.unsubscribe; + } catch { + return () => undefined; + } + return () => { + if (stopped) return; + stopped = true; + if (pendingPoll) { + clearTimeout(pendingPoll); + pendingPoll = null; + } + unsubscribe(); + }; +} diff --git a/apps/ade-cli/src/services/credentials/credentialStore.test.ts b/apps/ade-cli/src/services/credentials/credentialStore.test.ts index ecb1c4dc3..218faf50a 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.test.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.test.ts @@ -22,6 +22,7 @@ import { DEFAULT_REFRESH_ROTATION_WAIT_MS, } from "../account/accountAuthService"; import { BOOTSTRAP_TOKEN_KEY } from "../sync/brainProjectActionsSyncHandler"; +import { GITHUB_APP_USER_TOKEN_KEY } from "../../../../desktop/src/main/services/github/githubAppUserAuthService"; import { createMacKeychainMaterialResolver, resolveMacKeychainMaterialOutcome, @@ -909,6 +910,22 @@ describe("ElectronSafeStorageCredentialStore", () => { expect(raw).toContain("ADE_SAFE_STORAGE_CREDENTIALS_V1"); }); + it("leaves the GitHub App user token where the brain and the CLI read it", () => { + // Two copies of this record means two processes refreshing one rotating + // refresh token, and GitHub answers a reused refresh token by revoking the + // credential. + const brainWrite = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + brainWrite.setSync("github.appUserToken.v1", JSON.stringify({ accessToken: "ghu_brain" })); + + const desktopStore = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + desktopStore.getSync("linear.token.v1"); + + const brainRead = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + expect(brainRead.getSync("github.appUserToken.v1")).toContain("ghu_brain"); + const safeFile = fs.readFileSync(path.join(tempDir, "credentials.safe.enc"), "utf8"); + expect(safeFile).not.toContain("ghu_brain"); + }); + it("leaves the brain-readable account session in the legacy file store", () => { // The ADE brain (com.ade.runtime) and the CLI cannot read the Electron-only // safeStorage file. Migrating the account session into it and deleting the @@ -1079,6 +1096,9 @@ describe("ElectronSafeStorageCredentialStore", () => { // Asserted against the real constant: renaming it in the sync handler must // fail here instead of silently moving the token into safeStorage. expect(isFileBackedCredentialKey(BOOTSTRAP_TOKEN_KEY)).toBe(true); + // Same rationale, one incident later: two copies of the GitHub App token + // means two processes refreshing one rotating refresh token. + expect(isFileBackedCredentialKey(GITHUB_APP_USER_TOKEN_KEY)).toBe(true); expect(isFileBackedCredentialKey("linear.token.v1")).toBe(false); }); diff --git a/apps/ade-cli/src/services/credentials/credentialStore.ts b/apps/ade-cli/src/services/credentials/credentialStore.ts index 86d4e50f2..fc61778a0 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.ts @@ -119,6 +119,31 @@ export type SyncCredentialStore = CredentialStore & { deleteSync(key: string): void; /** Atomically update the complete synchronous store when supported. */ updateSync?(updater: (values: Record) => boolean | void): void; + /** + * Atomically update ONE key when supported. + * + * Distinct from `updateSync` because a store that routes keys to different + * files cannot answer a whole-map updater, while it can always say which file + * one key lives in. Return `undefined` to write nothing, `null` to delete. + */ + updateKeySync?( + key: string, + mutator: (current: string | null) => string | null | undefined, + ): void; + /** + * A stable name for the storage behind this store, equal for every store + * object over the same file. Callers that coordinate across instances (the + * GitHub App token refresh) key their process-wide state on it. + */ + credentialStoreIdentity?(): string; + /** + * An asynchronous read paired with the state THAT read produced. Preferred + * over `get()` + `getLastReadState()`, which answers about the store's most + * recent read — not necessarily this one. + */ + getWithReadState?( + key: string, + ): Promise<{ value: string | null; state: CredentialStoreReadState }>; /** Best-effort cross-process notification that persisted credentials changed. */ onDidChange?(listener: () => void): () => void; /** Result of the most recent synchronous credential-file read. */ @@ -199,10 +224,11 @@ const SAFE_STORAGE_FILE_MAGIC = Buffer.from("ADE_SAFE_STORAGE_CREDENTIALS_V1\n") * app is signed in. Keep the literals in sync with: * - ACCOUNT_SESSION_CREDENTIAL_KEY (services/account/accountAuthService.ts) * - BOOTSTRAP_TOKEN_KEY (services/sync/brainProjectActionsSyncHandler.ts) + * - GITHUB_APP_USER_TOKEN_KEY (desktop services/github/githubAppUserAuthService.ts) * They are duplicated here rather than imported to keep this module free of * service-layer dependencies; credentialStore.test.ts asserts they match. */ -const FILE_BACKED_CREDENTIAL_KEYS: readonly string[] = [ +export const FILE_BACKED_CREDENTIAL_KEYS: readonly string[] = [ "account.session.v1", // The crash-safe rotation journal is only meaningful next to the session it // describes. Migrating it into the Electron-only file would hide an @@ -210,6 +236,12 @@ const FILE_BACKED_CREDENTIAL_KEYS: readonly string[] = [ // the process pair the journal exists to coordinate. "account.session.rotation.v1", "sync.bootstrapToken.v1", + // The GitHub App user token carries a rotating refresh token and, next to it, + // the refresh ledger every ADE process coordinates through. Two copies of that + // record means two processes refreshing the same rotating token, which GitHub + // answers by revoking the credential — so it has to live in the one file the + // app, the brain and the CLI all read. + "github.appUserToken.v1", ]; export function isFileBackedCredentialKey(key: string): boolean { @@ -233,7 +265,26 @@ const CREDENTIAL_CHANGE_POLL_INTERVAL_MS = 250; const KEY_MATERIAL_SELF_HEAL_INTERVAL_MS = 30_000; -function normalizeKey(key: string): string { +/** + * The key a credential path is compared and looked up by. + * + * Two spellings of one secrets directory must never read as two different + * stores: the GitHub App refresh coordinates through this identity, and two + * identities for one file means two processes each believing they hold the only + * refresh lease. So redundant separators and `.`/`..` segments are folded away + * first, then — on Windows, where both separators name the same directory — the + * separator itself. Case is folded on Windows and macOS, whose filesystems are + * case-insensitive; Linux stays case-sensitive, so that step is conditional. + */ +export function credentialPathKey(value: string): string { + const normalized = path.normalize(value); + if (process.platform === "win32") return normalized.replace(/\//g, "\\").toLowerCase(); + return process.platform === "darwin" ? normalized.toLowerCase() : normalized; +} + + +/** The one spelling of a credential key every store agrees on. */ +export function normalizeCredentialKey(key: string): string { const normalized = key.trim(); if (!normalized.length) throw new Error("Credential key is required."); if (normalized.includes("\0")) throw new Error("Credential key cannot contain null bytes."); @@ -768,7 +819,7 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { async getWithReadState( key: string, ): Promise<{ value: string | null; state: CredentialStoreReadState }> { - const normalized = normalizeKey(key); + const normalized = normalizeCredentialKey(key); const { values, state } = await this.readAllAsync(); return { value: values[normalized] ?? null, state }; } @@ -782,7 +833,7 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { } getSync(key: string): string | null { - const normalized = normalizeKey(key); + const normalized = normalizeCredentialKey(key); // Locked because the read may re-seal an `os`-bound store to the machine // key (and merge a recovered quarantine back in), and those writes have to // exclude concurrent writers. @@ -801,7 +852,7 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { } setSync(key: string, value: string): void { - const normalized = normalizeKey(key); + const normalized = normalizeCredentialKey(key); const nextValue = value.trim(); if (!nextValue.length) { this.deleteSync(normalized); @@ -815,7 +866,7 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { } deleteSync(key: string): void { - const normalized = normalizeKey(key); + const normalized = normalizeCredentialKey(key); this.withLock(() => { const values = this.readAll({ forWrite: true }); if (!(normalized in values)) return; @@ -856,6 +907,34 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { }); } + updateKeySync( + key: string, + mutator: (current: string | null) => string | null | undefined, + ): void { + const normalized = normalizeCredentialKey(key); + this.updateSync((values) => { + const next = mutator(values[normalized] ?? null); + if (next === undefined) return false; + if (next === null) { + if (!(normalized in values)) return false; + delete values[normalized]; + return true; + } + const trimmed = next.trim(); + if (!trimmed.length) { + if (!(normalized in values)) return false; + delete values[normalized]; + return true; + } + values[normalized] = trimmed; + return true; + }); + } + + credentialStoreIdentity(): string { + return credentialPathKey(this.credentialsPath); + } + /** * What the "Can't read your sign-in" surface actually runs. * @@ -1343,7 +1422,7 @@ export class ElectronSafeStorageCredentialStore implements SyncCredentialStore { } getSync(key: string): string | null { - const normalized = normalizeKey(key); + const normalized = normalizeCredentialKey(key); return this.readAll()[normalized] ?? null; } @@ -1356,7 +1435,7 @@ export class ElectronSafeStorageCredentialStore implements SyncCredentialStore { } setSync(key: string, value: string): void { - const normalized = normalizeKey(key); + const normalized = normalizeCredentialKey(key); const nextValue = value.trim(); if (!nextValue.length) { this.deleteSync(normalized); @@ -1376,7 +1455,7 @@ export class ElectronSafeStorageCredentialStore implements SyncCredentialStore { } deleteSync(key: string): void { - const normalized = normalizeKey(key); + const normalized = normalizeCredentialKey(key); this.withLock(() => { const values = this.readAll({ safeLockHeld: true }); if (!(normalized in values)) return; @@ -1614,11 +1693,11 @@ export class KeytarCredentialStore implements CredentialStore { } async get(key: string): Promise { - return this.keytar.getPassword(this.service, normalizeKey(key)); + return this.keytar.getPassword(this.service, normalizeCredentialKey(key)); } async set(key: string, value: string): Promise { - const normalized = normalizeKey(key); + const normalized = normalizeCredentialKey(key); const nextValue = value.trim(); if (!nextValue.length) { await this.delete(normalized); @@ -1628,7 +1707,7 @@ export class KeytarCredentialStore implements CredentialStore { } async delete(key: string): Promise { - await this.keytar.deletePassword(this.service, normalizeKey(key)); + await this.keytar.deletePassword(this.service, normalizeCredentialKey(key)); } } diff --git a/apps/ade-cli/src/services/credentials/credentialStoreAdoption.ts b/apps/ade-cli/src/services/credentials/credentialStoreAdoption.ts new file mode 100644 index 000000000..3d5923582 --- /dev/null +++ b/apps/ade-cli/src/services/credentials/credentialStoreAdoption.ts @@ -0,0 +1,138 @@ +import { + FILE_BACKED_CREDENTIAL_KEYS, + credentialPathKey, + type SyncCredentialStore, +} from "./credentialStore"; +import { updateCredentialKeySync } from "./updateCredentialKey"; + +/** + * The one-way migration that moves file-backed credentials out of the + * Electron-only store and back into the shared machine file. + * + * Split out of credentialStore.ts because it is a migration, not a store: it + * runs once per secrets directory, it compares two copies of one record, and it + * is the only code here that has to decide which of two secrets is the real one. + * Consumers import it from this module directly: re-exporting it through + * credentialStore.ts made the two modules import each other, so the re-export + * was removed. + */ + +const adoptedSecretsDirs = new Set(); + +/** + * When one stored credential was last written, in epoch milliseconds, or `NaN` + * when the record does not say. + * + * Deliberately generic JSON rather than a typed credential: this module stays + * free of service-layer imports. `updatedAt` is what every record that can be + * stranded carries today; `obtainedAt` is read as well because the account + * session record spells the same fact that way, and a record ADE cannot date is + * a record it cannot safely replace. + */ +export function credentialUpdatedAtMs(raw: string | null | undefined): number { + if (!raw?.trim()) return Number.NaN; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return Number.NaN; + const record = parsed as Record; + const stamp = typeof record.updatedAt === "string" + ? record.updatedAt + : typeof record.obtainedAt === "string" ? record.obtainedAt : null; + return stamp == null ? Number.NaN : Date.parse(stamp); + } catch { + return Number.NaN; + } +} + +/** True when the record says when it was written. */ +function isDateable(raw: string | null | undefined): boolean { + return Number.isFinite(credentialUpdatedAtMs(raw)); +} + +/** + * True when the stranded desktop copy is provably newer than the shared one. + * + * A copy that does not say when it was written loses to one that does, and two + * silent copies leave the shared file alone — the shared file is where the + * brain writes, so it is the safer default. + */ +export function strandedCopyIsFresher(stranded: string, shared: string): boolean { + const strandedAt = credentialUpdatedAtMs(stranded); + if (!Number.isFinite(strandedAt)) return false; + const sharedAt = credentialUpdatedAtMs(shared); + return !Number.isFinite(sharedAt) || strandedAt > sharedAt; +} + +/** + * Moves file-backed credentials a previous build left in the Electron-only + * store back into the shared machine file. + * + * Runs once per secrets directory per process, and only a pass that completes + * counts: a pass that could not read the Electron store is retried by the next + * store built in this process, rather than leaving the credential stranded for + * the life of the app. + * + * When both stores hold the key, the fresher record wins by its `updatedAt`. + * The shared file is usually the fresher one, because the brain writes there — + * but a desktop build that wrote the GitHub App token into the Electron-only + * store left the ONLY current copy there, and keeping the older shared copy + * would hand GitHub a refresh token it has already rotated away. A value is + * only removed from the Electron-only store once the shared file holds one, and + * when NEITHER copy says when it was written the stranded one is left where it + * is: an undated record is not evidence that the shared copy is the newer one, + * and deleting the other secret on that guess is unrecoverable. + */ +export function adoptFileBackedCredentials(args: { + primary: SyncCredentialStore; + fileStore: SyncCredentialStore; + identity: string; +}): { adopted: string[]; pruned: string[] } { + const adopted: string[] = []; + const pruned: string[] = []; + const identity = credentialPathKey(args.identity); + if (adoptedSecretsDirs.has(identity)) return { adopted, pruned }; + let completed = true; + for (const key of FILE_BACKED_CREDENTIAL_KEYS) { + let stranded: string | null = null; + try { + stranded = args.primary.getSync(key); + } catch { + // An unreadable Electron store has nothing to adopt for this key, and + // saying so is the job of `getLastReadState`, not of this migration. The + // pass stays incomplete so a later one can try again. + completed = false; + continue; + } + if (!stranded?.trim()) continue; + const strandedValue = stranded; + try { + let wrote = false; + // Set when the shared copy was kept only because neither record could be + // dated — a guess, and not one worth destroying the other copy over. + let undatedStandoff = false; + const nextValue = (current: string | null): string | undefined => { + if (current?.trim() && !strandedCopyIsFresher(strandedValue, current)) { + undatedStandoff = !isDateable(strandedValue) && !isDateable(current); + return undefined; + } + wrote = true; + return strandedValue; + }; + // Atomic when the store can be: a brain write racing this adoption must + // be compared against what it actually wrote, and check-then-set leaves a + // window where the comparison is made against a value that is already + // gone. + updateCredentialKeySync(args.fileStore, key, nextValue); + if (wrote) adopted.push(key); + if (!wrote && undatedStandoff) continue; + args.primary.deleteSync(key); + pruned.push(key); + } catch { + // Best effort: leaving the duplicate behind is survivable, losing the + // credential is not. + completed = false; + } + } + if (completed) adoptedSecretsDirs.add(identity); + return { adopted, pruned }; +} diff --git a/apps/ade-cli/src/services/credentials/credentialStoreComposition.test.ts b/apps/ade-cli/src/services/credentials/credentialStoreComposition.test.ts new file mode 100644 index 000000000..d5a1032b4 --- /dev/null +++ b/apps/ade-cli/src/services/credentials/credentialStoreComposition.test.ts @@ -0,0 +1,435 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ElectronSafeStorageCredentialStore, + EncryptedFileCredentialStore, +} from "./credentialStore"; +import { createRoutedCredentialStore } from "./credentialStoreRouting"; +import { adoptFileBackedCredentials } from "./credentialStoreAdoption"; +import { ACCOUNT_SESSION_CREDENTIAL_KEY } from "../account/accountAuthService"; +import { GITHUB_APP_USER_TOKEN_KEY } from "../../../../desktop/src/main/services/github/githubAppUserAuthService"; +import { + supportsAtomicCredentialUpdate, + updateCredentialKeySync, + type UpdatableCredentialStore, +} from "./updateCredentialKey"; + +// One suite for the three facets of composing credential stores over one +// secrets directory: per-key routing, the one-time adoption migration, and the +// shared atomic-update ladder. Each facet keeps its own fixtures inside its +// describe so the merge changes nothing about what is proven. + +describe("routed credential store", () => { + let tempDir = ""; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-credentials-routing-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + /** The Electron-only store, faked so the tests need no OS keychain. */ + const safeStorage = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`safe:${value}`, "utf8"), + decryptString: (value: Buffer) => { + const raw = value.toString("utf8"); + if (!raw.startsWith("safe:")) throw new Error("not a safeStorage payload"); + return raw.slice("safe:".length); + }, + }; + + describe("createRoutedCredentialStore", () => { + let fileStore: EncryptedFileCredentialStore; + let primary: ElectronSafeStorageCredentialStore; + let routed: ReturnType; + + beforeEach(() => { + fileStore = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + primary = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + fileStore.setSync("github.appUserToken.v1", "stored-app-token"); + primary.setSync("linear.token.v1", "lin_secret"); + routed = createRoutedCredentialStore({ primary, fileStore }); + }); + + it("reads each key from the file that key belongs in", () => { + // The desktop app's own store is Electron-only, and the file-backed keys it + // shares with the brain live outside it. Routing per key is what makes "the + // brain and the app share this credential" true for readers. + expect(routed.getSync("github.appUserToken.v1")).toBe("stored-app-token"); + expect(routed.getSync("linear.token.v1")).toBe("lin_secret"); + expect(fileStore.getSync("linear.token.v1")).toBeNull(); + expect(primary.getSync("github.appUserToken.v1")).toBeNull(); + }); + + it("answers getLastReadState about the file the read actually went to", () => { + // An unreadable sibling must not make a good credential look unreadable. + routed.getSync("linear.token.v1"); + expect(routed.getLastReadState?.()).toBe("available"); + }); + + it("writes a file-backed key through to the shared file, including updateKeySync", () => { + routed.setSync("github.appUserToken.v1", "renewed-app-token"); + routed.updateKeySync?.("github.appUserToken.v1", (current) => `${current}+ledger`); + + expect(new EncryptedFileCredentialStore({ secretsDir: tempDir }) + .getSync("github.appUserToken.v1")).toBe("renewed-app-token+ledger"); + }); + + it("refuses a whole-map update instead of pointing it at one of the two files", () => { + // `updateSync` rewrites the ENTIRE credential map. A routed store has two + // maps in two files, so binding either one hands the updater a view that is + // missing the other file's keys and writes its results where nobody reads + // them. Callers already carry a per-key fallback for a store without it. + expect(routed.updateSync).toBeUndefined(); + }); + }); +}); + +describe("file-backed credential adoption", () => { + let tempDir = ""; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-credentials-adoption-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + /** The Electron-only store, faked so the tests need no OS keychain. */ + const safeStorage = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`safe:${value}`, "utf8"), + decryptString: (value: Buffer) => { + const raw = value.toString("utf8"); + if (!raw.startsWith("safe:")) throw new Error("not a safeStorage payload"); + return raw.slice("safe:".length); + }, + }; + + describe("adoptFileBackedCredentials", () => { + it("never overwrites the shared file's credential with a stale safeStorage copy", () => { + const fileStore = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + const fresh = JSON.stringify({ + accessToken: "fresh-from-brain", + updatedAt: "2026-08-01T00:00:00.000Z", + }); + fileStore.setSync("github.appUserToken.v1", fresh); + const primary = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + fs.writeFileSync( + path.join(tempDir, "credentials.safe.enc"), + Buffer.concat([ + Buffer.from("ADE_SAFE_STORAGE_CREDENTIALS_V1\n"), + safeStorage.encryptString(JSON.stringify({ + "github.appUserToken.v1": JSON.stringify({ + accessToken: "stale-from-june", + updatedAt: "2026-06-01T00:00:00.000Z", + }), + })), + ]), + ); + + const adoption = adoptFileBackedCredentials({ primary, fileStore, identity: tempDir }); + + expect(adoption.adopted).toEqual([]); + expect(adoption.pruned).toContain("github.appUserToken.v1"); + expect(fileStore.getSync("github.appUserToken.v1")).toBe(fresh); + expect(primary.getSync("github.appUserToken.v1")).toBeNull(); + }); + + it("dates a record that says obtainedAt rather than updatedAt", () => { + // The account session spells the same fact that way, and a record ADE + // cannot date is a record it will not replace. + const fileStore = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + fileStore.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify({ + accessToken: "session-from-june", + obtainedAt: "2026-06-01T00:00:00.000Z", + })); + const primary = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + const stranded = JSON.stringify({ + accessToken: "session-from-august", + obtainedAt: "2026-08-01T00:00:00.000Z", + }); + fs.writeFileSync( + path.join(tempDir, "credentials.safe.enc"), + Buffer.concat([ + Buffer.from("ADE_SAFE_STORAGE_CREDENTIALS_V1\n"), + safeStorage.encryptString(JSON.stringify({ [ACCOUNT_SESSION_CREDENTIAL_KEY]: stranded })), + ]), + ); + + const adoption = adoptFileBackedCredentials({ primary, fileStore, identity: tempDir }); + + expect(adoption.adopted).toContain(ACCOUNT_SESSION_CREDENTIAL_KEY); + expect(fileStore.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe(stranded); + }); + + // Two secrets, neither of which says when it was written. Keeping the shared + // one is a guess, and deleting the other on that guess is unrecoverable — so + // the stranded copy stays where it is. + it("leaves an undatable stranded copy alone rather than destroying it", () => { + const fileStore = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + fileStore.setSync("github.appUserToken.v1", "shared-copy-with-no-date"); + const primary = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + fs.writeFileSync( + path.join(tempDir, "credentials.safe.enc"), + Buffer.concat([ + Buffer.from("ADE_SAFE_STORAGE_CREDENTIALS_V1\n"), + safeStorage.encryptString(JSON.stringify({ + "github.appUserToken.v1": "stranded-copy-with-no-date", + })), + ]), + ); + + const adoption = adoptFileBackedCredentials({ primary, fileStore, identity: tempDir }); + + expect(adoption.adopted).toEqual([]); + expect(adoption.pruned).toEqual([]); + expect(fileStore.getSync("github.appUserToken.v1")).toBe("shared-copy-with-no-date"); + expect(primary.getSync("github.appUserToken.v1")).toBe("stranded-copy-with-no-date"); + }); + + it("adopts a stranded safeStorage copy that is newer than the shared one", () => { + // The build that wrote the App token into the Electron-only store left the + // ONLY current copy there. Keeping the older shared copy hands GitHub a + // refresh token it has already rotated away, which kills the credential. + const fileStore = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + fileStore.setSync(GITHUB_APP_USER_TOKEN_KEY, JSON.stringify({ + accessToken: "ghu_from_june", + updatedAt: "2026-06-01T00:00:00.000Z", + })); + const primary = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + const stranded = JSON.stringify({ + accessToken: "ghu_from_august", + updatedAt: "2026-08-01T00:00:00.000Z", + }); + fs.writeFileSync( + path.join(tempDir, "credentials.safe.enc"), + Buffer.concat([ + Buffer.from("ADE_SAFE_STORAGE_CREDENTIALS_V1\n"), + safeStorage.encryptString(JSON.stringify({ [GITHUB_APP_USER_TOKEN_KEY]: stranded })), + ]), + ); + + const adoption = adoptFileBackedCredentials({ primary, fileStore, identity: tempDir }); + + expect(adoption.adopted).toContain(GITHUB_APP_USER_TOKEN_KEY); + expect(fileStore.getSync(GITHUB_APP_USER_TOKEN_KEY)).toBe(stranded); + expect(primary.getSync(GITHUB_APP_USER_TOKEN_KEY)).toBeNull(); + }); + + it("keeps the shared copy when the stranded safeStorage copy is older", () => { + const fileStore = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + const shared = JSON.stringify({ + accessToken: "ghu_from_august", + updatedAt: "2026-08-01T00:00:00.000Z", + }); + fileStore.setSync(GITHUB_APP_USER_TOKEN_KEY, shared); + const primary = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + fs.writeFileSync( + path.join(tempDir, "credentials.safe.enc"), + Buffer.concat([ + Buffer.from("ADE_SAFE_STORAGE_CREDENTIALS_V1\n"), + safeStorage.encryptString(JSON.stringify({ + [GITHUB_APP_USER_TOKEN_KEY]: JSON.stringify({ + accessToken: "ghu_from_june", + updatedAt: "2026-06-01T00:00:00.000Z", + }), + })), + ]), + ); + + const adoption = adoptFileBackedCredentials({ primary, fileStore, identity: tempDir }); + + expect(adoption.adopted).toEqual([]); + expect(adoption.pruned).toContain(GITHUB_APP_USER_TOKEN_KEY); + expect(fileStore.getSync(GITHUB_APP_USER_TOKEN_KEY)).toBe(shared); + expect(primary.getSync(GITHUB_APP_USER_TOKEN_KEY)).toBeNull(); + }); + + it("retries adoption in the same process after a pass that could not read the Electron store", () => { + // Marking the directory adopted before the pass succeeds strands the + // credential for the whole life of the app: nothing constructs the store + // again with a fresh chance to read it. + const fileStore = new EncryptedFileCredentialStore({ secretsDir: tempDir }); + const stranded = JSON.stringify({ + accessToken: "ghu_stranded", + updatedAt: "2026-08-01T00:00:00.000Z", + }); + fs.writeFileSync( + path.join(tempDir, "credentials.safe.enc"), + Buffer.concat([ + Buffer.from("ADE_SAFE_STORAGE_CREDENTIALS_V1\n"), + safeStorage.encryptString(JSON.stringify({ [GITHUB_APP_USER_TOKEN_KEY]: stranded })), + ]), + ); + const locked = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + vi.spyOn(locked, "getSync").mockImplementation(() => { + throw new Error("safeStorage is locked"); + }); + + const firstPass = adoptFileBackedCredentials({ primary: locked, fileStore, identity: tempDir }); + expect(firstPass.adopted).toEqual([]); + + const unlocked = new ElectronSafeStorageCredentialStore({ secretsDir: tempDir, safeStorage }); + const secondPass = adoptFileBackedCredentials({ primary: unlocked, fileStore, identity: tempDir }); + + expect(secondPass.adopted).toContain(GITHUB_APP_USER_TOKEN_KEY); + expect(fileStore.getSync(GITHUB_APP_USER_TOKEN_KEY)).toBe(stranded); + }); + }); +}); + +describe("updateCredentialKeySync ladder", () => { + /** + * A store whose rungs can be removed one at a time, so each test picks the rung + * it means instead of hoping the ladder falls through. + */ + function createStore( + options: { atomicKey?: boolean; atomicMap?: boolean; values?: Record } = {}, + ) { + const values: Record = { ...(options.values ?? {}) }; + const calls: string[] = []; + const store: UpdatableCredentialStore = { + getSync: (key) => { + calls.push("getSync"); + return values[key] ?? null; + }, + setSync: (key, value) => { + calls.push("setSync"); + values[key] = value; + }, + deleteSync: (key) => { + calls.push("deleteSync"); + delete values[key]; + }, + }; + if (options.atomicMap !== false) { + store.updateSync = (updater) => { + calls.push("updateSync"); + const draft = { ...values }; + if (updater(draft) === false) return; + for (const key of Object.keys(values)) delete values[key]; + Object.assign(values, draft); + }; + } + if (options.atomicKey !== false) { + store.updateKeySync = (key, mutator) => { + calls.push("updateKeySync"); + const next = mutator(values[key] ?? null); + if (next === undefined) return; + if (next === null) delete values[key]; + else values[key] = next; + }; + } + return { store, values, calls }; + } + + describe("updateCredentialKeySync", () => { + it("takes the per-key rung first, because a routed store has only that one", () => { + const { store, values, calls } = createStore({ values: { "a.key": "old" } }); + + const mode = updateCredentialKeySync(store, "a.key", (current) => `${current}+new`); + + expect(mode).toBe("atomic"); + expect(values["a.key"]).toBe("old+new"); + expect(calls).toEqual(["updateKeySync"]); + }); + + it("wraps the whole-map rung when the store cannot update one key", () => { + const { store, values, calls } = createStore({ + atomicKey: false, + values: { "a.key": "old", "b.key": "untouched" }, + }); + + const mode = updateCredentialKeySync(store, "a.key", (current) => `${current}+new`); + + expect(mode).toBe("atomic"); + expect(values).toEqual({ "a.key": "old+new", "b.key": "untouched" }); + expect(calls).toEqual(["updateSync"]); + }); + + it("falls back to a non-atomic read-modify-write and says so", () => { + const { store, values, calls } = createStore({ + atomicKey: false, + atomicMap: false, + values: { "a.key": "old" }, + }); + + const mode = updateCredentialKeySync(store, "a.key", (current) => `${current}+new`); + + expect(mode).toBe("read_modify_write"); + expect(values["a.key"]).toBe("old+new"); + expect(calls).toEqual(["getSync", "setSync"]); + }); + + it("writes nothing on every rung when the mutator declines", () => { + for (const options of [ + {}, + { atomicKey: false }, + { atomicKey: false, atomicMap: false }, + ]) { + const { store, values } = createStore({ ...options, values: { "a.key": "old" } }); + const mutator = vi.fn(() => undefined); + + updateCredentialKeySync(store, "a.key", mutator); + + expect(mutator).toHaveBeenCalledWith("old"); + expect(values).toEqual({ "a.key": "old" }); + } + }); + + it("deletes the key on every rung when the mutator returns null", () => { + for (const options of [ + {}, + { atomicKey: false }, + { atomicKey: false, atomicMap: false }, + ]) { + const { store, values } = createStore({ + ...options, + values: { "a.key": "old", "b.key": "untouched" }, + }); + + updateCredentialKeySync(store, "a.key", () => null); + + expect(values).toEqual({ "b.key": "untouched" }); + } + }); + + it("shows an absent key to the mutator as null on every rung", () => { + for (const options of [ + {}, + { atomicKey: false }, + { atomicKey: false, atomicMap: false }, + ]) { + const { store, values } = createStore(options); + const mutator = vi.fn((current: string | null) => (current === null ? "fresh" : "wrong")); + + updateCredentialKeySync(store, "a.key", mutator); + + expect(mutator).toHaveBeenCalledWith(null); + expect(values["a.key"]).toBe("fresh"); + } + }); + }); + + describe("supportsAtomicCredentialUpdate", () => { + it("is true while either atomic rung is available", () => { + expect(supportsAtomicCredentialUpdate(createStore().store)).toBe(true); + expect(supportsAtomicCredentialUpdate(createStore({ atomicKey: false }).store)).toBe(true); + }); + + it("is false for a store that can only get and set", () => { + // Callers that compare-and-swap ask this first: degraded to check-then-set, + // their write is not weaker, it is a different write that clobbers a peer. + const { store } = createStore({ atomicKey: false, atomicMap: false }); + expect(supportsAtomicCredentialUpdate(store)).toBe(false); + }); + }); +}); diff --git a/apps/ade-cli/src/services/credentials/credentialStoreRouting.ts b/apps/ade-cli/src/services/credentials/credentialStoreRouting.ts new file mode 100644 index 000000000..5bb6d669c --- /dev/null +++ b/apps/ade-cli/src/services/credentials/credentialStoreRouting.ts @@ -0,0 +1,107 @@ +import { + isFileBackedCredentialKey, + normalizeCredentialKey, + type SyncCredentialStore, +} from "./credentialStore"; +import { updateCredentialKeySync } from "./updateCredentialKey"; + +/** + * Stores that stand in front of the real ones: the per-key router, and the + * store that answers every call with the reason it cannot work. + * + * Split out of credentialStore.ts because neither reads or writes a byte — they + * decide which store a call belongs to. Consumers import them from this module + * directly: re-exporting them through credentialStore.ts made the two modules + * import each other, so the re-export was removed. + */ + +/** + * One store that keeps file-backed keys in the shared machine file and + * everything else in `primary`. + * + * The desktop app's own store is Electron-only, and the migration that keeps + * file-backed keys OUT of it only runs once. That left the reader with nowhere + * to look: `account.session.v1` and the GitHub App token stayed in + * `credentials.json.enc` while every desktop read went to `credentials.safe.enc` + * and answered "not connected". Routing per key is what makes "the brain and the + * app share this credential" true for readers, not just for writers. + */ +export function createRoutedCredentialStore(args: { + primary: SyncCredentialStore; + fileStore: SyncCredentialStore; +}): SyncCredentialStore { + const storeFor = (key: string): SyncCredentialStore => + isFileBackedCredentialKey(normalizeCredentialKey(key)) ? args.fileStore : args.primary; + // `getLastReadState()` describes the store's MOST RECENT read, and a caller + // asks it immediately after the read it means. Answering from the wrong file + // is how a readable credential gets reported as "can't read your sign-in". + let lastReadStore: SyncCredentialStore = args.primary; + const readStoreFor = (key: string): SyncCredentialStore => { + lastReadStore = storeFor(key); + return lastReadStore; + }; + return { + get: async (key) => readStoreFor(key).get(key), + // Forwarded per call rather than through `lastReadStore`: this accessor + // exists precisely so an async caller learns the state of ITS read, and + // routing it through shared mutable state would hand back whichever file + // some interleaved read touched last. + getWithReadState: async (key) => { + const store = storeFor(key); + if (store.getWithReadState) return await store.getWithReadState(key); + const value = await store.get(key); + return { value, state: store.getLastReadState?.() ?? "missing" }; + }, + set: async (key, value) => storeFor(key).set(key, value), + delete: async (key) => storeFor(key).delete(key), + getSync: (key) => readStoreFor(key).getSync(key), + setSync: (key, value) => storeFor(key).setSync(key, value), + deleteSync: (key) => storeFor(key).deleteSync(key), + // No `updateSync`. It takes the WHOLE credential map and rewrites it, and a + // routed store has two maps in two files — so any single-file answer is + // wrong: binding the primary's silently drops every file-backed key from + // the view the updater is handed, and the routed keys it writes back land + // in the file nobody reads them from. Callers that reach for it already + // document a non-atomic get/set fallback, and that fallback routes per key, + // which is the behaviour they actually want here. + updateKeySync: (key, mutator) => { + updateCredentialKeySync(storeFor(key), key, mutator); + }, + credentialStoreIdentity: () => args.fileStore.credentialStoreIdentity?.() + ?? args.primary.credentialStoreIdentity?.() + ?? "ade.routed-credential-store", + onDidChange: (listener) => { + const unsubscribes = [ + args.primary.onDidChange?.(listener), + args.fileStore.onDidChange?.(listener), + ]; + return () => { + for (const unsubscribe of unsubscribes) unsubscribe?.(); + }; + }, + getLastReadState: () => lastReadStore.getLastReadState?.() ?? "missing", + getLastReadFailureReason: () => lastReadStore.getLastReadFailureReason?.() ?? null, + }; +} + +/** + * A store that cannot serve anything, and says why on every call. + * + * Built when the OS credential store is locked. It throws rather than answering + * "no value": an empty answer reads as "never connected" and invites the user + * to reconnect over credentials that are still on disk. Paired with the router + * above, so the credentials that need no keychain stay reachable. + */ +export function createUnavailableCredentialStore(message: string): SyncCredentialStore { + const refuse = (): never => { + throw new Error(message); + }; + return { + get: async () => refuse(), + set: async () => refuse(), + delete: async () => refuse(), + getSync: refuse, + setSync: refuse, + deleteSync: refuse, + }; +} diff --git a/apps/ade-cli/src/services/credentials/updateCredentialKey.ts b/apps/ade-cli/src/services/credentials/updateCredentialKey.ts new file mode 100644 index 000000000..62bddd64c --- /dev/null +++ b/apps/ade-cli/src/services/credentials/updateCredentialKey.ts @@ -0,0 +1,83 @@ +/** + * One ladder for "change one credential key", held in one place. + * + * Six call sites used to write the same three rungs by hand — `updateKeySync`, + * then `updateSync` wrapped over the whole map, then a plain get/set — and each + * copy was a chance to get the map wrapper subtly wrong. The rungs are ordered + * by how much of a race they close: + * + * 1. `updateKeySync` — atomic for ONE key. First, because the routed desktop + * store can answer for one key but not for the whole credential map. + * 2. `updateSync` — atomic over the whole map, wrapped so the caller still + * writes a per-key mutator. + * 3. get/set — no atomicity at all. Correct inside one process, and the only + * stores that get this far are process-local ones. + * + * A caller that must NOT take the third rung asks `supportsAtomicCredentialUpdate` + * first: a compare-and-swap degraded to check-then-set is not a weaker version + * of the same write, it is a different write that can clobber a peer. + */ + +/** + * The smallest store shape this helper needs. + * + * Structural on purpose: `SyncCredentialStore` satisfies it, and so does the + * duck type the desktop GitHub App service declares for its own store. Neither + * module has to import the other's type to share this ladder. + */ +export type UpdatableCredentialStore = { + getSync(key: string): string | null | undefined; + setSync(key: string, value: string): void; + deleteSync(key: string): void; + updateSync?(updater: (values: Record) => boolean | void): void; + updateKeySync?( + key: string, + mutator: (current: string | null) => string | null | undefined, + ): void; +}; + +/** Which rung of the ladder actually ran. */ +export type CredentialKeyUpdateMode = "atomic" | "read_modify_write"; + +/** True when `updateCredentialKeySync` can write this store without a race. */ +export function supportsAtomicCredentialUpdate(store: UpdatableCredentialStore): boolean { + return typeof store.updateKeySync === "function" || typeof store.updateSync === "function"; +} + +/** + * Applies `mutator` to one key and reports which rung of the ladder ran. + * + * The mutator sees the current value, or `null` when the key is absent. Return + * `undefined` to write nothing, `null` to delete the key, or a string to store + * it. + */ +export function updateCredentialKeySync( + store: UpdatableCredentialStore, + key: string, + mutator: (current: string | null) => string | null | undefined, +): CredentialKeyUpdateMode { + const updateKeySync = store.updateKeySync; + if (updateKeySync) { + updateKeySync.call(store, key, mutator); + return "atomic"; + } + const updateSync = store.updateSync; + if (updateSync) { + updateSync.call(store, (values) => { + const next = mutator(values[key] ?? null); + if (next === undefined) return false; + if (next === null) { + delete values[key]; + return true; + } + values[key] = next; + return true; + }); + return "atomic"; + } + const next = mutator(store.getSync(key) ?? null); + if (next === undefined) return "read_modify_write"; + if (next === null) store.deleteSync(key); + else store.setSync(key, next); + return "read_modify_write"; +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 69a235315..311704501 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -255,6 +255,11 @@ import { isElectronSafeStorageCredentialFile, type SyncCredentialStore, } from "../../../ade-cli/src/services/credentials/credentialStore"; +import { adoptFileBackedCredentials } from "../../../ade-cli/src/services/credentials/credentialStoreAdoption"; +import { + createRoutedCredentialStore, + createUnavailableCredentialStore, +} from "../../../ade-cli/src/services/credentials/credentialStoreRouting"; import { createKeybindingsService } from "./services/keybindings/keybindingsService"; import { createAgentToolsService } from "./services/agentTools/agentToolsService"; import { createAdeCliService } from "./services/cli/adeCliService"; @@ -639,11 +644,17 @@ function createDesktopCredentialStore(secretsDir: string): SyncCredentialStore { const legacyCredentialsPath = path.join(secretsDir, "credentials.json.enc"); try { if (safeStorage.isEncryptionAvailable()) { - return new ElectronSafeStorageCredentialStore({ + const primary = new ElectronSafeStorageCredentialStore({ secretsDir, safeStorage, legacyStore, }); + // Credentials the ADE brain and the CLI co-own live in the shared machine + // file, so the desktop app must READ them there too — the Electron-only + // store cannot see them, and a build that wrote one into it signs the + // brain out. + adoptFileBackedCredentials({ primary, fileStore: legacyStore, identity: secretsDir }); + return createRoutedCredentialStore({ primary, fileStore: legacyStore }); } } catch { // Fall through to the file store when Electron cannot reach the OS keychain. @@ -652,27 +663,14 @@ function createDesktopCredentialStore(secretsDir: string): SyncCredentialStore { isElectronSafeStorageCredentialFile(safeCredentialsPath) || isElectronSafeStorageCredentialFile(legacyCredentialsPath) ) { - const message = "Electron safeStorage is unavailable; unlock the OS credential store to read ADE credentials."; - return { - get: async () => { - throw new Error(message); - }, - set: async () => { - throw new Error(message); - }, - delete: async () => { - throw new Error(message); - }, - getSync: () => { - throw new Error(message); - }, - setSync: () => { - throw new Error(message); - }, - deleteSync: () => { - throw new Error(message); - }, - }; + // The shared file needs no keychain, so the credentials the brain co-owns + // stay reachable even while the Electron-only ones are locked away. + return createRoutedCredentialStore({ + primary: createUnavailableCredentialStore( + "Electron safeStorage is unavailable; unlock the OS credential store to read ADE credentials.", + ), + fileStore: legacyStore, + }); } return legacyStore; } @@ -3317,6 +3315,13 @@ app.whenReady().then(async () => { credentialStore: createDesktopCredentialStore(machineAdeLayout.secretsDir), githubRelaySecretReader: (ref) => githubRelaySecretService?.getSecret(ref) ?? null, getAccountAccessToken, + // A repaired or removed App credential ends the relay's auth-pending + // cooldown at once. Wired here rather than inside either service: the + // ingress loop must stay free of GitHub internals, and only this owner + // holds both of them. + onAppUserAuthChanged: () => { + void automationIngressServiceRef?.pollNow().catch(() => undefined); + }, }); const projectScaffoldService = createProjectScaffoldService({ diff --git a/apps/desktop/src/main/services/automations/automationIngressService.test.ts b/apps/desktop/src/main/services/automations/automationIngressService.test.ts index 90a1b6f7d..43967c22e 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.test.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.test.ts @@ -422,14 +422,74 @@ describe("automationIngressService", () => { await vi.advanceTimersByTimeAsync(GITHUB_RELAY_MIN_POLL_INTERVAL_MS); expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(1); - // Explicit pollNow (e.g. right after authorizing) bypasses the cooldown - // and the transition log fires only once. + // Explicit pollNow (e.g. right after authorizing) bypasses the cooldown. + // The repair did not take, and that is a new fact: the transition log says + // so again rather than staying latched on the failure it replaced. await service.pollNow(); expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(2); const authPendingLogs = (logger.info.mock.calls as unknown[][]) .filter((call) => call[0] === "automations.github_relay_auth_pending"); - expect(authPendingLogs).toHaveLength(1); + expect(authPendingLogs).toHaveLength(2); expect(logger.warn).not.toHaveBeenCalled(); + + // The timer-driven poll inside the fresh cooldown window stays quiet, so + // the log follows repairs rather than ticks. + await vi.advanceTimersByTimeAsync(GITHUB_RELAY_MIN_POLL_INTERVAL_MS); + expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(2); + expect((logger.info.mock.calls as unknown[][]) + .filter((call) => call[0] === "automations.github_relay_auth_pending")).toHaveLength(2); + }); + + it("cools down a broken GitHub App credential even while signed in", async () => { + vi.useFakeTimers(); + const logger = makeLogger(); + const webSockets = makeWebSocketHarness(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response( + JSON.stringify({ ok: true, events: [] }), + { status: 200, headers: { "content-type": "application/json" } }, + )); + const getAppUserTokenForRelay = vi.fn(async () => { + throw new Error("GitHub paused ADE's authorization renewal until 2026-08-20T13:00:00.000Z."); + }); + + service = createAutomationIngressService({ + logger: logger as never, + automationService: null, + prService: { ingestGithubWebhook: vi.fn() } as never, + secretService: { + getSecret: () => null, + } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getAppUserTokenForRelay, + }, + getAccountAccessToken: async () => "clerk-account-token", + listRules: () => [], + ingressCursorStore: { + get: () => null, + set: () => {}, + }, + pollIntervalMs: GITHUB_RELAY_MIN_POLL_INTERVAL_MS, + webSocketFactory: webSockets.factory, + }); + + await service.start(); + + // The account token still carries the poll, so the relay is not disabled... + expect(fetchSpy).toHaveBeenCalled(); + // ...but the broken GitHub credential is recorded, which is what starts the + // cooldown the signed-in path never had. + expect(logger.info).toHaveBeenCalledWith("automations.github_relay_auth_pending", expect.objectContaining({ + error: expect.stringContaining("paused ADE's authorization renewal"), + })); + expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(1); + + // The cooldown has to gate the LOOKUP as well. Every later poll runs on the + // account token alone until the window expires, so one broken credential + // costs one request rather than one every thirty seconds. + await vi.advanceTimersByTimeAsync(GITHUB_RELAY_MIN_POLL_INTERVAL_MS); + await vi.advanceTimersByTimeAsync(GITHUB_RELAY_MIN_POLL_INTERVAL_MS); + expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(1); }); it("can read GitHub relay config from runtime environment variables", async () => { diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index 2479f77ae..584a7049e 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -773,13 +773,25 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg }); }; - const enterHostedAuthPending = (message: string): void => { + /** + * Records that the GitHub App credential is unusable, and paces the next + * attempt. + * + * Separate from `enterHostedAuthPending` because a signed-in machine can still + * poll the relay with its account token, so the failure must not disable the + * subscription — but it must still start the cooldown, which is what stops the + * poll loop asking for the same broken credential every thirty seconds. + */ + const noteHostedAuthFailure = (message: string): void => { hostedAuthPendingUntilMs = Date.now() + HOSTED_RELAY_AUTH_PENDING_RETRY_MS; + if (hostedAuthPendingLogged) return; + hostedAuthPendingLogged = true; + args.logger.info("automations.github_relay_auth_pending", { error: message }); + }; + + const enterHostedAuthPending = (message: string): void => { + noteHostedAuthFailure(message); disableRelaySubscription(); - if (!hostedAuthPendingLogged) { - hostedAuthPendingLogged = true; - args.logger.info("automations.github_relay_auth_pending", { error: message }); - } updateGithubRelayStatus({ healthy: false, status: "disabled", @@ -873,17 +885,25 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg } let githubAppUserToken: string | null = null; - if (!useLegacyProjectRoute) { + // A signed-in machine reaches this line during the cooldown, because the + // account token can still carry the poll. Asking for the App token anyway + // is what turned one broken credential into a request every thirty + // seconds, so the cooldown gates the LOOKUP, not just the subscription. + const appTokenLookupPaused = !useLegacyProjectRoute + && Date.now() < hostedAuthPendingUntilMs; + if (!useLegacyProjectRoute && !appTokenLookupPaused) { try { githubAppUserToken = ((await run.wait(Promise.resolve( args.githubService?.getAppUserTokenForRelay(), ))) ?? "").trim() || null; } catch (error) { if (error instanceof GithubRelayPollSupersededError) throw error; + const message = error instanceof Error ? error.message : String(error); if (!accountAccessToken) { - enterHostedAuthPending(error instanceof Error ? error.message : String(error)); + enterHostedAuthPending(message); return; } + noteHostedAuthFailure(message); } } const hostedAuth = useLegacyProjectRoute @@ -893,8 +913,15 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg enterHostedAuthPending(hostedAuth.error); return; } - hostedAuthPendingUntilMs = 0; - hostedAuthPendingLogged = false; + if (hostedAuth && !hostedAuth.ok) { + // While the lookup is paused there is no new failure to record, and + // re-stamping the deadline on every poll would push it out forever — + // the App credential would never be tried again. + if (!appTokenLookupPaused) noteHostedAuthFailure(hostedAuth.error); + } else { + hostedAuthPendingUntilMs = 0; + hostedAuthPendingLogged = false; + } const authToken = useLegacyProjectRoute ? legacyAuthToken : hostedAuth?.ok @@ -1178,8 +1205,11 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg async pollNow() { // Explicit polls (e.g. right after the user authorizes the GitHub App) - // bypass the auth-pending cooldown. + // bypass the auth-pending cooldown. The "logged once" latch is released + // with it: a repair that did not take is a new fact, and the log line + // saying so must not be suppressed by the failure it replaced. hostedAuthPendingUntilMs = 0; + hostedAuthPendingLogged = false; clearRelayPollRetryTimer(); relayPollCooldownUntilMs = 0; relayPollFailureCount = 0; diff --git a/apps/desktop/src/main/services/github/githubAppUserAuth.testFixtures.ts b/apps/desktop/src/main/services/github/githubAppUserAuth.testFixtures.ts new file mode 100644 index 000000000..81744dc58 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubAppUserAuth.testFixtures.ts @@ -0,0 +1,27 @@ +import type { GitHubAppUserTokenRecord } from "./githubAppUserAuth"; + +/** + * One stored GitHub App user credential, as the credential store holds it. + * + * The desktop service tests and the headless CLI twin's tests both write this + * record, and both care about the same two fields: an access token already past + * its life (so every call has to refresh) next to a refresh token that is still + * good. Spelling the whole record out at each call site is how those two facts + * drifted apart between suites. + */ +export function makeStoredAppUserToken( + patch: Partial = {}, +): string { + const record: GitHubAppUserTokenRecord = { + accessToken: "ghu_stale_app_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() - 60_000).toISOString(), + refreshToken: "ghr_live_refresh_token", + refreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 3_600_000).toISOString(), + userLogin: "octocat", + updatedAt: new Date().toISOString(), + ...patch, + }; + return JSON.stringify(record); +} diff --git a/apps/desktop/src/main/services/github/githubAppUserAuth.ts b/apps/desktop/src/main/services/github/githubAppUserAuth.ts index 395cba593..86418863e 100644 --- a/apps/desktop/src/main/services/github/githubAppUserAuth.ts +++ b/apps/desktop/src/main/services/github/githubAppUserAuth.ts @@ -40,6 +40,57 @@ export type GitHubAppDevicePollResult = type FetchImpl = typeof fetch; +/** Which GitHub OAuth endpoint produced a failure. */ +export type GitHubOAuthEndpoint = "device_code" | "token"; + +/** + * A GitHub OAuth failure with the parts a caller has to act on. + * + * GitHub answers a rejected refresh token with HTTP 200 and an `error` field, so + * the transport cannot report failure by status alone; and it answers a + * rate-limited client with 429 plus `retry-after`, which is the only honest + * source for how long to wait. A bare `Error` carrying a string threw both away, + * which is how a dead refresh token became an unbounded retry loop. + */ +export class GitHubOAuthError extends Error { + constructor( + message: string, + readonly endpoint: GitHubOAuthEndpoint, + readonly status: number, + readonly oauthError: string | null, + readonly errorDescription: string | null, + readonly retryAfterSec: number | null, + ) { + super(message); + this.name = "GitHubOAuthError"; + } +} + +/** + * True when `error` is a {@link GitHubOAuthError}, whichever module instance + * created it. + * + * Name-based rather than `instanceof`: the desktop service and the headless CLI + * twin load this module through different paths, and a cross-realm `instanceof` + * answers wrong — which is how a classified OAuth failure fell through to the + * "network" branch and kept its retry loop alive. + */ +export function isGitHubOAuthError(error: unknown): error is GitHubOAuthError { + return typeof error === "object" + && error !== null + && (error as { name?: unknown }).name === "GitHubOAuthError"; +} + +function parseRetryAfterSeconds(response: Response): number | null { + const raw = response.headers.get("retry-after")?.trim(); + if (!raw) return null; + const seconds = Number(raw); + if (Number.isFinite(seconds)) return Math.max(0, Math.trunc(seconds)); + const at = Date.parse(raw); + if (!Number.isFinite(at)) return null; + return Math.max(0, Math.ceil((at - Date.now()) / 1000)); +} + function readString(source: Record, key: string): string { const value = source[key]; return typeof value === "string" ? value.trim() : ""; @@ -64,9 +115,10 @@ function parseJsonRecord(value: unknown): Record { async function postGitHubOAuthForm(args: { fetchImpl: FetchImpl; url: string; + endpoint: GitHubOAuthEndpoint; userAgent: string; body: Record; -}): Promise> { +}): Promise<{ payload: Record; status: number; retryAfterSec: number | null }> { const response = await args.fetchImpl(args.url, { method: "POST", headers: { @@ -77,13 +129,25 @@ async function postGitHubOAuthForm(args: { body: new URLSearchParams(args.body).toString(), }); const payload = parseJsonRecord(await response.json().catch(() => ({}))); + const retryAfterSec = parseRetryAfterSeconds(response); if (!response.ok) { - const message = readString(payload, "error_description") - || readString(payload, "error") - || `GitHub OAuth request failed (${response.status})`; - throw new Error(message); + const oauthError = readString(payload, "error") || null; + const errorDescription = readString(payload, "error_description") || null; + throw new GitHubOAuthError( + errorDescription + || oauthError + || `GitHub OAuth request failed (${response.status})`, + args.endpoint, + response.status, + oauthError, + errorDescription, + retryAfterSec, + ); } - return payload; + // An OK response keeps its payload: the device flow reads `error` itself to + // tell "still waiting" from "denied", and only the refresh path treats an + // error field as a failure. + return { payload, status: response.status, retryAfterSec }; } export async function startGitHubAppDeviceFlow(args: { @@ -92,9 +156,10 @@ export async function startGitHubAppDeviceFlow(args: { userAgent: string; }): Promise { const clientId = args.clientId?.trim() || ADE_GITHUB_APP_CLIENT_ID; - const payload = await postGitHubOAuthForm({ + const { payload } = await postGitHubOAuthForm({ fetchImpl: args.fetchImpl ?? fetch, url: GITHUB_DEVICE_CODE_URL, + endpoint: "device_code", userAgent: args.userAgent, body: { client_id: clientId }, }); @@ -125,9 +190,10 @@ export async function pollGitHubAppDeviceFlow(args: { fetchUserLogin?: (accessToken: string) => Promise; }): Promise { const clientId = args.clientId?.trim() || ADE_GITHUB_APP_CLIENT_ID; - const payload = await postGitHubOAuthForm({ + const { payload } = await postGitHubOAuthForm({ fetchImpl: args.fetchImpl ?? fetch, url: GITHUB_OAUTH_TOKEN_URL, + endpoint: "token", userAgent: args.userAgent, body: { client_id: clientId, @@ -182,9 +248,10 @@ export async function refreshGitHubAppUserToken(args: { fetchUserLogin?: (accessToken: string) => Promise; }): Promise { const clientId = args.clientId?.trim() || ADE_GITHUB_APP_CLIENT_ID; - const payload = await postGitHubOAuthForm({ + const { payload, status, retryAfterSec } = await postGitHubOAuthForm({ fetchImpl: args.fetchImpl ?? fetch, url: GITHUB_OAUTH_TOKEN_URL, + endpoint: "token", userAgent: args.userAgent, body: { client_id: clientId, @@ -192,8 +259,32 @@ export async function refreshGitHubAppUserToken(args: { refresh_token: args.refreshToken, }, }); + // GitHub reports a rejected refresh token as HTTP 200 with an error body. It + // has to be read here, or a dead credential looks exactly like a malformed + // response and gets retried forever. + const oauthError = readString(payload, "error"); + if (oauthError) { + const errorDescription = readString(payload, "error_description") || null; + throw new GitHubOAuthError( + errorDescription || `GitHub rejected the refresh token (${oauthError}).`, + "token", + status, + oauthError, + errorDescription, + retryAfterSec, + ); + } const accessToken = readString(payload, "access_token"); - if (!accessToken) throw new Error("GitHub did not return a refreshed user access token."); + if (!accessToken) { + throw new GitHubOAuthError( + "GitHub did not return a refreshed user access token.", + "token", + status, + null, + null, + retryAfterSec, + ); + } const userLogin = args.fetchUserLogin ? await args.fetchUserLogin(accessToken).catch(() => null) : null; return { accessToken, diff --git a/apps/desktop/src/main/services/github/githubAppUserAuthDeviceFlow.ts b/apps/desktop/src/main/services/github/githubAppUserAuthDeviceFlow.ts new file mode 100644 index 000000000..c573b109d --- /dev/null +++ b/apps/desktop/src/main/services/github/githubAppUserAuthDeviceFlow.ts @@ -0,0 +1,219 @@ +import { randomUUID } from "node:crypto"; +import type { + GitHubAppDeviceAuthPollResult, + GitHubAppDeviceAuthStartResult, + GitHubAppUserAuthStatus, +} from "../../../shared/types"; +import { + ADE_GITHUB_APP_CLIENT_ID, + type GitHubAppDeviceCode, + type GitHubAppUserTokenRecord, + pollGitHubAppDeviceFlow, + startGitHubAppDeviceFlow, +} from "./githubAppUserAuth"; +import { describeGitHubAppUserAuthFailure } from "./githubAppUserAuthFailure"; + +/** + * The GitHub device flow: how a person first authorizes ADE. + * + * Split out of githubAppUserAuthService.ts because it shares nothing with the + * refresh ledger that file exists for. It holds pending browser sessions, it + * talks to GitHub's device endpoints, and it hands the finished credential to + * the service through {@link GitHubAppUserDeviceFlowDeps.persistAppUserTokenRecord}. + */ + +const MAX_PENDING_DEVICE_AUTH_SESSIONS = 5; + +/** + * What a person is told when GitHub throttles the sign-in endpoint itself. + * + * Re-authorizing is what a user reaches for when the credential broke, and it + * goes to the same host that is throttling ADE. Say that, rather than handing + * back a status code. + */ +export const GITHUB_SIGN_IN_RATE_LIMITED_COPY = + "GitHub is rate-limiting ADE's sign-in requests right now. Try again in a few minutes."; + +type GitHubAppDeviceAuthSession = GitHubAppDeviceCode & { + sessionId: string; + intervalSec: number; +}; + +export type GitHubAppUserDeviceFlowDeps = { + fetchImpl: (input: string, init?: RequestInit) => Promise; + userAgent: string; + logger: { warn(message: string, meta?: Record): void }; + /** Resolves the login behind a fresh access token, for the stored record. */ + fetchAppUserLogin: (accessToken: string) => Promise; + /** Writes the authorized credential, or clears it when given null. */ + persistAppUserTokenRecord: (record: GitHubAppUserTokenRecord | null) => void; + /** Tells the service that stored auth was replaced outside the refresh path. */ + bumpAuthEpoch: () => void; + appUserAuthStatus: (patch?: Partial) => GitHubAppUserAuthStatus; + now?: () => number; +}; + +export function createGitHubAppUserDeviceFlow(deps: GitHubAppUserDeviceFlowDeps): { + startDeviceAuth(): Promise; + pollDeviceAuth(args: { sessionId: string }): Promise; + /** Drops every pending browser session, for sign-out. */ + clearSessions(): void; +} { + const sessions = new Map(); + const now = deps.now ?? (() => Date.now()); + // Bumped by clearSessions. A poll resolving after a sign-out must not put its + // session back in the map, and must not persist the credential the user just + // cleared. + let sessionGeneration = 0; + + const pruneExpiredSessions = (requestedSessionId?: string): boolean => { + const nowMs = now(); + let requestedExpired = false; + for (const [sessionId, session] of sessions.entries()) { + if (Date.parse(session.expiresAt) <= nowMs) { + sessions.delete(sessionId); + if (sessionId === requestedSessionId) requestedExpired = true; + } + } + return requestedExpired; + }; + + const startDeviceAuth = async (): Promise => { + pruneExpiredSessions(); + // Cap pending sessions so a runaway caller cannot grow the map or spam + // GitHub's device endpoint via ADE; evict oldest first. + while (sessions.size >= MAX_PENDING_DEVICE_AUTH_SESSIONS) { + const oldest = sessions.keys().next().value; + if (!oldest) break; + sessions.delete(oldest); + } + let device: GitHubAppDeviceCode; + try { + device = await startGitHubAppDeviceFlow({ + clientId: ADE_GITHUB_APP_CLIENT_ID, + fetchImpl: (input, init) => deps.fetchImpl(String(input), init), + userAgent: deps.userAgent, + }); + } catch (error) { + const described = describeGitHubAppUserAuthFailure(error); + deps.logger.warn("github.app_user_device_start_failed", { + error: described.message, + status: described.status, + oauthError: described.oauthError, + }); + if (described.status === 429) throw new Error(GITHUB_SIGN_IN_RATE_LIMITED_COPY); + throw error; + } + const sessionId = randomUUID(); + sessions.set(sessionId, { ...device, sessionId }); + return { + sessionId, + userCode: device.userCode, + verificationUri: device.verificationUri, + verificationUriComplete: device.verificationUriComplete, + expiresAt: device.expiresAt, + intervalSec: device.intervalSec, + }; + }; + + const pollDeviceAuth = async ( + pollArgs: { sessionId: string }, + ): Promise => { + const requestedSessionExpired = pruneExpiredSessions(pollArgs.sessionId); + const session = sessions.get(pollArgs.sessionId); + if (!session) { + if (requestedSessionExpired) { + return { + status: "expired", + intervalSec: null, + message: "GitHub device authorization expired.", + authStatus: deps.appUserAuthStatus(), + }; + } + return { + status: "error", + intervalSec: null, + message: "GitHub device authorization session was not found.", + authStatus: deps.appUserAuthStatus(), + }; + } + let result: Awaited>; + const generationAtStart = sessionGeneration; + try { + result = await pollGitHubAppDeviceFlow({ + clientId: ADE_GITHUB_APP_CLIENT_ID, + deviceCode: session.deviceCode, + intervalSec: session.intervalSec, + fetchImpl: (input, init) => deps.fetchImpl(String(input), init), + userAgent: deps.userAgent, + fetchUserLogin: deps.fetchAppUserLogin, + }); + } catch (error) { + // A transport failure is a poll RESULT, not a thrown IPC error: the caller + // is a polling UI, and it can only pace itself if it is told what + // happened. A 429 here is GitHub throttling ADE, not a denied user. + const described = describeGitHubAppUserAuthFailure(error); + const message = described.status === 429 + ? GITHUB_SIGN_IN_RATE_LIMITED_COPY + : described.message; + deps.logger.warn("github.app_user_device_poll_failed", { + error: described.message, + status: described.status, + oauthError: described.oauthError, + }); + return { + status: "error", + intervalSec: null, + message, + authStatus: deps.appUserAuthStatus({ error: message }), + }; + } + if (sessionGeneration !== generationAtStart) { + // Sign-out dropped every pending session while this poll was in flight. + // Re-inserting the session below would resurrect it, and an `authorized` + // result would write back the credential the user just cleared. + return { + status: "error", + intervalSec: null, + message: "GitHub device authorization session was not found.", + authStatus: deps.appUserAuthStatus(), + }; + } + if (result.status === "pending" || result.status === "slow_down") { + session.intervalSec = result.intervalSec; + sessions.set(session.sessionId, session); + return { + status: result.status, + intervalSec: result.intervalSec, + message: result.message, + authStatus: deps.appUserAuthStatus(), + }; + } + sessions.delete(pollArgs.sessionId); + if (result.status === "authorized") { + deps.bumpAuthEpoch(); + deps.persistAppUserTokenRecord(result.token); + return { + status: "authorized", + intervalSec: null, + message: null, + authStatus: deps.appUserAuthStatus(), + }; + } + return { + status: result.status, + intervalSec: null, + message: result.message, + authStatus: deps.appUserAuthStatus({ error: result.message }), + }; + }; + + return { + startDeviceAuth, + pollDeviceAuth, + clearSessions: () => { + sessionGeneration += 1; + sessions.clear(); + }, + }; +} diff --git a/apps/desktop/src/main/services/github/githubAppUserAuthFailure.ts b/apps/desktop/src/main/services/github/githubAppUserAuthFailure.ts new file mode 100644 index 000000000..4471a72a0 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubAppUserAuthFailure.ts @@ -0,0 +1,306 @@ +import type { + GitHubAppUserAuthCredentialState, + GitHubAppUserAuthStatus, + GitHubAppUserAuthUnavailable, + GitHubAuthFailure, + GitHubRateLimitState, +} from "../../../shared/types"; +import { GITHUB_APP_USER_AUTH_RENEWING_COPY } from "../../../shared/types"; +import { isGitHubOAuthError, type GitHubOAuthError } from "./githubAppUserAuth"; +import type { RefreshFailureKind, StoredRefreshFailure } from "./githubAppUserAuthLedger"; +import { classifyGitHubAuthFailure, isDefinitiveGitHubOAuthError } from "./githubRateLimit"; + +/** + * Why ADE cannot hand out a GitHub App user token, and what every caller does + * with that answer. + * + * Split out of the service because the two consumers — the desktop GitHub + * service and its headless CLI twin — need the classification without needing + * the service that produced it. + */ + +/** + * A classified refresh failure before it is stamped with the instant it + * happened, plus the verdict that decides whether the credential is worth + * retrying. + */ +export type RefreshFailure = Omit & { dead: boolean }; + +/** + * Why ADE cannot hand out an App user token right now, in terms the UI can + * render without guessing. + */ +export class GitHubAppUserAuthError extends Error { + constructor( + message: string, + readonly credentialState: Exclude, + readonly retryAt: string | null, + /** The refresh failure behind this state, when one was recorded. */ + readonly failure: Pick | null, + ) { + super(message); + this.name = "GitHubAppUserAuthError"; + } +} + +/** + * Turns a failed refresh POST into a verdict, and above all into an answer to + * one question: is this credential worth trying again? + * + * A credential is declared dead ONLY on an explicit 401 or an OAuth error code + * that names the grant as rejected. Everything else keeps the credential and + * backs off, because the cost is asymmetric — a wasted retry costs one request, + * while writing off a live credential costs the user their connection until + * they notice and re-authorize by hand. GitHub's secondary rate limits answer + * 403, sometimes with no `retry-after` and no error body at all, so a bare 403 + * was the exact shape that used to be mistaken for a dead grant. + */ +export function classifyRefreshFailure(error: unknown): RefreshFailure { + const message = error instanceof Error ? error.message : String(error); + if (isGitHubOAuthError(error)) { + const status = typeof error.status === "number" ? error.status : null; + const oauthError = error.oauthError ?? null; + const retryAfterSec = error.retryAfterSec ?? null; + if (isDefinitiveGitHubOAuthError(oauthError) || status === 401) { + return { kind: "dead_token", message, status, oauthError, retryAfterSec, dead: true }; + } + if ( + status === 429 + || status === 403 + || oauthError === "too_many_requests" + || retryAfterSec != null + ) { + return { kind: "rate_limited", message, status, oauthError, retryAfterSec, dead: false }; + } + if (status != null && status >= 500) { + return { kind: "outage", message, status, oauthError, retryAfterSec, dead: false }; + } + return { kind: "unknown", message, status, oauthError, retryAfterSec, dead: false }; + } + return { kind: "network", message, status: null, oauthError: null, retryAfterSec: null, dead: false }; +} + +/** + * The parts of a failed token lookup a caller needs to classify it, whether the + * failure came from the auth service or straight from the OAuth transport. + * + * Duck-typed for the same reason `githubAuthFailureKindOf` is: the desktop + * service and the headless twin both call this, and an `instanceof` check + * answers wrong across module instances. + */ +export function describeGitHubAppUserAuthFailure(error: unknown): { + message: string; + status: number | null; + oauthError: string | null; + retryAt: string | null; + credentialState: GitHubAppUserAuthCredentialState | null; + failureKind: RefreshFailureKind | null; +} { + const candidate = error as Partial | null; + // The service carries the OAuth details inside `failure`; the transport error + // carries them on itself. Read both, service first. + const failure = candidate?.failure ?? null; + const status = typeof failure?.status === "number" + ? failure.status + : typeof candidate?.status === "number" ? candidate.status : null; + const oauthError = typeof failure?.oauthError === "string" + ? failure.oauthError + : typeof candidate?.oauthError === "string" ? candidate.oauthError : null; + return { + message: error instanceof Error ? error.message : String(error), + status, + oauthError, + retryAt: typeof candidate?.retryAt === "string" ? candidate.retryAt : null, + credentialState: typeof candidate?.credentialState === "string" + ? candidate.credentialState + : null, + failureKind: typeof failure?.kind === "string" ? failure.kind : null, + }; +} + +/** What the two relay-token helpers below write their one log line through. */ +type AppUserAuthLogger = { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; +}; + +export type AppUserAuthFailure = { + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + described: ReturnType; +}; + +/** + * The credential-failure kind a classified refresh failure already names, or + * null when the refresh path did not classify it. + * + * The refresh path saw the OAuth response itself, so its verdict is the precise + * one. Re-deriving the kind from the message and status of the error it threw + * throws that away: the service reports a paused renewal with the status of the + * failure BELOW it, and GitHub answers a secondary rate limit with a bare 403 — + * which the generic classifier reads as "permission denied", an accusation + * against the account that nothing supports. + */ +function authFailureKindForRefreshKind( + kind: RefreshFailureKind | null, +): GitHubAuthFailure["kind"] | null { + switch (kind) { + case "rate_limited": + return "rate_limited"; + case "outage": + return "service_unavailable"; + case "network": + return "network"; + case "dead_token": + return "invalid_token"; + default: + return null; + } +} + +/** + * Turns a failed App user token lookup into the credential-failure shape the + * GitHub status surfaces already speak, with the OAuth details that decide the + * kind carried across instead of being flattened into a message string. + */ +export function classifyAppUserAuthFailure(error: unknown): AppUserAuthFailure { + const described = describeGitHubAppUserAuthFailure(error); + const classified = classifyGitHubAuthFailure({ + status: described.status ?? undefined, + message: described.message, + oauthError: described.oauthError, + retryAt: described.retryAt, + }); + if (described.credentialState === "needs_reauth") { + // The refresh token is gone, expired, or rejected. Only re-authorization + // helps, so it must never be reported as a rate limit that will pass. + return { + described, + rateLimit: classified.rateLimit, + authFailure: { kind: "invalid_token", message: described.message, retryAt: null }, + }; + } + if (described.credentialState === "blocked" && described.failureKind === null) { + // Blocked with nothing refused is ADE waiting on its own refresh lease: one + // process on this machine renews the credential and the rest wait a moment. + // The repo axis already says so; the credential inventory said "GitHub + // authentication check failed" for the same wait, which reads as a broken + // account. Carry the same sentence onto both axes. + return { + described, + rateLimit: classified.rateLimit, + authFailure: { + kind: "renewing", + message: GITHUB_APP_USER_AUTH_RENEWING_COPY, + retryAt: described.retryAt, + }, + }; + } + const namedKind = authFailureKindForRefreshKind(described.failureKind); + if (namedKind) { + return { + described, + rateLimit: classified.rateLimit, + authFailure: { + kind: namedKind, + message: described.message, + // A dead credential has no deadline to wait for; every other named kind + // carries the instant ADE will try again. + retryAt: namedKind === "invalid_token" ? null : described.retryAt, + }, + }; + } + return { ...classified, described }; +} + +/** + * The account problem as the repo axis is allowed to state it. + * + * The installation check reports the relay's 401 unless it is handed this, and + * the relay's wording blames the repository for a problem with ADE's own + * authorization. + */ +export function describeAppUserAuthUnavailable( + failure: AppUserAuthFailure | null, +): GitHubAppUserAuthUnavailable | null { + if (!failure) return null; + return { + message: failure.described.message, + credentialState: failure.described.credentialState, + retryAt: failure.authFailure.retryAt, + // Null when GitHub never refused anything — the wait belongs to ADE's own + // refresh lease, and the copy has to say that instead of blaming GitHub. + failureKind: failure.described.failureKind, + }; +} + +/** The App entry a credential inventory carries when the App token is unusable. */ +export function appCredentialFailureEntry( + failure: AppUserAuthFailure | null, +): Array<{ + source: "app"; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; +}> { + if (!failure) return []; + return [{ + source: "app" as const, + authFailure: failure.authFailure, + rateLimit: failure.rateLimit, + }]; +} + +/** + * Asks for a relay-ready App user token, and keeps the REASON when there is + * none. + * + * Every caller needs the same three things — the token, the classified failure, + * and one log line naming it — and the reason must never be swallowed: doing + * that is how "ADE's authorization is paused" reached the user as the relay's + * own "GitHub auth token is required" 401. + */ +export async function resolveAppUserTokenForRelay(args: { + appUserAuth: { getValidTokenForRelay(): Promise }; + logger: AppUserAuthLogger; + /** The log event name, which differs by call site. */ + event: string; +}): Promise<{ token: string | null; failure: AppUserAuthFailure | null }> { + try { + return { token: await args.appUserAuth.getValidTokenForRelay(), failure: null }; + } catch (error: unknown) { + const failure = classifyAppUserAuthFailure(error); + const meta = { + error: failure.described.message, + kind: failure.authFailure.kind, + credentialState: failure.described.credentialState, + status: failure.described.status, + oauthError: failure.described.oauthError, + retryAt: failure.authFailure.retryAt, + }; + // A machine that never authorized the App has nothing wrong with it. Warning + // on every credential read there fills the log of an ordinary install with a + // problem nobody has. + if (failure.described.credentialState === "missing") args.logger.info(args.event, meta); + else args.logger.warn(args.event, meta); + return { token: null, failure }; + } +} + +/** + * The same lookup, for the callers that must not ask at all when no credential + * is stored. + * + * Building a credential inventory is the hot path — every status read, PR call + * and relay poll builds one — and asking for a token that was never stored + * costs a store read and a classified failure per call. The guard is spelled + * here so both twins gate the same way. + */ +export async function resolveStoredAppUserTokenForRelay(args: { + status: Pick; + appUserAuth: { getValidTokenForRelay(): Promise }; + logger: AppUserAuthLogger; + event: string; +}): Promise<{ token: string | null; failure: AppUserAuthFailure | null }> { + if (!args.status.tokenStored) return { token: null, failure: null }; + return await resolveAppUserTokenForRelay(args); +} diff --git a/apps/desktop/src/main/services/github/githubAppUserAuthLedger.test.ts b/apps/desktop/src/main/services/github/githubAppUserAuthLedger.test.ts new file mode 100644 index 000000000..8dc306ad4 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubAppUserAuthLedger.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { parseLedger } from "./githubAppUserAuthLedger"; + +/** + * The credential file is written by peer ADE processes and by older versions of + * ADE, so nothing in it is guaranteed to match the current shape. `kind` above + * all: it travels on to the renderer as + * `GitHubAppUserAuthStatus.lastRefreshError.kind`. + */ +describe("parseLedger failure kinds", () => { + const failure = (kind: unknown): Record => ({ + lastFailure: { + kind, + message: "GitHub refused the renewal.", + at: "2026-08-20T12:00:00.000Z", + }, + }); + + it("keeps a kind this version knows", () => { + expect(parseLedger(failure("rate_limited")).lastFailure?.kind).toBe("rate_limited"); + }); + + it("reads a kind this version does not know as unknown", () => { + expect(parseLedger(failure("teapot")).lastFailure?.kind).toBe("unknown"); + }); + + it("keeps the failure itself when the kind is unrecognized", () => { + // Narrowing the kind must not throw the failure away: the record still says + // a refresh failed, and every surface that reports one reads this. + expect(parseLedger(failure("teapot")).lastFailure?.message) + .toBe("GitHub refused the renewal."); + }); + + it("reports no failure at all when the kind is absent", () => { + expect(parseLedger(failure(null)).lastFailure).toBeNull(); + expect(parseLedger(failure(" ")).lastFailure).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/github/githubAppUserAuthLedger.ts b/apps/desktop/src/main/services/github/githubAppUserAuthLedger.ts new file mode 100644 index 000000000..1324c43ae --- /dev/null +++ b/apps/desktop/src/main/services/github/githubAppUserAuthLedger.ts @@ -0,0 +1,195 @@ +import type { GitHubAppUserAuthRefreshError } from "../../../shared/types"; +import type { GitHubAppUserTokenRecord } from "./githubAppUserAuth"; + +/** + * The stored GitHub App user credential and the refresh ledger beside it, plus + * the pure functions that read and write that record. + * + * Split out of the service because none of it needs the service: it is parsing, + * serializing and defaulting, and it is what a test reaches for when it wants + * to state "this is what the credential file holds" without building a service. + */ + +export type RefreshFailureKind = GitHubAppUserAuthRefreshError["kind"]; + +/** + * One refresh failure as it is written into the ledger, and the shape every + * surface that reports a refresh failure reads. + */ +export type StoredRefreshFailure = { + kind: RefreshFailureKind; + message: string; + status: number | null; + oauthError: string | null; + retryAfterSec: number | null; + at: string; +}; + +/** + * The persistent memory of how the last refresh went, stored INSIDE the + * credential record so every process that reads the credential also reads the + * backoff that applies to it. + * + * Without this the only coordination was a per-instance in-flight promise, and + * a machine runs many instances: one per project scope in the desktop app, one + * per project scope in the brain, plus the CLI. Each of them independently + * POSTed the same refresh token, GitHub's rotation-reuse detection revoked the + * credential, and the resulting dead token was retried by all of them until + * GitHub rate-limited the whole OAuth host. + */ +export type RefreshLedger = { + /** No refresh may be attempted before this instant. */ + notBeforeAt: string | null; + consecutiveFailures: number; + /** GitHub rejected the refresh token itself; only re-authorization fixes it. */ + dead: boolean; + leaseUntil: string | null; + leaseHolder: string | null; + /** Bumped on every successful refresh, for log correlation across processes. */ + generation: number; + lastFailure: StoredRefreshFailure | null; +}; + +export type StoredAppUserAuth = { + token: GitHubAppUserTokenRecord | null; + refresh: RefreshLedger; +}; + +export function emptyLedger(): RefreshLedger { + return { + notBeforeAt: null, + consecutiveFailures: 0, + dead: false, + leaseUntil: null, + leaseHolder: null, + generation: 0, + lastFailure: null, + }; +} + +/** True when `iso` names an instant strictly after `cutoffMs`. */ +export function readIsoAfter(iso: string | null | undefined, cutoffMs: number): boolean { + if (!iso) return false; + const time = Date.parse(iso); + return Number.isFinite(time) && time > cutoffMs; +} + +/** + * True when `iso` names an instant that is both after `nowMs` and no further + * ahead than `maxAheadMs`. + * + * Every deadline in this ledger is written by a peer process, and a peer with a + * wrong clock writes one that no amount of waiting reaches. A lease stamped a + * year ahead locks every process out of the refresh forever, and a backoff + * stamped a year ahead pauses renewals for good — both poison the shared file + * until a person deletes it by hand. A deadline further ahead than the longest + * one ADE ever writes cannot have been written by a healthy process, so it is + * read as expired and the next writer replaces it. + */ +export function readIsoActiveWithin( + iso: string | null | undefined, + nowMs: number, + maxAheadMs: number, +): boolean { + if (!iso) return false; + const time = Date.parse(iso); + if (!Number.isFinite(time)) return false; + return time > nowMs && time <= nowMs + maxAheadMs; +} + +function trimmedOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +/** + * Every failure kind this ledger knows, so a stored one can be checked rather + * than trusted. The file is written by peer processes and by older versions of + * ADE, and the value travels on to the renderer as + * `GitHubAppUserAuthStatus.lastRefreshError.kind`. + * + * `satisfies Record` makes the compiler check this + * list against the union: a kind added to the union later fails the build here + * instead of silently arriving at the renderer as "unknown". + */ +const REFRESH_FAILURE_KINDS = { + rate_limited: true, + outage: true, + network: true, + dead_token: true, + unknown: true, +} satisfies Record; + +function isRefreshFailureKind(value: string): value is RefreshFailureKind { + return Object.hasOwn(REFRESH_FAILURE_KINDS, value); +} + +/** + * An unrecognized kind is read as "unknown": the record still says a refresh + * failed, and dropping the whole failure would hide that. + */ +function readFailureKind(value: unknown): RefreshFailureKind | null { + const trimmed = trimmedOrNull(value); + if (!trimmed) return null; + return isRefreshFailureKind(trimmed) ? trimmed : "unknown"; +} + +export function parseLedger(value: unknown): RefreshLedger { + const base = emptyLedger(); + if (!value || typeof value !== "object" || Array.isArray(value)) return base; + const raw = value as Record; + const failure = raw.lastFailure && typeof raw.lastFailure === "object" && !Array.isArray(raw.lastFailure) + ? raw.lastFailure as Record + : null; + const kind = readFailureKind(failure?.kind); + return { + notBeforeAt: trimmedOrNull(raw.notBeforeAt), + consecutiveFailures: typeof raw.consecutiveFailures === "number" && Number.isFinite(raw.consecutiveFailures) + ? Math.max(0, Math.trunc(raw.consecutiveFailures)) + : 0, + dead: raw.dead === true, + leaseUntil: trimmedOrNull(raw.leaseUntil), + leaseHolder: trimmedOrNull(raw.leaseHolder), + generation: typeof raw.generation === "number" && Number.isFinite(raw.generation) + ? Math.max(0, Math.trunc(raw.generation)) + : 0, + lastFailure: failure && kind + ? { + kind, + message: trimmedOrNull(failure.message) ?? "GitHub refused to renew the ADE GitHub App authorization.", + status: typeof failure.status === "number" && Number.isFinite(failure.status) + ? Math.trunc(failure.status) + : null, + oauthError: trimmedOrNull(failure.oauthError), + retryAfterSec: typeof failure.retryAfterSec === "number" && Number.isFinite(failure.retryAfterSec) + ? Math.max(0, Math.trunc(failure.retryAfterSec)) + : null, + at: trimmedOrNull(failure.at) ?? new Date().toISOString(), + } + : null, + }; +} + +export function parseStoredAppUserAuth(raw: string): StoredAppUserAuth { + const parsed = JSON.parse(raw) as Record; + const accessToken = trimmedOrNull(parsed.accessToken); + const refresh = parseLedger(parsed.refresh); + if (!accessToken) return { token: null, refresh }; + return { + token: { + accessToken, + tokenType: trimmedOrNull(parsed.tokenType) ?? "bearer", + scope: trimmedOrNull(parsed.scope), + expiresAt: trimmedOrNull(parsed.expiresAt), + refreshToken: trimmedOrNull(parsed.refreshToken), + refreshTokenExpiresAt: trimmedOrNull(parsed.refreshTokenExpiresAt), + userLogin: trimmedOrNull(parsed.userLogin), + updatedAt: trimmedOrNull(parsed.updatedAt) ?? new Date().toISOString(), + }, + refresh, + }; +} + +export function serializeStoredAppUserAuth(stored: StoredAppUserAuth): string | null { + if (!stored.token) return null; + return JSON.stringify({ ...stored.token, refresh: stored.refresh }); +} diff --git a/apps/desktop/src/main/services/github/githubAppUserAuthService.test.ts b/apps/desktop/src/main/services/github/githubAppUserAuthService.test.ts new file mode 100644 index 000000000..3e0374759 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubAppUserAuthService.test.ts @@ -0,0 +1,1537 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createGitHubAppUserAuthService, + judgeStoredAuth, + resetGitHubAppUserAuthCoordinatorsForTests, + type GitHubAppUserAuthCredentialStore, +} from "./githubAppUserAuthService"; +import { emptyLedger, type StoredAppUserAuth } from "./githubAppUserAuthLedger"; +import { + GitHubOAuthError, + pollGitHubAppDeviceFlow, + refreshGitHubAppUserToken, + startGitHubAppDeviceFlow, + type GitHubAppUserTokenRecord, +} from "./githubAppUserAuth"; +import { GITHUB_APP_USER_AUTH_RENEWING_COPY } from "../../../shared/types"; +import { + GitHubAppUserAuthError, + classifyAppUserAuthFailure, +} from "./githubAppUserAuthFailure"; +import { makeStoredAppUserToken } from "./githubAppUserAuth.testFixtures"; + +const TOKEN_KEY = "github.appUserToken.v1"; + +type StoredValues = Record; + +/** + * A stand-in for EncryptedFileCredentialStore: several store objects may share + * one `values` map, which is how the real machine file is shared by the desktop + * app, the ADE brain and the CLI. `updateKeySync` is the atomic read-modify-write + * those processes serialize their writes through. + */ +function createFakeStore(values: StoredValues) { + return { + getSync: (key: string): string | null => values[key] ?? null, + setSync: (key: string, value: string): void => { + values[key] = value; + }, + deleteSync: (key: string): void => { + delete values[key]; + }, + updateKeySync: ( + key: string, + mutator: (current: string | null) => string | null | undefined, + ): void => { + const next = mutator(values[key] ?? null); + if (next === undefined) return; + if (next === null) delete values[key]; + else values[key] = next; + }, + }; +} + +/** + * A store whose Nth atomic update throws, counting from the first one. + * + * Counting is how these suites name one specific write. Update 1 takes the + * refresh lease, update 2 records the outcome, update 3 is the immediate retry + * of that outcome write, and anything after belongs to the next call. Several + * numbers may fail at once, which is what a store that refuses an outcome write + * AND its retry looks like. + */ +function createStoreFailingUpdates(values: StoredValues, ...failAt: number[]) { + const base = createFakeStore(values); + const failing = new Set(failAt); + let updates = 0; + return { + ...base, + updateKeySync: ( + key: string, + mutator: (current: string | null) => string | null | undefined, + ): void => { + updates += 1; + if (failing.has(updates)) throw new Error("credential store write failed"); + base.updateKeySync(key, mutator); + }, + }; +} + +/** + * A store that refuses every write carrying a NEW credential while + * `gate.accepts` is false, and accepts the ledger-only writes around it — the + * lease being taken, and the lease being handed back. + * + * The counting store above cannot express that: the retry of a refused outcome + * write and the lease release that would follow it are the same update number, + * so a counter can never say which of the two a run actually skipped. The gate + * is what makes the store recover mid-test, the way a held file lock or a + * momentary permission error clears on the machine. + */ +function createStoreGatingCredentialWrites( + values: StoredValues, + gate: { accepts: boolean }, +) { + const base = createFakeStore(values); + return { + ...base, + updateKeySync: ( + key: string, + mutator: (current: string | null) => string | null | undefined, + ): void => { + base.updateKeySync(key, (current) => { + const next = mutator(current); + if (next == null) return next; + const before = current ? JSON.parse(current) as Record : null; + const after = JSON.parse(next) as Record; + if (!gate.accepts && before?.accessToken !== after.accessToken) { + throw new Error("credential store write failed"); + } + return next; + }); + }, + }; +} + +/** The gating store above, with the gate shut for the whole test. */ +function createStoreRefusingCredentialWrites(values: StoredValues) { + return createStoreGatingCredentialWrites(values, { accepts: false }); +} + +/** + * A store whose Nth atomic update LANDS its write and then throws. + * + * The one failure the counting store cannot express, and the one that misled a + * reader: from the caller's side it is indistinguishable from a write that + * never happened, so the retry finds this process's own record on disk and + * declines — which used to be reported as a peer superseding the refresh. + */ +function createStoreWritingThenFailing(values: StoredValues, failAt: number) { + const base = createFakeStore(values); + let updates = 0; + return { + ...base, + updateKeySync: ( + key: string, + mutator: (current: string | null) => string | null | undefined, + ): void => { + updates += 1; + base.updateKeySync(key, mutator); + if (updates === failAt) throw new Error("credential store write failed"); + }, + }; +} + +function createLogger() { + return { info: vi.fn(), warn: vi.fn() }; +} + +function storedRecord(values: StoredValues): Record | null { + const raw = values[TOKEN_KEY]; + return raw ? JSON.parse(raw) as Record : null; +} + +function writeRecord( + values: StoredValues, + patch: Partial = {}, +): void { + // The shared fixture already holds the two facts every test here needs: an + // access token past its life next to a live refresh token. Only the token + // strings are spelled out, because the assertions name them. + values[TOKEN_KEY] = makeStoredAppUserToken({ + accessToken: "ghu_old", + refreshToken: "ghr_live", + ...patch, + }); +} + +/** Writes the record with a refresh ledger already in it. */ +function writeRecordWithLedger(values: StoredValues, ledger: Record): void { + writeRecord(values); + values[TOKEN_KEY] = JSON.stringify({ + ...JSON.parse(values[TOKEN_KEY]!) as Record, + refresh: ledger, + }); +} + +function jsonResponse( + body: unknown, + init: { status?: number; headers?: Record } = {}, +): Response { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { "content-type": "application/json", ...(init.headers ?? {}) }, + }); +} + +const TOKEN_URL = "https://github.com/login/oauth/access_token"; +const DEVICE_CODE_URL = "https://github.com/login/device/code"; + +function tokenPostCount(fetchImpl: { mock: { calls: unknown[][] } }): number { + return fetchImpl.mock.calls.filter((call) => String(call[0]) === TOKEN_URL).length; +} + +/** A refresh endpoint that rotates the token and kills the one it replaced. */ +function createRotatingRefreshEndpoint() { + let live = "ghr_live"; + let rotations = 0; + return { + get rotations() { + return rotations; + }, + respond(body: string | null): Response { + const sent = new URLSearchParams(body ?? "").get("refresh_token") ?? ""; + if (sent !== live) { + return jsonResponse({ + error: "bad_refresh_token", + error_description: "The refresh token passed is incorrect or expired.", + }); + } + rotations += 1; + live = `ghr_rotated_${rotations}`; + return jsonResponse({ + access_token: `ghu_fresh_${rotations}`, + token_type: "bearer", + expires_in: 28_800, + refresh_token: live, + refresh_token_expires_in: 15_811_200, + }); + }, + }; +} + +let clockNowMs = Date.parse("2026-08-20T12:00:00.000Z"); +const now = (): number => clockNowMs; +const advance = (ms: number): void => { + clockNowMs += ms; +}; +// Yields to the event loop without spending wall-clock time, so a coordinator +// that waits for a peer's write still makes progress inside a test. +const sleep = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +let identityCounter = 0; +function nextIdentity(): string { + identityCounter += 1; + return `test-store-${identityCounter}`; +} + +beforeEach(() => { + clockNowMs = Date.parse("2026-08-20T12:00:00.000Z"); + vi.spyOn(Date, "now").mockImplementation(() => clockNowMs); + resetGitHubAppUserAuthCoordinatorsForTests(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("app user token refresh", () => { + it("stops retrying a refresh token GitHub has rejected", async () => { + const values: StoredValues = {}; + writeRecord(values); + const fetchImpl = vi.fn(async () => jsonResponse({ + error: "bad_refresh_token", + error_description: "The refresh token passed is incorrect or expired.", + })); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + // The record survives so the UI can say "re-authorize" instead of "never + // connected", which is the difference between a fixable state and a mystery. + expect(storedRecord(values)).not.toBeNull(); + expect(service.getAuthStatus().credentialState).toBe("needs_reauth"); + }); + + it("pauses refreshes for the retry-after window GitHub asked for", async () => { + const values: StoredValues = {}; + writeRecord(values); + const fetchImpl = vi.fn(async () => jsonResponse( + { error: "too_many_requests" }, + { status: 429, headers: { "retry-after": "120" } }, + )); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + const status = service.getAuthStatus(); + expect(status.credentialState).toBe("blocked"); + expect(status.lastRefreshError?.kind).toBe("rate_limited"); + expect(Date.parse(status.refreshBlockedUntil ?? "")).toBe(clockNowMs + 120_000); + + advance(119_000); + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + advance(2_000); + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("keeps a whole hour of demand under eight refresh requests", async () => { + const values: StoredValues = {}; + writeRecord(values); + const fetchImpl = vi.fn(async () => jsonResponse( + { error: "server_error" }, + { status: 503 }, + )); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + // The relay poller asks every 30 seconds; an hour of that is 120 demands. + for (let attempt = 0; attempt < 120; attempt += 1) { + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + advance(30_000); + } + + expect(fetchImpl.mock.calls.length).toBeLessThanOrEqual(8); + expect(fetchImpl.mock.calls.length).toBeGreaterThan(1); + }); + + it("sends one refresh for every service instance in a process", async () => { + const values: StoredValues = {}; + writeRecord(values); + const endpoint = createRotatingRefreshEndpoint(); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => endpoint.respond( + typeof init?.body === "string" ? init.body : null, + )); + const identity = nextIdentity(); + const store = createFakeStore(values); + const build = () => createGitHubAppUserAuthService({ + credentialStore: store, + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: identity, + now, + sleep, + }); + // One service per project scope is what the desktop app and the brain both + // build, and they all read the same credential record. + const services = [build(), build(), build(), build()]; + + const tokens = await Promise.all(services.map((service) => service.getValidTokenForRelay())); + + expect(endpoint.rotations).toBe(1); + expect(tokenPostCount(fetchImpl)).toBe(1); + expect(new Set(tokens)).toEqual(new Set(["ghu_fresh_1"])); + }); + + it("makes a second process wait for the refresh a peer is already running", async () => { + const values: StoredValues = {}; + writeRecord(values); + const endpoint = createRotatingRefreshEndpoint(); + let releaseWinner: () => void = () => undefined; + const winnerReleased = new Promise((resolve) => { + releaseWinner = resolve; + }); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { + const body = typeof init?.body === "string" ? init.body : null; + await winnerReleased; + return endpoint.respond(body); + }); + const build = (identity: string) => createGitHubAppUserAuthService({ + // A separate store object per process: same file, separate memory. + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: identity, + now, + sleep, + }); + + const brain = build(nextIdentity()); + const desktop = build(nextIdentity()); + const winner = brain.getValidTokenForRelay(); + // Let the winner take the lease before the peer looks at the record. + await Promise.resolve(); + const loser = desktop.getValidTokenForRelay(); + releaseWinner(); + + await expect(winner).resolves.toBe("ghu_fresh_1"); + await expect(loser).resolves.toBe("ghu_fresh_1"); + // A second POST would have carried the refresh token the first one just + // rotated away, and GitHub answers that by killing the credential. + expect(tokenPostCount(fetchImpl)).toBe(1); + expect(endpoint.rotations).toBe(1); + }); + + // The "I re-authorized and nothing changed" incident. A refresh POST was + // already in flight when the user finished the device flow, and its + // bad_refresh_token answer — about the credential that had just been + // REPLACED — was written over the brand-new one, which put the account + // straight back into "re-authorize". + it("keeps a credential the device flow just wrote when the older refresh POST is rejected", async () => { + const values: StoredValues = {}; + writeRecord(values); + let releaseRefresh: () => void = () => undefined; + const refreshReleased = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + const body = typeof init?.body === "string" ? init.body : ""; + if (String(url) === DEVICE_CODE_URL) { + return jsonResponse({ + device_code: "dev_code", + user_code: "ADE-CODE", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 1, + }); + } + if (String(url) === TOKEN_URL && body.includes("grant_type=refresh_token")) { + await refreshReleased; + return jsonResponse({ + error: "bad_refresh_token", + error_description: "The refresh token passed is incorrect or expired.", + }); + } + if (String(url) === TOKEN_URL) { + return jsonResponse({ + access_token: "ghu_after_reauth", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_after_reauth", + refresh_token_expires_in: 15_811_200, + }); + } + return jsonResponse({ login: "octocat" }); + }); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + const inFlight = service.getValidTokenForRelay(); + // Let the refresh POST reach the (blocked) endpoint before the user + // finishes authorizing. + await sleep(); + const session = await service.startDeviceAuth(); + const authorized = await service.pollDeviceAuth({ sessionId: session.sessionId }); + expect(authorized.status).toBe("authorized"); + releaseRefresh(); + + await expect(inFlight).resolves.toBe("ghu_after_reauth"); + const status = service.getAuthStatus(); + expect(status.credentialState).toBe("authorized"); + expect(status.lastRefreshError).toBeNull(); + expect(storedRecord(values)).toMatchObject({ accessToken: "ghu_after_reauth" }); + }); + + // The same race with a SUCCESSFUL POST, and with the newer credential written + // by a peer process — so nothing in this process knows the record changed and + // the refresh token itself is the only evidence. + it("does not write a successful refresh over a credential a peer authorized meanwhile", async () => { + const values: StoredValues = {}; + writeRecord(values); + let releaseRefresh: () => void = () => undefined; + const refreshReleased = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const fetchImpl = vi.fn(async () => { + await refreshReleased; + return jsonResponse({ + access_token: "ghu_from_stale_refresh", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_from_stale_refresh", + refresh_token_expires_in: 15_811_200, + }); + }); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + const inFlight = service.getValidTokenForRelay(); + // Let the refresh POST reach the (blocked) endpoint, then let another + // process finish its own device flow into the shared credential file. + await sleep(); + writeRecord(values, { + accessToken: "ghu_after_peer_reauth", + expiresAt: new Date(clockNowMs + 8 * 3_600_000).toISOString(), + refreshToken: "ghr_after_peer_reauth", + }); + releaseRefresh(); + + await expect(inFlight).resolves.toBe("ghu_after_peer_reauth"); + expect(storedRecord(values)).toMatchObject({ accessToken: "ghu_after_peer_reauth" }); + }); + + it("does not resurrect a credential another instance cleared", async () => { + const values: StoredValues = {}; + writeRecord(values, { expiresAt: new Date(clockNowMs + 3_600_000).toISOString() }); + const store = createFakeStore(values); + const fetchImpl = vi.fn(async () => jsonResponse({})); + const service = createGitHubAppUserAuthService({ + credentialStore: store, + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + expect(service.getStoredTokenForHealth()).toBe("ghu_old"); + // Another process signed out; the store is the truth, not this instance's + // last read of it. + delete values[TOKEN_KEY]; + + expect(service.getStoredTokenForHealth()).toBeNull(); + expect(service.getAuthStatus().tokenStored).toBe(false); + expect(service.getAuthStatus().credentialState).toBe("missing"); + }); + + it("refuses a lapsed access token whose refresh token has expired", async () => { + // The refresh POST succeeds, but a peer replaced the credential while it + // was in flight, so the write is declined and the store's own record is + // served instead. That record cannot be renewed by anyone, so the only + // honest answer is "re-authorize": handing back its lapsed access token + // produced a GitHub 401 the user had no way to act on. + const values: StoredValues = {}; + writeRecord(values); + const fetchImpl = vi.fn(async (input: string) => { + if (String(input) !== TOKEN_URL) return jsonResponse({}); + values[TOKEN_KEY] = makeStoredAppUserToken({ + accessToken: "ghu_replaced_and_lapsed", + refreshToken: "ghr_replaced", + expiresAt: new Date(clockNowMs - 60_000).toISOString(), + refreshTokenExpiresAt: new Date(clockNowMs - 3_600_000).toISOString(), + }); + return jsonResponse({ + access_token: "ghu_fresh", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_rotated", + refresh_token_expires_in: 15_811_200, + }); + }); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + await expect(service.getValidTokenForRelay()).rejects.toThrow(/Re-authorize ADE with GitHub/); + expect(service.getAuthStatus().credentialState).toBe("needs_reauth"); + }); +}); + +// Every deadline in the ledger is written by a peer process, and a peer whose +// clock is wrong writes one no amount of waiting reaches. Honouring it locks +// every process on the machine out of the refresh until someone deletes the +// credential file by hand. +describe("poisoned deadlines in the shared ledger", () => { + it("ignores a refresh lease stamped an hour ahead of the longest real one", async () => { + const values: StoredValues = {}; + writeRecordWithLedger(values, { + leaseUntil: new Date(clockNowMs + 3_600_000).toISOString(), + leaseHolder: "peer-with-a-wrong-clock", + }); + const endpoint = createRotatingRefreshEndpoint(); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => endpoint.respond( + typeof init?.body === "string" ? init.body : null, + )); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_fresh_1"); + expect(tokenPostCount(fetchImpl)).toBe(1); + }); + + it("ignores a refresh backoff stamped a year ahead", async () => { + const values: StoredValues = {}; + writeRecordWithLedger(values, { + notBeforeAt: new Date(clockNowMs + 365 * 24 * 3_600_000).toISOString(), + consecutiveFailures: 1, + lastFailure: { + kind: "rate_limited", + message: "GitHub paused this.", + status: 429, + at: new Date(clockNowMs).toISOString(), + }, + }); + const endpoint = createRotatingRefreshEndpoint(); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => endpoint.respond( + typeof init?.body === "string" ? init.body : null, + )); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + expect(service.getAuthStatus().credentialState).toBe("authorized"); + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_fresh_1"); + }); + + it("still honours a deadline inside the bounds ADE writes", async () => { + const values: StoredValues = {}; + writeRecordWithLedger(values, { + notBeforeAt: new Date(clockNowMs + 120_000).toISOString(), + consecutiveFailures: 1, + lastFailure: { + kind: "rate_limited", + message: "GitHub paused this.", + status: 429, + at: new Date(clockNowMs).toISOString(), + }, + }); + const fetchImpl = vi.fn(async () => jsonResponse({})); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + expect(service.getAuthStatus().credentialState).toBe("blocked"); + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +describe("refresh lease release", () => { + const freshTokenEndpoint = () => vi.fn(async () => jsonResponse({ + access_token: "ghu_fresh", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_rotated", + refresh_token_expires_in: 15_811_200, + })); + + const buildService = ( + store: GitHubAppUserAuthCredentialStore, + fetchImpl: unknown, + logger = createLogger(), + ) => createGitHubAppUserAuthService({ + credentialStore: store, + logger, + fetchImpl: fetchImpl as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + // The lease is written before the POST and cleared by whichever outcome gets + // recorded. A credential store that throws while recording a FAILURE records + // nothing — and used to leave the lease behind, which stalls every other + // process for the full minute for no reason at all. Nothing was spent at + // GitHub on this path, so there is no reason to keep peers out. + it("hands the lease back when the store refuses to record a failed refresh", async () => { + const values: StoredValues = {}; + writeRecord(values); + const service = buildService( + // Update 2 is the failure write; update 3 is the release it falls back on. + createStoreFailingUpdates(values, 2), + vi.fn(async () => { + throw new Error("network down"); + }), + ); + + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + + const ledger = storedRecord(values)?.refresh as Record | undefined; + expect(ledger?.leaseUntil).toBeNull(); + expect(ledger?.leaseHolder).toBeNull(); + }); + + /** + * A credential-store write fails for reasons that pass in milliseconds — a + * peer holding the file lock, a momentary permission error. The POST has + * already spent the stored refresh token by then, so the difference between + * one attempt and two is the difference between a durable record and a spent + * token left on disk. + */ + it("retries the outcome write once and lands it", async () => { + const values: StoredValues = {}; + writeRecord(values); + const logger = createLogger(); + // Update 2 is the first outcome write; update 3 is its retry, and passes. + const service = buildService( + createStoreFailingUpdates(values, 2), + freshTokenEndpoint(), + logger, + ); + + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_fresh"); + + expect(storedRecord(values)).toMatchObject({ + accessToken: "ghu_fresh", + refreshToken: "ghr_rotated", + }); + const ledger = storedRecord(values)?.refresh as Record | undefined; + expect(ledger?.leaseUntil).toBeNull(); + expect(ledger?.leaseHolder).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + "github.app_user_token_refresh_persist_retried", + expect.objectContaining({ error: "credential store write failed" }), + ); + // The retry landed, so nothing is being held back for a later write. + expect(logger.info).not.toHaveBeenCalledWith( + "github.app_user_token_refresh_unpersisted", + expect.anything(), + ); + }); + + /** + * The POST spends the old refresh token at GitHub whether or not the store + * can hold the answer. Treating the failed write as a REFRESH failure stamped + * backoff onto the spent token and threw the fresh one away, so the next POST + * replayed a spent token — which is how GitHub decides to revoke the whole + * credential. + */ + it("serves the credential GitHub issued when the store cannot record it", async () => { + const values: StoredValues = {}; + writeRecord(values); + const logger = createLogger(); + // Both the outcome write and its retry fail. + const service = buildService( + createStoreFailingUpdates(values, 2, 3), + freshTokenEndpoint(), + logger, + ); + + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_fresh"); + + // The write really did fail, so the spent token is still what is on disk. + const stored = storedRecord(values); + expect(stored?.accessToken).toBe("ghu_old"); + // And nothing about it was condemned: no backoff, no dead flag, no failure. + const ledger = stored?.refresh as Record | undefined; + expect(ledger?.notBeforeAt).toBeNull(); + expect(ledger?.dead).not.toBe(true); + expect(ledger?.lastFailure).toBeNull(); + expect(ledger?.consecutiveFailures).toBe(0); + // Reported once, at info: the store failure itself is already warned about + // by `github.app_user_token_write_failed` one level down. + expect(logger.info).toHaveBeenCalledWith( + "github.app_user_token_refresh_unpersisted", + expect.objectContaining({ error: "credential store write failed" }), + ); + expect(logger.warn).not.toHaveBeenCalledWith( + "github.app_user_token_refresh_failed", + expect.anything(), + ); + }); + + /** + * The one case where the lease must NOT be handed back. + * + * The exchange succeeded, so the refresh token on disk is already spent, and + * the record that replaces it exists only in this process's memory. Releasing + * the lease here lets a peer take it, read that spent token, and POST it — + * the exact double-spend the lease exists to prevent, and the one GitHub + * answers by revoking the credential. + */ + it("keeps the lease when its replacement never reached the store", async () => { + const values: StoredValues = {}; + writeRecord(values); + const identity = nextIdentity(); + const service = createGitHubAppUserAuthService({ + // Content-based, not counted: a counter cannot tell the RETRY of a + // refused outcome write apart from the lease release that would follow + // it, so counting proves nothing about which of the two was skipped. + credentialStore: createStoreRefusingCredentialWrites(values), + logger: createLogger(), + fetchImpl: freshTokenEndpoint() as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: identity, + now, + sleep, + }); + + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_fresh"); + + const ledger = storedRecord(values)?.refresh as Record | undefined; + expect(ledger?.leaseHolder).toEqual(expect.any(String)); + expect(Date.parse(String(ledger?.leaseUntil))).toBeGreaterThan(now()); + + // A PEER process — its own service instance, its own lease holder id, its + // own coordinator — must be refused for as long as this lease stands. + const peerFetch = vi.fn(async () => jsonResponse({ + error: "bad_refresh_token", + error_description: "The refresh token passed is incorrect or expired.", + })); + const peer = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: peerFetch as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: `${identity}-peer`, + now, + sleep, + }); + await expect(peer.getValidTokenForRelay()).rejects.toThrow(); + // The whole point: the peer never reached GitHub at all, so the spent token + // was never POSTed a second time and the credential is still alive. + expect(peerFetch).not.toHaveBeenCalled(); + expect(storedRecord(values)?.refreshToken).toBe("ghr_live"); + expect(peer.getAuthStatus().credentialState).not.toBe("needs_reauth"); + }); +}); + +/** + * The credential-killing sequence this whole ledger exists to stop. + * + * GitHub spends the refresh token the moment it answers the POST. When the + * write that records the replacement fails, the machine file is left holding a + * token that is already dead — and the next call reads that file, POSTs the + * dead token, and GitHub answers `bad_refresh_token`. That answer is not a + * transient failure: it is the credential gone, and the user is asked to + * re-authorize a session nobody revoked. + */ +describe("refresh whose replacement never reached the store", () => { + /** + * Fails the write that records a successful refresh AND its immediate retry, + * then recovers. Updates 2 and 3 are that write and that retry. + */ + const createStoreFailingOneOutcomeWrite = (values: StoredValues) => + createStoreFailingUpdates(values, 2, 3); + + const buildService = ( + store: GitHubAppUserAuthCredentialStore, + fetchImpl: unknown, + identity: string, + ) => createGitHubAppUserAuthService({ + credentialStore: store, + logger: createLogger(), + fetchImpl: fetchImpl as typeof fetch, + userAgent: "ade-test", + storeIdentity: identity, + now, + sleep, + }); + + it("never POSTs the refresh token it already spent, and persists the one it holds", async () => { + const values: StoredValues = {}; + writeRecord(values); + const endpoint = createRotatingRefreshEndpoint(); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => endpoint.respond( + typeof init?.body === "string" ? init.body : null, + )); + const service = buildService( + createStoreFailingOneOutcomeWrite(values), + fetchImpl, + nextIdentity(), + ); + + // Call 1 refreshes, and the store refuses to record the result. + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_fresh_1"); + expect(storedRecord(values)?.accessToken).toBe("ghu_old"); + + // Call 2 reads that same spent token off disk. It must NOT be POSTed: this + // endpoint answers a reused token exactly as GitHub does, with + // `bad_refresh_token`, which is the credential dying. + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_fresh_1"); + + expect(endpoint.rotations).toBe(1); + expect(tokenPostCount(fetchImpl)).toBe(1); + // The store recovered, so the record this process was holding is on disk. + expect(storedRecord(values)).toMatchObject({ + accessToken: "ghu_fresh_1", + refreshToken: "ghr_rotated_1", + }); + expect(service.getAuthStatus().credentialState).toBe("authorized"); + }); + + it("drops the held record when the user signs out between the two calls", async () => { + const values: StoredValues = {}; + writeRecord(values); + const endpoint = createRotatingRefreshEndpoint(); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => endpoint.respond( + typeof init?.body === "string" ? init.body : null, + )); + const service = buildService( + createStoreFailingOneOutcomeWrite(values), + fetchImpl, + nextIdentity(), + ); + + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_fresh_1"); + service.clearAuth(); + + // A signed-out machine has no credential to serve, and the record this + // process was holding must not resurrect the one the user just cleared. + await expect(service.getValidTokenForRelay()).rejects.toThrow(/Authorize the ADE GitHub App/); + expect(storedRecord(values)).toBeNull(); + expect(tokenPostCount(fetchImpl)).toBe(1); + }); + + /** + * The held-back record belongs to the PROCESS, so any service instance over + * the same credential file has to be able to finish it. + * + * The lease holder id used to belong to the instance instead. The instance + * that took the lease was then the only one whose holder check could pass, so + * a sibling — one of the several project scopes the desktop app and the brain + * each build — read its own process's lease as a peer's, waited it out, and + * threw `blocked`. The spent refresh token stayed on disk for the whole lease + * while the live credential sat in memory one instance away. + */ + it("lets a sibling instance finish the refresh its own process is holding", async () => { + const values: StoredValues = {}; + writeRecord(values); + const endpoint = createRotatingRefreshEndpoint(); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => endpoint.respond( + typeof init?.body === "string" ? init.body : null, + )); + const identity = nextIdentity(); + const gate = { accepts: false }; + // One credential file, one process: the two instances share both. + const store = createStoreGatingCredentialWrites(values, gate); + const first = buildService(store, fetchImpl, identity); + const second = buildService(store, fetchImpl, identity); + + // GitHub answered, so the stored refresh token is spent — and the store + // refused to record the credential that replaces it. + await expect(first.getValidTokenForRelay()).resolves.toBe("ghu_fresh_1"); + expect(storedRecord(values)?.refreshToken).toBe("ghr_live"); + gate.accepts = true; + + // The sibling must recover, not wait: it is the same process. + await expect(second.getValidTokenForRelay()).resolves.toBe("ghu_fresh_1"); + + // A second POST would carry the spent token, and GitHub answers that with + // `bad_refresh_token` — the credential dying for good. + expect(tokenPostCount(fetchImpl)).toBe(1); + expect(endpoint.rotations).toBe(1); + expect(storedRecord(values)).toMatchObject({ + accessToken: "ghu_fresh_1", + refreshToken: "ghr_rotated_1", + }); + const ledger = storedRecord(values)?.refresh as Record | undefined; + expect(ledger?.leaseUntil).toBeNull(); + expect(ledger?.leaseHolder).toBeNull(); + }); + + /** + * The mirror case: the write that DID land, and then threw. + * + * Nothing is held back here — the record is on disk. The retry finds it, + * declines because the stored refresh token is no longer the one that was + * POSTed, and that decline must not be reported as a peer's credential + * arriving. It sent readers of this log hunting for a second writer that was + * never there. + */ + it("names its own landed write rather than blaming a peer for it", async () => { + const values: StoredValues = {}; + writeRecord(values); + const logger = createLogger(); + const endpoint = createRotatingRefreshEndpoint(); + const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => endpoint.respond( + typeof init?.body === "string" ? init.body : null, + )); + const service = createGitHubAppUserAuthService({ + // Update 1 takes the lease; update 2 is the outcome write that lands and + // then reports failure. + credentialStore: createStoreWritingThenFailing(values, 2), + logger, + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_fresh_1"); + + expect(storedRecord(values)).toMatchObject({ + accessToken: "ghu_fresh_1", + refreshToken: "ghr_rotated_1", + }); + expect(tokenPostCount(fetchImpl)).toBe(1); + expect(logger.info).toHaveBeenCalledWith( + "github.app_user_token_refresh_superseded", + expect.objectContaining({ supersededBy: "own_first_attempt" }), + ); + // The record reached the store, so nothing is being held back for later. + expect(logger.info).not.toHaveBeenCalledWith( + "github.app_user_token_refresh_unpersisted", + expect.anything(), + ); + expect(service.getAuthStatus().credentialState).toBe("authorized"); + }); +}); + +describe("refresh failure classification", () => { + const buildService = (values: StoredValues, fetchImpl: unknown) => + createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + // GitHub's secondary rate limits answer 403, sometimes with no body and no + // retry-after at all. Reading that as a dead grant signed working accounts + // out and told them to re-authorize against the endpoint doing the limiting. + it("treats a bare 403 as a pause rather than a dead credential", async () => { + const values: StoredValues = {}; + writeRecord(values); + const service = buildService(values, vi.fn(async () => new Response("", { status: 403 }))); + + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + + const status = service.getAuthStatus(); + expect(status.credentialState).toBe("blocked"); + expect(status.lastRefreshError?.kind).toBe("rate_limited"); + expect(Date.parse(status.refreshBlockedUntil ?? "")).toBeGreaterThan(clockNowMs); + }); + + /** + * The refresh lease is what stops a peer POSTing the same refresh token, and + * it expires on a timer. A request nobody bounds outlives that lease and + * becomes the second POST the lease exists to prevent, so the exchange gives + * up first — and giving up says nothing about the credential, which makes it + * a transient network failure and never a dead one. + */ + it("gives up on a refresh that outlives its budget, and blames the network", async () => { + const values: StoredValues = {}; + writeRecord(values); + const neverResolves = vi.fn((_url: string, init?: RequestInit) => new Promise( + (_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); + }, + )); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: neverResolves as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + refreshTimeoutMs: 10, + }); + + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + + const status = service.getAuthStatus(); + expect(status.credentialState).toBe("blocked"); + expect(status.lastRefreshError?.kind).toBe("network"); + expect(status.lastRefreshError?.message).toContain("within 10 ms"); + // The refresh token is still the live one: a request ADE abandoned is not + // evidence that GitHub rejected anything. + expect(storedRecord(values)?.refreshToken).toBe("ghr_live"); + }); + + // A proxy or captive portal answers with HTML and a 4xx. Nothing about that + // is evidence against the credential. + it("treats a 400 with a non-OAuth body as unknown rather than a dead credential", async () => { + const values: StoredValues = {}; + writeRecord(values); + const service = buildService(values, vi.fn(async () => new Response( + "Bad Request", + { status: 400, headers: { "content-type": "text/html" } }, + ))); + + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + + const status = service.getAuthStatus(); + expect(status.credentialState).toBe("blocked"); + expect(status.lastRefreshError?.kind).toBe("unknown"); + }); + + it("treats a 200 carrying bad_refresh_token as a dead credential", async () => { + const values: StoredValues = {}; + writeRecord(values); + const service = buildService(values, vi.fn(async () => jsonResponse({ + error: "bad_refresh_token", + error_description: "The refresh token passed is incorrect or expired.", + }))); + + await expect(service.getValidTokenForRelay()).rejects.toThrow(); + + const status = service.getAuthStatus(); + expect(status.credentialState).toBe("needs_reauth"); + expect(status.lastRefreshError?.kind).toBe("dead_token"); + }); +}); + +describe("app user auth status", () => { + it("reports a stale access token with a live refresh token as authorized", async () => { + const values: StoredValues = {}; + writeRecord(values); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: (async () => jsonResponse({})) as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + const status = service.getAuthStatus(); + expect(status.credentialState).toBe("authorized"); + expect(status.refreshBlockedUntil).toBeNull(); + expect(status.lastRefreshError).toBeNull(); + }); + + it("keeps serving a still-fresh token that has no renewal left", async () => { + // A refresh token can expire, or be revoked, while the access token it last + // minted is still good — and this one lapses in an hour. The token WORKS, so + // every gate hands it out; the status axis is where ADE asks for a + // replacement, hours before it lapses. + const values: StoredValues = {}; + writeRecord(values, { + accessToken: "ghu_fresh_but_final", + expiresAt: new Date(clockNowMs + 3_600_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + }); + const fetchImpl = vi.fn(async () => jsonResponse({})); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_fresh_but_final"); + expect(tokenPostCount(fetchImpl)).toBe(0); + expect(service.getAuthStatus().credentialState).toBe("needs_reauth"); + }); + + it("treats a non-expiring token with no refresh token as authorized", async () => { + // An App configured for NON-EXPIRING user tokens stores neither an expiry + // nor a refresh token. That credential never lapses, so the status axis + // must not ask the user to replace it. + const values: StoredValues = {}; + writeRecord(values, { + accessToken: "ghu_never_expires", + expiresAt: null, + refreshToken: null, + refreshTokenExpiresAt: null, + }); + const fetchImpl = vi.fn(async () => jsonResponse({})); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + await expect(service.getValidTokenForRelay()).resolves.toBe("ghu_never_expires"); + expect(tokenPostCount(fetchImpl)).toBe(0); + expect(service.getAuthStatus().credentialState).toBe("authorized"); + }); + + it("reports an expired refresh token as needing re-authorization", async () => { + const values: StoredValues = {}; + writeRecord(values, { + refreshTokenExpiresAt: new Date(clockNowMs - 3_600_000).toISOString(), + }); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: (async () => jsonResponse({})) as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + expect(service.getAuthStatus().credentialState).toBe("needs_reauth"); + }); +}); + +describe("judgeStoredAuth", () => { + // The ladder four gates share. They used to run their own copies in three + // different orders, and the orders disagreed: one handed out a lapsed access + // token whose refresh token had already expired. + const NOW_MS = Date.parse("2026-08-20T12:00:00.000Z"); + const iso = (offsetMs: number): string => new Date(NOW_MS + offsetMs).toISOString(); + + function stored( + token: Partial | null, + ledger: Partial> = {}, + ): StoredAppUserAuth { + return { + token: token + ? { + accessToken: "ghu_access", + tokenType: "bearer", + scope: null, + expiresAt: iso(3_600_000), + refreshToken: "ghr_live", + refreshTokenExpiresAt: iso(30 * 86_400_000), + userLogin: "alice", + updatedAt: iso(-60_000), + ...token, + } + : null, + refresh: { ...emptyLedger(), ...ledger }, + }; + } + + it("reports missing when nothing is stored", () => { + expect(judgeStoredAuth(stored(null), NOW_MS)).toEqual({ outcome: "missing" }); + }); + + it("serves a fresh token and says it can still be renewed", () => { + const verdict = judgeStoredAuth(stored({}), NOW_MS); + + expect(verdict.outcome).toBe("fresh"); + if (verdict.outcome !== "fresh") throw new Error("expected fresh"); + expect(verdict.record.accessToken).toBe("ghu_access"); + expect(verdict.renewableRecord?.refreshToken).toBe("ghr_live"); + }); + + it("serves a fresh token with no renewal left, and says so", () => { + // The token WORKS, so no gate may withhold it — but the status axis has to + // ask for a replacement before it lapses. + const verdict = judgeStoredAuth( + stored({ refreshToken: null, refreshTokenExpiresAt: null }), + NOW_MS, + ); + + expect(verdict.outcome).toBe("fresh"); + if (verdict.outcome !== "fresh") throw new Error("expected fresh"); + expect(verdict.renewableRecord).toBeNull(); + }); + + it("reports needs_reauth for a lapsed token whose refresh token expired", () => { + const failure = { + kind: "dead_token" as const, + message: "bad credentials", + status: 401, + oauthError: null, + retryAfterSec: null, + at: iso(-1_000), + }; + const verdict = judgeStoredAuth( + stored( + { expiresAt: iso(-60_000), refreshTokenExpiresAt: iso(-1_000) }, + { lastFailure: failure }, + ), + NOW_MS, + ); + + expect(verdict).toEqual({ outcome: "needs_reauth", failure }); + }); + + it("reports needs_reauth for a lapsed token the ledger already declared dead", () => { + const verdict = judgeStoredAuth( + stored({ expiresAt: iso(-60_000) }, { dead: true }), + NOW_MS, + ); + + expect(verdict.outcome).toBe("needs_reauth"); + }); + + it("reports blocked while a refresh backoff deadline has not passed", () => { + const verdict = judgeStoredAuth( + stored({ expiresAt: iso(-60_000) }, { notBeforeAt: iso(30_000) }), + NOW_MS, + ); + + expect(verdict).toMatchObject({ outcome: "blocked", retryAt: iso(30_000) }); + }); + + it("reports refreshable for a lapsed token with a healthy refresh token", () => { + const verdict = judgeStoredAuth(stored({ expiresAt: iso(-60_000) }), NOW_MS); + + expect(verdict.outcome).toBe("refreshable"); + if (verdict.outcome !== "refreshable") throw new Error("expected refreshable"); + expect(verdict.record.refreshToken).toBe("ghr_live"); + }); + + it("treats an access token inside the refresh skew as lapsed", () => { + // The skew exists so ADE renews BEFORE the token stops working, rather than + // handing out one that expires mid-request. + expect(judgeStoredAuth(stored({ expiresAt: iso(30_000) }), NOW_MS).outcome) + .toBe("refreshable"); + }); +}); + +async function captureError(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + expect(error).toBeInstanceOf(GitHubOAuthError); + return error as GitHubOAuthError; + } + throw new Error("Expected the call to reject."); +} + +describe("refreshGitHubAppUserToken", () => { + it("rejects an HTTP-200 OAuth error body as a definitive dead-token failure", async () => { + // GitHub answers a rejected refresh token with HTTP 200 and an error body, + // which is why "did the response parse" is not the same question as "did the + // refresh work". + const fetchImpl = async (): Promise => jsonResponse({ + error: "bad_refresh_token", + error_description: "The refresh token passed is incorrect or expired.", + error_uri: "https://docs.github.com/apps", + }); + + const error = await captureError(() => refreshGitHubAppUserToken({ + refreshToken: "ghr_dead", + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + })); + + expect(error.oauthError).toBe("bad_refresh_token"); + expect(error.status).toBe(200); + expect(error.message).toContain("refresh token"); + }); + + it("carries the status and retry-after of a rate-limited refresh", async () => { + const fetchImpl = async (): Promise => jsonResponse( + { error: "too_many_requests", error_description: "You have exceeded a secondary rate limit." }, + { status: 429, headers: { "retry-after": "120" } }, + ); + + const error = await captureError(() => refreshGitHubAppUserToken({ + refreshToken: "ghr_live", + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + })); + + expect(error.status).toBe(429); + expect(error.retryAfterSec).toBe(120); + expect(error.oauthError).toBe("too_many_requests"); + }); + + it("returns the rotated refresh token when GitHub rotates it", async () => { + const fetchImpl = async (): Promise => jsonResponse({ + access_token: "ghu_new", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_rotated", + refresh_token_expires_in: 15_811_200, + }); + + const record = await refreshGitHubAppUserToken({ + refreshToken: "ghr_old", + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + }); + + expect(record.accessToken).toBe("ghu_new"); + expect(record.refreshToken).toBe("ghr_rotated"); + }); +}); + +describe("device flow transport", () => { + it("carries the status and retry-after of a rate-limited device-code request", async () => { + const fetchImpl = async (): Promise => jsonResponse( + { error: "too_many_requests" }, + { status: 429, headers: { "retry-after": "60" } }, + ); + + const error = await captureError(() => startGitHubAppDeviceFlow({ + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + })); + + expect(error.status).toBe(429); + expect(error.retryAfterSec).toBe(60); + }); + + it("keeps reporting HTTP-200 device-flow errors as poll results", async () => { + const fetchImpl = async (): Promise => jsonResponse({ error: "authorization_pending" }); + + const result = await pollGitHubAppDeviceFlow({ + deviceCode: "device", + intervalSec: 5, + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + }); + + expect(result.status).toBe("pending"); + }); + + /** + * Sign-out drops every pending browser session. A poll already awaiting + * GitHub used to resolve afterwards and write its session back — and an + * `authorized` answer wrote back the very credential the user just cleared. + */ + it("drops a poll that resolves after sign-out cleared the session", async () => { + const values: StoredValues = {}; + let releasePoll = (): void => {}; + const pollReached = new Promise((resolve) => { + releasePoll = resolve; + }); + let allowPoll = (): void => {}; + const pollReleased = new Promise((resolve) => { + allowPoll = resolve; + }); + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === DEVICE_CODE_URL) { + return jsonResponse({ + device_code: "device", + user_code: "ABCD-1234", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 5, + }); + } + releasePoll(); + await pollReleased; + return jsonResponse({ + access_token: "ghu_authorized", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_authorized", + refresh_token_expires_in: 15_811_200, + }); + }); + const service = createGitHubAppUserAuthService({ + credentialStore: createFakeStore(values), + logger: createLogger(), + fetchImpl: fetchImpl as unknown as typeof fetch, + userAgent: "ade-test", + storeIdentity: nextIdentity(), + now, + sleep, + }); + + const started = await service.startDeviceAuth(); + const polling = service.pollDeviceAuth({ sessionId: started.sessionId }); + await pollReached; + service.clearAuth(); + allowPoll(); + + const result = await polling; + + expect(result.status).toBe("error"); + expect(storedRecord(values)).toBeNull(); + // And the cleared session is really gone, not put back by that poll. + const again = await service.pollDeviceAuth({ sessionId: started.sessionId }); + expect(again.status).toBe("error"); + }); +}); + +const RETRY_AT = "2026-08-20T12:00:30.000Z"; + +describe("classifyAppUserAuthFailure", () => { + it("reports ADE's own refresh lease as renewing rather than a failed check", () => { + // `blocked` with nothing refused is one ADE process renewing the credential + // while the rest wait. The repo axis already said so; the credential + // inventory called the same wait a failed authentication check. + const failure = classifyAppUserAuthFailure(new GitHubAppUserAuthError( + "GitHub paused ADE's authorization renewal. ADE retries on its own.", + "blocked", + RETRY_AT, + null, + )); + + expect(failure.authFailure).toEqual({ + kind: "renewing", + message: GITHUB_APP_USER_AUTH_RENEWING_COPY, + retryAt: RETRY_AT, + }); + }); + + it("keeps GitHub's own refusal when the pause carries one", () => { + const failure = classifyAppUserAuthFailure(new GitHubAppUserAuthError( + "GitHub paused ADE's authorization renewal. ADE retries on its own.", + "blocked", + RETRY_AT, + { kind: "rate_limited", status: 429, oauthError: null }, + )); + + expect(failure.authFailure.kind).toBe("rate_limited"); + expect(failure.authFailure.retryAt).toBe(RETRY_AT); + }); + + it("asks for re-authorization when the refresh token is gone", () => { + const failure = classifyAppUserAuthFailure(new GitHubAppUserAuthError( + "ADE GitHub App authorization expired. Re-authorize ADE with GitHub.", + "needs_reauth", + null, + { kind: "dead_token", status: 401, oauthError: "bad_refresh_token" }, + )); + + expect(failure.authFailure.kind).toBe("invalid_token"); + expect(failure.authFailure.retryAt).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/github/githubAppUserAuthService.ts b/apps/desktop/src/main/services/github/githubAppUserAuthService.ts index 0c5ae5b46..50f102f65 100644 --- a/apps/desktop/src/main/services/github/githubAppUserAuthService.ts +++ b/apps/desktop/src/main/services/github/githubAppUserAuthService.ts @@ -1,17 +1,31 @@ import { randomUUID } from "node:crypto"; +import { updateCredentialKeySync } from "../../../../../ade-cli/src/services/credentials/updateCredentialKey"; import type { GitHubAppDeviceAuthPollResult, GitHubAppDeviceAuthStartResult, + GitHubAppUserAuthCredentialState, GitHubAppUserAuthStatus, } from "../../../shared/types"; import { ADE_GITHUB_APP_CLIENT_ID, - type GitHubAppDeviceCode, type GitHubAppUserTokenRecord, - pollGitHubAppDeviceFlow, refreshGitHubAppUserToken, - startGitHubAppDeviceFlow, } from "./githubAppUserAuth"; +import { createGitHubAppUserDeviceFlow } from "./githubAppUserAuthDeviceFlow"; +import { + emptyLedger, + parseStoredAppUserAuth, + readIsoActiveWithin, + readIsoAfter, + serializeStoredAppUserAuth, + type StoredAppUserAuth, + type StoredRefreshFailure, +} from "./githubAppUserAuthLedger"; +import { + GitHubAppUserAuthError, + classifyRefreshFailure, + type RefreshFailure, +} from "./githubAppUserAuthFailure"; import { createGitHubRelayAuthAuditLog, type GitHubRelayAuthAuditLog, @@ -19,19 +33,46 @@ import { import { GITHUB_REST_API_VERSION } from "./githubApiVersion"; import { asString } from "../shared/utils"; -const GITHUB_APP_USER_TOKEN_KEY = "github.appUserToken.v1"; +export const GITHUB_APP_USER_TOKEN_KEY = "github.appUserToken.v1"; const GITHUB_APP_USER_TOKEN_REFRESH_SKEW_MS = 2 * 60_000; -const MAX_PENDING_DEVICE_AUTH_SESSIONS = 5; +/** How long one process may hold the right to run the refresh POST. */ +const REFRESH_LEASE_MS = 60_000; +/** + * How long the whole refresh exchange may take: the OAuth POST plus the + * `/user` lookup that names the account. + * + * Deliberately BELOW {@link REFRESH_LEASE_MS}. The lease is the only thing that + * stops a peer process from POSTing the same refresh token, and GitHub answers + * a refresh token POSTed twice by revoking the credential. A request still + * running when the lease expires is exactly that second POST, so the request + * has to give up first — with the margin covering the store writes that record + * the outcome. + */ +const REFRESH_REQUEST_TIMEOUT_MS = 45_000; +const REFRESH_BACKOFF_BASE_MS = 60_000; +const REFRESH_BACKOFF_MAX_MS = 60 * 60_000; +const LEASE_POLL_INTERVAL_MS = 250; +/** Bounds the wait for a peer's refresh at roughly three seconds. */ +const LEASE_POLL_MAX_ATTEMPTS = 12; export type GitHubAppUserAuthCredentialStore = { getSync(key: string): string | null | undefined; setSync(key: string, value: string): void; deleteSync(key: string): void; -}; - -type GitHubAppDeviceAuthSession = GitHubAppDeviceCode & { - sessionId: string; - intervalSec: number; + /** + * Atomic read-modify-write of ONE key, when the store supports it. + * + * The refresh ledger below is shared by every ADE process on the machine, so + * "read, decide, write" has to be one step or two processes can both conclude + * they hold the refresh lease. Return `undefined` to write nothing, `null` to + * delete the key. + */ + updateKeySync?( + key: string, + mutator: (current: string | null) => string | null | undefined, + ): void; + /** A stable name for the underlying storage, shared by every store over it. */ + credentialStoreIdentity?(): string; }; type GitHubAppUserAuthLogger = { @@ -39,11 +80,250 @@ type GitHubAppUserAuthLogger = { warn(message: string, meta?: Record): void; }; +/** + * One refresh at a time per credential store, for every service instance in the + * process. Keyed by the storage the instances share, NOT by the instance. + */ +type StoreCoordinator = { + inFlight: Promise | null; + /** + * The name this process writes into the on-disk refresh lease, shared by every + * service instance over one storage. + * + * It belongs to the coordinator and NOT to the instance, because the record a + * refresh holds back after a failed store write also belongs to the + * coordinator. An id per instance made the two disagree: the instance that + * held the lease was the only one whose `leaseHolder` check could pass, so a + * SIBLING instance read the held lease as a peer's, refused to run + * `recoverUnpersistedRefresh`, and left the spent refresh token on disk for + * the whole lease. One id per storage lets any sibling re-acquire the lease + * its own process took and finish the write. + */ + leaseHolderId: string; + /** + * When a peer process was still holding the on-disk lease at the end of a + * wait, in epoch milliseconds, or 0 when no peer is known to hold it. + * + * Process-local and never written to disk. Without it every caller in this + * process re-runs the whole three-second poll before reaching the same + * verdict, which turns one peer's slow refresh into a stall for every project + * scope in the app. + */ + peerLeaseUntilMs: number; + /** + * A refresh GitHub accepted whose replacement never reached the store, kept + * for this process only. + * + * The POST spends the refresh token at GitHub the moment GitHub answers, so a + * store write that fails afterwards leaves the machine file holding a token + * that is already dead. Without this memory the next call reads that file, + * POSTs the spent token, and GitHub answers `bad_refresh_token` — which is + * the credential dying for good. `spentRefreshToken` is the token that must + * never be POSTed again; `record` is the live credential to persist and serve + * in its place. + */ + unpersisted: { spentRefreshToken: string; record: GitHubAppUserTokenRecord } | null; +}; + +const storeCoordinators = new Map(); +const unnamedStoreIdentities = new WeakMap(); +let anonymousStoreCounter = 0; + +/** + * The name every service instance over one storage must agree on. + * + * A store that cannot name itself falls back to per-OBJECT identity rather than + * a shared constant: two unrelated stores must not share a coordinator, and the + * real stores all report a path. + */ +function resolveStoreIdentity( + store: GitHubAppUserAuthCredentialStore | null | undefined, + declared: string | null | undefined, +): string { + const explicit = declared?.trim(); + if (explicit) return explicit; + if (!store) return `ade.memory-credential-store.${(anonymousStoreCounter += 1)}`; + const named = store.credentialStoreIdentity?.().trim(); + if (named) return named; + const existing = unnamedStoreIdentities.get(store); + if (existing) return existing; + const assigned = `ade.unnamed-credential-store.${(anonymousStoreCounter += 1)}`; + unnamedStoreIdentities.set(store, assigned); + return assigned; +} + +function coordinatorFor(identity: string): StoreCoordinator { + let coordinator = storeCoordinators.get(identity); + if (!coordinator) { + coordinator = { + inFlight: null, + leaseHolderId: randomUUID(), + peerLeaseUntilMs: 0, + unpersisted: null, + }; + storeCoordinators.set(identity, coordinator); + } + return coordinator; +} + +/** Drops the process-wide coordinators so one test cannot leak into the next. */ +export function resetGitHubAppUserAuthCoordinatorsForTests(): void { + storeCoordinators.clear(); +} + +function refreshBackoffMs( + failure: Pick, + consecutiveFailures: number, +): number { + const exponential = Math.min( + REFRESH_BACKOFF_BASE_MS * 2 ** Math.max(0, consecutiveFailures - 1), + REFRESH_BACKOFF_MAX_MS, + ); + const requested = failure.retryAfterSec != null ? failure.retryAfterSec * 1_000 : 0; + // Capped at the same bound the readers accept. A longer pause than this is + // indistinguishable from a poisoned deadline, and a deadline the readers + // discard is worse than a short one: it puts every process straight back into + // the refresh storm this ledger exists to stop. + return Math.min(Math.max(exponential, requested), REFRESH_BACKOFF_MAX_MS); +} + +/** True when the backoff deadline is both in the future and plausible. */ +function backoffActive(notBeforeAt: string | null, nowMs: number): boolean { + return readIsoActiveWithin(notBeforeAt, nowMs, REFRESH_BACKOFF_MAX_MS); +} + +/** True when the refresh lease is both in the future and plausible. */ +function leaseActive(leaseUntil: string | null, nowMs: number): boolean { + return readIsoActiveWithin(leaseUntil, nowMs, REFRESH_LEASE_MS); +} + +/** A credential ADE can actually POST a refresh for. */ +type UsableRefreshRecord = GitHubAppUserTokenRecord & { refreshToken: string }; + +/** + * True when the stored credential still carries a refresh token worth POSTing. + * + * A missing `refreshTokenExpiresAt` is unknown, not expired — attempt the + * refresh instead of writing off a possibly-valid credential. + */ +function hasUsableRefreshToken( + record: GitHubAppUserTokenRecord, + nowMs: number, +): record is UsableRefreshRecord { + if (!record.refreshToken) return false; + return record.refreshTokenExpiresAt == null + || readIsoAfter(record.refreshTokenExpiresAt, nowMs); +} + +/** True while the access token is good for longer than the refresh skew. */ +function isAccessTokenFresh(record: GitHubAppUserTokenRecord, nowMs: number): boolean { + const refreshCutoff = nowMs + GITHUB_APP_USER_TOKEN_REFRESH_SKEW_MS; + return !record.expiresAt || readIsoAfter(record.expiresAt, refreshCutoff); +} + +/** + * What the stored credential is, judged once and read by every gate. + * + * Declared here because four callers used to run their own copy of this + * ladder in three different orders, and the orders disagreed: one of them + * handed out a lapsed access token whose refresh token had already expired, + * which every other gate called `needs_reauth`. + * + * The order is the contract: + * 1. `missing` — nothing stored at all. + * 2. `fresh` — the access token is still good, so nothing below may + * refuse the call. A renewal pause must never withhold a working token, + * and an App configured for non-expiring user tokens has no refresh token + * to judge in the first place. `renewableRecord` says whether that token + * has a future once it lapses, which is what the STATUS axis reports on. + * 3. `needs_reauth`— lapsed, and the refresh token is gone, expired, or + * rejected. Nobody can renew this credential, so no gate may hand it out: + * serving it produced a GitHub 401 the user had no way to act on. + * 4. `blocked` — lapsed, and a refresh deadline has not passed yet. + * 5. `refreshable` — lapsed, healthy, and ADE may POST a refresh for it. + */ +export type StoredAuthVerdict = + | { outcome: "missing" } + | { + outcome: "fresh"; + record: GitHubAppUserTokenRecord; + /** Null when this token cannot be renewed once it lapses. */ + renewableRecord: UsableRefreshRecord | null; + } + | { outcome: "needs_reauth"; failure: StoredRefreshFailure | null } + | { outcome: "blocked"; retryAt: string | null; failure: StoredRefreshFailure | null } + | { outcome: "refreshable"; record: UsableRefreshRecord }; + +export function judgeStoredAuth(stored: StoredAppUserAuth, nowMs: number): StoredAuthVerdict { + const failure = stored.refresh.lastFailure; + const token = stored.token; + if (!token?.accessToken) return { outcome: "missing" }; + // The type predicate sits at the end of the `&&` chain, so the true branch + // hands back the narrowed record and no cast is needed below. + const renewableRecord: UsableRefreshRecord | null = + !stored.refresh.dead && hasUsableRefreshToken(token, nowMs) ? token : null; + if (isAccessTokenFresh(token, nowMs)) { + return { outcome: "fresh", record: token, renewableRecord }; + } + if (!renewableRecord) return { outcome: "needs_reauth", failure }; + if (backoffActive(stored.refresh.notBeforeAt, nowMs)) { + return { outcome: "blocked", retryAt: stored.refresh.notBeforeAt, failure }; + } + return { outcome: "refreshable", record: renewableRecord }; +} + +function credentialStateOf( + stored: StoredAppUserAuth, + nowMs: number, +): GitHubAppUserAuthCredentialState { + const verdict = judgeStoredAuth(stored, nowMs); + if (verdict.outcome === "missing") return "missing"; + // A working token that is GOING TO LAPSE with no renewal left is something + // the user must replace, and this axis is where ADE asks them to — hours + // before it lapses, rather than at the moment it stops working. A token + // with no expiry at all (an App configured for non-expiring user tokens) + // never lapses, so the missing refresh token is not a problem to report. + if (verdict.outcome === "fresh") { + const lapses = Boolean(verdict.record.expiresAt); + return !lapses || verdict.renewableRecord ? "authorized" : "needs_reauth"; + } + if (verdict.outcome === "needs_reauth") return "needs_reauth"; + if (verdict.outcome === "blocked") return "blocked"; + return "authorized"; +} + +/** + * What one atomic look at the refresh ledger decided: the shared verdict, plus + * the two outcomes only the lease itself can reach. + * + * `refreshable` never escapes the lease acquisition — it is exactly the case + * the lease then takes or finds already held. + */ +type RefreshLeaseAttempt = + | Exclude + | { outcome: "held"; leaseUntil: string | null; leaseHolder: string | null } + | { outcome: "acquired"; record: UsableRefreshRecord }; + export function createGitHubAppUserAuthService(args: { credentialStore?: GitHubAppUserAuthCredentialStore | null; logger: GitHubAppUserAuthLogger; fetchImpl: (input: string, init?: RequestInit) => Promise; userAgent: string; + /** + * Names the storage this service shares with its siblings. Defaults to what + * the store reports, so every service instance over one credential file lands + * on one coordinator. + */ + storeIdentity?: string | null; + now?: () => number; + sleep?: (ms: number) => Promise; + /** + * Bounds one refresh exchange. Defaults to + * {@link REFRESH_REQUEST_TIMEOUT_MS}; tests set it to a few milliseconds so a + * request that never resolves can be exercised without waiting out the real + * budget. + */ + refreshTimeoutMs?: number; }): { getAuthStatus(patch?: Partial): GitHubAppUserAuthStatus; startDeviceAuth(): Promise; @@ -53,62 +333,98 @@ export function createGitHubAppUserAuthService(args: { getValidTokenForRelay(): Promise; auditLog: GitHubRelayAuthAuditLog; } { - const appDeviceAuthSessions = new Map(); - let appUserTokenMemory: GitHubAppUserTokenRecord | null = null; - let refreshInFlight: Promise | null = null; + const now = args.now ?? (() => Date.now()); + const sleep = args.sleep ?? ((ms: number) => new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + })); + const refreshTimeoutMs = args.refreshTimeoutMs ?? REFRESH_REQUEST_TIMEOUT_MS; + // Only serves a service built WITHOUT a credential store. A store that reads + // empty is a cleared credential, not a cache miss: falling back to memory + // there resurrected credentials another process had signed out. + let appUserTokenMemory: StoredAppUserAuth | null = null; // Bumped whenever stored auth is replaced or cleared outside the refresh // path, so an in-flight refresh cannot overwrite the newer auth state. let authEpoch = 0; - const auditLog = createGitHubRelayAuthAuditLog(args.logger.info.bind(args.logger)); + const storeIdentity = resolveStoreIdentity(args.credentialStore, args.storeIdentity); - const pruneExpiredDeviceAuthSessions = (requestedSessionId?: string): boolean => { - const now = Date.now(); - let requestedExpired = false; - for (const [sessionId, session] of appDeviceAuthSessions.entries()) { - if (Date.parse(session.expiresAt) <= now) { - appDeviceAuthSessions.delete(sessionId); - if (sessionId === requestedSessionId) requestedExpired = true; - } - } - return requestedExpired; - }; + const auditLog = createGitHubRelayAuthAuditLog(args.logger.info.bind(args.logger)); - const readAppUserTokenRecord = (): GitHubAppUserTokenRecord | null => { + const readStoredAuth = (): StoredAppUserAuth => { + if (!args.credentialStore) return appUserTokenMemory ?? { token: null, refresh: emptyLedger() }; try { - const raw = args.credentialStore?.getSync(GITHUB_APP_USER_TOKEN_KEY)?.trim() || ""; - if (!raw) return appUserTokenMemory; - const parsed = JSON.parse(raw) as Partial; - if (typeof parsed.accessToken !== "string" || !parsed.accessToken.trim()) return null; - return { - accessToken: parsed.accessToken.trim(), - tokenType: typeof parsed.tokenType === "string" && parsed.tokenType.trim() ? parsed.tokenType.trim() : "bearer", - scope: typeof parsed.scope === "string" && parsed.scope.trim() ? parsed.scope.trim() : null, - expiresAt: typeof parsed.expiresAt === "string" && parsed.expiresAt.trim() ? parsed.expiresAt.trim() : null, - refreshToken: typeof parsed.refreshToken === "string" && parsed.refreshToken.trim() ? parsed.refreshToken.trim() : null, - refreshTokenExpiresAt: - typeof parsed.refreshTokenExpiresAt === "string" && parsed.refreshTokenExpiresAt.trim() - ? parsed.refreshTokenExpiresAt.trim() - : null, - userLogin: typeof parsed.userLogin === "string" && parsed.userLogin.trim() ? parsed.userLogin.trim() : null, - updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt.trim() : new Date().toISOString(), - }; + const raw = args.credentialStore.getSync(GITHUB_APP_USER_TOKEN_KEY)?.trim() || ""; + if (!raw) return { token: null, refresh: emptyLedger() }; + return parseStoredAppUserAuth(raw); } catch { args.logger.warn("github.app_user_token_read_failed", { error: "failed to parse stored app user token", }); - return null; + return { token: null, refresh: emptyLedger() }; } }; - const persistAppUserTokenRecord = (record: GitHubAppUserTokenRecord | null): void => { - appUserTokenMemory = record; + const readAppUserTokenRecord = (): GitHubAppUserTokenRecord | null => readStoredAuth().token; + + const writeStoredAuth = (stored: StoredAppUserAuth | null): void => { + appUserTokenMemory = stored; + if (!args.credentialStore) return; try { - if (record) { - args.credentialStore?.setSync(GITHUB_APP_USER_TOKEN_KEY, JSON.stringify(record)); - } else { - args.credentialStore?.deleteSync(GITHUB_APP_USER_TOKEN_KEY); + const serialized = stored ? serializeStoredAppUserAuth(stored) : null; + if (serialized) args.credentialStore.setSync(GITHUB_APP_USER_TOKEN_KEY, serialized); + else args.credentialStore.deleteSync(GITHUB_APP_USER_TOKEN_KEY); + } catch (error) { + args.logger.warn("github.app_user_token_write_failed", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }; + + const persistAppUserTokenRecord = (record: GitHubAppUserTokenRecord | null): void => { + writeStoredAuth(record ? { token: record, refresh: emptyLedger() } : null); + }; + + /** + * Applies `mutator` to the stored credential as one step, and returns what the + * mutator decided. `updateCredentialKeySync` holds the ladder: a store with no + * atomic update degrades to a plain read-modify-write, still correct inside + * one process, and the only stores without one are process-local ones. + */ + const updateStoredAuth = ( + mutator: (stored: StoredAppUserAuth) => { next: StoredAppUserAuth | null | undefined; result: T }, + ): T => { + let result!: T; + const parseCurrent = (current: string | null): StoredAppUserAuth => { + if (!current?.trim()) return { token: null, refresh: emptyLedger() }; + try { + return parseStoredAppUserAuth(current); + } catch { + // Unparseable is reported as absent, and every mutator declines to write + // over an absent record — so a corrupt value is left for a reader to + // report rather than overwritten from inside a refresh. + return { token: null, refresh: emptyLedger() }; } + }; + const applyRaw = (current: string | null): string | null | undefined => { + const stored = parseCurrent(current); + const decision = mutator(stored); + result = decision.result; + if (decision.next === undefined) return undefined; + if (decision.next === null) return null; + appUserTokenMemory = decision.next; + return serializeStoredAppUserAuth(decision.next); + }; + const store = args.credentialStore; + if (!store) { + const decision = mutator(appUserTokenMemory ?? { token: null, refresh: emptyLedger() }); + if (decision.next !== undefined) appUserTokenMemory = decision.next; + return decision.result; + } + try { + updateCredentialKeySync(store, GITHUB_APP_USER_TOKEN_KEY, applyRaw); + return result; } catch (error) { args.logger.warn("github.app_user_token_write_failed", { error: error instanceof Error ? error.message : String(error), @@ -118,26 +434,35 @@ export function createGitHubAppUserAuthService(args: { }; const appUserAuthStatus = (patch: Partial = {}): GitHubAppUserAuthStatus => { - const record = readAppUserTokenRecord(); + const stored = readStoredAuth(); + const credentialState = credentialStateOf(stored, now()); + const failure = stored.refresh.lastFailure; return { configured: true, - tokenStored: Boolean(record?.accessToken), - userLogin: record?.userLogin ?? null, - expiresAt: record?.expiresAt ?? null, - refreshTokenExpiresAt: record?.refreshTokenExpiresAt ?? null, - checkedAt: new Date().toISOString(), + tokenStored: Boolean(stored.token?.accessToken), + userLogin: stored.token?.userLogin ?? null, + expiresAt: stored.token?.expiresAt ?? null, + refreshTokenExpiresAt: stored.token?.refreshTokenExpiresAt ?? null, + credentialState, + refreshBlockedUntil: credentialState === "blocked" ? stored.refresh.notBeforeAt : null, + lastRefreshError: failure + ? { + kind: failure.kind, + message: failure.message, + status: failure.status, + at: failure.at, + } + : null, + checkedAt: new Date(now()).toISOString(), error: null, ...patch, }; }; - const isIsoAfter = (iso: string | null, cutoffMs: number): boolean => { - if (!iso) return false; - const time = Date.parse(iso); - return Number.isFinite(time) && time > cutoffMs; - }; - - const fetchAppUserLogin = async (accessToken: string): Promise => { + const fetchAppUserLogin = async ( + accessToken: string, + signal?: AbortSignal, + ): Promise => { const response = await args.fetchImpl("https://api.github.com/user", { method: "GET", headers: { @@ -146,146 +471,606 @@ export function createGitHubAppUserAuthService(args: { "user-agent": args.userAgent, "x-github-api-version": GITHUB_REST_API_VERSION, }, + signal, }); const payload = (await response.json().catch(() => ({}))) as Record; if (!response.ok) return null; return asString(payload.login).trim() || null; }; - const getValidAppUserTokenForRelay = async (): Promise => { - const record = readAppUserTokenRecord(); - if (!record?.accessToken) { - throw new Error("Authorize the ADE GitHub App with GitHub before using the hosted relay."); + const missingAuthError = (): GitHubAppUserAuthError => new GitHubAppUserAuthError( + "Authorize the ADE GitHub App with GitHub before using the hosted relay.", + "missing", + null, + null, + ); + + const needsReauthError = (failure: StoredRefreshFailure | null): GitHubAppUserAuthError => + new GitHubAppUserAuthError( + "ADE GitHub App authorization expired. Re-authorize ADE with GitHub.", + "needs_reauth", + null, + failure, + ); + + const blockedError = ( + retryAt: string | null, + failure: StoredRefreshFailure | null, + ): GitHubAppUserAuthError => new GitHubAppUserAuthError( + // The deadline rides on `retryAt` rather than inside the sentence: callers + // that show it to a person format it, and callers that log it read the field. + "GitHub paused ADE's authorization renewal. ADE retries on its own.", + "blocked", + retryAt, + failure, + ); + + /** + * Throws the error that states why an unusable verdict cannot be served. + * + * Three call sites translate the same three outcomes into the same three + * errors, and they must keep agreeing: a gate that reports `blocked` where + * another reports `needs_reauth` asks the user to re-authorize a credential + * ADE is about to renew by itself. + */ + function rejectVerdict( + verdict: Extract< + StoredAuthVerdict, + { outcome: "missing" | "needs_reauth" | "blocked" } + >, + ): never { + if (verdict.outcome === "missing") throw missingAuthError(); + if (verdict.outcome === "needs_reauth") throw needsReauthError(verdict.failure); + throw blockedError(verdict.retryAt, verdict.failure); + } + + /** + * Judges every refresh gate and takes the lease, as ONE atomic step. + * + * The record to POST is captured INSIDE this atomic update. Capturing it from + * an earlier read leaves a window where a peer finishes its refresh, rotates + * the token, and releases the lease — and the old refresh token gets POSTed + * anyway, which GitHub answers by revoking the credential. The dead and + * backoff gates are judged here for the same reason, and so that one turn of + * the wait loop costs one locked read instead of two. + */ + const acquireRefreshLease = (): RefreshLeaseAttempt => + updateStoredAuth((stored) => { + const nowMs = now(); + const leaseHolderId = coordinatorFor(storeIdentity).leaseHolderId; + const decline = (result: RefreshLeaseAttempt) => ({ next: undefined, result }); + const verdict = judgeStoredAuth(stored, nowMs); + if (verdict.outcome !== "refreshable") return decline(verdict); + if ( + leaseActive(stored.refresh.leaseUntil, nowMs) + && stored.refresh.leaseHolder !== leaseHolderId + ) { + // No failure travels with this one: a peer holding the lease is ADE + // renewing the credential, and GitHub has said nothing about it. + return decline({ + outcome: "held", + leaseUntil: stored.refresh.leaseUntil, + leaseHolder: stored.refresh.leaseHolder, + }); + } + const leaseUntil = new Date(nowMs + REFRESH_LEASE_MS).toISOString(); + return { + next: { + token: verdict.record, + refresh: { ...stored.refresh, leaseUntil, leaseHolder: leaseHolderId }, + }, + result: { outcome: "acquired", record: verdict.record }, + }; + }); + + /** Stamps one classified failure with the instant it happened. */ + const stampFailure = (failure: RefreshFailure): StoredRefreshFailure => ({ + kind: failure.kind, + message: failure.message, + status: failure.status, + oauthError: failure.oauthError, + retryAfterSec: failure.retryAfterSec, + at: new Date(now()).toISOString(), + }); + + type PersistedRefreshSuccess = { + written: boolean; + generation: number; + /** + * Why the write was declined, or null when it landed. + * + * `peer` is another writer's credential on disk — a sign-out, or a device + * flow that finished meanwhile. `own_first_attempt` is this process finding + * its OWN rotated record already stored, which happens when the first write + * attempt reached the store and then threw. Nothing superseded that one, and + * logging it as if a peer had written it sent readers hunting for a second + * writer that does not exist. + */ + supersededBy: "own_first_attempt" | "peer" | null; + }; + + /** + * Writes a refreshed credential back, but only over the credential that was + * POSTed. + * + * A record that vanished while the POST was in flight was cleared by a + * sign-out, and a record whose refresh token changed was replaced by a device + * flow that finished meanwhile. Writing this result over either one undoes a + * newer, deliberate decision. + */ + const persistRefreshSuccess = ( + refreshed: GitHubAppUserTokenRecord, + postedRefreshToken: string | null, + ): PersistedRefreshSuccess => + updateStoredAuth((stored) => { + if (!stored.token || stored.token.refreshToken !== postedRefreshToken) { + // Only this process ever saw the rotated token, so a stored record + // carrying it is a write of ours that landed and then reported failure — + // never a peer's newer credential. + const supersededBy = refreshed.refreshToken != null + && stored.token?.refreshToken === refreshed.refreshToken + ? "own_first_attempt" + : "peer"; + return { next: undefined, result: { written: false, generation: 0, supersededBy } }; + } + const generation = stored.refresh.generation + 1; + return { + next: { token: refreshed, refresh: { ...emptyLedger(), generation } }, + result: { written: true, generation, supersededBy: null }, + }; + }); + + type PersistedRefreshFailure = { + notBeforeAt: string | null; + backoffMs: number; + consecutiveFailures: number; + /** True when the stored credential was no longer the one that was POSTed. */ + declined: boolean; + }; + + /** + * Records a failed refresh against the credential that was POSTed. + * + * Declines the write when the stored credential is a different one. A failure + * that belongs to a credential nobody holds any more must not mark a fresh + * one dead — that is how a completed re-authorization looked to the user like + * nothing had changed. + */ + const persistRefreshFailure = ( + failure: StoredRefreshFailure, + dead: boolean, + postedRefreshToken: string | null, + ): PersistedRefreshFailure => + updateStoredAuth((stored) => { + if (!stored.token || stored.token.refreshToken !== postedRefreshToken) { + return { + next: undefined, + result: { notBeforeAt: null, backoffMs: 0, consecutiveFailures: 0, declined: true }, + }; + } + const consecutiveFailures = stored.refresh.consecutiveFailures + 1; + const backoffMs = refreshBackoffMs(failure, consecutiveFailures); + const notBeforeAt = new Date(now() + backoffMs).toISOString(); + return { + next: { + // The credential itself is kept even when it is dead: the UI can only + // say "re-authorize" if it can still see that a credential is there. + token: stored.token, + refresh: { + ...stored.refresh, + notBeforeAt, + consecutiveFailures, + dead: stored.refresh.dead || dead, + leaseUntil: null, + leaseHolder: null, + lastFailure: failure, + }, + }, + result: { notBeforeAt, backoffMs, consecutiveFailures, declined: false }, + }; + }); + + /** + * Serves whatever the store holds right now. + * + * Used by the two paths where this POST's outcome no longer describes the + * stored credential: auth was replaced while the request was in flight, or + * the write was declined because the refresh token had already been rotated. + */ + const serveCurrentStoredAuth = (): GitHubAppUserTokenRecord => { + const verdict = judgeStoredAuth(readStoredAuth(), now()); + if (verdict.outcome !== "fresh" && verdict.outcome !== "refreshable") { + rejectVerdict(verdict); } - const refreshCutoff = Date.now() + GITHUB_APP_USER_TOKEN_REFRESH_SKEW_MS; - if (!record.expiresAt || isIsoAfter(record.expiresAt, refreshCutoff)) { - return record.accessToken; + // Fresh, or stale but healthy. The next call runs the gate again and renews + // it; this call must not report a failure that belongs to a replaced + // credential. + return verdict.record; + }; + + /** + * Hands the on-disk lease back when this process still holds it. + * + * The lease also expires on its own after {@link REFRESH_LEASE_MS}, so this is + * about latency, not correctness: without it a refresh that dies before it can + * record an outcome — a credential-store write that throws, most of all — + * makes every other process wait out the full minute for nothing. + */ + const releaseRefreshLease = (): void => { + updateStoredAuth((stored) => { + if ( + !stored.token + || stored.refresh.leaseHolder !== coordinatorFor(storeIdentity).leaseHolderId + ) { + return { next: undefined, result: undefined }; + } + return { + next: { + token: stored.token, + refresh: { ...stored.refresh, leaseUntil: null, leaseHolder: null }, + }, + result: undefined, + }; + }); + }; + + /** + * Hands the lease back on a path that has no outcome to report. + * + * A store that just refused an outcome will refuse this too, and reporting a + * second store failure over the first one helps nobody. The lease expires on + * its own, so this is about latency, not correctness. + */ + const releaseRefreshLeaseQuietly = (): void => { + try { + releaseRefreshLease(); + } catch { + // Deliberately silent; see above. } - // A missing refreshTokenExpiresAt is unknown, not expired — attempt the - // refresh instead of deleting a possibly-valid credential. - const refreshTokenDead = record.refreshTokenExpiresAt != null - && !isIsoAfter(record.refreshTokenExpiresAt, Date.now()); - if (!record.refreshToken || refreshTokenDead) { - persistAppUserTokenRecord(null); - throw new Error("ADE GitHub App authorization expired. Re-authorize ADE with GitHub."); + }; + + /** + * Persists a successful refresh, with ONE immediate retry. + * + * By the time this runs GitHub has already spent the stored refresh token, so + * a write that never lands leaves that spent token on disk for the next + * reader to POST — and a refresh token POSTed twice is what GitHub answers by + * revoking the credential. Credential-store writes fail for reasons that pass + * in milliseconds: a peer holding the file lock, a momentary permission + * error. One retry converts most of that window into a durable record. + */ + const persistRefreshSuccessDurably = ( + refreshed: GitHubAppUserTokenRecord, + storedRefreshToken: string, + ): PersistedRefreshSuccess => { + try { + return persistRefreshSuccess(refreshed, storedRefreshToken); + } catch (error) { + args.logger.warn("github.app_user_token_refresh_persist_retried", { + error: error instanceof Error ? error.message : String(error), + userLogin: refreshed.userLogin, + }); + return persistRefreshSuccess(refreshed, storedRefreshToken); } - const epochAtJoin = authEpoch; - if (!refreshInFlight) { - refreshInFlight = refreshGitHubAppUserToken({ + }; + + /** + * Runs the refresh POST for `record`. + * + * `storedRefreshToken` names the refresh token the STORE holds, which every + * write below is allowed over and nothing else. It differs from the POSTed + * one on exactly one path: a retry of a refresh whose replacement never + * reached the store, where the live token is the one this process remembers + * and the store still holds the spent one. + */ + const runRefreshPost = async ( + record: UsableRefreshRecord, + storedRefreshToken: string = record.refreshToken, + ): Promise => { + const epochAtStart = authEpoch; + const postedRefreshToken = record.refreshToken; + // Both persist paths clear the lease as part of the record they write, so + // only an exit that reaches NEITHER of them leaves it held. + let ledgerWritten = false; + // Set when this process must KEEP the lease rather than hand it back: the + // exchange succeeded but its replacement never reached the store, so the + // spent refresh token is still what a peer would read and POST. The lease + // is the only thing stopping that until this process re-persists the record + // (see `recoverUnpersistedRefresh`) or the lease expires on its own. + let holdLeaseForUnpersisted = false; + // The whole exchange is bounded well inside the lease. See + // REFRESH_REQUEST_TIMEOUT_MS: a request that outlives the lease becomes the + // duplicate POST the lease exists to prevent. + const requestAbort = new AbortController(); + let requestTimedOut = false; + const requestTimer = setTimeout(() => { + requestTimedOut = true; + requestAbort.abort(); + }, refreshTimeoutMs); + requestTimer.unref?.(); + try { + const refreshed = await refreshGitHubAppUserToken({ clientId: ADE_GITHUB_APP_CLIENT_ID, - refreshToken: record.refreshToken, - fetchImpl: (input, init) => args.fetchImpl(String(input), init), + refreshToken: postedRefreshToken, + fetchImpl: (input, init) => args.fetchImpl( + String(input), + { ...init, signal: requestAbort.signal }, + ), userAgent: args.userAgent, - fetchUserLogin: fetchAppUserLogin, - }).then((refreshed) => { - if (authEpoch === epochAtJoin) persistAppUserTokenRecord(refreshed); + fetchUserLogin: (accessToken) => fetchAppUserLogin(accessToken, requestAbort.signal), + }); + if (authEpoch !== epochAtStart) { + // Auth was cleared or replaced while this POST was in flight; the store + // is the truth, not this result. + return serveCurrentStoredAuth(); + } + let persisted: PersistedRefreshSuccess; + try { + persisted = persistRefreshSuccessDurably(refreshed, storedRefreshToken); + } catch (error) { + // The POST succeeded, so the refresh token on disk is already spent and + // this record is the only live credential there is. Falling into the + // catch below would classify a STORE failure as a refresh failure and + // stamp backoff onto the spent token — and the next POST would replay + // it, which is exactly how GitHub decides to revoke the credential. + // Serve what the exchange returned, and remember it: the store still + // holds the spent token, so without this the very next call reads that + // file and POSTs it. The deep write failure is already reported by + // `github.app_user_token_write_failed`. + coordinatorFor(storeIdentity).unpersisted = { + spentRefreshToken: postedRefreshToken, + record: refreshed, + }; + // Keep the lease. Handing it back here lets a PEER acquire it, read the + // spent refresh token this process failed to replace, and POST it — + // the exact double-spend the lease exists to prevent. Holding it costs + // peers the remainder of one lease at worst, and the recovery path + // re-persists and clears it as soon as the store accepts a write. + holdLeaseForUnpersisted = true; + args.logger.info("github.app_user_token_refresh_unpersisted", { + error: error instanceof Error ? error.message : String(error), + userLogin: refreshed.userLogin, + expiresAt: refreshed.expiresAt, + }); return refreshed; - }).finally(() => { - refreshInFlight = null; + } + ledgerWritten = true; + // A newer record is on disk, so nothing this process is holding back can + // still be the live credential. + if (persisted.written) coordinatorFor(storeIdentity).unpersisted = null; + if (!persisted.written) { + args.logger.info("github.app_user_token_refresh_superseded", { + userLogin: refreshed.userLogin, + supersededBy: persisted.supersededBy, + }); + return serveCurrentStoredAuth(); + } + args.logger.info("github.app_user_token_refresh_succeeded", { + generation: persisted.generation, + userLogin: refreshed.userLogin, + expiresAt: refreshed.expiresAt, + refreshTokenRotated: refreshed.refreshToken !== record.refreshToken, }); + return refreshed; + } catch (error) { + if (error instanceof GitHubAppUserAuthError) throw error; + // A request this process gave up on says nothing about the credential, so + // it is a transient network failure — the classification that keeps the + // refresh token alive and retries it later. The abort reason itself reads + // as "This operation was aborted", which tells a reader nothing. + const failure = classifyRefreshFailure( + requestTimedOut + ? new Error( + `GitHub did not answer the token refresh within ${refreshTimeoutMs} ms.`, + ) + : error, + ); + const stamped = stampFailure(failure); + const persisted = persistRefreshFailure(stamped, failure.dead, storedRefreshToken); + ledgerWritten = true; + args.logger.warn("github.app_user_token_refresh_failed", { + error: failure.message, + kind: failure.kind, + status: failure.status, + oauthError: failure.oauthError, + retryAfterSec: failure.retryAfterSec, + backoffMs: persisted.backoffMs, + consecutiveFailures: persisted.consecutiveFailures, + dead: failure.dead, + superseded: persisted.declined, + }); + // Nothing was written, because the stored credential is not the one this + // failure is about. Serve the current state instead of reporting a dead + // credential over a live one. + if (persisted.declined) return serveCurrentStoredAuth(); + throw failure.dead + ? needsReauthError(stamped) + : blockedError(persisted.notBeforeAt, stamped); + } finally { + clearTimeout(requestTimer); + if (!ledgerWritten && !holdLeaseForUnpersisted) releaseRefreshLeaseQuietly(); } - const refreshed = await refreshInFlight; - if (authEpoch !== epochAtJoin) { - // Auth state changed while refreshing (cleared or re-authorized). - // Resolve against the current state instead of the stale refresh. - return getValidAppUserTokenForRelay(); - } - return refreshed.accessToken; }; - const startDeviceAuth = async (): Promise => { - pruneExpiredDeviceAuthSessions(); - // Cap pending sessions so a runaway caller cannot grow the map or spam - // GitHub's device endpoint via ADE; evict oldest first. - while (appDeviceAuthSessions.size >= MAX_PENDING_DEVICE_AUTH_SESSIONS) { - const oldest = appDeviceAuthSessions.keys().next().value; - if (!oldest) break; - appDeviceAuthSessions.delete(oldest); + /** + * Remembers, for this process only, that a peer still held the lease when the + * wait ran out. + * + * A lease carrying THIS coordinator's holder id is not a peer's, whoever took + * it. Remembering it as one would make every sibling instance over the same + * storage refuse a refresh its own process is already holding — the gate in + * `getValidAppUserTokenForRelay` throws `blocked` on that memory alone, + * without looking at the store. + */ + const rememberPeerLease = (leaseUntil: string | null, leaseHolder: string | null): void => { + const coordinator = coordinatorFor(storeIdentity); + if (leaseHolder && leaseHolder === coordinator.leaseHolderId) return; + if (!leaseUntil || !leaseActive(leaseUntil, now())) return; + coordinator.peerLeaseUntilMs = Date.parse(leaseUntil); + }; + + /** + * What to do instead of POSTing a refresh token this process already spent. + * + * `serve` hands back a credential without touching GitHub. `post` runs the + * refresh with the token this process remembers — never the spent one on + * disk — and names the stored token its writes are allowed over. + */ + type UnpersistedRecovery = + | { action: "serve"; record: GitHubAppUserTokenRecord } + | { action: "post"; record: UsableRefreshRecord; storedRefreshToken: string }; + + /** + * Answers the one question the on-disk ledger cannot: has this process + * already spent the refresh token it is about to POST? + * + * A store write that failed after a successful refresh leaves the spent token + * on disk, so every gate below reads it as the credential to renew. POSTing + * it is what GitHub answers with `bad_refresh_token`, and that answer is the + * credential dying. Retry the write that failed first, then serve or renew + * the record this process is holding. + * + * Returns null when the record to POST is not one this process has spent. + * Every exit that does NOT reach a POST hands the lease back first: it has no + * outcome to record, and a lease left held makes every peer wait out the full + * minute for a refresh nobody is running. + */ + const recoverUnpersistedRefresh = (record: UsableRefreshRecord): UnpersistedRecovery | null => { + const coordinator = coordinatorFor(storeIdentity); + const pending = coordinator.unpersisted; + if (!pending || pending.spentRefreshToken !== record.refreshToken) return null; + let storedRefreshToken = pending.spentRefreshToken; + try { + const persisted = persistRefreshSuccessDurably(pending.record, pending.spentRefreshToken); + coordinator.unpersisted = null; + if (!persisted.written) { + // The store holds a different credential now — a sign-out or a finished + // device flow. That decision is newer than this held-back record. + releaseRefreshLeaseQuietly(); + return { action: "serve", record: serveCurrentStoredAuth() }; + } + args.logger.info("github.app_user_token_refresh_repersisted", { + generation: persisted.generation, + userLogin: pending.record.userLogin, + }); + storedRefreshToken = pending.record.refreshToken ?? storedRefreshToken; + } catch { + // The store is still refusing writes. Keep holding the record: it is the + // only live credential this machine has. `github.app_user_token_write_failed` + // already reported the failure. } - const device = await startGitHubAppDeviceFlow({ - clientId: ADE_GITHUB_APP_CLIENT_ID, - fetchImpl: (input, init) => args.fetchImpl(String(input), init), - userAgent: args.userAgent, - }); - const sessionId = randomUUID(); - appDeviceAuthSessions.set(sessionId, { ...device, sessionId }); - return { - sessionId, - userCode: device.userCode, - verificationUri: device.verificationUri, - verificationUriComplete: device.verificationUriComplete, - expiresAt: device.expiresAt, - intervalSec: device.intervalSec, - }; + if (isAccessTokenFresh(pending.record, now())) { + releaseRefreshLeaseQuietly(); + return { action: "serve", record: pending.record }; + } + if (!hasUsableRefreshToken(pending.record, now())) { + releaseRefreshLeaseQuietly(); + throw needsReauthError(null); + } + return { action: "post", record: pending.record, storedRefreshToken }; }; - const pollDeviceAuth = async (pollArgs: { sessionId: string }): Promise => { - const requestedSessionExpired = pruneExpiredDeviceAuthSessions(pollArgs.sessionId); - const session = appDeviceAuthSessions.get(pollArgs.sessionId); - if (!session) { - if (requestedSessionExpired) { - return { - status: "expired", - intervalSec: null, - message: "GitHub device authorization expired.", - authStatus: appUserAuthStatus(), - }; + /** + * The one refresh body, run under this process's coordinator and the on-disk + * lease. Every gate is judged inside `acquireRefreshLease`, so each turn takes + * one locked look at the store and a peer's outcome is honoured, not raced. + */ + const refreshUnderLease = async (): Promise => { + for (let attempt = 0; attempt <= LEASE_POLL_MAX_ATTEMPTS; attempt += 1) { + const lease = acquireRefreshLease(); + if (lease.outcome === "fresh") return lease.record; + if (lease.outcome === "acquired") { + const recovery = recoverUnpersistedRefresh(lease.record); + if (recovery?.action === "serve") return recovery.record; + return recovery + ? await runRefreshPost(recovery.record, recovery.storedRefreshToken) + : await runRefreshPost(lease.record); } - return { - status: "error", - intervalSec: null, - message: "GitHub device authorization session was not found.", - authStatus: appUserAuthStatus(), - }; + if (lease.outcome !== "held") rejectVerdict(lease); + // A peer is mid-refresh. Never POST the same refresh token behind it: the + // peer may already have rotated it, and GitHub answers a reused refresh + // token by revoking the credential outright. + if (attempt === LEASE_POLL_MAX_ATTEMPTS) { + rememberPeerLease(lease.leaseUntil, lease.leaseHolder); + // No failure: ADE is renewing, and every surface that reads this must + // say so rather than blame GitHub for a wait ADE itself is causing. + throw blockedError(lease.leaseUntil, null); + } + await sleep(LEASE_POLL_INTERVAL_MS); } - const result = await pollGitHubAppDeviceFlow({ - clientId: ADE_GITHUB_APP_CLIENT_ID, - deviceCode: session.deviceCode, - intervalSec: session.intervalSec, - fetchImpl: (input, init) => args.fetchImpl(String(input), init), - userAgent: args.userAgent, - fetchUserLogin: fetchAppUserLogin, - }); - if (result.status === "pending" || result.status === "slow_down") { - session.intervalSec = result.intervalSec; - appDeviceAuthSessions.set(session.sessionId, session); - return { - status: result.status, - intervalSec: result.intervalSec, - message: result.message, - authStatus: appUserAuthStatus(), - }; + throw blockedError(null, readStoredAuth().refresh.lastFailure); + }; + + const getValidAppUserTokenForRelay = async (): Promise => { + const coordinator = coordinatorFor(storeIdentity); + const verdict = judgeStoredAuth(readStoredAuth(), now()); + if (verdict.outcome === "fresh") { + // A fresh token proves the peer's refresh landed, so the remembered + // deadline has done its job. + coordinator.peerLeaseUntilMs = 0; + return verdict.record.accessToken; } - appDeviceAuthSessions.delete(pollArgs.sessionId); - if (result.status === "authorized") { - authEpoch += 1; - persistAppUserTokenRecord(result.token); - return { - status: "authorized", - intervalSec: null, - message: null, - authStatus: appUserAuthStatus(), - }; + if (verdict.outcome !== "refreshable") rejectVerdict(verdict); + // A peer still held the lease when an earlier wait ran out. Polling for + // three more seconds cannot learn anything before that deadline passes, and + // every project scope in this process would pay the wait separately. This + // is ADE waiting on ADE, so it carries no failure. + const nowMs = now(); + if (coordinator.peerLeaseUntilMs > nowMs + && coordinator.peerLeaseUntilMs <= nowMs + REFRESH_LEASE_MS) { + throw blockedError(new Date(coordinator.peerLeaseUntilMs).toISOString(), null); + } + coordinator.peerLeaseUntilMs = 0; + if (!coordinator.inFlight) { + coordinator.inFlight = refreshUnderLease(); + // Nothing is awaiting this copy, and an unhandled rejection here would be + // reported before the real awaiters attach. + coordinator.inFlight.catch(() => undefined); + } + const inFlight = coordinator.inFlight; + try { + return (await inFlight).accessToken; + } finally { + if (coordinator.inFlight === inFlight) coordinator.inFlight = null; } - return { - status: result.status, - intervalSec: null, - message: result.message, - authStatus: appUserAuthStatus({ error: result.message }), - }; }; + const deviceFlow = createGitHubAppUserDeviceFlow({ + fetchImpl: args.fetchImpl, + userAgent: args.userAgent, + logger: args.logger, + fetchAppUserLogin, + persistAppUserTokenRecord, + bumpAuthEpoch: () => { + authEpoch += 1; + // A device flow finished, so a held-back record from the credential it + // replaces must not be resurrected over it. + coordinatorFor(storeIdentity).unpersisted = null; + }, + appUserAuthStatus, + now, + }); + const clearAuth = (): GitHubAppUserAuthStatus => { authEpoch += 1; - persistAppUserTokenRecord(null); - appDeviceAuthSessions.clear(); + // The user signed out. A held-back record is a credential they just asked + // ADE to forget, so drop it before anything can serve it again. + coordinatorFor(storeIdentity).unpersisted = null; + try { + persistAppUserTokenRecord(null); + } finally { + // A store delete that throws must not keep the device sessions alive: the + // generation bump inside `clearSessions` is what stops an in-flight poll + // from writing the credential back. + deviceFlow.clearSessions(); + } return appUserAuthStatus(); }; return { getAuthStatus: appUserAuthStatus, - startDeviceAuth, - pollDeviceAuth, + startDeviceAuth: deviceFlow.startDeviceAuth, + pollDeviceAuth: deviceFlow.pollDeviceAuth, clearAuth, getStoredTokenForHealth: () => readAppUserTokenRecord()?.accessToken ?? null, getValidTokenForRelay: getValidAppUserTokenForRelay, diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.ts index 435772438..6515d5d52 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.ts @@ -475,12 +475,15 @@ export function githubBackgroundRequestPauseUntilMs( * *shorter* wait. Change one, change both. */ const REQUEST_BUDGET_FAILURE_SEVERITY: Record = { - rate_limited: 5, - service_unavailable: 4, - invalid_token: 3, - permission_denied: 2, - network: 1, - unknown: 0, + rate_limited: 6, + service_unavailable: 5, + invalid_token: 4, + permission_denied: 3, + network: 2, + unknown: 1, + // Last: ADE renewing its own credential is the one "failure" here that GitHub + // never refused, and it clears in seconds. Any real refusal outranks it. + renewing: 0, }; /** diff --git a/apps/desktop/src/main/services/github/githubRateLimit.ts b/apps/desktop/src/main/services/github/githubRateLimit.ts index 46d1050d9..0b8e2d167 100644 --- a/apps/desktop/src/main/services/github/githubRateLimit.ts +++ b/apps/desktop/src/main/services/github/githubRateLimit.ts @@ -1,6 +1,29 @@ import type { GitHubAuthFailure, GitHubRateLimitState } from "../../../shared/types"; import { isGithubServiceUnavailable } from "../../../shared/githubServiceHealth"; +/** + * OAuth error codes that mean "this credential will never work again". + * + * Everything outside this set is treated as transient, because retrying a + * transient failure costs a request while giving up on a live credential costs + * the user their connection. + * + * Lives here, with the rest of GitHub's failure vocabulary, rather than in the + * OAuth transport: the transport reports what GitHub said, and this module is + * where ADE decides what any of it means. + */ +const DEFINITIVE_OAUTH_ERRORS: ReadonlySet = new Set([ + "bad_refresh_token", + "incorrect_client_credentials", + "invalid_grant", + "unauthorized_client", + "unsupported_grant_type", +]); + +export function isDefinitiveGitHubOAuthError(oauthError: string | null | undefined): boolean { + return typeof oauthError === "string" && DEFINITIVE_OAUTH_ERRORS.has(oauthError.trim()); +} + export class GitHubRateLimitError extends Error { constructor( message: string, @@ -59,11 +82,27 @@ export function classifyGitHubAuthFailure(args: { status?: number; message: string; headers?: Pick; + /** + * The OAuth `error` code, when the failure came from GitHub's OAuth endpoints. + * Those answer a rejected refresh token with HTTP 200, so the status alone + * cannot tell a dead credential from a healthy response. + */ + oauthError?: string | null; + /** + * A deadline the caller already knows — the refresh backoff, typically. It + * wins over anything derived from headers, because it is the instant ADE will + * actually try again. + */ + retryAt?: string | null; }): { authFailure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null } { const message = args.message.trim() || "GitHub token validation failed."; const rateLimit = args.headers ? readGitHubRateLimitState(args.headers) : null; + const retryAfterSeconds = args.headers ? parseHeaderInteger(args.headers.get("retry-after")) : null; + const knownRetryAt = args.retryAt?.trim() || null; const rateLimited = args.status === 429 + || retryAfterSeconds != null + || args.oauthError === "too_many_requests" || rateLimit?.remaining === 0 || /rate limit|too many requests|abuse detection/i.test(message); if (rateLimited) { @@ -72,11 +111,16 @@ export function classifyGitHubAuthFailure(args: { authFailure: { kind: "rate_limited", message, - retryAt: args.headers ? rateLimitRetryAt(args.headers, rateLimit) : rateLimit?.resetAt ?? null, + retryAt: knownRetryAt + ?? (args.headers ? rateLimitRetryAt(args.headers, rateLimit) : rateLimit?.resetAt ?? null), }, }; } - if (args.status === 401 || /bad credentials|invalid token|requires authentication/i.test(message)) { + if ( + args.status === 401 + || isDefinitiveGitHubOAuthError(args.oauthError) + || /bad credentials|invalid token|requires authentication/i.test(message) + ) { return { rateLimit, authFailure: { @@ -92,7 +136,7 @@ export function classifyGitHubAuthFailure(args: { authFailure: { kind: "permission_denied", message, - retryAt: null, + retryAt: knownRetryAt, }, }; } @@ -105,7 +149,7 @@ export function classifyGitHubAuthFailure(args: { authFailure: { kind: "service_unavailable", message, - retryAt: null, + retryAt: knownRetryAt, }, }; } @@ -115,7 +159,7 @@ export function classifyGitHubAuthFailure(args: { authFailure: { kind: "network", message, - retryAt: null, + retryAt: knownRetryAt, }, }; } @@ -124,7 +168,7 @@ export function classifyGitHubAuthFailure(args: { authFailure: { kind: "unknown", message, - retryAt: null, + retryAt: knownRetryAt, }, }; } diff --git a/apps/desktop/src/main/services/github/githubRelayConfig.test.ts b/apps/desktop/src/main/services/github/githubRelayConfig.test.ts new file mode 100644 index 000000000..fd2bde11a --- /dev/null +++ b/apps/desktop/src/main/services/github/githubRelayConfig.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GitHubAppUserAuthUnavailable } from "../../../shared/types"; +import { GITHUB_APP_USER_AUTH_RENEWING_COPY } from "../../../shared/types"; +import { fetchGitHubAppInstallationStatus } from "./githubRelayConfig"; + +const REPO = { owner: "acme", name: "ade" }; + +function unavailable( + patch: Partial = {}, +): GitHubAppUserAuthUnavailable { + return { + message: "GitHub paused ADE's authorization renewal. ADE retries on its own.", + credentialState: "blocked", + retryAt: null, + failureKind: "rate_limited", + ...patch, + }; +} + +function jsonResponse(status: number, body: unknown): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers({ "content-type": "application/json" }), + json: async () => body, + } as unknown as Response; +} + +describe("fetchGitHubAppInstallationStatus account failures", () => { + it("carries the account failure as a typed field, not only as wording", async () => { + const failure = unavailable(); + + const status = await fetchGitHubAppInstallationStatus({ + repo: REPO, + appUserAuthFailure: failure, + fetchImpl: vi.fn() as unknown as typeof fetch, + }); + + expect(status.appUserAuthFailure).toEqual(failure); + expect(status.state).toBe("error"); + }); + + // A peer process holding the refresh lease blocks the credential with nothing + // recorded against it. Saying GitHub paused anything there is an accusation + // ADE has no evidence for, and it sends the user to look at GitHub's status + // page over a wait that ends in a second. + it("says ADE is renewing when the block carries no GitHub failure", async () => { + const status = await fetchGitHubAppInstallationStatus({ + repo: REPO, + appUserAuthFailure: unavailable({ failureKind: null }), + fetchImpl: vi.fn() as unknown as typeof fetch, + }); + + expect(status.error).toBe(GITHUB_APP_USER_AUTH_RENEWING_COPY); + expect(status.error).not.toMatch(/github paused/i); + }); + + it("still blames GitHub when GitHub is what refused the renewal", async () => { + const status = await fetchGitHubAppInstallationStatus({ + repo: REPO, + appUserAuthFailure: unavailable({ failureKind: "rate_limited" }), + fetchImpl: vi.fn() as unknown as typeof fetch, + }); + + expect(status.error).toContain("Waiting on GitHub authorization"); + }); + + it("carries the account failure through the relay's own 401", async () => { + const fetchImpl = vi.fn(async () => jsonResponse(401, { error: "GitHub auth token is required" })); + + const status = await fetchGitHubAppInstallationStatus({ + repo: REPO, + githubAppUserToken: "ghu_stale_but_present", + appUserAuthFailure: unavailable(), + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(status.appUserAuthFailure).not.toBeNull(); + expect(status.error).toContain("Waiting on GitHub authorization"); + }); + + // Any other status was answered with a credential the relay accepted, so it + // says something about the repository — and reporting it as an account + // problem would hide a real install failure behind a wait. + it("leaves the account field null when the relay answered with a repo failure", async () => { + const fetchImpl = vi.fn(async () => jsonResponse(500, { error: "relay exploded" })); + + const status = await fetchGitHubAppInstallationStatus({ + repo: REPO, + githubAppUserToken: "ghu_live", + appUserAuthFailure: unavailable(), + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(status.appUserAuthFailure).toBeNull(); + expect(status.error).toBe("relay exploded"); + }); +}); diff --git a/apps/desktop/src/main/services/github/githubRelayConfig.ts b/apps/desktop/src/main/services/github/githubRelayConfig.ts index 22cefad74..4e504355f 100644 --- a/apps/desktop/src/main/services/github/githubRelayConfig.ts +++ b/apps/desktop/src/main/services/github/githubRelayConfig.ts @@ -1,5 +1,14 @@ import { createHmac } from "node:crypto"; -import type { GitHubAppInstallationStatus, GitHubRepoRef } from "../../../shared/types"; +import type { + GitHubAppInstallationStatus, + GitHubAppUserAuthUnavailable, + GitHubRepoRef, +} from "../../../shared/types"; +import { GITHUB_APP_USER_AUTH_RENEWING_COPY } from "../../../shared/types"; +import { + describeAppUserAuthUnavailable, + resolveAppUserTokenForRelay, +} from "./githubAppUserAuthFailure"; export const ADE_GITHUB_APP_DISPLAY_NAME = "ADE"; export const ADE_GITHUB_APP_SLUG = "ade-for-github"; @@ -147,6 +156,7 @@ function baseStatus(repo: GitHubRepoRef | null, patch: Partial { @@ -236,11 +264,15 @@ export async function fetchGitHubAppInstallationStatus(args: { const hostedAuth = useLegacyProjectRoute ? null : resolveHostedGitHubRelayAuthToken({ githubAppUserToken }); + const appUserAuthFailure = args.appUserAuthFailure ?? null; if (hostedAuth && !hostedAuth.ok && !accountAccessToken) { return baseStatus(args.repo, { relayConfigured: true, state: "error", - error: hostedAuth.error, + error: appUserAuthFailure + ? appUserAuthUnavailableCopy(appUserAuthFailure) + : hostedAuth.error, + appUserAuthFailure, }); } const authToken = useLegacyProjectRoute @@ -275,13 +307,23 @@ export async function fetchGitHubAppInstallationStatus(args: { }); const payload = await response.json().catch(() => ({})); if (!response.ok) { - const message = payload && typeof payload === "object" && "error" in payload + const relayMessage = payload && typeof payload === "object" && "error" in payload ? String((payload as { error?: unknown }).error) : `GitHub App relay status check failed (${response.status})`; + // A 401 with no usable App user token is the account problem the relay + // sees from the outside. Report the account problem, not the relay's + // wording for it. + const accountProblem = response.status === 401 ? appUserAuthFailure : null; + const message = accountProblem + ? appUserAuthUnavailableCopy(accountProblem) + : relayMessage; return baseStatus(args.repo, { relayConfigured: true, state: "error", error: message, + // Only on the 401: any other status was answered with a credential the + // relay accepted, so it says something about the repo, not the account. + appUserAuthFailure: accountProblem, }); } return normalizeRelayStatusPayload(args.repo, payload, config.configured); @@ -293,3 +335,51 @@ export async function fetchGitHubAppInstallationStatus(args: { }); } } + +/** + * The whole installation check for one repository: ask for the App user token, + * keep the reason when there is none, and fetch the status with both. + * + * The desktop GitHub service and its headless CLI twin ran identical copies of + * this sequence, and the reason it must not be split back apart is the middle + * step: the failure the token lookup produced is what the repo axis is ALLOWED + * to say. A copy that drops it reports the relay's own 401 — "GitHub auth token + * is required" — which blames the repository for a problem with ADE's + * authorization. Only the repo, and how each caller reaches its own config, + * differ between the two. + */ +export async function fetchAppInstallationStatusForRepo(args: { + repo: GitHubRepoRef | null; + appUserAuth: { + getValidTokenForRelay(): Promise; + auditLog: GitHubRelayAuthAuditLog; + }; + logger: { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + }; + secretReader?: GitHubRelaySecretReader | null; + forceRefresh?: boolean; + /** Resolved lazily: a signed-in machine can carry the check without the App. */ + getAccountAccessToken?: (() => Promise) | null; + fetchImpl?: typeof fetch; +}): Promise { + const appUserToken = await resolveAppUserTokenForRelay({ + appUserAuth: args.appUserAuth, + logger: args.logger, + event: "github.app_installation_status_auth_unavailable", + }); + const accountAccessToken = args.getAccountAccessToken + ? await args.getAccountAccessToken().catch(() => null) + : null; + return await fetchGitHubAppInstallationStatus({ + repo: args.repo, + secretReader: args.secretReader, + fetchImpl: args.fetchImpl, + forceRefresh: args.forceRefresh === true, + githubAppUserToken: appUserToken.token, + appUserAuthFailure: describeAppUserAuthUnavailable(appUserToken.failure), + accountAccessToken, + auditLog: args.appUserAuth.auditLog, + }); +} diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index e2c3019e5..aeb5f0034 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -61,6 +61,7 @@ import { githubCredentialRepositoryAccess, recordGithubCredentialFailure, } from "./githubCredentialHealth"; +import { makeStoredAppUserToken } from "./githubAppUserAuth.testFixtures"; // --------------------------------------------------------------------------- // Helpers @@ -98,6 +99,8 @@ afterAll(() => { } }); +let memoryCredentialStoreCounter = 0; + class MemoryCredentialStore { values = new Map(); @@ -124,6 +127,31 @@ class MemoryCredentialStore { deleteSync(key: string): void { this.values.delete(key); } + + /** + * The atomic single-key update the shipped store provides, and the one the + * refresh ledger actually writes through. Without it these tests exercised + * the read-modify-write fallback instead of the production path. + */ + updateKeySync( + key: string, + mutator: (current: string | null) => string | null | undefined, + ): void { + const next = mutator(this.values.get(key) ?? null); + if (next === undefined) return; + if (next === null) this.values.delete(key); + else this.values.set(key, next); + } + + /** + * Distinct per instance: the refresh coordinator is keyed on this, and two + * unrelated stores in one suite must not share one. + */ + credentialStoreIdentity(): string { + return `memory-credential-store-${this.storeId}`; + } + + private readonly storeId = (memoryCredentialStoreCounter += 1); } /** @@ -153,6 +181,7 @@ function makeService(options: { | Promise<{ token: string | null; ghCliPath: string | null; ghAuthError: string | null }>; githubRelaySecretReader?: (ref: string) => string | null; getAccountAccessToken?: () => Promise; + onAppUserAuthChanged?: () => void; } = {}) { return createGithubService({ logger: makeLogger(), @@ -162,6 +191,7 @@ function makeService(options: { ghAuthTokenProvider: options.ghAuthTokenProvider, githubRelaySecretReader: options.githubRelaySecretReader, getAccountAccessToken: options.getAccountAccessToken, + onAppUserAuthChanged: options.onAppUserAuthChanged, }); } @@ -1538,21 +1568,68 @@ describe("githubService.getStatus", () => { ); }); + it("reports a throttled GitHub App refresh as rate limited with the retry time", async () => { + stubOriginRemote(); + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", makeStoredAppUserToken({ userLogin: "alice" })); + mockFetch.mockResolvedValueOnce(jsonResponse(429, { error: "too_many_requests" }, { "retry-after": "120" })); + + const status = await makeService({ credentialStore }).getStatus(); + + expect(status.authFailure?.kind).toBe("rate_limited"); + // The deadline is ADE's own backoff, which is what actually gates the next + // attempt — a cosmetic retryAt would tell the user to wait for nothing. + const retryAtMs = Date.parse(status.authFailure?.retryAt ?? ""); + expect(retryAtMs).toBeGreaterThan(Date.now()); + expect(retryAtMs).toBeLessThanOrEqual(Date.now() + 121_000); + }); + + // GitHub answers a secondary rate limit with a bare 403 and no body at all. + // The refresh path reads that as a pause; re-deriving the kind from the error + // it throws reads the same 403 as "permission denied" — an accusation against + // the account, with a Fix-GitHub-auth button attached to it. + it("keeps a bare-403 refresh a rate limit rather than a permission problem", async () => { + stubOriginRemote(); + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", makeStoredAppUserToken({ userLogin: "alice" })); + mockFetch.mockResolvedValueOnce(jsonResponse(403, {})); + + const status = await makeService({ credentialStore }).getStatus(); + + expect(status.authFailure?.kind).toBe("rate_limited"); + expect(status.credentialStates).toEqual(expect.arrayContaining([ + expect.objectContaining({ + source: "app", + failure: expect.objectContaining({ kind: "rate_limited" }), + }), + ])); + }); + + it("reports a refresh that never reached GitHub as a network failure", async () => { + stubOriginRemote(); + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", makeStoredAppUserToken({ userLogin: "alice" })); + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); + + const status = await makeService({ credentialStore }).getStatus(); + + expect(status.authFailure?.kind).toBe("network"); + }); + it("reports a GitHub App refresh failure instead of treating authorization as missing", async () => { stubOriginRemote(); const credentialStore = new MemoryCredentialStore(); - credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + credentialStore.setSync("github.appUserToken.v1", makeStoredAppUserToken({ accessToken: "ghu_expiring_app_token", - tokenType: "bearer", - scope: null, expiresAt: new Date(Date.now() + 10_000).toISOString(), refreshToken: "ghr_refresh_token", refreshTokenExpiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), userLogin: "alice", - updatedAt: new Date().toISOString(), })); - mockFetch.mockResolvedValueOnce(jsonResponse(400, { - error: "bad_verification_code", + // GitHub's real answer for a rejected refresh token: HTTP 200 with an error + // body. Only a definitive code like this one may write the credential off. + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + error: "bad_refresh_token", error_description: "Bad credentials", })); @@ -1564,7 +1641,10 @@ describe("githubService.getStatus", () => { connected: false, authFailure: { kind: "invalid_token", - message: "Bad credentials", + // GitHub's own wording ("Bad credentials") is kept for the log and the + // stored refresh ledger; what reaches a status surface is the sentence + // that says what to do about it. + message: "ADE GitHub App authorization expired. Re-authorize ADE with GitHub.", retryAt: null, }, credentialStates: expect.arrayContaining([ @@ -1582,19 +1662,16 @@ describe("githubService.getStatus", () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; const credentialStore = new MemoryCredentialStore(); - credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + credentialStore.setSync("github.appUserToken.v1", makeStoredAppUserToken({ accessToken: "ghu_expiring_app_token", - tokenType: "bearer", - scope: null, expiresAt: new Date(Date.now() + 10_000).toISOString(), refreshToken: "ghr_refresh_token", refreshTokenExpiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), userLogin: "alice", - updatedAt: new Date().toISOString(), })); mockFetch - .mockResolvedValueOnce(jsonResponse(400, { - error: "bad_verification_code", + .mockResolvedValueOnce(jsonResponse(200, { + error: "bad_refresh_token", error_description: "Bad credentials", })) .mockResolvedValueOnce( @@ -2826,6 +2903,24 @@ describe("githubService.getAppInstallationStatus", () => { expect(mockFetch).not.toHaveBeenCalled(); }); + it("names the account problem instead of the relay's 401 when authorization is paused", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", makeStoredAppUserToken({ userLogin: "alice" })); + // GitHub throttles the refresh, then the relay answers the token-less status + // request with its own "GitHub auth token is required". + mockFetch.mockResolvedValueOnce(jsonResponse(429, { error: "too_many_requests" }, { "retry-after": "120" })); + mockFetch.mockResolvedValueOnce(jsonResponse(401, { ok: false, error: "GitHub auth token is required" })); + + const status = await makeService({ + credentialStore, + getAccountAccessToken: async () => "clerk-account-token", + }).getAppInstallationStatus({ owner: "acme", name: "repo" }); + + expect(status.state).toBe("error"); + expect(status.error).not.toBe("GitHub auth token is required"); + expect(status.error).toContain("Waiting on GitHub authorization"); + }); + it("does not use the user's existing GitHub token for hosted relay checks", async () => { process.env.ADE_GITHUB_TOKEN = "ghp_user_token"; @@ -2988,6 +3083,53 @@ describe("githubService GitHub App user authorization", () => { resetMocks(); }); + // The relay ingress loop stops asking for a credential it found broken, and + // that cooldown outlives the repair. Without this signal the user finishes + // the device flow and then waits out the remainder of it before anything + // reconnects. + it("announces every change of the App credential to its owner", async () => { + const credentialStore = new MemoryCredentialStore(); + const onAppUserAuthChanged = vi.fn(); + const service = makeService({ credentialStore, onAppUserAuthChanged }); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { + device_code: "device-code", + user_code: "ADE-CODE", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 1, + })) + .mockResolvedValueOnce(jsonResponse(200, { + access_token: "ghu_app_user_token", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "ghr_refresh_token", + refresh_token_expires_in: 15_552_000, + })) + .mockResolvedValueOnce(jsonResponse(200, { login: "octocat" })); + + const start = await service.startAppUserDeviceAuth(); + expect(onAppUserAuthChanged).not.toHaveBeenCalled(); + + await service.pollAppUserDeviceAuth({ sessionId: start.sessionId }); + expect(onAppUserAuthChanged).toHaveBeenCalledTimes(1); + + service.clearAppUserAuth(); + expect(onAppUserAuthChanged).toHaveBeenCalledTimes(2); + }); + + it("does not let a failing owner break the device flow", async () => { + const credentialStore = new MemoryCredentialStore(); + const service = makeService({ + credentialStore, + onAppUserAuthChanged: () => { + throw new Error("ingress service is gone"); + }, + }); + + expect(() => service.clearAppUserAuth()).not.toThrow(); + }); + it("stores the GitHub App user token returned by device flow polling", async () => { const credentialStore = new MemoryCredentialStore(); const service = makeService({ credentialStore }); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 564bccbfc..3c6b72040 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -23,7 +23,9 @@ import { resolveAdeLayout } from "../../../shared/adeLayout"; import { parseGithubRemoteUrl } from "../../../shared/githubRemote"; import { parseGitHubScopeHeaders } from "../../../shared/githubScopes"; import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/credentials/credentialStore"; +import { createExpiringPromiseCache } from "../../../shared/expiringPromiseCache"; import { + GITHUB_CREDENTIAL_CACHE_TTL_MS, evaluateGithubCredentialCapabilities, githubOperationCredentialCandidates, githubOperationCredentialPrecedence, @@ -40,8 +42,12 @@ import { } from "../../../shared/githubApiPath"; import { createGithubConditionalRequestCache } from "../../../shared/githubConditionalRequestCache"; import { mergePathEntries, resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; -import { fetchGitHubAppInstallationStatus, type GitHubRelaySecretReader } from "./githubRelayConfig"; +import { fetchAppInstallationStatusForRepo, type GitHubRelaySecretReader } from "./githubRelayConfig"; import { createGitHubAppUserAuthService } from "./githubAppUserAuthService"; +import { + appCredentialFailureEntry, + resolveStoredAppUserTokenForRelay, +} from "./githubAppUserAuthFailure"; import { GITHUB_REST_API_VERSION } from "./githubApiVersion"; import { readCredentialWithState } from "./credentialReadState"; import { @@ -87,7 +93,6 @@ const MACHINE_TOKEN_KEY = "github.token.v1"; const GITHUB_API_TIMEOUT_MS = 20_000; export const GITHUB_API_BODY_TIMEOUT_MS = 30_000; const GH_AUTH_TOKEN_CACHE_TTL_MS = 30_000; -const GITHUB_CREDENTIAL_INVENTORY_CACHE_TTL_MS = 30_000; const GH_HOSTS_TOKEN_CACHE_MAX_ENTRIES = 32; const GITHUB_STATUS_FAILURE_COOLDOWN_MS = 30_000; const execFileAsync = promisify(execFile); @@ -558,6 +563,7 @@ export function createGithubService({ ghAuthTokenProvider, githubRelaySecretReader, getAccountAccessToken, + onAppUserAuthChanged, }: { logger: Logger; projectRoot: string; @@ -566,6 +572,16 @@ export function createGithubService({ ghAuthTokenProvider?: GitHubCliAuthProvider | null; githubRelaySecretReader?: GitHubRelaySecretReader | null; getAccountAccessToken?: (() => Promise) | null; + /** + * Called when the stored GitHub App credential is replaced or removed. + * + * The relay ingress loop stops asking for a credential it just found broken, + * and that cooldown outlives the repair: after a successful device flow the + * user waits out the remainder of it before anything reconnects. The service + * cannot clear that itself — the ingress service must stay free of GitHub + * internals — so the owner that holds both wires this up. + */ + onAppUserAuthChanged?: (() => void) | null; }) { const legacyGithubStateDir = resolveAdeLayout(projectRoot).githubSecretsDir; const legacyTokenPath = path.join(legacyGithubStateDir, AUTH_STORE_FILE_NAME); @@ -587,14 +603,17 @@ export function createGithubService({ const sharedGhAuth = processGithubAuthState(ghAuthProvider); let statusInFlight: Promise | null = null; let cachedStatusCredentialInventoryKey: string | null = null; - let credentialInventoryCache: { - expiresAt: number; - revision: number; - promise: Promise; - } | null = null; + + // The revision guard rides alongside the TTL: signing out of the `gh` CLI + // bumps it, and that must demote the write credential now rather than in + // thirty seconds. + const credentialInventoryCache = createExpiringPromiseCache({ + ttlMs: GITHUB_CREDENTIAL_CACHE_TTL_MS, + build: buildCredentialInventory, + }); const invalidateCredentialInventory = (): void => { - credentialInventoryCache = null; + credentialInventoryCache.clear(); sharedGhAuth.credentialInventoryRevision += 1; }; @@ -619,6 +638,17 @@ export function createGithubService({ invalidateStatusCache(); }; + /** Tells the owner the App credential changed, without letting it fail a call. */ + const notifyAppUserAuthChanged = (): void => { + try { + onAppUserAuthChanged?.(); + } catch (error) { + logger.warn("github.app_user_auth_changed_notify_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }; + /** * Records whether the credential file was readable on the read that just ran, * warning once per transition into unreadable. @@ -848,7 +878,9 @@ export function createGithubService({ : null; }; - const buildCredentialInventory = async (): Promise => { + // A hoisted declaration, so the cache that names it can be declared next to + // the invalidator that owns it rather than after every function it calls. + async function buildCredentialInventory(): Promise { const patLookup = readPatAuthToken(); const patTokenStored = Boolean(patLookup); // Snapshotted here, next to `patTokenStored`, because the read that just ran @@ -859,20 +891,12 @@ export function createGithubService({ const environment = readEnvironmentAuthToken(); const appStatus = appUserAuth.getAuthStatus(); const [appResult, gh] = await Promise.all([ - appStatus.tokenStored - ? appUserAuth.getValidTokenForRelay() - .then((token) => ({ token, failure: null })) - .catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - const failure = classifyGitHubAuthFailure({ message }); - logger.warn("github.app_user_token_refresh_failed", { - error: message, - kind: failure.authFailure.kind, - retryAt: failure.authFailure.retryAt, - }); - return { token: null, failure }; - }) - : Promise.resolve({ token: null, failure: null }), + resolveStoredAppUserTokenForRelay({ + status: appStatus, + appUserAuth, + logger, + event: "github.app_user_token_unavailable", + }), readGhAuthToken(), ]); const appToken = appResult.token; @@ -918,9 +942,7 @@ export function createGithubService({ return { candidates, availableSources: new Set(candidates.map((candidate) => candidate.source)), - failures: appResult.failure - ? [{ source: "app", ...appResult.failure }] - : [], + failures: appCredentialFailureEntry(appResult.failure), appTokenStored, patTokenStored, ghCliPath: gh.ghCliPath, @@ -931,31 +953,10 @@ export function createGithubService({ // inventory rather than per source. credentialStoreUnreadable: storeUnreadableForThisRead, }; - }; + } - const readCredentialInventory = async (): Promise => { - const now = Date.now(); - const revision = sharedGhAuth.credentialInventoryRevision; - if ( - credentialInventoryCache - && credentialInventoryCache.expiresAt > now - && credentialInventoryCache.revision === revision - ) { - return await credentialInventoryCache.promise; - } - const promise = buildCredentialInventory(); - credentialInventoryCache = { - expiresAt: now + GITHUB_CREDENTIAL_INVENTORY_CACHE_TTL_MS, - revision, - promise, - }; - try { - return await promise; - } catch (error) { - if (credentialInventoryCache?.promise === promise) credentialInventoryCache = null; - throw error; - } - }; + const readCredentialInventory = async (): Promise => + await credentialInventoryCache.read(sharedGhAuth.credentialInventoryRevision); const readAuthToken = async ( capability: GithubOperationCredentialCapability = "read", @@ -2075,17 +2076,13 @@ export function createGithubService({ const owner = args.owner?.trim(); const name = args.name?.trim(); const repo = owner && name ? { owner, name } : await detectRepo(); - const githubAppUserToken = await appUserAuth.getValidTokenForRelay().catch(() => null); - const accountAccessToken = getAccountAccessToken - ? await getAccountAccessToken().catch(() => null) - : null; - return fetchGitHubAppInstallationStatus({ + return await fetchAppInstallationStatusForRepo({ repo, + appUserAuth, + logger, secretReader: githubRelaySecretReader, - forceRefresh: args.forceRefresh === true, - githubAppUserToken, - accountAccessToken, - auditLog: appUserAuth.auditLog, + forceRefresh: args.forceRefresh, + getAccountAccessToken, }); }; @@ -2468,6 +2465,7 @@ export function createGithubService({ if (result.status === "authorized") { const currentToken = appUserAuth.getStoredTokenForHealth(); credentialsChanged({ tokensToClear: [previousToken, currentToken] }); + notifyAppUserAuthChanged(); } return result; }, @@ -2476,6 +2474,7 @@ export function createGithubService({ const previousToken = appUserAuth.getStoredTokenForHealth(); const status = appUserAuth.clearAuth(); credentialsChanged({ tokensToClear: [previousToken] }); + notifyAppUserAuthChanged(); return status; }, diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 6af609e4e..69fe1ba2d 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -1698,6 +1698,31 @@ describe("prService.getGithubSnapshot", () => { expect(githubService.apiRequest).not.toHaveBeenCalled(); }); + // ADE renewing its own App authorization is not a credential problem, so the + // snapshot error must not send the user to Settings to replace anything. + it("says ADE is renewing rather than blaming the credential mid-renewal", async () => { + const githubService = makeGithubService({ + getStatus: vi.fn(async () => makeGithubStatus({ + connected: false, + authFailure: { + kind: "renewing", + message: "ADE is renewing this authorization — this takes a moment.", + retryAt: null, + }, + })), + apiRequest: vi.fn(async () => ({ data: [] })), + }); + const { service } = buildService({ githubService, laneService: makeLaneService([]) }); + + const error = await service.getGithubSnapshot().then( + () => { throw new Error("expected getGithubSnapshot to reject"); }, + (reason: unknown) => reason as Error, + ); + expect(error.message).toContain("renewing its GitHub authorization"); + expect(error.message).not.toContain("Update it in Settings"); + expect(githubService.apiRequest).not.toHaveBeenCalled(); + }); + // Corroboration is attached to `unknown` failures too (GitHub answered with // something we could not classify). A confirmed incident is positive evidence // regardless of how the response itself was classified. diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index e9503e975..de4e2e8e4 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -9250,6 +9250,12 @@ export function createPrService({ : " Try again after GitHub resets the API limit."; return `GitHub API rate limit reached.${retry}`; } + // ADE is renewing its own GitHub App authorization. Nothing about the + // credential is in question, so this must not fall through to the + // "auth is invalid — update it in Settings" default. + if (githubStatus.authFailure?.kind === "renewing") { + return "ADE is renewing its GitHub authorization — pull requests will sync again in a moment."; + } // GitHub itself failed, so nothing about the credential is in question. // Without this arm a 503 falls through to the "auth is invalid — update it // in Settings" default, which is the exact accusation the outage work diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index c4258271b..71cdf76de 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -5938,6 +5938,9 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { userLogin: "arul", expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(), refreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString(), + credentialState: "authorized", + refreshBlockedUntil: null, + lastRefreshError: null, checkedAt: new Date().toISOString(), error: null, }), @@ -5959,6 +5962,9 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { userLogin: "arul", expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(), refreshTokenExpiresAt: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString(), + credentialState: "authorized", + refreshBlockedUntil: null, + lastRefreshError: null, checkedAt: new Date().toISOString(), error: null, }, @@ -5969,6 +5975,9 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { userLogin: null, expiresAt: null, refreshTokenExpiresAt: null, + credentialState: "missing", + refreshBlockedUntil: null, + lastRefreshError: null, checkedAt: new Date().toISOString(), error: null, }), @@ -5992,6 +6001,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { webhookLastSeenAt: new Date().toISOString(), checkedAt: new Date().toISOString(), error: null, + appUserAuthFailure: null, }), listRepoAutolinks: resolved([]), createRepoAutolink: resolvedArg({ id: 1, keyPrefix: "ADEPR-", urlTemplate: "https://ade-app.dev/open?type=pr&repo=arul28%2FADE&number=", isAlphanumeric: false }), diff --git a/apps/desktop/src/renderer/components/app/IntegrationBannerHost.test.tsx b/apps/desktop/src/renderer/components/app/IntegrationBannerHost.test.tsx index 0bde21854..f3e1a3135 100644 --- a/apps/desktop/src/renderer/components/app/IntegrationBannerHost.test.tsx +++ b/apps/desktop/src/renderer/components/app/IntegrationBannerHost.test.tsx @@ -4,12 +4,12 @@ import { act, cleanup, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GitHubAppInstallationStatus, - GitHubAppUserAuthStatus, GitHubStatus, SyncRouteHealth, } from "../../../shared/types"; import { IntegrationBannerHost, type IntegrationBannerHostProps } from "./IntegrationBannerHost"; import { deriveGitHubServiceHealth } from "../../../shared/githubServiceHealth"; +import { makeAppAuth } from "../../lib/githubIntegrationStatus.testFixtures"; // Built through the real parser rather than hand-written, so the fixture stays // honest against deriveGitHubServiceHealth's own rules. @@ -60,19 +60,7 @@ function makeInstall(overrides: Partial = {}): GitH webhookLastSeenAt: null, checkedAt: "2026-07-01T00:00:00.000Z", error: null, - ...overrides, - }; -} - -function makeAuth(overrides: Partial = {}): GitHubAppUserAuthStatus { - return { - configured: true, - tokenStored: true, - userLogin: "octocat", - expiresAt: null, - refreshTokenExpiresAt: null, - checkedAt: "2026-07-01T00:00:00.000Z", - error: null, + appUserAuthFailure: null, ...overrides, }; } @@ -155,7 +143,7 @@ describe("IntegrationBannerHost", () => { it("caps at two banners and collapses the rest behind an expandable row", async () => { setAdeMock({ getAppInstallationStatus: vi.fn(async () => makeInstall()), - getAppUserAuthStatus: vi.fn(async () => makeAuth()), + getAppUserAuthStatus: vi.fn(async () => makeAppAuth()), onStatusChanged: vi.fn(() => () => {}), }); @@ -232,6 +220,47 @@ describe("IntegrationBannerHost", () => { expect(screen.getByText("GitHub write access isn't connected")).toBeTruthy(); }); + + /** + * `loadAppStatus` sets `appStatusLoaded` even when the auth read throws or the + * host does not implement the call, so "loaded" alone does not mean an auth + * DTO arrived. A null one is a failed read, not a report of "not authorized" — + * and acting on it painted a banner the user could do nothing about. + */ + it("stays quiet when the account read fails but the install check succeeds", async () => { + setAdeMock({ + getAppInstallationStatus: vi.fn(async () => makeInstall()), + getAppUserAuthStatus: vi.fn(async () => { + throw new Error("the host refused the account read"); + }), + onStatusChanged: vi.fn(() => () => {}), + }); + + await act(async () => { + render(); + }); + await act(async () => {}); + + expect(screen.queryByText("GitHub App not authorized")).toBeNull(); + expect(screen.queryAllByRole("status")).toHaveLength(0); + }); + + // The same install DTO WITH an account read that landed still raises it, so + // the check above is about the missing read and not about the install state. + it("still raises the account banner when the read lands and says missing", async () => { + setAdeMock({ + getAppInstallationStatus: vi.fn(async () => makeInstall()), + getAppUserAuthStatus: vi.fn(async () => makeAppAuth({ tokenStored: false, credentialState: "missing" })), + onStatusChanged: vi.fn(() => () => {}), + }); + + await act(async () => { + render(); + }); + await act(async () => {}); + + expect(screen.getByText("GitHub App not authorized")).toBeTruthy(); + }); }); describe("IntegrationBannerHost relay-offline banner", () => { @@ -363,7 +392,7 @@ describe("IntegrationBannerHost relay-offline banner", () => { it("replaces every GitHub credential complaint with one neutral outage notice", async () => { setAdeMock({ getAppInstallationStatus: vi.fn(async () => makeInstall()), - getAppUserAuthStatus: vi.fn(async () => makeAuth({ tokenStored: false })), + getAppUserAuthStatus: vi.fn(async () => makeAppAuth({ tokenStored: false })), onStatusChanged: vi.fn(() => () => {}), }); @@ -399,7 +428,7 @@ describe("IntegrationBannerHost relay-offline banner", () => { // an outage must not hide the one thing the user can actually fix. setAdeMock({ getAppInstallationStatus: vi.fn(async () => makeInstall()), - getAppUserAuthStatus: vi.fn(async () => makeAuth({ tokenStored: false })), + getAppUserAuthStatus: vi.fn(async () => makeAppAuth({ tokenStored: false })), onStatusChanged: vi.fn(() => () => {}), }); @@ -459,7 +488,7 @@ describe("IntegrationBannerHost relay-offline banner", () => { it("keeps the outage notice visible when higher-severity banners compete", async () => { setAdeMock({ getAppInstallationStatus: vi.fn(async () => makeInstall()), - getAppUserAuthStatus: vi.fn(async () => makeAuth()), + getAppUserAuthStatus: vi.fn(async () => makeAppAuth()), onStatusChanged: vi.fn(() => () => {}), }); diff --git a/apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx b/apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx index 1a693f10f..6ea834ef3 100644 --- a/apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx +++ b/apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx @@ -19,6 +19,7 @@ import { githubStatusHasWriteCredential, githubAccountIssueCopy, githubRepoIssueCopy, + isGithubAppUserAuthSupported, } from "../../lib/githubIntegrationStatus"; import { settingsRouteFor } from "../settings/settingsManifest"; import { useBannerDismissals } from "../../lib/bannerDismiss"; @@ -250,7 +251,7 @@ export function IntegrationBannerHost({ if (!currentProjectRoot) return; if (appStatusLoaded && loadedRoot === currentProjectRoot) { const account = deriveGithubAccountAuthState(appAuth); - const repo = deriveGithubRepoConnectionState(appInstall); + const repo = deriveGithubRepoConnectionState(appInstall, account); if (account === "valid") clearDismissal("github-app-account"); if (repo === "connected") { const repoKey = appInstall?.repo ? `${appInstall.repo.owner}/${appInstall.repo.name}` : currentProjectRoot; @@ -320,21 +321,21 @@ export function IntegrationBannerHost({ // the current project (loadedRoot === currentProjectRoot), so an unloaded/ // absent API never masquerades as "not authorized" and a project switch // can't paint the previous repo's state. Also require the runtime App-status - // DTOs: the standalone web-client adapter returns stubs - // (`{authenticated,user}` / `{installed:false,state:"unknown"}`) that lack the - // real fields, and treating a stub as loaded would flash a false - // "not authorized" banner on every hosted-web project. Detect the real DTO by - // fields the stub omits (appName/relayConfigured on install, configured on auth). + // DTOs: the standalone web-client adapter returns stubs, and treating a stub + // as loaded would flash a false "not authorized" banner on every hosted-web + // project. The auth stub says so itself (`isGithubAppUserAuthSupported`); + // the install stub is still detected by the fields it omits. const rawInstall = appInstall as Record | null; - const rawAuth = appAuth as Record | null; + // A real status from a host that implements the call. `null` is neither. + const authDtoIsReal = appAuth != null && isGithubAppUserAuthSupported(appAuth); const githubAppStatusSupported = !!rawInstall && typeof rawInstall.appName === "string" && typeof rawInstall.relayConfigured === "boolean" - && (!rawAuth || typeof rawAuth.configured === "boolean"); + && authDtoIsReal; if (!githubSuppressed && appStatusLoaded && currentProjectRoot && loadedRoot === currentProjectRoot && githubAppStatusSupported) { const account = deriveGithubAccountAuthState(appAuth); - const repo = deriveGithubRepoConnectionState(appInstall); + const repo = deriveGithubRepoConnectionState(appInstall, account); const block = deriveGithubRealtimeBlock(account, repo); if (block?.kind === "account") { const copy = githubAccountIssueCopy(block.account); diff --git a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.tsx b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.tsx index d3e957ae6..23deef319 100644 --- a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.tsx +++ b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.test.tsx @@ -2,9 +2,34 @@ import React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { GitHubAppInstallationStatus } from "../../../shared/types"; import { GitHubAppInstallPanel } from "./GitHubAppInstallPanel"; +import { resetGithubAppUserAuthForTests } from "../../lib/useGithubAppUserAuth"; +import { makeAppAuth } from "../../lib/githubIntegrationStatus.testFixtures"; + +function installedStatus(): GitHubAppInstallationStatus { + return { + repo: { owner: "arul28", name: "ADE" }, + appName: "ADE", + appSlug: "ade-for-github", + installUrl: "https://github.com/apps/ade-for-github/installations/new", + manageUrl: "https://github.com/settings/installations", + relayConfigured: true, + installed: true, + state: "configured", + installationId: 1, + repositorySelection: "all", + lastSeenAt: null, + webhookEvents: ["pull_request"], + missingWebhookEvents: [], + webhookState: "active", + webhookLastSeenAt: null, + checkedAt: new Date().toISOString(), + error: null, + appUserAuthFailure: null, + }; +} describe("GitHubAppInstallPanel", () => { const originalAde = window.ade; @@ -12,6 +37,9 @@ describe("GitHubAppInstallPanel", () => { afterEach(() => { cleanup(); window.ade = originalAde; + // The App account status is shared across surfaces, so it outlives one + // render and would otherwise carry into the next test. + resetGithubAppUserAuthForTests(); }); it("does not claim account authorization while a repo check is rate limited", async () => { @@ -33,6 +61,7 @@ describe("GitHubAppInstallPanel", () => { webhookLastSeenAt: null, checkedAt: new Date().toISOString(), error: "GitHub API rate limit reached", + appUserAuthFailure: null, }; window.ade = { github: { @@ -49,4 +78,69 @@ describe("GitHubAppInstallPanel", () => { expect(screen.queryByText(/still authorized/i)).toBeNull(); expect(screen.queryByText(/re-authorizing is not needed/i)).toBeNull(); }); + + // The device flow polls the same OAuth host that is refusing the renewals, so + // offering the button here is what kept the account locked out. + it("offers no re-authorize button while GitHub has renewals paused", async () => { + const startAppUserDeviceAuth = vi.fn(); + window.ade = { + github: { + getAppInstallationStatus: vi.fn(async () => installedStatus()), + getAppUserAuthStatus: vi.fn(async () => makeAppAuth({ + credentialState: "blocked", + refreshBlockedUntil: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + lastRefreshError: { kind: "rate_limited", message: "429", status: 429, at: new Date().toISOString() }, + })), + startAppUserDeviceAuth, + }, + } as unknown as typeof window.ade; + + render(); + + expect(await screen.findByText(/^Paused until /)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /re-authorize/i })).toBeNull(); + expect(screen.queryByText(/authorization expired/i)).toBeNull(); + expect(startAppUserDeviceAuth).not.toHaveBeenCalled(); + }); + + it("clears the stored authorization only after the disconnect is confirmed", async () => { + const clearAppUserAuth = vi.fn(async () => makeAppAuth({ tokenStored: false, credentialState: "missing" })); + window.ade = { + github: { + getAppInstallationStatus: vi.fn(async () => installedStatus()), + getAppUserAuthStatus: vi.fn(async () => makeAppAuth()), + clearAppUserAuth, + }, + } as unknown as typeof window.ade; + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Disconnect" })); + expect(clearAppUserAuth).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Confirm disconnect" })); + expect(clearAppUserAuth).toHaveBeenCalledTimes(1); + expect(await screen.findByText("Not authorized")).toBeTruthy(); + }); + + // Arming unmounts the button the user just activated. Without moving focus, + // it lands on the document body and the user has to tab back into the card to + // finish or cancel a destructive action. Focus goes to Cancel, never to + // Confirm: the Enter that armed this may still be down, and a key repeat must + // not be able to clear the credential. + it("moves keyboard focus to Cancel when the disconnect arms", async () => { + window.ade = { + github: { + getAppInstallationStatus: vi.fn(async () => installedStatus()), + getAppUserAuthStatus: vi.fn(async () => makeAppAuth()), + clearAppUserAuth: vi.fn(async () => makeAppAuth({ tokenStored: false, credentialState: "missing" })), + }, + } as unknown as typeof window.ade; + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Disconnect" })); + + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Cancel" })); + }); }); diff --git a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx index 95ade73a8..e10c9c7c1 100644 --- a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx +++ b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx @@ -11,12 +11,18 @@ import { COLORS, MONO_FONT, SANS_FONT, cardStyle, inlineBadge, outlineButton, pr import { deriveGithubAccountAuthState, deriveGithubRepoConnectionState, - githubAccountIssueCopy, + describeGithubAccountAxis, + deviceAuthErrorCopy, + deviceAuthMessageCopy, githubRepoIssueCopy, isGithubRateLimitMessage, + isGithubAppUserAuthSupported, isGithubRealtimeHealthy, isGithubRepoAccessPending, + type GithubAccountAuthState, + type GithubAccountAxisTone, } from "../../lib/githubIntegrationStatus"; +import { useGithubAppUserAuth } from "../../lib/useGithubAppUserAuth"; import { isGithubServiceUnavailable } from "../../../shared/githubServiceHealth"; const ADE_GITHUB_APP_NAME = "ADE"; @@ -32,12 +38,21 @@ type GitHubAppInstallPanelProps = { export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstallPanelProps) { const compact = variant === "onboarding"; const [status, setStatus] = useState(null); - const [appAuth, setAppAuth] = useState(null); + // Shared with the Settings connection ladder, so disconnecting here updates + // the badge there instead of leaving it reporting a removed authorization. + const { + appAuth, + loaded: appAuthLoaded, + refresh: refreshAppAuth, + set: setAppAuth, + } = useGithubAppUserAuth(); const [deviceSession, setDeviceSession] = useState(null); const [deviceMessage, setDeviceMessage] = useState(null); const [deviceCodeCopied, setDeviceCodeCopied] = useState(false); const [loading, setLoading] = useState(false); const [authLoading, setAuthLoading] = useState(false); + const [disconnectArmed, setDisconnectArmed] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); const autoRenewCountRef = useRef(0); const copyFeedbackTimeoutRef = useRef(null); const appAuthRef = useRef(null); @@ -88,6 +103,8 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall webhookLastSeenAt: null, checkedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error), + // The call itself failed, so nothing was learned about the account. + appUserAuthFailure: null, }); } finally { const isCurrentRequest = () => mountedRef.current && statusRequestSeqRef.current === requestSeq; @@ -95,9 +112,11 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall // expired stored token can be cleared during the status check, and the // panel must reflect that immediately. if (isCurrentRequest()) { - const authStatus = await window.ade.github.getAppUserAuthStatus?.().catch(() => null); + // Forced: this read has to have started after the status call, or it + // joins one from before it and reports the credential that check + // replaced. + await refreshAppAuth({ force: true }); if (isCurrentRequest()) { - setAppAuth(authStatus ?? null); if (opts.retryAfterAuthorization) { setDeviceMessage( latestStatus && isGithubRepoAccessPending(latestStatus) @@ -109,7 +128,7 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall } } } - }, []); + }, [refreshAppAuth]); const startAppAuthorization = useCallback(async () => { autoRenewCountRef.current = 0; @@ -122,12 +141,28 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall setDeviceSession(session); openExternalUrl(session.verificationUriComplete ?? session.verificationUri); } catch (error) { - setDeviceMessage(error instanceof Error ? error.message : String(error)); + setDeviceMessage(deviceAuthErrorCopy(error)); } finally { setAuthLoading(false); } }, []); + const disconnectAppAuthorization = useCallback(async () => { + if (!window.ade?.github?.clearAppUserAuth) return; + setDisconnecting(true); + try { + const next = await window.ade.github.clearAppUserAuth(); + setAppAuth(next ?? null); + setDeviceSession(null); + setDeviceMessage("ADE's GitHub authorization was removed on this machine."); + } catch (error) { + setDeviceMessage(deviceAuthErrorCopy(error)); + } finally { + setDisconnecting(false); + setDisconnectArmed(false); + } + }, [setAppAuth]); + const copyDeviceCode = useCallback(async () => { if (!deviceSession) return; try { @@ -156,14 +191,14 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall result = { status: "error", intervalSec: null, - message: error instanceof Error ? error.message : String(error), + message: deviceAuthErrorCopy(error), authStatus: appAuthRef.current, }; } if (cancelled || !result) return; setAppAuth(result.authStatus); if (result.status === "pending" || result.status === "slow_down") { - setDeviceMessage(result.message); + setDeviceMessage(deviceAuthMessageCopy(result.message)); setDeviceSession({ ...deviceSession, intervalSec: result.intervalSec ?? deviceSession.intervalSec }); return; } @@ -182,12 +217,12 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall } catch (error) { if (cancelled) return; setDeviceSession(null); - setDeviceMessage(error instanceof Error ? error.message : String(error)); + setDeviceMessage(deviceAuthErrorCopy(error)); } return; } setDeviceSession(null); - setDeviceMessage(result.message); + setDeviceMessage(deviceAuthMessageCopy(result.message)); if (result.status === "authorized") { autoRenewCountRef.current = 0; setDeviceMessage("GitHub authorization is complete. Checking repository access..."); @@ -203,7 +238,7 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall cancelled = true; window.clearTimeout(timeout); }; - }, [deviceSession, loadStatus]); + }, [deviceSession, loadStatus, setAppAuth]); useEffect(() => { setDeviceCodeCopied(false); @@ -227,30 +262,80 @@ export function GitHubAppInstallPanel({ variant = "settings" }: GitHubAppInstall // Two independent axes, derived by the shared helper so Settings and the // banner can never disagree. const accountState = deriveGithubAccountAuthState(appAuth); - const repoState = deriveGithubRepoConnectionState(status); - const appAuthorized = accountState !== "missing"; + const repoState = deriveGithubRepoConnectionState(status, accountState); + const appAuthorized = accountState === "valid"; const repoLabel = status?.repo ? `${status.repo.owner}/${status.repo.name}` : null; const healthy = isGithubRealtimeHealthy(accountState, repoState); - // `appAuth === null` = not fetched yet (distinct from a fetched "no token"). - const accountChecking = appAuth === null && loading; + // The account axis is "checking" until the account read lands, and nothing + // else decides that. `loading` belongs to the REPO status beside it, and + // gating on it reported an unread account as `missing` — an offer to + // authorize a machine that may already be authorized — whenever the repo + // status settled first. + const accountChecking = !appAuthLoaded; const secondaryBtnStyle = outlineButton(compact ? compactSecondaryButtonStyle : undefined); const primaryBtnStyle = primaryButton(compact ? compactPrimaryButtonStyle : undefined); const authBusy = authLoading || Boolean(deviceSession); - const accountCta = ( + const account = describeGithubAccountAxis(accountChecking ? "checking" : accountState, appAuth); + // No button in the states ADE recovers from on its own. Offering one while + // GitHub has the account paused is what sent users back through the device + // flow against the very endpoint that was refusing them. + const accountCta = account.cta ? ( - ); - - const account = accountView(accountState, appAuth, accountChecking); + ) : null; + + // Unobtrusive by design: a plain muted control, and only where there is + // something to disconnect. Two clicks, because clearing the credential stops + // real-time updates until the user goes through the device flow again. + const showDisconnect = accountState !== "missing" + && !accountChecking + && !deviceSession + // Hosts without the clear action (older brains) must not show a control + // that silently does nothing — and neither must the web client, whose stub + // implements the call but has no credential to clear. Both are judged by + // one mechanism, so a new stub cannot pass half of the check. + && typeof window.ade?.github?.clearAppUserAuth === "function" + && isGithubAppUserAuthSupported(appAuth); + const disconnectControl = showDisconnect ? ( + disconnectArmed ? ( + <> + + + + ) : ( + + ) + ) : null; const recheckButton = (primaryStyle: boolean) => (