From 44eab4a215d6599cf8886b6e4c33df422d424546 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:01:20 -0400 Subject: [PATCH 01/12] fix(github): fail over credentials and reduce polling --- apps/ade-cli/README.md | 6 + apps/ade-cli/src/bootstrap.ts | 3 + apps/ade-cli/src/cli.ts | 9 +- .../src/headlessLinearServices.test.ts | 164 +++- apps/ade-cli/src/headlessLinearServices.ts | 685 ++++++++++++++--- apps/desktop/src/main/main.ts | 5 + .../automationIngressService.test.ts | 30 +- .../automations/automationIngressService.ts | 67 +- .../github/githubCredentialHealth.test.ts | 135 ++++ .../services/github/githubCredentialHealth.ts | 258 +++++++ .../main/services/github/githubRateLimit.ts | 69 ++ .../services/github/githubService.test.ts | 255 ++++--- .../src/main/services/github/githubService.ts | 705 +++++++++++++----- .../src/main/services/prs/prAsync.test.ts | 41 + .../src/main/services/prs/prPollingService.ts | 38 +- .../src/main/services/prs/prService.test.ts | 29 +- .../src/main/services/prs/prService.ts | 5 +- apps/desktop/src/renderer/browserMock.ts | 52 +- .../components/app/FeedbackReporterModal.tsx | 3 +- .../app/IntegrationBannerHost.test.tsx | 18 + .../components/app/IntegrationBannerHost.tsx | 11 +- .../components/settings/GitHubSection.tsx | 122 ++- .../lib/githubIntegrationStatus.test.ts | 4 +- .../renderer/lib/githubIntegrationStatus.ts | 38 +- .../src/shared/githubOperationCredential.ts | 88 ++- apps/desktop/src/shared/types/git.ts | 35 +- apps/webhook-relay/src/relay.ts | 19 +- apps/webhook-relay/test/account.test.ts | 21 +- docs/features/automations/README.md | 8 +- .../onboarding-and-settings/README.md | 76 +- docs/features/pull-requests/README.md | 160 ++-- 31 files changed, 2577 insertions(+), 582 deletions(-) create mode 100644 apps/desktop/src/main/services/github/githubCredentialHealth.test.ts create mode 100644 apps/desktop/src/main/services/github/githubCredentialHealth.ts diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 3f136cc60..f3247ae3b 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -506,6 +506,7 @@ ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fi 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 --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/ ade open --linear-issue ADE-123 --branch arul/ade-123-fix ade link lane @@ -521,6 +522,11 @@ ade skill list --text ade skill show ade-browser --text ``` +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, +fallback details, and any background-refresh pause without exposing tokens. + Use typed commands first. They validate common arguments and provide stable JSON fields or readable text summaries. Use `ade help ` for exact flags, `ade actions list --text` to discover the full service-backed action catalog, and `ade actions run ` only when there is no typed command for the workflow yet. For stored project credentials, prefer `ade secrets`; `list` is metadata-only and `get --text` prints the secret value, so agents should read only the named secret the user asked for and avoid logging it. Output modes are explicit: `--text` for human-readable summaries, `--json` (default for piped output) for stable JSON, and `--pretty` for pretty-printed JSON. diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 4ebc9d023..f449c473c 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1446,6 +1446,9 @@ export async function createAdeRuntime(args: { prService: headlessLinearServices.prService, projectConfigService, db, + isGithubRelayHealthy: () => automationIngressService.isGithubRelayHealthy(), + getGithubBackgroundPauseUntilMs: () => + headlessLinearServices.githubService.getBackgroundRequestPauseUntilMs(), onEvent: emitPrEvent, onPullRequestsSnapshot: (snapshot) => prMergeAutoSettlementService?.processSnapshot(snapshot), diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index be56a61f7..25e7d711b 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1212,15 +1212,18 @@ const HELP_BY_COMMAND: Record = { $ ade github app-auth status --text Show whether a token is stored (login, 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 + Show active read/write credentials and cooldowns Notes: - login, clear (and the raw start/poll actions) require --role cto. - login keeps one connection open for the whole device flow because the device-auth session lives in runtime memory; do not split start and poll across separate invocations in headless mode. - - GitHub operations prefer an explicit environment token, then GitHub CLI, - and finally a stored PAT. The GitHub App remains read-only and is used - only for webhook-backed PR updates. + - GitHub reads try an explicit environment token, the ADE GitHub App, + 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. Flags (login): --max-wait Give up waiting after N seconds (default: GitHub's diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 417b4a1fa..f0093d598 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 { createHeadlessGitHubService, createHeadlessLinearServices } from "./headlessLinearServices"; +import { clearGithubCredentialHealth } from "../../desktop/src/main/services/github/githubCredentialHealth"; function createDeps(overrides: Record = {}) { const projectRoot = overrides.projectRoot ?? "/tmp/ade-project"; @@ -71,6 +72,7 @@ function createDeps(overrides: Record = {}) { describe("headlessLinearServices", () => { beforeEach(() => { + clearGithubCredentialHealth(); process.env.ADE_DISABLE_GH_AUTH_FALLBACK = "1"; vi.clearAllMocks(); }); @@ -711,7 +713,7 @@ describe("headlessLinearServices", () => { } }); - it("keeps the read-only GitHub App out of operational REST credential selection", async () => { + it("falls back from a rejected GitHub App token to a stored PAT", async () => { const previousAdeHome = process.env.ADE_HOME; const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; const previousGitHubToken = process.env.GITHUB_TOKEN; @@ -757,6 +759,7 @@ describe("headlessLinearServices", () => { try { await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ authSource: "pat", + writeAuthSource: "pat", connected: true, patTokenStored: true, userLogin: "octocat", @@ -769,6 +772,154 @@ describe("headlessLinearServices", () => { }), }), ); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: "Bearer ghu_app_user_token" }), + }), + ); + } finally { + globalThis.fetch = previousFetch; + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN; + else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken; + if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = previousGitHubToken; + if (previousGhToken == null) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = previousGhToken; + if (previousGhConfigDir == null) delete process.env.GH_CONFIG_DIR; + else process.env.GH_CONFIG_DIR = previousGhConfigDir; + } + }); + + it("falls back when headless GraphQL returns rate-limit errors with HTTP 200", async () => { + const previousAdeHome = process.env.ADE_HOME; + const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; + const previousGitHubToken = process.env.GITHUB_TOKEN; + const previousGhToken = process.env.GH_TOKEN; + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-graphql-")); + delete process.env.ADE_GITHUB_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + const machineCredentialStore = new EncryptedFileCredentialStore(); + machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "octocat", + updatedAt: new Date().toISOString(), + })); + const authorizations: string[] = []; + const resetAt = Math.floor(Date.now() / 1_000) + 3600; + globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get("authorization") ?? ""; + authorizations.push(authorization); + if (authorization === "Bearer ghu_app_user_token") { + return new Response(JSON.stringify({ + data: null, + errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }], + }), { + status: 200, + headers: { + "content-type": "application/json", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetAt), + "x-ratelimit-resource": "graphql", + }, + }); + } + return new Response(JSON.stringify({ data: { viewer: { login: "octocat" } } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + try { + await expect(githubService.apiRequest({ + method: "POST", + path: "/graphql", + capability: "read", + body: { query: "query { viewer { login } }" }, + })).resolves.toMatchObject({ data: { data: { viewer: { login: "octocat" } } } }); + expect(authorizations).toEqual([ + "Bearer ghu_app_user_token", + "Bearer gho_cli_token", + ]); + } finally { + globalThis.fetch = previousFetch; + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN; + else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken; + if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = previousGitHubToken; + if (previousGhToken == null) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = previousGhToken; + } + }); + + it("keeps App-only headless reads connected while writes remain unavailable", async () => { + const previousAdeHome = process.env.ADE_HOME; + const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; + const previousGitHubToken = process.env.GITHUB_TOKEN; + const previousGhToken = process.env.GH_TOKEN; + const previousGhConfigDir = process.env.GH_CONFIG_DIR; + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-app-only-")); + process.env.GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-gh-empty-")); + delete process.env.ADE_GITHUB_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + const machineCredentialStore = new EncryptedFileCredentialStore(); + machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "octocat", + updatedAt: new Date().toISOString(), + })); + globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { "content-type": "application/json" }, + })) 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 expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ + authSource: "app", + writeAuthSource: "none", + connected: true, + userLogin: "octocat", + }); + await expect(githubService.getTokenOrThrowAsync()).rejects.toThrow("GitHub auth missing"); } finally { globalThis.fetch = previousFetch; if (previousAdeHome == null) delete process.env.ADE_HOME; @@ -784,7 +935,7 @@ describe("headlessLinearServices", () => { } }); - it("keeps GitHub CLI auth ahead of GitHub App authorization for async REST calls", async () => { + it("falls back from a rejected GitHub App token to GitHub CLI", async () => { const previousAdeHome = process.env.ADE_HOME; const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; const previousGitHubToken = process.env.GITHUB_TOKEN; @@ -836,6 +987,7 @@ describe("headlessLinearServices", () => { try { await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ authSource: "gh", + writeAuthSource: "gh", connected: true, patTokenStored: true, userLogin: "octocat", @@ -848,6 +1000,14 @@ describe("headlessLinearServices", () => { }), }), ); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(fetchImpl).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: "Bearer ghu_app_user_token" }), + }), + ); } finally { globalThis.fetch = previousFetch; if (previousAdeHome == null) delete process.env.ADE_HOME; diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 765d93806..d58e85809 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -28,9 +28,11 @@ import { parseGitHubScopeHeaders, } from "../../desktop/src/shared/githubScopes"; import type { + GitHubAuthFailure, GitHubAppDeviceAuthPollResult, GitHubAppDeviceAuthStartResult, GitHubAppUserAuthStatus, + GitHubRateLimitState, GitHubStatus, } from "../../desktop/src/shared/types"; import type { @@ -46,7 +48,14 @@ import { type GitHubRelaySecretReader, } from "../../desktop/src/main/services/github/githubRelayConfig"; import { createGitHubAppUserAuthService } from "../../desktop/src/main/services/github/githubAppUserAuthService"; -import { classifyGitHubAuthFailure } from "../../desktop/src/main/services/github/githubRateLimit"; +import { + classifyGitHubAuthFailure, + classifyGitHubGraphqlCredentialFailure, + GitHubRateLimitError, + githubRateLimitResourceForPath, + githubRateLimitRetryAtMs, + readGitHubRateLimitState, +} from "../../desktop/src/main/services/github/githubRateLimit"; import type { AdeRuntimePaths } from "./bootstrap"; import { createLinearClient as createLinearClientImpl } from "../../desktop/src/main/services/cto/linearClient"; import { ADE_LINEAR_APP_CLIENT_ID, type LinearOAuthClientSource } from "../../desktop/src/main/services/cto/linearAppClient"; @@ -56,9 +65,22 @@ import { createPrService as createPrServiceImpl } from "../../desktop/src/main/s import { createAutomationSecretService as createAutomationSecretServiceImpl } from "../../desktop/src/main/services/automations/automationSecretService"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import { + githubOperationCredentialCandidates, selectGithubOperationCredential, - selectGithubOperationCredentialAsync, + type GithubOperationCredentialCapability, } from "../../desktop/src/shared/githubOperationCredential"; +import { + clearGithubCredentialHealth, + githubBackgroundRequestPauseUntilMs, + githubCredentialCooldown, + githubCredentialStates, + githubCredentialTokenDigest, + recordGithubCredentialFailure, + recordGithubCredentialProbeSuccess, + recordGithubCredentialSuccess, + registerGithubCredentialIdentity, + type GithubCredentialCandidate, +} from "../../desktop/src/main/services/github/githubCredentialHealth"; import { linearInvalidGrantLikelyStaleRotation, linearTokenNeedsRefresh, @@ -265,6 +287,40 @@ type HeadlessGitHubTokenLookup = { ghAuthError: string | null; }; +type HeadlessGitHubTokenCandidate = HeadlessGitHubTokenLookup & GithubCredentialCandidate & { + token: string; +}; + +type HeadlessGitHubCredentialInventory = { + candidates: HeadlessGitHubTokenCandidate[]; + availableSources: Set; + patTokenStored: boolean; + ghCliPath: string | null; + ghAuthError: string | null; +}; + +class HeadlessGithubCredentialAttemptError extends Error { + constructor( + message: string, + readonly authFailure: GitHubAuthFailure, + readonly rateLimit: GitHubRateLimitState | null, + ) { + super(message); + this.name = "HeadlessGithubCredentialAttemptError"; + } +} + +class HeadlessGitHubTokenValidationError extends Error { + constructor( + message: string, + readonly authFailure: GitHubAuthFailure, + readonly rateLimit: GitHubRateLimitState | null, + ) { + super(message); + this.name = "HeadlessGitHubTokenValidationError"; + } +} + /** * gh's file-based credential store. The launchd brain has a minimal PATH (no * Homebrew) and spawning bare "gh" fails silently, which left every headless @@ -616,6 +672,7 @@ export function createHeadlessGitHubService( ? { token, source: "environment", patTokenStored, ghCliPath: null, ghAuthError: null } : null; }, + app: () => null, gh: () => { const gh = options.ghAuthTokenProvider?.() ?? ghAuthToken(); ghFallback = { ...gh, source: "none", patTokenStored }; @@ -624,7 +681,7 @@ export function createHeadlessGitHubService( pat: () => patToken ? { ...ghFallback, token: patToken, source: "pat", patTokenStored } : null, - }) ?? ghFallback; + }, "write") ?? ghFallback; }; const readStoredPatTokenAsync = async (): Promise => { @@ -639,32 +696,82 @@ export function createHeadlessGitHubService( return null; }; - const readTokenAsync = async (): Promise => { + const readCredentialInventoryAsync = async (): Promise => { const patToken = await readStoredPatTokenAsync(); const patTokenStored = Boolean(patToken); - let ghFallback: HeadlessGitHubTokenLookup = { - token: null, - source: "none", + const environmentToken = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); + const appStatus = appUserAuth.getAuthStatus(); + const [appToken, gh] = await Promise.all([ + appStatus.tokenStored + ? appUserAuth.getValidTokenForRelay().catch(() => null) + : Promise.resolve(null), + Promise.resolve(options.ghAuthTokenProvider?.() ?? ghAuthTokenAsync()), + ]); + const candidates: HeadlessGitHubTokenCandidate[] = []; + if (environmentToken) { + candidates.push({ + token: environmentToken, + source: "environment", + patTokenStored, + ghCliPath: null, + ghAuthError: null, + capabilities: ["read", "write"], + }); + } + if (appToken) { + candidates.push({ + token: appToken, + source: "app", + patTokenStored, + ghCliPath: null, + ghAuthError: null, + capabilities: ["read"], + userLogin: appStatus.userLogin, + }); + } + if (gh.token) { + candidates.push({ + token: gh.token, + source: "gh", + patTokenStored, + ghCliPath: gh.ghCliPath, + ghAuthError: gh.ghAuthError, + capabilities: ["read", "write"], + }); + } + if (patToken) { + candidates.push({ + token: patToken, + source: "pat", + patTokenStored, + ghCliPath: gh.ghCliPath, + ghAuthError: gh.ghAuthError, + capabilities: ["read", "write"], + }); + } + return { + candidates, + availableSources: new Set(candidates.map((candidate) => candidate.source)), patTokenStored, - ghCliPath: null, - ghAuthError: null, + ghCliPath: gh.ghCliPath, + ghAuthError: gh.ghAuthError, }; - return await selectGithubOperationCredentialAsync({ - environment: () => { - const token = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); - return token - ? { token, source: "environment", patTokenStored, ghCliPath: null, ghAuthError: null } - : null; - }, - gh: async () => { - const gh = options.ghAuthTokenProvider?.() ?? await ghAuthTokenAsync(); - ghFallback = { ...gh, source: "none", patTokenStored }; - return gh.token ? { ...gh, source: "gh", patTokenStored } : null; - }, - pat: () => patToken - ? { ...ghFallback, token: patToken, source: "pat", patTokenStored } - : null, - }) ?? ghFallback; + }; + + const readTokenAsync = async ( + capability: GithubOperationCredentialCapability = "write", + ): Promise => { + const inventory = await readCredentialInventoryAsync(); + const candidates = githubOperationCredentialCandidates(inventory.candidates, capability); + return candidates.find((candidate) => !githubCredentialCooldown(candidate)) + ?? candidates[0] + ?? { + token: null, + source: "none", + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + }; }; const getToken = (): string => readToken().token ?? ""; @@ -713,6 +820,7 @@ export function createHeadlessGitHubService( userLogin: string | null; scopes: string[]; tokenType: HeadlessGitHubStatus["tokenType"]; + rateLimit: GitHubRateLimitState | null; }> => { const response = await fetchGitHub("https://api.github.com/user", { method: "GET", @@ -723,13 +831,22 @@ export function createHeadlessGitHubService( }, }); const scopes = parseGitHubScopeHeaders(response.headers); + const rateLimit = readGitHubRateLimitState(response.headers); const payload = await response.json().catch(() => ({})); if (!response.ok) { - throw new Error( - readApiMessage( - payload, - `GitHub token validation failed (HTTP ${response.status})`, - ), + const message = readApiMessage( + payload, + `GitHub token validation failed (HTTP ${response.status})`, + ); + const failure = classifyGitHubAuthFailure({ + status: response.status, + message, + headers: response.headers, + }); + throw new HeadlessGitHubTokenValidationError( + message, + failure.authFailure, + failure.rateLimit, ); } const userLogin = @@ -738,12 +855,17 @@ export function createHeadlessGitHubService( typeof (payload as { login?: unknown }).login === "string" ? (payload as { login: string }).login : null; - return { userLogin, scopes, tokenType: getTokenType(token) }; + return { userLogin, scopes, tokenType: getTokenType(token), rateLimit }; }; const probeRepoAccess = async ( token: string, repo: { owner: string; name: string }, - ): Promise<{ ok: boolean; error: string | null }> => { + ): Promise<{ + ok: boolean; + error: string | null; + authFailure: GitHubAuthFailure | null; + rateLimit: GitHubRateLimitState | null; + }> => { try { const response = await fetchGitHub( `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`, @@ -756,29 +878,79 @@ export function createHeadlessGitHubService( }, }, ); - if (response.ok) return { ok: true, error: null }; + if (response.ok) { + return { + ok: true, + error: null, + authFailure: null, + rateLimit: readGitHubRateLimitState(response.headers), + }; + } const payload = await response.json().catch(() => ({})); + const message = readApiMessage(payload, `HTTP ${response.status}`); + const failure = classifyGitHubAuthFailure({ + status: response.status, + message, + headers: response.headers, + }); + const authFailure = failure.authFailure.kind === "unknown" + && (response.status === 403 || response.status === 404) + ? { + kind: "permission_denied" as const, + message: `This credential cannot access ${repo.owner}/${repo.name}.`, + retryAt: null, + } + : failure.authFailure.kind === "unknown" + ? null + : failure.authFailure; return { ok: false, - error: `${response.status}: ${readApiMessage(payload, `HTTP ${response.status}`)}`, + error: `${response.status}: ${message}`, + authFailure, + rateLimit: failure.rateLimit, }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error), + authFailure: classifyGitHubAuthFailure({ + message: error instanceof Error ? error.message : String(error), + }).authFailure, + rateLimit: null, }; } }; + const etagCache = new Map(); + const ETAG_CACHE_MAX_SIZE = 200; + const apiRequest = async (args: { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; path: string; query?: Record; body?: unknown; token?: string; + accept?: string; + capability?: GithubOperationCredentialCapability; }): Promise<{ data: T; response: Response | null; linkHeader?: string | null }> => { - const token = (args.token ?? (await readTokenAsync()).token ?? "").trim(); - if (!token) { + const capability = args.capability ?? (args.method === "GET" ? "read" : "write"); + const inventory = args.token ? null : await readCredentialInventoryAsync(); + const explicitToken = args.token?.trim() ?? ""; + const candidates: HeadlessGitHubTokenCandidate[] = explicitToken + ? [{ + token: explicitToken, + source: "environment", + patTokenStored: false, + ghCliPath: null, + ghAuthError: null, + capabilities: [capability], + }] + : githubOperationCredentialCandidates(inventory!.candidates, capability); + if (candidates.length === 0) { throw new Error( "GitHub auth missing. Set ADE_GITHUB_TOKEN/GITHUB_TOKEN, run `gh auth login -h github.com -s repo -s workflow`, or add a PAT in Settings.", ); @@ -788,34 +960,152 @@ export function createHeadlessGitHubService( if (value == null) continue; url.searchParams.set(key, String(value)); } - const response = await fetchGitHub(url, { - method: args.method, - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, + const accept = args.accept?.trim() || "application/vnd.github+json"; + const rateLimitResource = githubRateLimitResourceForPath(args.path); + let firstUnavailable: { + failure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + } | null = null; + let lastAttemptError: HeadlessGithubCredentialAttemptError | null = null; + + for (const candidate of candidates) { + const cooldown = args.token + ? null + : githubCredentialCooldown(candidate, Date.now(), { resource: rateLimitResource }); + if (cooldown) { + firstUnavailable ??= cooldown; + continue; + } + const cacheKey = `${githubCredentialTokenDigest(candidate.token)}:${accept}:${url.toString()}`; + const headers: Record = { + accept, + authorization: `Bearer ${candidate.token}`, "user-agent": "ade-cli", ...(args.body == null ? {} : { "content-type": "application/json" }), - }, - body: args.body == null ? undefined : JSON.stringify(args.body), - }); - const text = await response.text(); - let data: unknown = text; - try { - data = text.trim().length ? JSON.parse(text) : {}; - } catch { - // keep text payload + }; + if (args.method === "GET") { + const cached = etagCache.get(cacheKey); + if (cached) headers["if-none-match"] = cached.etag; + } + const response = await fetchGitHub(url, { + method: args.method, + headers, + body: args.body == null ? undefined : JSON.stringify(args.body), + }); + if (response.status === 304) { + const cached = etagCache.get(cacheKey); + if (cached) { + recordGithubCredentialSuccess(candidate, response.headers); + return { data: cached.data as T, response, linkHeader: cached.linkHeader }; + } + } + const text = await response.text(); + let data: unknown = text; + try { + data = text.trim().length ? JSON.parse(text) : {}; + } catch { + // Keep non-JSON response bodies for callers and error messages. + } + if (!response.ok) { + const message = readApiMessage( + data, + `GitHub API request failed (HTTP ${response.status})`, + ); + const failure = classifyGitHubAuthFailure({ + status: response.status, + message, + headers: response.headers, + }); + recordGithubCredentialFailure(candidate, failure.authFailure, failure.rateLimit); + const attemptError = new HeadlessGithubCredentialAttemptError( + message, + failure.authFailure, + failure.rateLimit, + ); + lastAttemptError = attemptError; + const canTryNext = !args.token + && (response.status === 401 || response.status === 403 || response.status === 429); + if (canTryNext) continue; + if (attemptError.authFailure.kind === "rate_limited") { + const resetAtMs = githubRateLimitRetryAtMs(attemptError.authFailure, attemptError.rateLimit); + const resetDetail = resetAtMs == null + ? "rate limit exceeded" + : `rate limit exceeded; resets at ${new Date(resetAtMs).toLocaleString()}`; + throw new GitHubRateLimitError( + `${attemptError.message} (${resetDetail})`, + resetAtMs, + attemptError.rateLimit, + ); + } + throw attemptError; + } + + const graphqlFailure = rateLimitResource === "graphql" + ? classifyGitHubGraphqlCredentialFailure(data, response.headers) + : null; + if (graphqlFailure) { + recordGithubCredentialFailure( + candidate, + graphqlFailure.authFailure, + graphqlFailure.rateLimit, + ); + const attemptError = new HeadlessGithubCredentialAttemptError( + graphqlFailure.message, + graphqlFailure.authFailure, + graphqlFailure.rateLimit, + ); + lastAttemptError = attemptError; + if (!args.token) continue; + if (attemptError.authFailure.kind === "rate_limited") { + const resetAtMs = githubRateLimitRetryAtMs(attemptError.authFailure, attemptError.rateLimit); + throw new GitHubRateLimitError(attemptError.message, resetAtMs, attemptError.rateLimit); + } + throw attemptError; + } + + recordGithubCredentialSuccess(candidate, response.headers); + if (candidate !== candidates[0]) { + logger.info("github.credential_fallback_used", { + capability, + fromSource: candidates[0]?.source ?? null, + toSource: candidate.source, + }); + } + const linkHeader = response.headers.get("link"); + if (args.method === "GET") { + const etag = response.headers.get("etag"); + if (etag) { + while (etagCache.size >= ETAG_CACHE_MAX_SIZE && !etagCache.has(cacheKey)) { + const oldest = etagCache.keys().next().value as string | undefined; + if (!oldest) break; + etagCache.delete(oldest); + } + etagCache.set(cacheKey, { etag, data, linkHeader }); + } + } + return { data: data as T, response, linkHeader }; } - if (!response.ok) { - const message = - typeof data === "object" && - data && - "message" in data && - typeof (data as { message?: unknown }).message === "string" - ? String((data as { message?: unknown }).message) - : `GitHub API request failed (HTTP ${response.status})`; - throw new Error(message); + + const exhausted = lastAttemptError ?? (firstUnavailable + ? new HeadlessGithubCredentialAttemptError( + firstUnavailable.failure.message, + firstUnavailable.failure, + firstUnavailable.rateLimit, + ) + : null); + if (exhausted?.authFailure.kind === "rate_limited") { + const resetAtMs = githubRateLimitRetryAtMs(exhausted.authFailure, exhausted.rateLimit); + const resetDetail = resetAtMs == null + ? "rate limit exceeded" + : `rate limit exceeded; resets at ${new Date(resetAtMs).toLocaleString()}`; + throw new GitHubRateLimitError( + `${exhausted.message} (${resetDetail})`, + resetAtMs, + exhausted.rateLimit, + ); } - return { data: data as T, response }; + if (exhausted) throw exhausted; + throw new Error("No usable GitHub credential is available for this operation."); }; const apiRequestAllPages = async (args: { @@ -826,7 +1116,7 @@ export function createHeadlessGitHubService( const first = await apiRequest({ method: "GET", ...args }); const out = Array.isArray(first.data) ? [...first.data] : []; let nextUrl = parseNextGitHubLink( - first.response?.headers.get("link") ?? null, + first.linkHeader ?? first.response?.headers.get("link") ?? null, ); while (nextUrl) { const url = new URL(nextUrl); @@ -836,7 +1126,7 @@ export function createHeadlessGitHubService( token: args.token, }); if (Array.isArray(next.data)) out.push(...next.data); - nextUrl = parseNextGitHubLink(next.response?.headers.get("link") ?? null); + nextUrl = parseNextGitHubLink(next.linkHeader ?? next.response?.headers.get("link") ?? null); } return out; }; @@ -1023,10 +1313,10 @@ export function createHeadlessGitHubService( service = { async getStatus(opts: { forceRefresh?: boolean } = {}) { - if ( - opts.forceRefresh - && statusLookupInFlight?.generation !== statusLookupGeneration - ) { + if (opts.forceRefresh) { + if (statusLookupInFlight?.generation === statusLookupGeneration) { + return await statusLookupInFlight.promise; + } invalidateStatusCache(); } const now = Date.now(); @@ -1037,105 +1327,239 @@ export function createHeadlessGitHubService( } const lookup = (async (): Promise => { - const [origin, tokenLookup] = await Promise.all([ + const [origin, inventory] = await Promise.all([ readGitOriginAsync(projectRoot), - readTokenAsync(), + readCredentialInventoryAsync(), ]); const repo = parseGitHubRepoFromRemoteUrl(origin ?? ""); const hasOrigin = Boolean(origin); - const token = tokenLookup.token; - if (!token) { + const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); + const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); + const statusCooldown = (candidate: HeadlessGitHubTokenCandidate) => githubCredentialCooldown( + candidate, + Date.now(), + { ignoreNonRateLimit: opts.forceRefresh === true }, + ); + const primaryCandidate = readCandidates[0] ?? null; + if (!primaryCandidate) { return { tokenStored: false, - patTokenStored: tokenLookup.patTokenStored, + patTokenStored: inventory.patTokenStored, tokenDecryptionFailed, storageScope: "app", authSource: "none", + writeAuthSource: "none", tokenType: "unknown", repo, hasOrigin, userLogin: null, scopes: [], - ghCliPath: tokenLookup.ghCliPath, - ghAuthError: tokenLookup.ghAuthError, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, checkedAt: null, + authFailure: null, + rateLimit: null, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + activeReadSource: null, + activeWriteSource: null, + }), + credentialFallback: null, + backgroundRefreshPausedUntil: null, repoAccessOk: null, repoAccessError: null, connected: false, }; } - try { - const validated = await validateToken(token); - let repoAccessOk: boolean | null = null; - let repoAccessError: string | null = null; - if (repo) { - const probe = await probeRepoAccess(token, repo); - repoAccessOk = probe.ok; - repoAccessError = probe.error; - if (!probe.ok) { - logger.warn("github.repo_probe_failed", { - repo: `${repo.owner}/${repo.name}`, - tokenType: validated.tokenType, - error: probe.error, - }); + const failures: Array<{ + candidate: HeadlessGitHubTokenCandidate; + error: string; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + }> = []; + let active: { + candidate: HeadlessGitHubTokenCandidate; + validated: Awaited>; + repoAccessOk: boolean | null; + repoAccessError: string | null; + } | null = null; + + for (const [candidateIndex, candidate] of readCandidates.entries()) { + const cooldown = statusCooldown(candidate); + if (cooldown) { + failures.push({ + candidate, + error: cooldown.failure.message, + authFailure: cooldown.failure, + rateLimit: cooldown.rateLimit, + }); + continue; + } + try { + const validated = await validateToken(candidate.token); + let repoAccessOk: boolean | null = null; + let repoAccessError: string | null = null; + if (repo && (candidate.source === "app" || validated.tokenType === "fine-grained")) { + const probe = await probeRepoAccess(candidate.token, repo); + repoAccessOk = probe.ok; + repoAccessError = probe.error; + validated.rateLimit = probe.rateLimit ?? validated.rateLimit; + if (!probe.ok) { + logger.warn("github.repo_probe_failed", { + source: candidate.source, + repo: `${repo.owner}/${repo.name}`, + tokenType: validated.tokenType, + error: probe.error, + }); + if (probe.authFailure) { + const hasFallbackCandidate = readCandidates + .slice(candidateIndex + 1) + .some((nextCandidate) => !statusCooldown(nextCandidate)); + if (probe.authFailure.kind === "permission_denied" && !hasFallbackCandidate) { + registerGithubCredentialIdentity(candidate, validated.userLogin); + active = { candidate, validated, repoAccessOk, repoAccessError }; + break; + } + recordGithubCredentialFailure(candidate, probe.authFailure, probe.rateLimit); + failures.push({ + candidate, + error: probe.error ?? probe.authFailure.message, + authFailure: probe.authFailure, + rateLimit: probe.rateLimit, + }); + continue; + } + } + } + registerGithubCredentialIdentity(candidate, validated.userLogin); + recordGithubCredentialProbeSuccess(candidate, validated.rateLimit, validated.userLogin); + active = { candidate, validated, repoAccessOk, repoAccessError }; + break; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const classified = error instanceof HeadlessGitHubTokenValidationError + ? { authFailure: error.authFailure, rateLimit: error.rateLimit } + : classifyGitHubAuthFailure({ message }); + recordGithubCredentialFailure(candidate, classified.authFailure, classified.rateLimit); + failures.push({ candidate, error: message, ...classified }); + logger.warn("github.token_validation_failed", { + source: candidate.source, + error: message, + kind: classified.authFailure.kind, + retryAt: classified.authFailure.retryAt, + }); + if ( + classified.authFailure.kind === "network" + || classified.authFailure.kind === "unknown" + ) { + break; } } + } + + const currentWriteCandidate = writeCandidates.find((candidate) => !statusCooldown(candidate)) + ?? null; + const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); + if (active) { + const { candidate, validated, repoAccessOk, repoAccessError } = active; return { tokenStored: true, - patTokenStored: tokenLookup.patTokenStored, + patTokenStored: inventory.patTokenStored, tokenDecryptionFailed: false, storageScope: "app", - authSource: tokenLookup.source, + authSource: candidate.source, + writeAuthSource: currentWriteCandidate?.source === "app" + ? "none" + : currentWriteCandidate?.source ?? "none", tokenType: validated.tokenType, repo, hasOrigin, userLogin: validated.userLogin, scopes: validated.scopes, - ghCliPath: tokenLookup.ghCliPath, - ghAuthError: tokenLookup.ghAuthError, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, checkedAt: new Date(now).toISOString(), + authFailure: null, + rateLimit: validated.rateLimit, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + activeReadSource: candidate.source, + activeWriteSource: currentWriteCandidate?.source === "app" + ? null + : currentWriteCandidate?.source ?? null, + }), + credentialFallback: failures[0] && failures[0].candidate.source !== candidate.source + ? { + capability: "read", + fromSource: failures[0].candidate.source, + toSource: candidate.source, + reason: failures[0].authFailure.kind, + retryAt: failures[0].authFailure.retryAt, + } + : null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), repoAccessOk, repoAccessError, connected: computeConnected({ tokenStored: true, userLogin: validated.userLogin, - authSource: tokenLookup.source, + authSource: candidate.source, tokenType: validated.tokenType, scopes: validated.scopes, repo, repoAccessOk, }), }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const failure = classifyGitHubAuthFailure({ message }); - logger.warn("github.token_validation_failed", { - error: message, - kind: failure.authFailure.kind, - retryAt: failure.authFailure.retryAt, - }); - return { - tokenStored: true, - patTokenStored: tokenLookup.patTokenStored, - tokenDecryptionFailed: false, - storageScope: "app", - authSource: tokenLookup.source, - tokenType: getTokenType(token), - repo, - hasOrigin, - userLogin: null, - scopes: [], - ghCliPath: tokenLookup.ghCliPath, - ghAuthError: tokenLookup.ghAuthError, - checkedAt: new Date(now).toISOString(), - authFailure: failure.authFailure, - rateLimit: failure.rateLimit, - repoAccessOk: null, - repoAccessError: null, - connected: false, - }; } + + const failure = failures.find((entry) => entry.authFailure.kind === "rate_limited") + ?? failures[0] + ?? { + candidate: primaryCandidate, + error: "GitHub authentication could not be verified.", + authFailure: classifyGitHubAuthFailure({ message: "GitHub authentication could not be verified." }).authFailure, + rateLimit: null, + }; + return { + tokenStored: true, + patTokenStored: inventory.patTokenStored, + tokenDecryptionFailed: false, + storageScope: "app", + authSource: primaryCandidate.source, + writeAuthSource: currentWriteCandidate?.source === "app" + ? "none" + : currentWriteCandidate?.source ?? "none", + tokenType: getTokenType(primaryCandidate.token), + repo, + hasOrigin, + userLogin: null, + scopes: [], + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + checkedAt: new Date(now).toISOString(), + authFailure: failure.authFailure, + rateLimit: failure.rateLimit, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + activeReadSource: null, + activeWriteSource: currentWriteCandidate?.source === "app" + ? null + : currentWriteCandidate?.source ?? null, + }), + credentialFallback: null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + repoAccessOk: null, + repoAccessError: null, + connected: false, + }; })(); statusLookupInFlight = { generation, promise: lookup }; try { @@ -1151,6 +1575,13 @@ export function createHeadlessGitHubService( } } }, + async getBackgroundRequestPauseUntilMs() { + const inventory = await readCredentialInventoryAsync(); + return githubBackgroundRequestPauseUntilMs( + Date.now(), + githubOperationCredentialCandidates(inventory.candidates, "read"), + ); + }, async getRemoteStatus() { const origin = await readGitOriginAsync(projectRoot); return { @@ -1185,10 +1616,18 @@ export function createHeadlessGitHubService( return await appUserAuth.startDeviceAuth(); }, async pollAppUserDeviceAuth(args: { sessionId: string }): Promise { - return await appUserAuth.pollDeviceAuth(args); + const result = await appUserAuth.pollDeviceAuth(args); + if (result.status === "authorized") { + clearGithubCredentialHealth(); + invalidateStatusCache(); + } + return result; }, clearAppUserAuth(): GitHubAppUserAuthStatus { - return appUserAuth.clearAuth(); + const status = appUserAuth.clearAuth(); + clearGithubCredentialHealth(); + invalidateStatusCache(); + return status; }, async getRepoOrThrow() { const repo = await detectGitHubRepoAsync(projectRoot); @@ -1229,6 +1668,7 @@ export function createHeadlessGitHubService( credentialStore.deleteSync(tokenKey); } tokenDecryptionFailed = false; + clearGithubCredentialHealth(); invalidateStatusCache(); emitStatusChanged(); }, @@ -1236,6 +1676,7 @@ export function createHeadlessGitHubService( tokenOverride = null; credentialStore.deleteSync(tokenKey); tokenDecryptionFailed = false; + clearGithubCredentialHealth(); invalidateStatusCache(); emitStatusChanged(); }, diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 8b43d4027..8f515b339 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -2398,6 +2398,8 @@ app.whenReady().then(async () => { const searchServiceHolder: { current: SearchService | null } = { current: null }; let prPollingServiceRef: ReturnType | null = null; + let automationIngressServiceRef: ReturnType | null = + null; let testServiceRef: ReturnType | null = null; let laneServiceRef: ReturnType | null = null; let gitServiceRef: ReturnType | null = @@ -2901,6 +2903,8 @@ app.whenReady().then(async () => { prService, projectConfigService, db, + isGithubRelayHealthy: () => automationIngressServiceRef?.isGithubRelayHealthy() === true, + getGithubBackgroundPauseUntilMs: () => githubService.getBackgroundRequestPauseUntilMs(), onEvent: emitPrEvent, onPullRequestsSnapshot: (snapshot) => prMergeAutoSettlementServiceRef?.processSnapshot(snapshot), @@ -3400,6 +3404,7 @@ app.whenReady().then(async () => { // our own relay worker (no GitHub data cost); the service floors at 30s. pollIntervalMs: 30_000, }); + automationIngressServiceRef = automationIngressService; const githubPollingService = automationService ? createGithubPollingService({ diff --git a/apps/desktop/src/main/services/automations/automationIngressService.test.ts b/apps/desktop/src/main/services/automations/automationIngressService.test.ts index 4684d07d3..e91867098 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.test.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.test.ts @@ -773,7 +773,7 @@ describe("automationIngressService", () => { expect(onPrStateIngested).toHaveBeenCalledWith(["pr-3"]); expect(logger.warn).toHaveBeenCalledWith( "automations.github_relay_poll_failed", - expect.objectContaining({ error: "GitHub relay poll failed (500)" }), + expect.objectContaining({ error: "GitHub relay poll failed (500): upstream error" }), ); }); @@ -1195,4 +1195,32 @@ describe("automationIngressService", () => { expect.any(Object), ); }); + + it("clears relay health when relay configuration is removed", async () => { + const secrets = new Map([ + ["automations.githubRelay.apiBaseUrl", "https://relay.example.com"], + ["automations.githubRelay.remoteProjectId", "project-1"], + ["automations.githubRelay.accessToken", "relay-token"], + ]); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ events: [], nextCursor: null }), { + headers: { "content-type": "application/json" }, + }), + ); + service = createAutomationIngressService({ + logger: makeLogger() as never, + automationService: null, + secretService: { getSecret: (ref: string) => secrets.get(ref) ?? null } as never, + listRules: () => [], + ingressCursorStore: { get: () => null, set: () => {} }, + }); + + await service.pollNow(); + expect(service.isGithubRelayHealthy()).toBe(true); + secrets.clear(); + await service.pollNow(); + + expect(service.isGithubRelayHealthy()).toBe(false); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index 4830e36cf..a72060fae 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -75,6 +75,27 @@ export const GITHUB_RELAY_CONNECTED_SAFETY_POLL_MS = 5 * 60_000; export const GITHUB_RELAY_SUBSCRIPTION_CONNECT_TIMEOUT_MS = 20_000; export const GITHUB_RELAY_SUBSCRIPTION_BACKOFF_BASE_MS = 1_000; export const GITHUB_RELAY_SUBSCRIPTION_BACKOFF_CAP_MS = 60_000; +const GITHUB_RELAY_POLL_BACKOFF_BASE_MS = 30_000; +const GITHUB_RELAY_POLL_BACKOFF_CAP_MS = 15 * 60_000; + +class GithubRelayPollError extends Error { + constructor( + message: string, + readonly retryAtMs: number | null, + ) { + super(message); + this.name = "GithubRelayPollError"; + } +} + +function relayRetryAtMs(headers: Pick): number | null { + const value = headers.get("retry-after")?.trim() ?? ""; + if (!value) return null; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) return Date.now() + seconds * 1_000; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} export function computeGithubRelaySubscriptionBackoffMs( attempt: number, @@ -328,6 +349,9 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg let subscriptionConnectTimer: NodeJS.Timeout | null = null; let subscriptionReconnectAttempt = 0; let subscriptionConnected = false; + let githubRelayHealthy = false; + let relayPollCooldownUntilMs = 0; + let relayPollFailureCount = 0; let subscriptionLoggedState: "connected" | "disconnected" | null = null; // When the hosted relay needs a GitHub App user token that the user has not // granted yet, that is an idle state, not an error: skip polling for a @@ -339,6 +363,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg ); const updateGithubRelayStatus = (patch: Partial) => { + if (typeof patch.healthy === "boolean") githubRelayHealthy = patch.healthy; args.automationService?.updateIngressStatus({ githubRelay: patch, }); @@ -716,6 +741,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg }; const pollGithubRelay = async () => { + if (Date.now() < relayPollCooldownUntilMs) return; const config = buildGithubRelayConfig(); const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config); const accountAccessToken = args.getAccountAccessToken @@ -736,6 +762,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg }); if (!config.configured) { disableRelaySubscription(); + updateGithubRelayStatus({ healthy: false }); return; } const committedIngestedPrIds = new Set(); @@ -872,7 +899,24 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg if (pollAbortController === controller) pollAbortController = null; }); if (!response.ok) { - throw new Error(`GitHub relay poll failed (${response.status})`); + const responseText = await response.text().catch(() => ""); + let responseMessage = responseText.trim(); + try { + const parsed = JSON.parse(responseText) as { error?: unknown; message?: unknown }; + responseMessage = typeof parsed.error === "string" + ? parsed.error + : typeof parsed.message === "string" + ? parsed.message + : responseMessage; + } catch { + // Keep the plain-text response. + } + throw new GithubRelayPollError( + responseMessage + ? `GitHub relay poll failed (${response.status}): ${responseMessage}` + : `GitHub relay poll failed (${response.status})`, + relayRetryAtMs(response.headers), + ); } const payload = await response.json() as { events?: Array>; @@ -959,6 +1003,8 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg pageCursor = pageLastCursor; } flushCommittedPrReconciliation(); + relayPollFailureCount = 0; + relayPollCooldownUntilMs = 0; updateGithubRelayStatus({ healthy: true, status: "ready", @@ -970,8 +1016,18 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg } catch (error) { flushCommittedPrReconciliation(); if (stopped && error instanceof Error && error.name === "AbortError") return; + relayPollFailureCount += 1; + const backoffMs = Math.min( + GITHUB_RELAY_POLL_BACKOFF_CAP_MS, + GITHUB_RELAY_POLL_BACKOFF_BASE_MS * 2 ** Math.max(0, relayPollFailureCount - 1), + ); + relayPollCooldownUntilMs = Math.max( + Date.now() + backoffMs, + error instanceof GithubRelayPollError ? error.retryAtMs ?? 0 : 0, + ); args.logger.warn("automations.github_relay_poll_failed", { error: error instanceof Error ? error.message : String(error), + retryAt: new Date(relayPollCooldownUntilMs).toISOString(), }); updateGithubRelayStatus({ healthy: false, @@ -991,7 +1047,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg do { pollRerunRequested = false; await pollGithubRelay(); - } while (pollRerunRequested && !stopped); + } while (pollRerunRequested && !stopped && Date.now() >= relayPollCooldownUntilMs); })().finally(() => { pollInFlight = null; }); @@ -1034,6 +1090,10 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg return args.automationService?.getIngressStatus() ?? null; }, + isGithubRelayHealthy() { + return githubRelayHealthy; + }, + listRecentEvents(limit = 20) { return args.automationService?.listIngressEvents(limit) ?? []; }, @@ -1042,12 +1102,15 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg // Explicit polls (e.g. right after the user authorizes the GitHub App) // bypass the auth-pending cooldown. hostedAuthPendingUntilMs = 0; + relayPollCooldownUntilMs = 0; + relayPollFailureCount = 0; await pollGithubRelayOnce(); }, stop() { stopped = true; started = false; + githubRelayHealthy = false; if (pollTimer) { clearInterval(pollTimer); pollTimer = null; diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts new file mode 100644 index 000000000..81b660382 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitHubAuthFailure } from "../../../shared/types"; +import { + clearGithubCredentialHealth, + githubBackgroundRequestPauseUntilMs, + githubCredentialCooldown, + recordGithubCredentialFailure, + recordGithubCredentialSuccess, + registerGithubCredentialIdentity, + type GithubCredentialCandidate, +} from "./githubCredentialHealth"; + +const appCandidate: GithubCredentialCandidate = { + source: "app", + token: "ghu_app_token", + capabilities: ["read"], + userLogin: "alice", +}; + +const ghCandidate: GithubCredentialCandidate = { + source: "gh", + token: "gho_cli_token", + capabilities: ["read", "write"], + userLogin: "alice", +}; + +describe("githubCredentialHealth", () => { + beforeEach(() => clearGithubCredentialHealth()); + + afterEach(() => { + vi.useRealTimers(); + clearGithubCredentialHealth(); + }); + + it("coordinates shared cooldowns, background reserve, and manual recovery", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-01T12:00:00.000Z")); + registerGithubCredentialIdentity(appCandidate, "alice"); + registerGithubCredentialIdentity(ghCandidate, "alice"); + const retryAt = "2026-08-01T13:00:00.000Z"; + const failure: GitHubAuthFailure = { + kind: "rate_limited", + message: "API rate limit exceeded", + retryAt, + }; + + recordGithubCredentialFailure(appCandidate, failure, { + limit: 5000, + remaining: 0, + used: 5000, + resetAt: retryAt, + resource: "core", + }); + + expect(githubCredentialCooldown(appCandidate)?.failure.kind).toBe("rate_limited"); + expect(githubCredentialCooldown(ghCandidate)?.failure.kind).toBe("rate_limited"); + + vi.setSystemTime(new Date("2026-08-01T13:00:01.000Z")); + expect(githubCredentialCooldown(appCandidate)).toBeNull(); + expect(githubCredentialCooldown(ghCandidate)).toBeNull(); + + clearGithubCredentialHealth(); + vi.setSystemTime(new Date("2026-08-01T12:00:00.000Z")); + const reserveResetAt = "2026-08-01T13:00:00.000Z"; + + recordGithubCredentialSuccess(ghCandidate, new Headers({ + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "500", + "x-ratelimit-used": "4500", + "x-ratelimit-reset": String(Date.parse(reserveResetAt) / 1_000), + "x-ratelimit-resource": "core", + })); + + expect(githubBackgroundRequestPauseUntilMs()).toBe(Date.parse(reserveResetAt)); + + recordGithubCredentialSuccess(ghCandidate, new Headers({ + "x-ratelimit-limit": "30", + "x-ratelimit-remaining": "30", + "x-ratelimit-reset": String(Date.parse(reserveResetAt) / 1_000), + "x-ratelimit-resource": "search", + })); + expect(githubBackgroundRequestPauseUntilMs()).toBe(Date.parse(reserveResetAt)); + + const otherCandidate: GithubCredentialCandidate = { + source: "pat", + token: "ghp_other_account", + capabilities: ["read", "write"], + userLogin: "bob", + }; + recordGithubCredentialSuccess(otherCandidate, new Headers({ + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "100", + "x-ratelimit-reset": String(Date.parse(reserveResetAt) / 1_000), + "x-ratelimit-resource": "core", + })); + + expect(githubBackgroundRequestPauseUntilMs(Date.now(), [ghCandidate])) + .toBe(Date.parse(reserveResetAt)); + expect(githubBackgroundRequestPauseUntilMs(Date.now(), [otherCandidate])) + .toBe(Math.floor(Date.parse(reserveResetAt) / 1_000) * 1_000); + + clearGithubCredentialHealth(); + recordGithubCredentialSuccess(otherCandidate, new Headers({ + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "100", + "x-ratelimit-reset": String(Date.parse(reserveResetAt) / 1_000), + "x-ratelimit-resource": "core", + })); + expect(githubBackgroundRequestPauseUntilMs(Date.now(), [ghCandidate])).toBeNull(); + + clearGithubCredentialHealth(); + recordGithubCredentialFailure(appCandidate, { + kind: "permission_denied", + message: "Resource not accessible", + retryAt: null, + }, null); + expect(githubCredentialCooldown(appCandidate)).not.toBeNull(); + expect(githubCredentialCooldown(appCandidate, Date.now(), { ignoreNonRateLimit: true })) + .toBeNull(); + + recordGithubCredentialFailure(appCandidate, { + kind: "rate_limited", + message: "API rate limit exceeded", + retryAt: new Date(Date.now() + 60_000).toISOString(), + }, { + limit: 5000, + remaining: 0, + used: 5000, + resetAt: new Date(Date.now() + 60_000).toISOString(), + resource: "core", + }); + expect(githubCredentialCooldown(appCandidate, Date.now(), { ignoreNonRateLimit: true }) + ?.failure.kind).toBe("rate_limited"); + }); +}); diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.ts new file mode 100644 index 000000000..4c8e3da4d --- /dev/null +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.ts @@ -0,0 +1,258 @@ +import { createHash } from "node:crypto"; +import type { + GitHubAuthFailure, + GitHubCredentialCapability, + GitHubCredentialSource, + GitHubCredentialState, + GitHubRateLimitState, +} from "../../../shared/types"; +import { + githubRateLimitRetryAtMs, + readGitHubRateLimitState, +} from "./githubRateLimit"; +import { + GITHUB_OPERATION_CREDENTIAL_PRECEDENCE, + githubOperationCredentialCapabilities, +} from "../../../shared/githubOperationCredential"; + +const FALLBACK_COOLDOWN_MS = 5 * 60_000; +const SECONDARY_RATE_LIMIT_COOLDOWN_MS = 60_000; +export const GITHUB_BACKGROUND_RATE_LIMIT_RESERVE = 500; + +export type GithubCredentialCandidate = { + source: GitHubCredentialSource; + token: string; + capabilities: readonly GitHubCredentialCapability[]; + userLogin?: string | null; +}; + +type CredentialResourceHealth = { + failure: GitHubAuthFailure | null; + rateLimit: GitHubRateLimitState | null; + cooldownUntilMs: number; +}; + +type CredentialHealth = { + resources: Map; + userLogin: string | null; +}; + +const healthByTokenDigest = new Map(); + +export function githubCredentialTokenDigest(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function normalizedLogin(login: string | null | undefined): string | null { + const value = login?.trim().toLowerCase() ?? ""; + return value || null; +} + +function healthFor(candidate: GithubCredentialCandidate): CredentialHealth | null { + return healthByTokenDigest.get(githubCredentialTokenDigest(candidate.token)) ?? null; +} + +function rateLimitResource(rateLimit: GitHubRateLimitState | null): string { + return rateLimit?.resource?.trim().toLowerCase() || "unknown"; +} + +function updateResourceHealth( + existing: CredentialHealth | undefined, + rateLimit: GitHubRateLimitState | null, + update: (current: CredentialResourceHealth | undefined) => CredentialResourceHealth, +): Map { + const resources = new Map(existing?.resources ?? []); + const resource = rateLimitResource(rateLimit); + resources.set(resource, update(resources.get(resource))); + return resources; +} + +export function registerGithubCredentialIdentity( + candidate: GithubCredentialCandidate, + userLogin: string | null, +): void { + const digest = githubCredentialTokenDigest(candidate.token); + const existing = healthByTokenDigest.get(digest); + healthByTokenDigest.set(digest, { + resources: new Map(existing?.resources ?? []), + userLogin: normalizedLogin(userLogin ?? candidate.userLogin), + }); +} + +export function recordGithubCredentialSuccess( + candidate: GithubCredentialCandidate, + headers: Pick, + userLogin?: string | null, +): GitHubRateLimitState | null { + const digest = githubCredentialTokenDigest(candidate.token); + const existing = healthByTokenDigest.get(digest); + const rateLimit = readGitHubRateLimitState(headers); + healthByTokenDigest.set(digest, { + resources: updateResourceHealth(existing, rateLimit, (current) => ({ + failure: null, + rateLimit: rateLimit ?? current?.rateLimit ?? null, + cooldownUntilMs: 0, + })), + userLogin: normalizedLogin(userLogin ?? candidate.userLogin ?? existing?.userLogin), + }); + return rateLimit; +} + +export function recordGithubCredentialProbeSuccess( + candidate: GithubCredentialCandidate, + rateLimit: GitHubRateLimitState | null, + userLogin: string | null, +): void { + const digest = githubCredentialTokenDigest(candidate.token); + const existing = healthByTokenDigest.get(digest); + healthByTokenDigest.set(digest, { + resources: updateResourceHealth(existing, rateLimit, (current) => ({ + failure: null, + rateLimit: rateLimit ?? current?.rateLimit ?? null, + cooldownUntilMs: 0, + })), + userLogin: normalizedLogin(userLogin ?? candidate.userLogin ?? existing?.userLogin), + }); +} + +export function recordGithubCredentialFailure( + candidate: GithubCredentialCandidate, + failure: GitHubAuthFailure, + rateLimit: GitHubRateLimitState | null, +): void { + const now = Date.now(); + const digest = githubCredentialTokenDigest(candidate.token); + const existing = healthByTokenDigest.get(digest); + const userLogin = normalizedLogin(candidate.userLogin ?? existing?.userLogin); + const retryAtMs = githubRateLimitRetryAtMs(failure, rateLimit); + const cooldownUntilMs = failure.kind === "rate_limited" + ? Math.max(now + SECONDARY_RATE_LIMIT_COOLDOWN_MS, retryAtMs ?? 0) + : failure.kind === "invalid_token" || failure.kind === "permission_denied" + ? now + FALLBACK_COOLDOWN_MS + : 0; + const next: CredentialHealth = { + resources: updateResourceHealth(existing, rateLimit, (current) => ({ + failure, + rateLimit: rateLimit ?? current?.rateLimit ?? null, + cooldownUntilMs, + })), + userLogin, + }; + healthByTokenDigest.set(digest, next); + + // GitHub App user tokens, OAuth tokens, and PATs used on behalf of the same + // account share the user's primary quota. Once GitHub reports that quota as + // exhausted, stop every credential already known to represent that account. + if (failure.kind !== "rate_limited" || !userLogin || rateLimit?.remaining !== 0) return; + const resource = rateLimitResource(rateLimit); + for (const [candidateDigest, candidateHealth] of healthByTokenDigest) { + if (candidateHealth.userLogin !== userLogin) continue; + const resources = new Map(candidateHealth.resources); + const current = resources.get(resource); + resources.set(resource, { + failure, + rateLimit: rateLimit ?? current?.rateLimit ?? null, + cooldownUntilMs, + }); + healthByTokenDigest.set(candidateDigest, { + ...candidateHealth, + resources, + }); + } +} + +export function githubCredentialCooldown( + candidate: GithubCredentialCandidate, + nowMs = Date.now(), + options: { + resource?: string | null; + ignoreNonRateLimit?: boolean; + } = {}, +): { failure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null } | null { + const health = healthFor(candidate); + if (!health) return null; + const requestedResource = options.resource?.trim().toLowerCase() || null; + const entries = requestedResource + ? [health.resources.get(requestedResource), health.resources.get("unknown")] + : [...health.resources.values()]; + const cooling = entries + .filter((entry): entry is NonNullable => Boolean( + entry?.failure + && entry.cooldownUntilMs > nowMs + && (!options.ignoreNonRateLimit || entry.failure.kind === "rate_limited"), + )) + .sort((left, right) => right.cooldownUntilMs - left.cooldownUntilMs)[0]; + if (!cooling?.failure) return null; + return { failure: cooling.failure, rateLimit: cooling.rateLimit }; +} + +export function clearGithubCredentialHealth(token?: string): void { + if (token) { + healthByTokenDigest.delete(githubCredentialTokenDigest(token)); + return; + } + healthByTokenDigest.clear(); +} + +export function githubCredentialStates(args: { + candidates: readonly GithubCredentialCandidate[]; + availableSources: ReadonlySet; + activeReadSource: GitHubCredentialSource | null; + activeWriteSource: Exclude | null; +}): GitHubCredentialState[] { + const bySource = new Map(args.candidates.map((candidate) => [candidate.source, candidate])); + const sources: readonly GitHubCredentialSource[] = GITHUB_OPERATION_CREDENTIAL_PRECEDENCE; + return sources.map((source) => { + const candidate = bySource.get(source) ?? null; + const health = candidate ? healthFor(candidate) : null; + const cooling = candidate ? githubCredentialCooldown(candidate) : null; + const capabilities = [...githubOperationCredentialCapabilities(source)]; + const activeFor: GitHubCredentialCapability[] = [ + ...(args.activeReadSource === source ? ["read" as const] : []), + ...(args.activeWriteSource === source ? ["write" as const] : []), + ]; + return { + source, + available: args.availableSources.has(source), + capabilities, + activeFor, + state: activeFor.length > 0 + ? "active" + : cooling + ? "cooldown" + : args.availableSources.has(source) + ? "ready" + : "unavailable", + failure: cooling?.failure ?? null, + rateLimit: cooling?.rateLimit + ?? [...(health?.resources.values() ?? [])].find((entry) => entry.rateLimit)?.rateLimit + ?? null, + }; + }); +} + +export function githubBackgroundRequestPauseUntilMs( + nowMs = Date.now(), + candidates?: readonly GithubCredentialCandidate[], +): number | null { + let pauseUntilMs: number | null = null; + const healthEntries = candidates + ? candidates.map((candidate) => healthFor(candidate)).filter(Boolean) + : [...healthByTokenDigest.values()]; + for (const health of healthEntries) { + if (!health) continue; + for (const [resource, resourceHealth] of health.resources) { + const rateLimit = resourceHealth.rateLimit; + const protectsPullRequestReads = resource === "core" + || resource === "graphql" + || (resource === "unknown" && (rateLimit?.limit ?? 0) >= 1_000); + if (!protectsPullRequestReads) continue; + const remaining = rateLimit?.remaining; + const resetAt = rateLimit?.resetAt ? Date.parse(rateLimit.resetAt) : NaN; + if (remaining == null || remaining > GITHUB_BACKGROUND_RATE_LIMIT_RESERVE) continue; + if (!Number.isFinite(resetAt) || resetAt <= nowMs) continue; + pauseUntilMs = Math.max(pauseUntilMs ?? 0, resetAt); + } + } + return pauseUntilMs; +} diff --git a/apps/desktop/src/main/services/github/githubRateLimit.ts b/apps/desktop/src/main/services/github/githubRateLimit.ts index 72e2f0dd9..953ab4cfb 100644 --- a/apps/desktop/src/main/services/github/githubRateLimit.ts +++ b/apps/desktop/src/main/services/github/githubRateLimit.ts @@ -4,6 +4,7 @@ export class GitHubRateLimitError extends Error { constructor( message: string, readonly rateLimitResetAtMs: number | null, + readonly rateLimit: GitHubRateLimitState | null = null, ) { super(message); this.name = "GitHubRateLimitError"; @@ -84,6 +85,16 @@ export function classifyGitHubAuthFailure(args: { }, }; } + if (args.status === 403) { + return { + rateLimit, + authFailure: { + kind: "permission_denied", + message, + retryAt: null, + }, + }; + } if (isTransientGithubProbeFailure(message)) { return { rateLimit, @@ -104,6 +115,58 @@ export function classifyGitHubAuthFailure(args: { }; } +export function classifyGitHubGraphqlCredentialFailure( + payload: unknown, + headers: Pick, +): { + status: 403 | 429; + message: string; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; +} | null { + if (!payload || typeof payload !== "object" || !Array.isArray((payload as { errors?: unknown }).errors)) { + return null; + } + const errors = (payload as { errors: unknown[] }).errors; + const messages = errors.flatMap((error) => { + if (!error || typeof error !== "object") return []; + const message = (error as { message?: unknown }).message; + return typeof message === "string" && message.trim() ? [message.trim()] : []; + }); + const errorTypes = errors.flatMap((error) => { + if (!error || typeof error !== "object") return []; + const record = error as { + type?: unknown; + extensions?: { code?: unknown; type?: unknown }; + }; + return [record.type, record.extensions?.code, record.extensions?.type] + .filter((value): value is string => typeof value === "string") + .map((value) => value.toUpperCase()); + }); + const message = messages.join("; ") || "GitHub GraphQL request failed."; + const rateLimit = readGitHubRateLimitState(headers); + const rateLimited = rateLimit?.remaining === 0 + || errorTypes.some((type) => type === "RATE_LIMITED" || type === "RATE_LIMIT") + || /rate limit|too many requests|abuse detection/i.test(message); + if (rateLimited) { + return { + status: 429, + message, + ...classifyGitHubAuthFailure({ status: 429, message, headers }), + }; + } + const permissionDenied = errorTypes.includes("FORBIDDEN") + || /forbidden|resource not accessible|not accessible by integration|does not have access/i.test(message); + if (permissionDenied) { + return { + status: 403, + message, + ...classifyGitHubAuthFailure({ status: 403, message, headers }), + }; + } + return null; +} + export function githubRateLimitResetAtMs(rateLimit: GitHubRateLimitState | null): number | null { if (!rateLimit?.resetAt) return null; const parsed = Date.parse(rateLimit.resetAt); @@ -120,3 +183,9 @@ export function githubRateLimitRetryAtMs( } return githubRateLimitResetAtMs(rateLimit); } + +export function githubRateLimitResourceForPath(path: string): string { + if (path === "/graphql" || path.startsWith("/graphql?")) return "graphql"; + if (path.startsWith("/search/")) return "search"; + return "core"; +} diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 60ccb34ea..82ff9b542 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -55,6 +55,7 @@ import { createGithubService, fetchAdeLatestRelease, } from "./githubService"; +import { clearGithubCredentialHealth } from "./githubCredentialHealth"; // --------------------------------------------------------------------------- // Helpers @@ -70,6 +71,7 @@ function makeLogger() { } function resetMocks() { + clearGithubCredentialHealth(); vi.clearAllMocks(); mockFetch.mockReset(); runGitMock.mockReset(); @@ -370,6 +372,103 @@ describe("githubService.apiRequest", () => { expect((init.headers as Record).authorization).toBe("Bearer ghp_machine_token"); }); + it("falls back when GraphQL reports a rate limit in an HTTP 200 response", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + const resetAt = Math.floor(Date.now() / 1_000) + 3600; + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { + data: null, + errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }], + }, { + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetAt), + "x-ratelimit-resource": "graphql", + })) + .mockResolvedValueOnce(jsonResponse(200, { data: { viewer: { login: "alice" } } })); + + const result = await makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }).apiRequest<{ data: { viewer: { login: string } } }>({ + method: "POST", + path: "/graphql", + capability: "read", + body: { query: "query { viewer { login } }" }, + }); + + expect(result.data.data.viewer.login).toBe("alice"); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect((mockFetch.mock.calls[0]?.[1]?.headers as Record).authorization) + .toBe("Bearer ghu_app_user_token"); + expect((mockFetch.mock.calls[1]?.[1]?.headers as Record).authorization) + .toBe("Bearer gho_cli_token"); + }); + + it("skips the read-only GitHub App for write requests", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch.mockResolvedValueOnce(jsonResponse(200, { ok: true })); + + await makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }).apiRequest({ method: "POST", path: "/repos/acme/ade/issues", body: { title: "Test" } }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect((mockFetch.mock.calls[0]?.[1]?.headers as Record).authorization) + .toBe("Bearer gho_cli_token"); + }); + + it("keeps conditional response data isolated by credential", async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { value: "first" }, { etag: '"first"' })) + .mockResolvedValueOnce(jsonResponse(200, { value: "second" }, { etag: '"second"' })) + .mockResolvedValueOnce(jsonResponse(304, {})); + const service = makeService(); + + await service.apiRequest({ method: "GET", path: "/repos/acme/ade", token: "ghp_first" }); + await service.apiRequest({ method: "GET", path: "/repos/acme/ade", token: "ghp_second" }); + const firstAgain = await service.apiRequest<{ value: string }>({ + method: "GET", + path: "/repos/acme/ade", + token: "ghp_first", + }); + + expect(mockFetch.mock.calls[1]?.[1]?.headers).not.toMatchObject({ "if-none-match": '"first"' }); + expect(mockFetch.mock.calls[2]?.[1]?.headers).toMatchObject({ "if-none-match": '"first"' }); + expect(firstAgain.data).toEqual({ value: "first" }); + }); + it("stores and clears GitHub PATs in the shared machine credential store", () => { const credentialStore = new MemoryCredentialStore(); const service = makeService({ credentialStore }); @@ -784,10 +883,9 @@ describe("githubService.getStatus", () => { expect(mockFetch).toHaveBeenCalledTimes(1); }); - it("keeps the read-only GitHub App out of operational REST credential selection", async () => { + it("uses the GitHub App for reads but reports write access missing when it is the only credential", async () => { stubOriginRemote(); const credentialStore = new MemoryCredentialStore(); - credentialStore.setSync("github.token.v1", "ghp_stored_token"); credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ accessToken: "ghu_app_user_token", tokenType: "bearer", @@ -798,91 +896,22 @@ describe("githubService.getStatus", () => { userLogin: "alice", updatedAt: new Date().toISOString(), })); - mockFetch.mockResolvedValueOnce( - jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), - ); - - const status = await makeService({ credentialStore }).getStatus(); - - expect(status).toMatchObject({ - authSource: "pat", - connected: true, - patTokenStored: true, - repoAccessOk: null, - userLogin: "alice", - }); - expect(mockFetch).toHaveBeenCalledTimes(1); - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - expect((init.headers as Record).authorization) - .toBe("Bearer ghp_stored_token"); - }); - - it("keeps GitHub CLI auth ahead of GitHub App authorization for async REST calls", async () => { - stubOriginRemote(); - delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; - const credentialStore = new MemoryCredentialStore(); - credentialStore.setSync("github.token.v1", "ghp_stored_token"); - credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ - accessToken: "ghu_app_user_token", - tokenType: "bearer", - scope: null, - expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), - refreshToken: null, - refreshTokenExpiresAt: null, - userLogin: "alice", - updatedAt: new Date().toISOString(), - })); - mockFetch.mockResolvedValueOnce( - jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), - ); - - const service = makeService({ - credentialStore, - ghAuthTokenProvider: () => ({ - token: "gho_cli_token", - ghCliPath: "/opt/homebrew/bin/gh", - ghAuthError: null, - }), - }); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { login: "alice" })) + .mockResolvedValueOnce(jsonResponse(200, { full_name: "acme/ade" })); + const service = makeService({ credentialStore }); const status = await service.getStatus(); expect(status).toMatchObject({ - authSource: "gh", + authSource: "app", + writeAuthSource: "none", connected: true, - patTokenStored: true, - repoAccessOk: null, - userLogin: "alice", - }); - expect(service.getTokenOrThrow()).toBe("gho_cli_token"); - expect(mockFetch).toHaveBeenCalledTimes(1); - const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; - expect((init.headers as Record).authorization) - .toBe("Bearer gho_cli_token"); - }); - - it("does not use a relay-only GitHub App token when no operation credential is available", async () => { - stubOriginRemote(); - const credentialStore = new MemoryCredentialStore(); - credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ - accessToken: "ghu_app_user_token", - tokenType: "bearer", - scope: null, - expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), - refreshToken: null, - refreshTokenExpiresAt: null, - userLogin: "alice", - updatedAt: new Date().toISOString(), - })); - const status = await makeService({ credentialStore }).getStatus(); - - expect(status).toMatchObject({ - authSource: "none", - connected: false, patTokenStored: false, - repoAccessOk: null, - userLogin: null, + repoAccessOk: true, + userLogin: "alice", }); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(2); + await expect(service.getTokenOrThrowAsync()).rejects.toThrow("GitHub auth missing"); }); it("reports an exhausted GitHub API quota as rate limited instead of missing permissions", async () => { @@ -1100,32 +1129,6 @@ describe("githubService.getStatus", () => { now.mockRestore(); }); - it("does not extend the shared cooldown for an invalid gh token", async () => { - stubOriginRemote(); - delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; - const baseNow = Date.now(); - const now = vi.spyOn(Date, "now").mockReturnValue(baseNow); - const ghAuthTokenProvider = vi.fn(() => ({ - token: "gho_invalid_token", - ghCliPath: "/opt/homebrew/bin/gh", - ghAuthError: null, - })); - mockFetch.mockResolvedValue(jsonResponse(401, { message: "Bad credentials" })); - - const first = await makeService({ ghAuthTokenProvider }).getStatus(); - expect(first.connected).toBe(false); - expect(first.authFailure?.kind).toBe("invalid_token"); - expect(ghAuthTokenProvider).toHaveBeenCalledTimes(1); - expect(mockFetch).toHaveBeenCalledTimes(1); - - now.mockReturnValue(baseNow + 31_000); - const second = await makeService({ ghAuthTokenProvider }).getStatus(); - expect(second.connected).toBe(false); - expect(ghAuthTokenProvider).toHaveBeenCalledTimes(2); - expect(mockFetch).toHaveBeenCalledTimes(2); - now.mockRestore(); - }); - it("does not reuse a project-local status after the shared gh token changes", async () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; @@ -1184,6 +1187,44 @@ describe("githubService.getStatus", () => { .toBe("Bearer gho_hosts_bob"); }); + it("lets a forced status refresh retry a credential after a permission failure", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + process.env.GITHUB_TOKEN = "ghp_environment_token"; + mockFetch + .mockResolvedValueOnce(jsonResponse(403, { message: "Resource not accessible" })) + .mockResolvedValueOnce(jsonResponse( + 200, + { login: "fallback-user" }, + { "x-oauth-scopes": "repo, workflow" }, + )) + .mockResolvedValueOnce(jsonResponse( + 200, + { login: "environment-user" }, + { "x-oauth-scopes": "repo, workflow" }, + )); + const service = makeService({ + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + + await expect(service.getStatus()).resolves.toMatchObject({ + authSource: "gh", + userLogin: "fallback-user", + }); + await expect(service.getStatus({ forceRefresh: true })).resolves.toMatchObject({ + authSource: "environment", + userLogin: "environment-user", + }); + + expect(mockFetch).toHaveBeenCalledTimes(3); + expect((mockFetch.mock.calls[2]?.[1]?.headers as Record).authorization) + .toBe("Bearer ghp_environment_token"); + }); + it("clearing a stored PAT falls back to gh auth", async () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index caeb7abc9..6876533bf 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; import { promisify } from "node:util"; import { safeStorage } from "electron"; import type { Logger } from "../logging/logger"; @@ -23,8 +22,9 @@ import { parseGithubRemoteUrl } from "../../../shared/githubRemote"; import { getGitHubTokenAccessState, parseGitHubScopeHeaders } from "../../../shared/githubScopes"; import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/credentials/credentialStore"; import { + githubOperationCredentialCandidates, selectGithubOperationCredential, - selectGithubOperationCredentialAsync, + type GithubOperationCredentialCapability, } from "../../../shared/githubOperationCredential"; import { mergePathEntries, resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; import { fetchGitHubAppInstallationStatus, type GitHubRelaySecretReader } from "./githubRelayConfig"; @@ -32,11 +32,25 @@ import { createGitHubAppUserAuthService } from "./githubAppUserAuthService"; import { GITHUB_REST_API_VERSION } from "./githubApiVersion"; import { classifyGitHubAuthFailure, + classifyGitHubGraphqlCredentialFailure, GitHubRateLimitError, + githubRateLimitResourceForPath, githubRateLimitRetryAtMs, isTransientGithubProbeFailure, readGitHubRateLimitState, } from "./githubRateLimit"; +import { + clearGithubCredentialHealth, + githubBackgroundRequestPauseUntilMs, + githubCredentialCooldown, + githubCredentialStates, + githubCredentialTokenDigest, + recordGithubCredentialFailure, + recordGithubCredentialProbeSuccess, + recordGithubCredentialSuccess, + registerGithubCredentialIdentity, + type GithubCredentialCandidate, +} from "./githubCredentialHealth"; import { nowIso, asString } from "../shared/utils"; @@ -93,6 +107,7 @@ type SharedGithubStatusProbeResult = error: string; authFailure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null; + value?: SharedGithubStatusProbe; }; type ProcessGithubAuthState = { @@ -129,19 +144,38 @@ function processGithubAuthState(provider: GitHubCliAuthProvider): ProcessGithubA } function githubStatusProbeKey(token: string, repo: GitHubRepoRef | null): string { - const tokenDigest = githubTokenDigest(token); + const tokenDigest = githubCredentialTokenDigest(token); return `${tokenDigest}:${repo ? `${repo.owner.toLowerCase()}/${repo.name.toLowerCase()}` : "no-repo"}`; } -function githubTokenDigest(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} - type GitHubTokenLookup = GitHubCliAuthResult & { source: GitHubAuthSource; patTokenStored: boolean; }; +type GitHubTokenCandidate = GitHubTokenLookup & GithubCredentialCandidate & { + token: string; +}; + +type GitHubCredentialInventory = { + candidates: GitHubTokenCandidate[]; + availableSources: Set; + patTokenStored: boolean; + ghCliPath: string | null; + ghAuthError: string | null; +}; + +class GithubCredentialAttemptError extends Error { + constructor( + message: string, + readonly authFailure: GitHubAuthFailure, + readonly rateLimit: GitHubRateLimitState | null, + ) { + super(message); + this.name = "GithubCredentialAttemptError"; + } +} + /** * Read the gh CLI's stored oauth token directly from its hosts.yml. gh keeps * file-based tokens here (keychain-stored ones won't appear — those need the @@ -712,28 +746,79 @@ export function createGithubService({ : null; }; - const readAuthToken = async (): Promise => { + const readCredentialInventory = async (): Promise => { const patLookup = readPatAuthToken(); const patTokenStored = Boolean(patLookup); - let ghFallback: GitHubTokenLookup = { + const environment = readEnvironmentAuthToken(); + const appStatus = appUserAuth.getAuthStatus(); + const [appToken, gh] = await Promise.all([ + appStatus.tokenStored + ? appUserAuth.getValidTokenForRelay().catch(() => null) + : Promise.resolve(null), + readGhAuthToken(), + ]); + const candidates: GitHubTokenCandidate[] = []; + if (environment?.token) { + candidates.push({ + ...environment, + token: environment.token, + source: "environment", + patTokenStored, + capabilities: ["read", "write"], + }); + } + if (appToken) { + candidates.push({ + token: appToken, + source: "app", + patTokenStored, + ghCliPath: null, + ghAuthError: null, + capabilities: ["read"], + userLogin: appStatus.userLogin, + }); + } + if (gh.token) { + candidates.push({ + ...gh, + token: gh.token, + source: "gh", + patTokenStored, + capabilities: ["read", "write"], + }); + } + if (patLookup?.token) { + candidates.push({ + ...patLookup, + token: patLookup.token, + source: "pat", + capabilities: ["read", "write"], + }); + } + return { + candidates, + availableSources: new Set(candidates.map((candidate) => candidate.source)), + patTokenStored, + ghCliPath: gh.ghCliPath, + ghAuthError: gh.ghAuthError, + }; + }; + + const readAuthToken = async ( + capability: GithubOperationCredentialCapability = "read", + ): Promise => { + const inventory = await readCredentialInventory(); + const candidates = githubOperationCredentialCandidates(inventory.candidates, capability); + const selected = candidates.find((candidate) => !githubCredentialCooldown(candidate)) + ?? candidates[0] + ?? null; + return selected ?? { token: null, source: "none", - patTokenStored, - ghCliPath: null, - ghAuthError: null, + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, }; - return await selectGithubOperationCredentialAsync({ - environment: () => { - const environment = readEnvironmentAuthToken(); - return environment ? { ...environment, patTokenStored } : null; - }, - gh: async () => { - const gh = await readGhAuthToken(); - ghFallback = { ...gh, source: "none", patTokenStored }; - return gh.token ? { ...gh, source: "gh", patTokenStored } : null; - }, - pat: () => patLookup, - }) ?? ghFallback; }; const readAuthTokenSync = (): GitHubTokenLookup => { @@ -751,6 +836,7 @@ export function createGithubService({ const environment = readEnvironmentAuthToken(); return environment ? { ...environment, patTokenStored } : null; }, + app: () => null, gh: () => { if (process.env.ADE_DISABLE_GH_AUTH_FALLBACK === "1") { sharedGhAuth.authCache = null; @@ -769,7 +855,7 @@ export function createGithubService({ return gh.token ? { ...gh, source: "gh", patTokenStored } : null; }, pat: () => patLookup, - }) ?? ghFallback; + }, "write") ?? ghFallback; }; const persistToken = (token: string | null): void => { @@ -896,10 +982,20 @@ export function createGithubService({ message, headers: response.headers, }); + const authFailure = failure.authFailure.kind === "unknown" + && (response.status === 403 || response.status === 404) + ? { + kind: "permission_denied" as const, + message: `This credential cannot access ${repo.owner}/${repo.name}.`, + retryAt: null, + } + : failure.authFailure.kind === "unknown" + ? null + : failure.authFailure; return { ok: false, error: `${response.status}: ${message}`, - authFailure: failure.authFailure.kind === "unknown" ? null : failure.authFailure, + authFailure, rateLimit: failure.rateLimit, }; } catch (error) { @@ -932,6 +1028,7 @@ export function createGithubService({ error: probe.error ?? probe.authFailure.message, authFailure: probe.authFailure, rateLimit: probe.rateLimit, + value: { validated, repoAccessOk: probe.ok, repoAccessError: probe.error }, }; } repoAccessOk = probe.ok; @@ -1032,6 +1129,7 @@ export function createGithubService({ query?: Record; body?: unknown; token?: string; + capability?: GithubOperationCredentialCapability; /** * Override the default `Accept` header. Used to request GitHub schema * previews (e.g. `application/vnd.github.merge-info-preview+json` for the @@ -1039,8 +1137,20 @@ export function createGithubService({ */ accept?: string; }): Promise<{ data: T; response: Response | null; linkHeader?: string | null }> => { - const token = (args.token ?? (await readAuthToken()).token ?? "").trim(); - if (!token) { + const capability = args.capability ?? (args.method === "GET" ? "read" : "write"); + const inventory = args.token ? null : await readCredentialInventory(); + const explicitToken = args.token?.trim() ?? ""; + const candidates: GitHubTokenCandidate[] = explicitToken + ? [{ + token: explicitToken, + source: "environment", + patTokenStored: false, + ghCliPath: null, + ghAuthError: null, + capabilities: [capability], + }] + : githubOperationCredentialCandidates(inventory!.candidates, capability); + if (candidates.length === 0) { throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); } @@ -1051,104 +1161,178 @@ export function createGithubService({ url.searchParams.set(key, String(value)); } - const urlKey = url.toString(); - const headers: Record = { - accept: args.accept?.trim() || "application/vnd.github+json", - authorization: `Bearer ${token}`, - "content-type": args.body != null ? "application/json" : "text/plain", - "user-agent": "ade-desktop", - "x-github-api-version": GITHUB_REST_API_VERSION, - }; + const accept = args.accept?.trim() || "application/vnd.github+json"; + const rateLimitResource = githubRateLimitResourceForPath(args.path); + let firstUnavailable: { + failure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + } | null = null; + let lastAttemptError: GithubCredentialAttemptError | null = null; - // For GET requests, send If-None-Match with cached ETag if available. - // GitHub returns 304 Not Modified for free (no rate limit cost). - let sentConditionalGet = false; - if (args.method === "GET") { - const cached = etagCache.get(urlKey); - if (cached) { - headers["if-none-match"] = cached.etag; - sentConditionalGet = true; - inFlightConditionalGetKeys.add(urlKey); + for (const candidate of candidates) { + const cooldown = args.token + ? null + : githubCredentialCooldown(candidate, Date.now(), { resource: rateLimitResource }); + if (cooldown) { + firstUnavailable ??= cooldown; + continue; } - } - let response: Response; - try { - response = await fetchGitHub(url.toString(), { - method: args.method, - headers, - body: args.body != null ? JSON.stringify(args.body) : undefined - }); - } finally { - if (sentConditionalGet) { - inFlightConditionalGetKeys.delete(urlKey); + const cacheKey = `${githubCredentialTokenDigest(candidate.token)}:${accept}:${url.toString()}`; + const headers: Record = { + accept, + authorization: `Bearer ${candidate.token}`, + "content-type": args.body != null ? "application/json" : "text/plain", + "user-agent": "ade-desktop", + "x-github-api-version": GITHUB_REST_API_VERSION, + }; + let sentConditionalGet = false; + if (args.method === "GET") { + const cached = etagCache.get(cacheKey); + if (cached) { + headers["if-none-match"] = cached.etag; + sentConditionalGet = true; + inFlightConditionalGetKeys.add(cacheKey); + } } - } - // 304 Not Modified — return cached data (free, no rate limit cost) - if (response.status === 304) { - const cached = etagCache.get(urlKey); - if (cached) { - releaseGitHubResponse(response); - return { data: cached.data as T, response, linkHeader: cached.linkHeader }; + let response: Response; + try { + response = await fetchGitHub(url.toString(), { + method: args.method, + headers, + body: args.body != null ? JSON.stringify(args.body) : undefined, + }); + } finally { + if (sentConditionalGet) inFlightConditionalGetKeys.delete(cacheKey); } - } - const text = await response.text(); - let data: unknown = text; - try { - data = text.trim().length ? JSON.parse(text) : {}; - } catch { - // keep text - } + if (response.status === 304) { + const cached = etagCache.get(cacheKey); + if (cached) { + recordGithubCredentialSuccess(candidate, response.headers); + releaseGitHubResponse(response); + return { data: cached.data as T, response, linkHeader: cached.linkHeader }; + } + } - if (!response.ok) { - const body = data && typeof data === "object" && !Array.isArray(data) ? (data as Record) : null; - const message = (body ? asString(body.message) : "") || `GitHub API request failed (HTTP ${response.status})`; - let detail = ""; - if (body && Array.isArray(body.errors)) { - const errorMessages = (body.errors as any[]) - .map((e) => (typeof e === "object" && e && typeof e.message === "string" ? e.message : null)) - .filter(Boolean); - if (errorMessages.length > 0) { - detail = ": " + errorMessages.join("; "); + const text = await response.text(); + let data: unknown = text; + try { + data = text.trim().length ? JSON.parse(text) : {}; + } catch { + // Keep non-JSON response bodies for callers and error messages. + } + + if (!response.ok) { + const body = data && typeof data === "object" && !Array.isArray(data) + ? data as Record + : null; + const message = (body ? asString(body.message) : "") + || `GitHub API request failed (HTTP ${response.status})`; + const errorMessages = body && Array.isArray(body.errors) + ? body.errors + .map((error) => typeof error === "object" && error && "message" in error + ? asString((error as { message?: unknown }).message) + : "") + .filter(Boolean) + : []; + const detail = errorMessages.length > 0 ? `: ${errorMessages.join("; ")}` : ""; + const failure = classifyGitHubAuthFailure({ + status: response.status, + message, + headers: response.headers, + }); + recordGithubCredentialFailure(candidate, failure.authFailure, failure.rateLimit); + const attemptError = new GithubCredentialAttemptError( + message + detail, + failure.authFailure, + failure.rateLimit, + ); + lastAttemptError = attemptError; + const canTryNext = !args.token + && (response.status === 401 || response.status === 403 || response.status === 429); + if (canTryNext) continue; + if (attemptError.authFailure.kind === "rate_limited") { + const resetAtMs = githubRateLimitRetryAtMs(attemptError.authFailure, attemptError.rateLimit); + const resetDetail = resetAtMs == null + ? "rate limit exceeded" + : `rate limit exceeded; resets at ${new Date(resetAtMs).toLocaleString()}`; + throw new GitHubRateLimitError( + `${attemptError.message} (${resetDetail})`, + resetAtMs, + attemptError.rateLimit, + ); } + throw attemptError; } - const failure = classifyGitHubAuthFailure({ - status: response.status, - message, - headers: response.headers, - }); - if (failure.authFailure.kind === "rate_limited") { - const resetAtMs = githubRateLimitRetryAtMs(failure.authFailure, failure.rateLimit); - const resetDetail = resetAtMs == null - ? "rate limit exceeded" - : `rate limit exceeded; resets at ${new Date(resetAtMs).toLocaleString()}`; - throw new GitHubRateLimitError( - `${message}${detail} (${resetDetail})`, - resetAtMs, + + const graphqlFailure = rateLimitResource === "graphql" + ? classifyGitHubGraphqlCredentialFailure(data, response.headers) + : null; + if (graphqlFailure) { + recordGithubCredentialFailure( + candidate, + graphqlFailure.authFailure, + graphqlFailure.rateLimit, ); + const attemptError = new GithubCredentialAttemptError( + graphqlFailure.message, + graphqlFailure.authFailure, + graphqlFailure.rateLimit, + ); + lastAttemptError = attemptError; + if (!args.token) continue; + if (attemptError.authFailure.kind === "rate_limited") { + const resetAtMs = githubRateLimitRetryAtMs(attemptError.authFailure, attemptError.rateLimit); + throw new GitHubRateLimitError(attemptError.message, resetAtMs, attemptError.rateLimit); + } + throw attemptError; } - throw new Error(message + detail); - } - const linkHeader = response.headers.get("link"); - - // Cache ETag for future conditional requests - if (args.method === "GET") { - const etag = response.headers.get("etag"); - if (etag) { - // Evict oldest entries if cache is full - while (etagCache.size >= ETAG_CACHE_MAX_SIZE && !etagCache.has(urlKey)) { - const before = etagCache.size; - evictOldestEtagCacheEntry(inFlightConditionalGetKeys); - if (etagCache.size === before) break; + recordGithubCredentialSuccess(candidate, response.headers); + if (candidate !== candidates[0]) { + logger.info("github.credential_fallback_used", { + capability, + fromSource: candidates[0]?.source ?? null, + toSource: candidate.source, + }); + } + const linkHeader = response.headers.get("link"); + if (args.method === "GET") { + const etag = response.headers.get("etag"); + if (etag) { + while (etagCache.size >= ETAG_CACHE_MAX_SIZE && !etagCache.has(cacheKey)) { + const before = etagCache.size; + evictOldestEtagCacheEntry(inFlightConditionalGetKeys); + if (etagCache.size === before) break; + } + etagCache.set(cacheKey, { etag, data, linkHeader }); } - etagCache.set(urlKey, { etag, data, linkHeader }); } + return { data: data as T, response, linkHeader }; } - return { data: data as T, response, linkHeader }; + const exhausted = lastAttemptError ?? (firstUnavailable + ? new GithubCredentialAttemptError( + firstUnavailable.failure.message, + firstUnavailable.failure, + firstUnavailable.rateLimit, + ) + : null); + if (exhausted?.authFailure.kind === "rate_limited") { + const resetAtMs = githubRateLimitRetryAtMs(exhausted.authFailure, exhausted.rateLimit); + const resetDetail = resetAtMs == null + ? "rate limit exceeded" + : `rate limit exceeded; resets at ${new Date(resetAtMs).toLocaleString()}`; + throw new GitHubRateLimitError( + `${exhausted.message} (${resetDetail})`, + resetAtMs, + exhausted.rateLimit, + ); + } + if (exhausted) throw exhausted; + throw new Error("No usable GitHub credential is available for this operation."); }; const apiRequestAllPages = async (args: { @@ -1214,26 +1398,47 @@ export function createGithubService({ sharedGhAuth.statusCache.clear(); processGhHostsTokenCache.clear(); } - const tokenLookup = await readAuthToken(); - const token = tokenLookup.token; - const { repo, hasOrigin } = await detectOrigin().catch(() => ({ repo: null, hasOrigin: false })); - if (!token) { + const [inventory, origin] = await Promise.all([ + readCredentialInventory(), + detectOrigin().catch(() => ({ repo: null, hasOrigin: false })), + ]); + const { repo, hasOrigin } = origin; + const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); + const primaryCandidate = readCandidates[0] ?? null; + const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); + const statusCooldown = (candidate: GitHubTokenCandidate) => githubCredentialCooldown( + candidate, + Date.now(), + { ignoreNonRateLimit: opts.forceRefresh === true }, + ); + const currentWriteCandidate = writeCandidates.find((candidate) => !statusCooldown(candidate)) + ?? null; + if (!primaryCandidate) { cachedStatus = { tokenStored: false, - patTokenStored: tokenLookup.patTokenStored, + patTokenStored: inventory.patTokenStored, tokenDecryptionFailed, storageScope: "app", authSource: "none", + writeAuthSource: "none", tokenType: "unknown", repo, hasOrigin, userLogin: null, scopes: [], - ghCliPath: tokenLookup.ghCliPath, - ghAuthError: tokenLookup.ghAuthError, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, checkedAt: null, authFailure: null, rateLimit: null, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + activeReadSource: null, + activeWriteSource: null, + }), + credentialFallback: null, + backgroundRefreshPausedUntil: null, repoAccessOk: null, repoAccessError: null, connected: false, @@ -1244,55 +1449,112 @@ export function createGithubService({ } const now = Date.now(); - const tokenDigest = githubTokenDigest(token); + const primaryTokenDigest = githubCredentialTokenDigest(primaryCandidate.token); if (cachedStatus && now - cachedAt < 30_000 && cachedStatus.tokenStored) { - // Still re-detect repo and re-evaluate `connected` so a remote change is reflected. const repoChanged = (cachedStatus.repo?.owner ?? null) !== (repo?.owner ?? null) || (cachedStatus.repo?.name ?? null) !== (repo?.name ?? null); - const authSourceChanged = - cachedStatus.authSource !== tokenLookup.source || - cachedStatus.patTokenStored !== tokenLookup.patTokenStored; - if (authSourceChanged || cachedStatusTokenDigest !== tokenDigest) { + if (repoChanged || cachedStatusTokenDigest !== primaryTokenDigest) { cachedStatus = null; cachedAt = 0; cachedStatusTokenDigest = null; } else { - // If the repo just changed we can't trust the cached probe result. - const repoAccessOk = repoChanged ? null : cachedStatus.repoAccessOk; - const repoAccessError = repoChanged ? null : cachedStatus.repoAccessError; - const connected = computeConnected({ - tokenStored: true, - userLogin: cachedStatus.userLogin, - authSource: tokenLookup.source, - tokenType: cachedStatus.tokenType, - scopes: cachedStatus.scopes, - repo, - repoAccessOk, - }); + const activeReadSource = cachedStatus.authSource === "none" ? null : cachedStatus.authSource; + const activeWriteSource = currentWriteCandidate?.source === "app" + ? null + : currentWriteCandidate?.source ?? null; + const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); return { ...cachedStatus, repo, hasOrigin, - ghCliPath: tokenLookup.ghCliPath ?? cachedStatus.ghCliPath, - ghAuthError: tokenLookup.ghAuthError, - repoAccessOk, - repoAccessError, - connected, + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath ?? cachedStatus.ghCliPath, + ghAuthError: inventory.ghAuthError, + writeAuthSource: activeWriteSource ?? "none", + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + activeReadSource, + activeWriteSource, + }), + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), }; } } - let probeFailure: Extract | null = null; - try { - const statusProbe = tokenLookup.source === "gh" - ? await readSharedGithubStatusProbe(token, repo, opts.forceRefresh === true) - : await computeGithubStatusProbe(token, repo, tokenLookup.source); + const failures: Array<{ + candidate: GitHubTokenCandidate; + error: string; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + }> = []; + let active: { + candidate: GitHubTokenCandidate; + value: SharedGithubStatusProbe; + } | null = null; + + for (const [candidateIndex, candidate] of readCandidates.entries()) { + const cooldown = statusCooldown(candidate); + if (cooldown) { + failures.push({ + candidate, + error: cooldown.failure.message, + authFailure: cooldown.failure, + rateLimit: cooldown.rateLimit, + }); + continue; + } + const statusProbe = candidate.source === "gh" + ? await readSharedGithubStatusProbe(candidate.token, repo, opts.forceRefresh === true) + : await computeGithubStatusProbe(candidate.token, repo, candidate.source); if (!statusProbe.ok) { - probeFailure = statusProbe; - throw new Error(statusProbe.error); + const hasFallbackCandidate = readCandidates + .slice(candidateIndex + 1) + .some((nextCandidate) => !statusCooldown(nextCandidate)); + if ( + statusProbe.authFailure.kind === "permission_denied" + && statusProbe.value + && !hasFallbackCandidate + ) { + active = { candidate, value: statusProbe.value }; + registerGithubCredentialIdentity(candidate, statusProbe.value.validated.userLogin); + break; + } + recordGithubCredentialFailure(candidate, statusProbe.authFailure, statusProbe.rateLimit); + failures.push({ candidate, ...statusProbe }); + logger.warn("github.token_validation_failed", { + source: candidate.source, + error: statusProbe.error, + kind: statusProbe.authFailure.kind, + retryAt: statusProbe.authFailure.retryAt, + }); + if ( + statusProbe.authFailure.kind === "network" + || statusProbe.authFailure.kind === "unknown" + ) { + break; + } + continue; } - const { validated, repoAccessOk, repoAccessError } = statusProbe.value; + active = { candidate, value: statusProbe.value }; + registerGithubCredentialIdentity(candidate, statusProbe.value.validated.userLogin); + recordGithubCredentialProbeSuccess( + candidate, + statusProbe.value.validated.rateLimit, + statusProbe.value.validated.userLogin, + ); + break; + } + + const activeWriteCandidate = writeCandidates.find((candidate) => !statusCooldown(candidate)) + ?? null; + const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); + if (active) { + const { candidate, value } = active; + const { validated, repoAccessOk, repoAccessError } = value; // Classic PATs and gh OAuth tokens expose scopes in the /user response. // Fine-grained tokens do not expose selected repos and need a repo probe. if (repo && validated.tokenType === "fine-grained" && repoAccessOk === false) { @@ -1305,7 +1567,7 @@ export function createGithubService({ const connected = computeConnected({ tokenStored: true, userLogin: validated.userLogin, - authSource: tokenLookup.source, + authSource: candidate.source, tokenType: validated.tokenType, scopes: validated.scopes, repo, @@ -1313,61 +1575,100 @@ export function createGithubService({ }); cachedStatus = { tokenStored: true, - patTokenStored: tokenLookup.patTokenStored, + patTokenStored: inventory.patTokenStored, tokenDecryptionFailed: false, storageScope: "app", - authSource: tokenLookup.source, + authSource: candidate.source, + writeAuthSource: activeWriteCandidate?.source === "app" + ? "none" + : activeWriteCandidate?.source ?? "none", tokenType: validated.tokenType, repo, hasOrigin, userLogin: validated.userLogin, scopes: validated.scopes, - ghCliPath: tokenLookup.ghCliPath, - ghAuthError: tokenLookup.ghAuthError, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, checkedAt: nowIso(), authFailure: null, rateLimit: validated.rateLimit, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + activeReadSource: candidate.source, + activeWriteSource: activeWriteCandidate?.source === "app" + ? null + : activeWriteCandidate?.source ?? null, + }), + credentialFallback: failures[0] && failures[0].candidate.source !== candidate.source + ? { + capability: "read", + fromSource: failures[0].candidate.source, + toSource: candidate.source, + reason: failures[0].authFailure.kind, + retryAt: failures[0].authFailure.retryAt, + } + : null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), repoAccessOk, repoAccessError, connected, }; cachedAt = now; - cachedStatusTokenDigest = tokenDigest; - return cachedStatus; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const fallbackFailure = classifyGitHubAuthFailure({ message }); - const authFailure = probeFailure?.authFailure ?? fallbackFailure.authFailure; - const rateLimit = probeFailure?.rateLimit ?? fallbackFailure.rateLimit; - logger.warn("github.token_validation_failed", { - error: message, - kind: authFailure.kind, - retryAt: authFailure.retryAt, - }); - cachedStatus = { - tokenStored: true, - patTokenStored: tokenLookup.patTokenStored, - tokenDecryptionFailed: false, - storageScope: "app", - authSource: tokenLookup.source, - tokenType: detectGitHubTokenType(token), - repo, - hasOrigin, - userLogin: null, - scopes: [], - ghCliPath: tokenLookup.ghCliPath, - ghAuthError: tokenLookup.ghAuthError, - checkedAt: nowIso(), - authFailure, - rateLimit, - repoAccessOk: null, - repoAccessError: null, - connected: false, - }; - cachedAt = now; - cachedStatusTokenDigest = tokenDigest; + // Track the precedence head, not the fallback, so a source appearing or + // disappearing invalidates this cache immediately. + cachedStatusTokenDigest = primaryTokenDigest; return cachedStatus; } + + const failure = failures.find((entry) => entry.authFailure.kind === "rate_limited") + ?? failures[0] + ?? { + candidate: primaryCandidate, + error: "GitHub authentication could not be verified.", + authFailure: classifyGitHubAuthFailure({ message: "GitHub authentication could not be verified." }).authFailure, + rateLimit: null, + }; + cachedStatus = { + tokenStored: true, + patTokenStored: inventory.patTokenStored, + tokenDecryptionFailed: false, + storageScope: "app", + authSource: primaryCandidate.source, + writeAuthSource: activeWriteCandidate?.source === "app" + ? "none" + : activeWriteCandidate?.source ?? "none", + tokenType: detectGitHubTokenType(primaryCandidate.token), + repo, + hasOrigin, + userLogin: null, + scopes: [], + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + checkedAt: nowIso(), + authFailure: failure.authFailure, + rateLimit: failure.rateLimit, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + activeReadSource: null, + activeWriteSource: activeWriteCandidate?.source === "app" + ? null + : activeWriteCandidate?.source ?? null, + }), + credentialFallback: null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + repoAccessOk: null, + repoAccessError: null, + connected: false, + }; + cachedAt = now; + cachedStatusTokenDigest = primaryTokenDigest; + return cachedStatus; }; const getStatus = async (opts: { forceRefresh?: boolean } = {}): Promise => { @@ -1646,7 +1947,7 @@ export function createGithubService({ // `/user/repos`. The renderer now populates `owner` from the connected // login, so detect that case and avoid the org route for personal publishes. const authenticatedLogin = owner - ? ((await validateToken((await readAuthToken()).token ?? "").catch(() => ({ userLogin: null as string | null }))).userLogin?.trim() || null) + ? ((await validateToken((await readAuthToken("write")).token ?? "").catch(() => ({ userLogin: null as string | null }))).userLogin?.trim() || null) : null; // Only take the org route when we POSITIVELY resolved the authenticated // login and it differs from `owner`. If token validation failed (transient @@ -1705,7 +2006,7 @@ export function createGithubService({ const publishCurrentProject = async ( args: { owner?: string; name: string; description?: string; isPrivate: boolean }, ): Promise<{ state: "pushed" | "remote_added"; owner: string; name: string; fullName: string; htmlUrl: string }> => { - const token = (await readAuthToken()).token; + const token = (await readAuthToken("write")).token; if (!token) { const err = new Error("GitHub is not connected. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings.") as Error & { code?: string }; err.code = "github_not_connected"; @@ -1815,6 +2116,14 @@ export function createGithubService({ getStatus, + async getBackgroundRequestPauseUntilMs(): Promise { + const inventory = await readCredentialInventory(); + return githubBackgroundRequestPauseUntilMs( + Date.now(), + githubOperationCredentialCandidates(inventory.candidates, "read"), + ); + }, + getAppUserAuthStatus(): GitHubAppUserAuthStatus { return appUserAuth.getAuthStatus(); }, @@ -1824,11 +2133,23 @@ export function createGithubService({ }, async pollAppUserDeviceAuth(args: { sessionId: string }): Promise { - return await appUserAuth.pollDeviceAuth(args); + const result = await appUserAuth.pollDeviceAuth(args); + if (result.status === "authorized") { + clearGithubCredentialHealth(); + cachedStatus = null; + cachedAt = 0; + cachedStatusTokenDigest = null; + } + return result; }, clearAppUserAuth(): GitHubAppUserAuthStatus { - return appUserAuth.clearAuth(); + const status = appUserAuth.clearAuth(); + clearGithubCredentialHealth(); + cachedStatus = null; + cachedAt = 0; + cachedStatusTokenDigest = null; + return status; }, setToken(token: string): void { @@ -1839,6 +2160,7 @@ export function createGithubService({ cachedStatusTokenDigest = null; sharedGhAuth.authCache = null; sharedGhAuth.statusCache.clear(); + clearGithubCredentialHealth(); }, clearToken(): void { @@ -1849,6 +2171,7 @@ export function createGithubService({ cachedStatusTokenDigest = null; sharedGhAuth.authCache = null; sharedGhAuth.statusCache.clear(); + clearGithubCredentialHealth(); }, async getRepoOrThrow(): Promise { @@ -1864,7 +2187,7 @@ export function createGithubService({ }, async getTokenOrThrowAsync(): Promise { - const token = (await readAuthToken()).token; + const token = (await readAuthToken("write")).token; if (!token) throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); return token; }, diff --git a/apps/desktop/src/main/services/prs/prAsync.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts index 2afe79e52..83830047f 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -166,6 +166,47 @@ describe("prPollingService", () => { expect(refresh).toHaveBeenLastCalledWith(); }); + it("uses webhook reconciliation instead of hot polling while the relay is healthy", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-24T12:00:00.000Z")); + vi.spyOn(Math, "random").mockReturnValue(0.5); + + const summary = createSummary(); + const refresh = vi.fn(async () => [summary]); + const prService = { + listAll: () => [summary], + refresh, + getHotRefreshDelayMs: () => 5_000, + getHotRefreshPrIds: () => ["pr-1"], + } as any; + const service = createPrPollingService({ + logger: createLogger() as any, + prService, + projectConfigService: { get: () => ({ effective: {} }) } as any, + isGithubRelayHealthy: () => true, + onEvent: vi.fn(), + }); + + service.start(); + await vi.advanceTimersByTimeAsync(12_000); + expect(refresh).toHaveBeenCalledTimes(1); + expect(refresh).toHaveBeenLastCalledWith(); + + service.poke(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(5_000); + expect(refresh).toHaveBeenCalledTimes(1); + + service.reconcilePrs(["pr-1"]); + await vi.advanceTimersByTimeAsync(0); + expect(refresh).toHaveBeenCalledTimes(2); + expect(refresh).toHaveBeenLastCalledWith({ prIds: ["pr-1"] }); + + await vi.advanceTimersByTimeAsync(15 * 60_000); + expect(refresh).toHaveBeenCalledTimes(3); + expect(refresh).toHaveBeenLastCalledWith(); + }); + it("discovers lane PRs when the local PR cache starts empty", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00.000Z")); diff --git a/apps/desktop/src/main/services/prs/prPollingService.ts b/apps/desktop/src/main/services/prs/prPollingService.ts index d8459c1d8..46002d947 100644 --- a/apps/desktop/src/main/services/prs/prPollingService.ts +++ b/apps/desktop/src/main/services/prs/prPollingService.ts @@ -4,6 +4,7 @@ import type { createPrService } from "./prService"; import type { AdeDb } from "../state/kvDb"; import type { PrEventPayload, PrNotificationKind, PrSummary } from "../../../shared/types"; import { nowIso } from "../shared/utils"; +import { githubBackgroundRequestPauseUntilMs } from "../github/githubCredentialHealth"; function clampMs(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; @@ -113,6 +114,8 @@ export function createPrPollingService({ onEvent, onPullRequestsSnapshot, onPullRequestsChanged, + isGithubRelayHealthy, + getGithubBackgroundPauseUntilMs, db, }: { logger: Logger; @@ -136,6 +139,10 @@ export function createPrPollingService({ }>; polledAt: string; }) => void | Promise; + /** True while the hosted webhook relay is delivering repository events. */ + isGithubRelayHealthy?: () => boolean; + /** Restricts quota protection to credentials available to this project. */ + getGithubBackgroundPauseUntilMs?: () => number | null | Promise; /** Optional database handle used to persist `last_polled_at` per PR for delta polling. */ db?: AdeDb; }) { @@ -143,6 +150,8 @@ export function createPrPollingService({ const MIN_INTERVAL_MS = 5_000; const MAX_INTERVAL_MS = 5 * 60_000; const EMPTY_DISCOVERY_MIN_INTERVAL_MS = 10 * 60_000; + const RELAY_EMPTY_DISCOVERY_MIN_INTERVAL_MS = 30 * 60_000; + const RELAY_SAFETY_SWEEP_INTERVAL_MS = 15 * 60_000; // Epoch (not "never") so the first tick after start still discovers. let lastEmptyDiscoveryAtMs = 0; @@ -183,6 +192,7 @@ export function createPrPollingService({ let consecutiveFailures = 0; let nextDelayOverrideMs: number | null = null; let rateLimitResumeAtMs = 0; + let lastRelaySafetySweepAtMs = 0; let lastPrFingerprint = ""; const lastFingerprintByPrId = new Map(); const pendingTargetedPrIds = new Set(); @@ -239,15 +249,28 @@ export function createPrPollingService({ const polledAt = nowIso(); try { + const backgroundPauseUntilMs = await Promise.resolve( + getGithubBackgroundPauseUntilMs?.() ?? githubBackgroundRequestPauseUntilMs(), + ); + if (backgroundPauseUntilMs != null && backgroundPauseUntilMs > Date.now()) { + const untilReset = Math.max(10_000, backgroundPauseUntilMs - Date.now() + 5_000); + nextDelayOverrideMs = untilReset; + rateLimitResumeAtMs = Date.now() + untilReset; + return; + } const targetedPrIds = Array.from(pendingTargetedPrIds); pendingTargetedPrIds.clear(); + const relayHealthy = isGithubRelayHealthy?.() === true; let existing = prService.listAll(); if (existing.length === 0) { // Discovery force-refreshes the whole repo snapshot, which is far // heavier than a tracked-PR delta poll. With zero tracked PRs (new // users, non-PR projects) run it on a slow cadence instead of every // tick — user-driven surfaces discover PRs on their own reads anyway. - if (Date.now() - lastEmptyDiscoveryAtMs >= EMPTY_DISCOVERY_MIN_INTERVAL_MS) { + const discoveryIntervalMs = relayHealthy + ? RELAY_EMPTY_DISCOVERY_MIN_INTERVAL_MS + : EMPTY_DISCOVERY_MIN_INTERVAL_MS; + if (Date.now() - lastEmptyDiscoveryAtMs >= discoveryIntervalMs) { lastEmptyDiscoveryAtMs = Date.now(); try { existing = await prService.discoverLanePullRequests(); @@ -280,6 +303,11 @@ export function createPrPollingService({ const hotPrIds = prService.getHotRefreshPrIds(); if (targetedPrIds.length > 0) { await prService.refresh({ prIds: targetedPrIds }); + } else if (relayHealthy) { + if (Date.now() - lastRelaySafetySweepAtMs >= RELAY_SAFETY_SWEEP_INTERVAL_MS) { + await prService.refresh(); + lastRelaySafetySweepAtMs = Date.now(); + } } else if (hotPrIds.length > 0) { await prService.refresh({ prIds: hotPrIds }); } else { @@ -456,8 +484,12 @@ export function createPrPollingService({ if (pendingTargetedPrIds.size > 0 && rateLimitResumeAtMs <= Date.now()) { schedule(0); } else { - const hotDelay = prService.getHotRefreshDelayMs(); - const base = hotDelay ?? computeBackoffMs(); + const relayHealthy = isGithubRelayHealthy?.() === true; + const hotDelay = relayHealthy ? null : prService.getHotRefreshDelayMs(); + const relaySafetyDelay = relayHealthy + ? Math.max(1_000, RELAY_SAFETY_SWEEP_INTERVAL_MS - (Date.now() - lastRelaySafetySweepAtMs)) + : null; + const base = hotDelay ?? relaySafetyDelay ?? computeBackoffMs(); const delay = jitterMs(Math.max(base, nextDelayOverrideMs ?? 0)); nextDelayOverrideMs = null; if (rateLimitResumeAtMs > 0 && Date.now() >= rateLimitResumeAtMs) { diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index b68fc4c00..a641ea374 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -255,6 +255,7 @@ function makeGithubStatus(overrides?: Record) { tokenDecryptionFailed: false, storageScope: "app", authSource: "pat", + writeAuthSource: "pat", tokenType: "classic", connected: true, repo: REPO, @@ -932,6 +933,30 @@ describe("prService.getGithubSnapshot", () => { })); }); + it("allows read-only GitHub App snapshots without a write credential", async () => { + const githubService = makeGithubService({ + getStatus: vi.fn(async () => makeGithubStatus({ + authSource: "app", + writeAuthSource: "none", + patTokenStored: false, + connected: true, + repoAccessOk: true, + })), + getTokenOrThrow: vi.fn(() => { + throw new Error("GitHub auth missing"); + }), + apiRequest: vi.fn(async () => ({ data: [] })), + }); + const { service } = buildService({ githubService, laneService: makeLaneService([]) }); + + await expect(service.getGithubSnapshot({ force: true })).resolves.toMatchObject({ + repo: REPO, + viewerLogin: "octocat", + repoPullRequests: [], + }); + expect(githubService.apiRequest).toHaveBeenCalled(); + }); + it("fetches all PR state totals in one GraphQL request when mobile asks for counts", async () => { const githubService = makeGithubService({ getStatus: vi.fn(async () => makeGithubStatus()), @@ -6362,13 +6387,13 @@ describe("prService hot refresh", () => { const { service } = buildService({ onHotRefreshChanged }); service.markHotRefresh(["pr-1"]); - expect(service.getHotRefreshDelayMs()).toBe(5_000); + expect(service.getHotRefreshDelayMs()).toBe(15_000); vi.setSystemTime(new Date("2026-01-01T00:00:59.000Z")); service.markHotRefresh(["pr-1"]); vi.setSystemTime(new Date("2026-01-01T00:01:01.000Z")); - expect(service.getHotRefreshDelayMs()).toBe(15_000); + expect(service.getHotRefreshDelayMs()).toBe(30_000); vi.setSystemTime(new Date("2026-01-01T00:03:01.000Z")); expect(service.getHotRefreshPrIds()).toEqual([]); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 7c2ace028..0fd94c925 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -2065,8 +2065,8 @@ export function createPrService({ const HOT_REFRESH_PHASE_ONE_MS = 60_000; const HOT_REFRESH_PHASE_TWO_MS = 3 * 60_000; - const HOT_REFRESH_INTERVAL_PHASE_ONE_MS = 5_000; - const HOT_REFRESH_INTERVAL_PHASE_TWO_MS = 15_000; + const HOT_REFRESH_INTERVAL_PHASE_ONE_MS = 15_000; + const HOT_REFRESH_INTERVAL_PHASE_TWO_MS = 30_000; const hotRefreshStartedAtByPrId = new Map(); const invalidateGithubSnapshotCache = (): void => { @@ -4212,6 +4212,7 @@ export function createPrService({ }>({ method: "POST", path: "/graphql", + capability: /^\s*mutation\b/i.test(query) ? "write" : "read", body: { query, variables }, ...(options.accept ? { accept: options.accept } : {}), }); diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 67d8861d9..77de1fa16 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2957,6 +2957,13 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { publishHealth: null, lastWedge: null, runtimeMode: "primary", + versionSkew: { + state: "none", + appVersion: "0.0.0-browser", + runtimeVersion: "0.0.0-browser", + message: null, + updatedAt: null, + }, serviceInstall: { state: "skipped", attempted: false, @@ -5627,7 +5634,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { patTokenStored: false, tokenDecryptionFailed: false, storageScope: "app", - authSource: "gh", + authSource: "app", + writeAuthSource: "gh", tokenType: "oauth", repo: { owner: "arul28", name: "ADE" }, hasOrigin: true, @@ -5636,6 +5644,48 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { ghCliPath: "/opt/homebrew/bin/gh", ghAuthError: null, checkedAt: new Date().toISOString(), + authFailure: null, + rateLimit: null, + credentialStates: [ + { + source: "environment", + available: false, + capabilities: ["read", "write"], + activeFor: [], + state: "unavailable", + failure: null, + rateLimit: null, + }, + { + source: "app", + available: true, + capabilities: ["read"], + activeFor: ["read"], + state: "active", + failure: null, + rateLimit: null, + }, + { + source: "gh", + available: true, + capabilities: ["read", "write"], + activeFor: ["write"], + state: "active", + failure: null, + rateLimit: null, + }, + { + source: "pat", + available: false, + capabilities: ["read", "write"], + activeFor: [], + state: "unavailable", + failure: null, + rateLimit: null, + }, + ], + credentialFallback: null, + backgroundRefreshPausedUntil: null, repoAccessOk: true, repoAccessError: null, connected: true, diff --git a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx index 85ea0400b..fdddfd20e 100644 --- a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx +++ b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx @@ -17,6 +17,7 @@ import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPick import { useAppStore } from "../../state/appStore"; import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; import { useOpenProviderSignIn } from "../shared/useOpenProviderSignIn"; +import { githubStatusHasWriteCredential } from "../../lib/githubIntegrationStatus"; import type { AppInfo, ProjectInfo } from "../../../shared/types/core"; import type { GitCommitSummary } from "../../../shared/types/git"; import type { LaneSummary } from "../../../shared/types/lanes"; @@ -1153,7 +1154,7 @@ export function FeedbackReporterModal({ if (!open) return; void window.ade.github .getStatus() - .then((status) => setHasGithubToken(status.connected)) + .then((status) => setHasGithubToken(githubStatusHasWriteCredential(status))) .catch(() => setHasGithubToken(false)); }, [open]); diff --git a/apps/desktop/src/renderer/components/app/IntegrationBannerHost.test.tsx b/apps/desktop/src/renderer/components/app/IntegrationBannerHost.test.tsx index addf15440..90ab2a4b6 100644 --- a/apps/desktop/src/renderer/components/app/IntegrationBannerHost.test.tsx +++ b/apps/desktop/src/renderer/components/app/IntegrationBannerHost.test.tsx @@ -183,6 +183,24 @@ describe("IntegrationBannerHost", () => { const stored = window.localStorage.getItem("ade.bannerDismiss.v1"); expect(stored).toContain("ai-provider:/project/a"); }); + + it("shows the write-access banner for a connected read-only GitHub App", async () => { + setAdeMock(undefined); + + await act(async () => { + render(); + }); + + expect(screen.getByText("GitHub write access isn't connected")).toBeTruthy(); + }); }); describe("IntegrationBannerHost relay-offline banner", () => { diff --git a/apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx b/apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx index 3854d3a08..71fe24464 100644 --- a/apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx +++ b/apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx @@ -15,6 +15,7 @@ import { deriveGithubRealtimeBlock, deriveGithubRepoConnectionState, describeGithubCliBanner, + githubStatusHasWriteCredential, githubAccountIssueCopy, githubRepoIssueCopy, } from "../../lib/githubIntegrationStatus"; @@ -252,7 +253,9 @@ export function IntegrationBannerHost({ clearDismissal(`github-app-repo:${repoKey}`); } } - if (githubStatus?.connected) clearDismissal(`github-cli:${currentProjectRoot}`); + if (githubStatus?.connected && githubStatusHasWriteCredential(githubStatus)) { + clearDismissal(`github-cli:${currentProjectRoot}`); + } if (hasAnyAiProvider) clearDismissal(`ai-provider:${currentProjectRoot}`); if (!(providerMode === "subscription" && aiMockProvider)) { clearDismissal(`mock-provider:${currentProjectRoot}`); @@ -354,7 +357,11 @@ export function IntegrationBannerHost({ // 2) gh CLI / PAT not connected (MIGRATED). A DISTINCT concern from the App // block: this is the token ADE uses for git & PR operations, not webhooks. - if (currentProjectRoot && githubStatus && !githubStatus.connected) { + if ( + currentProjectRoot + && githubStatus + && (!githubStatus.connected || !githubStatusHasWriteCredential(githubStatus)) + ) { const cli = describeGithubCliBanner(githubStatus); list.push({ id: "github-cli", diff --git a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx index b697fc460..38118df8a 100644 --- a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type CSSProperties } from "react"; -import type { GitHubStatus } from "../../../shared/types"; +import type { GitHubCredentialState, GitHubStatus } from "../../../shared/types"; import { GithubLogo, CheckCircle, @@ -62,8 +62,8 @@ function tokenTypeDetectionLabel(type: TokenType): string { } } -function authSourceLabel(status: GitHubStatus | null): string { - switch (status?.authSource) { +function credentialSourceLabel(source: GitHubStatus["authSource"] | undefined): string { + switch (source) { case "app": return "ADE GitHub App"; case "gh": @@ -77,6 +77,31 @@ function authSourceLabel(status: GitHubStatus | null): string { } } +function authSourceLabel(status: GitHubStatus | null): string { + return credentialSourceLabel(status?.authSource); +} + +function shortRetryTime(value: string | null | undefined): string | null { + if (!value) return null; + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime())) return null; + return parsed.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + +function credentialStateLabel(state: GitHubCredentialState): string { + if (state.activeFor.length === 2) return "Reads & writes"; + if (state.activeFor[0] === "read") return "Reads"; + if (state.activeFor[0] === "write") return "Writes"; + if (state.state === "cooldown") { + const retryAt = shortRetryTime(state.failure?.retryAt); + if (state.failure?.kind === "rate_limited") return retryAt ? `Paused until ${retryAt}` : "Paused"; + if (state.failure?.kind === "invalid_token") return "Reconnect needed"; + if (state.failure?.kind === "permission_denied") return "Access unavailable"; + return "Temporarily unavailable"; + } + return state.available ? "Fallback" : "Not set up"; +} + export function GitHubSection({ embedded = false }: { embedded?: boolean }) { const [actionError, setActionError] = useState(null); const [saveNotice, setSaveNotice] = useState(null); @@ -177,6 +202,12 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { const isFineGrainedToken = githubStatus?.tokenType === "fine-grained"; const authFailure = githubStatus?.authFailure ?? null; const authFailurePresentation = githubStatus ? describeGithubAuthFailure(githubStatus) : null; + const credentialFallback = githubStatus?.credentialFallback ?? null; + const credentialStates = githubStatus?.credentialStates ?? []; + const activeReadCredential = credentialStates.find((credential) => credential.activeFor.includes("read")) ?? null; + const effectiveWriteAuthSource = githubStatus?.writeAuthSource + ?? (githubStatus?.authSource && githubStatus.authSource !== "app" ? githubStatus.authSource : "none"); + const backgroundPausedUntil = shortRetryTime(githubStatus?.backgroundRefreshPausedUntil); const hasInspectableScopes = credentialPresentation.hasInspectableScopes; const accessState = getGitHubTokenAccessState(githubStatus?.scopes ?? []); const repoProbeFailed = tokenAuthenticated && githubStatus?.repoAccessOk === false; @@ -186,7 +217,10 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { && !accessState.hasRequiredAccess; let statusColor: string; let statusLabel: string; - if (isConnected) { + if (isConnected && credentialFallback) { + statusColor = COLORS.warning; + statusLabel = "Connected · fallback"; + } else if (isConnected) { statusColor = COLORS.success; statusLabel = "Connected"; } else if (authFailurePresentation) { @@ -215,9 +249,6 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { || authFailure?.kind === "invalid_token" || hasMissingScopes ); - const rateLimitLabel = githubStatus?.rateLimit - ? `${githubStatus.rateLimit.remaining ?? "?"} / ${githubStatus.rateLimit.limit ?? "?"} remaining` - : null; const classicTokenUrl = transcriptGistsEnabled ? GITHUB_CLASSIC_TOKEN_WITH_GIST_NEW_URL : GITHUB_CLASSIC_TOKEN_NEW_URL; const openExternal = (url: string) => { void window.ade.app.openExternal(url); @@ -360,11 +391,80 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { > {summaryCell("USER", githubStatus?.userLogin ?? null)} {summaryCell("REPOSITORY", githubStatus?.repo ? `${githubStatus.repo.owner}/${githubStatus.repo.name}` : null)} - {summaryCell("AUTH METHOD", authSourceLabel(githubStatus))} - {summaryCell("TOKEN TYPE", credentialPresentation.tokenTypeLabel)} - {githubStatus?.rateLimit ? summaryCell("API QUOTA", rateLimitLabel) : null} + {summaryCell("READS WITH", activeReadCredential + ? credentialSourceLabel(activeReadCredential.source) + : authFailure?.kind === "rate_limited" + ? "Paused" + : authSourceLabel(githubStatus))} + {summaryCell("WRITES WITH", credentialSourceLabel(effectiveWriteAuthSource))} + {credentialFallback ? ( +
+ {credentialSourceLabel(credentialFallback.fromSource)} is temporarily unavailable. ADE is using{" "} + {credentialSourceLabel(credentialFallback.toSource)} and will try the preferred connection again automatically + {credentialFallback.retryAt ? ` after ${shortRetryTime(credentialFallback.retryAt)}` : ""}. +
+ ) : null} + + {!credentialFallback && backgroundPausedUntil && authFailure?.kind !== "rate_limited" ? ( +
+ Real-time updates remain on. ADE paused background catch-up until {backgroundPausedUntil} to protect GitHub access for your own actions. +
+ ) : null} + + {credentialStates.length > 0 ? ( +
+
CONNECTION ORDER
+
+ {credentialStates.map((credential, index) => { + const stateColor = credential.state === "active" + ? COLORS.success + : credential.state === "cooldown" + ? COLORS.warning + : credential.available + ? COLORS.textSecondary + : COLORS.textDim; + return ( +
+
+
+ {index + 1}. {credentialSourceLabel(credential.source)} +
+
+ {credential.capabilities.length === 1 ? "Read-only" : "Read and write"} +
+
+ {credentialStateLabel(credential)} +
+ ); + })} +
+
+ ADE uses the first working connection. Read requests can use the GitHub App; write actions skip it and use the first available write connection. +
+
+ ) : null} +
{credentialPresentation.permissionHeading} @@ -388,7 +488,7 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { {credentialPresentation.repoAccessLabel}
- The ADE GitHub App is intentionally read-only and is used only for webhook-backed, real-time pull request updates. GitHub operations use an explicit environment token first, then GitHub CLI, and finally a stored PAT. + The ADE GitHub App is read-only. ADE uses it for pull request data and real-time updates, then uses GitHub CLI or a personal access token for actions that change GitHub.
) : permissionMode === "fine-grained" ? ( diff --git a/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts b/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts index e986ec28d..959aeb917 100644 --- a/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts +++ b/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts @@ -119,8 +119,8 @@ describe("describeGithubCliBanner", () => { }, })); - expect(banner.title).toBe("GitHub API rate limit reached"); - expect(banner.detail).toContain("No authentication command is needed"); + expect(banner.title).toBe("GitHub requests are temporarily paused"); + expect(banner.detail).toContain("will resume automatically"); expect(banner.action).toBe("View GitHub status"); expect(banner.subState).toContain("rate-limited"); }); diff --git a/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts b/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts index f891595c7..7d27b4c74 100644 --- a/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts +++ b/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts @@ -238,6 +238,14 @@ export function isGithubRateLimitMessage(message: string | null | undefined): bo * GitHub App" block. Lives here alongside the account/repo copy so Settings and * the banner draw from one source and can't disagree. */ +export function githubStatusHasWriteCredential(status: GitHubStatus | null): boolean { + if (!status) return false; + if (status.writeAuthSource != null) return status.writeAuthSource !== "none"; + return status.connected + && status.authSource !== "app" + && status.authSource !== "none"; +} + export function describeGithubCliBanner(status: GitHubStatus): { subState: string; title: string; @@ -252,6 +260,14 @@ export function describeGithubCliBanner(status: GitHubStatus): { action: "Connect GitHub", }; } + if (status.writeAuthSource === "none") { + return { + subState: "no-write-credential", + title: "GitHub write access isn't connected", + detail: "The ADE GitHub App can keep pull request data fresh, but GitHub CLI or a personal access token is needed for create, update, and merge actions.", + action: "Connect GitHub", + }; + } const authFailure = describeGithubAuthFailure(status); if (authFailure) { return authFailure; @@ -285,14 +301,14 @@ export function describeGithubAuthFailure(status: GitHubStatus): { const retryAt = formatGithubRetryAt(status.authFailure.retryAt); return { subState: `rate-limited:${status.authFailure.retryAt ?? "unknown"}`, - statusLabel: "Rate limited", - title: "GitHub API rate limit reached", + statusLabel: "GitHub paused", + title: "GitHub requests are temporarily paused", detail: retryAt - ? `ADE is signed in, but GitHub paused API requests until ${retryAt}. No authentication command is needed.` - : "ADE is signed in, but GitHub temporarily paused API requests. No authentication command is needed.", + ? `ADE stopped background checks and will resume automatically at ${retryAt}. GitHub App, CLI, and personal tokens for the same account may share this pause.` + : "ADE stopped background checks and will resume automatically. GitHub App, CLI, and personal tokens for the same account may share this pause.", settingsDetail: retryAt - ? `ADE is signed in, but GitHub paused API requests until ${retryAt}. No authentication command is needed.` - : "ADE is signed in, but GitHub temporarily paused API requests. No authentication command is needed.", + ? `GitHub paused requests for this account until ${retryAt}. ADE has stopped background checks and will resume automatically; reconnecting will not make it recover sooner.` + : "GitHub paused requests for this account. ADE has stopped background checks and will resume automatically; reconnecting is not needed.", action: "View GitHub status", }; } @@ -316,6 +332,16 @@ export function describeGithubAuthFailure(status: GitHubStatus): { action: "View GitHub status", }; } + if (status.authFailure?.kind === "permission_denied") { + return { + subState: "permission-denied", + statusLabel: "Access needed", + title: "GitHub access was not granted", + detail: "ADE tried every available GitHub connection, but none can access this operation.", + settingsDetail: status.authFailure.message, + action: "Fix GitHub auth", + }; + } if (status.authFailure) { return { subState: "validation-failed", diff --git a/apps/desktop/src/shared/githubOperationCredential.ts b/apps/desktop/src/shared/githubOperationCredential.ts index be862cd6d..7631195d3 100644 --- a/apps/desktop/src/shared/githubOperationCredential.ts +++ b/apps/desktop/src/shared/githubOperationCredential.ts @@ -1,39 +1,79 @@ -export const GITHUB_OPERATION_CREDENTIAL_PRECEDENCE = [ - "environment", - "gh", - "pat", -] as const; +import type { + GitHubCredentialCapability, + GitHubCredentialSource, +} from "./types/git"; -type GithubOperationCredentialSource = - (typeof GITHUB_OPERATION_CREDENTIAL_PRECEDENCE)[number]; +export type GithubOperationCredentialSource = GitHubCredentialSource; +export type GithubOperationCredentialCapability = GitHubCredentialCapability; + +export const GITHUB_OPERATION_CREDENTIALS = [ + { source: "environment", capabilities: ["read", "write"] }, + { source: "app", capabilities: ["read"] }, + { source: "gh", capabilities: ["read", "write"] }, + { source: "pat", capabilities: ["read", "write"] }, +] as const satisfies readonly { + source: GithubOperationCredentialSource; + capabilities: readonly GithubOperationCredentialCapability[]; +}[]; + +export const GITHUB_OPERATION_CREDENTIAL_PRECEDENCE = GITHUB_OPERATION_CREDENTIALS.map( + ({ source }) => source, +); + +const GITHUB_WRITE_CREDENTIAL_PRECEDENCE = GITHUB_OPERATION_CREDENTIAL_PRECEDENCE.filter( + (source): source is Exclude => source !== "app", +); + +export function githubOperationCredentialPrecedence( + capability: GithubOperationCredentialCapability, +): readonly GithubOperationCredentialSource[] { + return capability === "read" + ? GITHUB_OPERATION_CREDENTIAL_PRECEDENCE + : GITHUB_WRITE_CREDENTIAL_PRECEDENCE; +} + +export function githubOperationCredentialCapabilities( + source: GithubOperationCredentialSource, +): readonly GithubOperationCredentialCapability[] { + return GITHUB_OPERATION_CREDENTIALS.find((credential) => credential.source === source) + ?.capabilities ?? []; +} + +export function githubOperationCredentialCandidates< + T extends { + source: GithubOperationCredentialSource; + token: string; + capabilities: readonly GithubOperationCredentialCapability[]; + }, +>( + candidates: readonly T[], + capability: GithubOperationCredentialCapability, +): T[] { + const seenTokens = new Set(); + return githubOperationCredentialPrecedence(capability) + .flatMap((source) => candidates.filter((candidate) => candidate.source === source)) + .filter((candidate) => { + if (!candidate.capabilities.includes(capability)) return false; + // Different sources can expose the same OAuth token. Retrying it cannot + // recover and only consumes another request. + if (seenTokens.has(candidate.token)) return false; + seenTokens.add(candidate.token); + return true; + }); +} type CredentialResolvers = Record< GithubOperationCredentialSource, () => T | null >; -type AsyncCredentialResolvers = Record< - GithubOperationCredentialSource, - () => T | null | Promise ->; - export function selectGithubOperationCredential( resolvers: CredentialResolvers, + capability: GithubOperationCredentialCapability = "read", ): T | null { - for (const source of GITHUB_OPERATION_CREDENTIAL_PRECEDENCE) { + for (const source of githubOperationCredentialPrecedence(capability)) { const credential = resolvers[source](); if (credential) return credential; } return null; } - -export async function selectGithubOperationCredentialAsync( - resolvers: AsyncCredentialResolvers, -): Promise { - for (const source of GITHUB_OPERATION_CREDENTIAL_PRECEDENCE) { - const candidate = resolvers[source](); - const credential = candidate instanceof Promise ? await candidate : candidate; - if (credential) return credential; - } - return null; -} diff --git a/apps/desktop/src/shared/types/git.ts b/apps/desktop/src/shared/types/git.ts index a24b84be0..86c8f6476 100644 --- a/apps/desktop/src/shared/types/git.ts +++ b/apps/desktop/src/shared/types/git.ts @@ -308,11 +308,33 @@ export type GitHubRateLimitState = { }; export type GitHubAuthFailure = { - kind: "rate_limited" | "invalid_token" | "network" | "unknown"; + kind: "rate_limited" | "invalid_token" | "permission_denied" | "network" | "unknown"; message: string; retryAt: string | null; }; +export type GitHubCredentialSource = "environment" | "app" | "gh" | "pat"; + +export type GitHubCredentialCapability = "read" | "write"; + +export type GitHubCredentialState = { + source: GitHubCredentialSource; + available: boolean; + capabilities: GitHubCredentialCapability[]; + activeFor: GitHubCredentialCapability[]; + state: "active" | "ready" | "cooldown" | "unavailable"; + failure: GitHubAuthFailure | null; + rateLimit: GitHubRateLimitState | null; +}; + +export type GitHubCredentialFallback = { + capability: GitHubCredentialCapability; + fromSource: GitHubCredentialSource; + toSource: GitHubCredentialSource; + reason: GitHubAuthFailure["kind"]; + retryAt: string | null; +}; + export type GitHubStatus = { tokenStored: boolean; patTokenStored: boolean; @@ -335,14 +357,19 @@ export type GitHubStatus = { // flattened into "missing scopes" by clients. authFailure?: GitHubAuthFailure | null; rateLimit?: GitHubRateLimitState | null; + // Optional for compatibility with older runtimes. These fields describe the + // operation credential chain without exposing credential material. + writeAuthSource?: Exclude; + credentialStates?: GitHubCredentialState[]; + credentialFallback?: GitHubCredentialFallback | null; + backgroundRefreshPausedUntil?: string | null; // null = no repo to probe / probe not run; true/false = result of GET /repos/{owner}/{repo}. // Required because fine-grained tokens pass /user validation even when the user forgot to // grant the active repo, which then 403s every PR-tab call. repoAccessOk: boolean | null; repoAccessError: string | null; - // Single source of truth for "GitHub is usable here" — UI banners and badges read this so - // they cannot disagree (the bug we just fixed: Settings said CONNECTED while the AppShell - // banner stayed up). + // Single source of truth for "GitHub reads are usable here". Write surfaces + // additionally require writeAuthSource !== "none". connected: boolean; }; diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index be3ce2c0f..7ba28c2e1 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -554,7 +554,7 @@ async function githubRepositoryAccountMatches( accountId: string, ): Promise { const row = await env.DB - .prepare("select account_id from github_app_repositories where repository_key = ? limit 1") + .prepare("select account_id from github_app_repositories where repository_key = ? and installed = 1 limit 1") .bind(`${repo.owner}/${repo.name}`.toLowerCase()) .first(); return row?.account_id === accountId; @@ -1517,14 +1517,19 @@ async function authorizeRepoEventRead( env: RelayEnv, repo: { owner: string; name: string }, ): Promise { - const auth = await assertGitHubRepoAuthorized(request, env, repo); - if (auth.authorized) return { authorized: true, accountId: null }; - + // Signed-in ADE clients already have a relay account identity whose repo + // binding was established during GitHub App setup. Check that first so every + // event poll and WebSocket reconnect does not spend a GitHub REST request on + // re-proving repository access. The GitHub-token path remains for legacy + // clients that do not send an ADE account token. const accountId = await authenticateAccount(request, env); - if (!accountId || !await githubRepositoryAccountMatches(env, repo, accountId)) { - return { authorized: false, response: auth.response }; + if (accountId && await githubRepositoryAccountMatches(env, repo, accountId)) { + return { authorized: true, accountId }; } - return { authorized: true, accountId }; + + const auth = await assertGitHubRepoAuthorized(request, env, repo); + if (auth.authorized) return { authorized: true, accountId: null }; + return { authorized: false, response: auth.response }; } async function handleListRepoEvents(request: Request, env: RelayEnv, repo: { owner: string; name: string }): Promise { diff --git a/apps/webhook-relay/test/account.test.ts b/apps/webhook-relay/test/account.test.ts index cec3a52e7..98eb64611 100644 --- a/apps/webhook-relay/test/account.test.ts +++ b/apps/webhook-relay/test/account.test.ts @@ -92,7 +92,7 @@ class FakeAccountD1 { if (sql.includes("from github_app_repositories")) { const repositoryKey = String(values[0]); const row = this.repositories.find((entry) => entry.repository_key === repositoryKey); - if (!row) return null; + if (!row || (sql.includes("installed = 1") && row.installed !== 1)) return null; return (sql.includes("select account_id") ? { account_id: row.account_id } : row) as T; } if (sql.includes("select webhook_secret from linear_organizations")) { @@ -527,7 +527,9 @@ describe("account integration re-keying", () => { }); const accountStatus = await handleRequest(request("/github/repos/acme/repo/status", { accountToken }), env); + const providerCallsBeforeAccountGitHub = vi.mocked(fetch).mock.calls.length; const accountGitHub = await handleRequest(request("/github/repos/acme/repo/events", { accountToken }), env); + expect(fetch).toHaveBeenCalledTimes(providerCallsBeforeAccountGitHub); const providerCallsBeforeBearerAccountAuth = vi.mocked(fetch).mock.calls.length; const bearerAccountGitHub = await handleRequest(request("/github/repos/acme/repo/events", { authorization: `Bearer ${accountToken}`, @@ -659,4 +661,21 @@ describe("account integration re-keying", () => { authorization: `Bearer ${otherAccountToken}`, }), env)).json()).toEqual({ repositories: [], linearOrganizations: [] }); }); + + it("does not authorize account event reads after the GitHub App is removed", async () => { + const env = makeEnv(); + seedRepository(env.DB, "user_1"); + env.DB.repositories[0]!.installed = 0; + env.DB.repositories[0]!.removed_at = "2026-07-15T00:00:00.000Z"; + stubLegacyApis(); + const accountToken = await mintToken("user_1"); + const providerCallsBeforeRead = vi.mocked(fetch).mock.calls.length; + + const response = await handleRequest(request("/github/repos/acme/repo/events", { + accountToken, + }), env); + + expect(response.status).toBe(401); + expect(fetch).toHaveBeenCalledTimes(providerCallsBeforeRead); + }); }); diff --git a/docs/features/automations/README.md b/docs/features/automations/README.md index 93ff8cfee..d96189264 100644 --- a/docs/features/automations/README.md +++ b/docs/features/automations/README.md @@ -24,7 +24,7 @@ These services are loaded by the ADE runtime's project scope (and by the desktop - `automationService.ts` — main service. Rule CRUD, execution dispatch (`agent-session`, `built-in`), cron scheduling (via `node-cron`), durable deferred-lane cleanup, lane lifecycle dispatch, file-change watching (via `chokidar`), queue management, run history, confidence scoring, billing codes, ingress cursor storage. Scheduled callbacks first atomically claim a deterministic `(project, automation, trigger index, cron expression, minute slot)` occurrence in the machine-local `automation_schedule_occurrences` table, so concurrent runtimes sharing a project database elect exactly one executor before any chat or lane is created; claims are pruned after 35 days. Deleting an automation-owned chat tears down its provider runtime and cancels any still-open associated run. **Ingress retention:** it no longer persists raw webhook payloads (`raw_payload_json` is always written `null`), and on every insert it prunes `automation_ingress_events` for that project at write time — dropping rows older than 7 days and, for non-`dispatched` rows, everything beyond the newest 2,000 (insert + prune run in one `BEGIN IMMEDIATE`). At construction it runs a one-time, chunked reclaim that nulls any legacy `raw_payload_json` still on disk and prunes the review-artifact / PR-snapshot tables to their retention windows; a reclaim failure is logged and retried next boot, never blocking service startup. The retention/count bounds are imported from `state/dbMaintenanceApi` so the writer, the storage-doctor DB hooks, and the storage ledger enforce one policy. - `automationPlannerService.ts` — natural-language rule authoring. `parseNaturalLanguage`, `validateDraft`, `saveDraft`, `simulate`. Runs a planner subprocess (Claude or Codex) to turn a free-text brief into an `AutomationRuleDraft`. -- `automationIngressService.ts` — HTTP webhook ingress (GitHub, custom webhooks) plus GitHub relay cursor drains and a repo-scoped WebSocket wake-up subscription. Signature verification for webhooks. `AutomationIngressEventRecord` is the normalized event shape. Accepts `automationService: null` for the **PR-freshness-only mode** described under [Runtime ownership](#runtime-ownership): the relay still feeds `prService.ingestGithubWebhook`, but rule dispatch, the local webhook server, and ingress status/event reads are skipped. In that mode the relay cursor is persisted through an injected `ingressCursorStore` — `createKvIngressCursorStore(db)`, which reads/writes `automations.ingress.cursor.` in the kv table — instead of `automationService`'s cursor storage. Linked PR ids from relay deliveries are accumulated only after their page cursor commits, then flushed as one targeted `prPollingService.reconcilePrs(prIds)` call per successful drain; if a later page fails, ids from earlier committed pages are still reconciled. Local GitHub webhook deliveries request the same targeted reconciliation immediately. A missing GitHub App user or ADE account token puts both polling and socket connection attempts into a quiet 5-minute auth-pending cooldown (relay status `disabled`, a single `automations.github_relay_auth_pending` info log) rather than warning every tick; `pollNow()` bypasses the cooldown. `stop()`/`dispose()` abort active polling and close the socket plus polling/connect/reconnect timers. +- `automationIngressService.ts` — HTTP webhook ingress (GitHub, custom webhooks) plus GitHub relay cursor drains and a repo-scoped WebSocket wake-up subscription. Signature verification for webhooks. `AutomationIngressEventRecord` is the normalized event shape. Accepts `automationService: null` for the **PR-freshness-only mode** described under [Runtime ownership](#runtime-ownership): the relay still feeds `prService.ingestGithubWebhook`, but rule dispatch, the local webhook server, and ingress status/event reads are skipped. In that mode the relay cursor is persisted through an injected `ingressCursorStore` — `createKvIngressCursorStore(db)`, which reads/writes `automations.ingress.cursor.` in the kv table — instead of `automationService`'s cursor storage. Linked PR ids from relay deliveries are accumulated only after their page cursor commits, then flushed as one targeted `prPollingService.reconcilePrs(prIds)` call per successful drain; if a later page fails, ids from earlier committed pages are still reconciled. Local GitHub webhook deliveries request the same targeted reconciliation immediately. A successful drain sets the relay-health signal consumed by `prPollingService`; config removal, poll failure, and shutdown clear it so direct GitHub polling resumes. Page/transport failures honor `Retry-After` and use an exponential 30 s–15 min poll cooldown. A missing GitHub App user or ADE account token instead puts polling and socket connection attempts into a quiet 5-minute auth-pending cooldown (relay status `disabled`, a single `automations.github_relay_auth_pending` info log). `pollNow()` clears either cooldown for an explicit retry. `stop()`/`dispose()` abort active polling and close the socket plus polling/connect/reconnect timers. - `githubPollingService.ts` — direct GitHub REST polling for the origin repo plus `extraRepos`. Each tick first asks `automationService.hasEnabledGithubRules()` (or the injected equivalent) and does no GitHub work unless at least one enabled rule has a canonical `github.*` trigger. Active ticks diff per-poll snapshots of issues/PRs/comments to emit `github.issue_*` and `github.pr_*` events without requiring a webhook or relay. Cursor format is `=|=` to support multi-repo state in a single stored string; see `readCursor`/`writeCursor` for the legacy-compat parser. - `automationSecretService.ts` — secret resolution for automation actions (env-ref style). Referenced as `${env:VAR}` in action config; resolved at execution time. - `linearIngressService.ts` — Linear event ingress over the hosted relay. Two delivery modes: a per-workspace Linear webhook that `setup()` creates through the shared Linear client (`createWebhook` / `listWebhooks` / `deleteWebhook`, resource types `Issue`/`Comment`/`IssueLabel`), or the ADE Linear OAuth app's auto-provisioned webhook (sentinel id `ade-linear-app`, surfaced as `appManaged` in status — never created or deleted here). Polls the relay's `seq:` cursor for new Linear deliveries and exposes `getStatus` / `setup` / `teardown` / `pollNow`. `AutomationLinearIngressStatus` (`shared/types/automations.ts`) is the status shape; app-connected workspaces self-configure on the first poll and their teardown leaves the app webhook alone. @@ -32,7 +32,7 @@ These services are loaded by the ADE runtime's project scope (and by the desktop ### GitHub relay and App -- `apps/webhook-relay/` — the hosted GitHub relay: a Cloudflare Worker (`src/index.ts` / `src/relay.ts`) plus D1 migrations. Receives ADE-GitHub-App webhooks, verifies the HMAC signature, stores deliveries idempotently by delivery id, serves repo-scoped `/github/repos/:owner/:repo/status` and `/events` reads (monotonic `seq:` cursors; `order=asc&limit` adds forward pagination with a `hasMore` flag, while the default descending shape stays unchanged for old clients), and exposes `/github/repos/:owner/:repo/subscribe` for debounced WebSocket wake-up frames. The socket is only a hint; D1 plus the cursor remains the durable stream. The `RepoEventsDurableObject` (`src/repoEventsDurableObject.ts`, one hibernating instance per lowercased `owner/repo`, bound as `REPO_EVENTS` in `wrangler.jsonc` with a SQLite-class migration) coalesces a repo's webhook burst into at most one `{"t":"github_delivery","repo":"owner/repo"}` frame per ~1s debounce, carries no payload or cursor, closes each socket after ~4h with code `4401` to force credential revalidation, and answers an app-level `ping` with `pong` at the edge without waking. A webhook write's `notifyRepoEvents` failure is swallowed (the safety poll recovers) so a committed delivery never becomes a GitHub retry. Storage is bounded three ways: default event retention dropped from 30 to **7 days** (`DEFAULT_RETENTION_DAYS`, overridable via `EVENT_RETENTION_DAYS`); `slimGitHubPayloadForStorage` strips the avatar-heavy top-level `sender`/`organization`/`enterprise` duplicates and `check_run.output` from stored `check_run`/`check_suite`/`workflow_run`/`status` payloads (idempotency still hashes the raw body); and a 5-minute isolate-scoped cache of write-level repo-access verdicts (keyed by token digest, never the token) cuts repeat GitHub authorization round-trips — admin-level checks are never cached. Two repo-scoped webhook-maintenance routes back drift recovery and diagnostics: `POST /github/repos/:owner/:repo/webhook/heal` (repo-**admin** gated) re-syncs the GitHub App's webhook secret to the Worker's own `GITHUB_WEBHOOK_SECRET` via `PATCH /app/hook/config` — the recovery path when a rotated secret causes signature-mismatch drift, and idempotent because it can only converge on the Worker's current secret; `GET /github/repos/:owner/:repo/webhook/deliveries` (push/write gated) proxies the GitHub App delivery log filtered to the caller's repository, failing closed (a repo-scoped delivery is dropped unless its `repository_id` matches the authorized repo; app-level ping/meta deliveries with no repository are kept). The shared `assertGitHubRepoAuthorized` gate now takes a `write | admin` access level and returns the `repositoryId` used for that filter. Legacy `/projects/:projectId/github/...` project-token routes remain for self-hosted deployments. See `apps/webhook-relay/README.md` for deploy/setup. +- `apps/webhook-relay/` — the hosted GitHub relay: a Cloudflare Worker (`src/index.ts` / `src/relay.ts`) plus D1 migrations. Receives ADE-GitHub-App webhooks, verifies the HMAC signature, stores deliveries idempotently by delivery id, serves repo-scoped `/github/repos/:owner/:repo/status` and `/events` reads (monotonic `seq:` cursors; `order=asc&limit` adds forward pagination with a `hasMore` flag, while the default descending shape stays unchanged for old clients), and exposes `/github/repos/:owner/:repo/subscribe` for debounced WebSocket wake-up frames. The socket is only a hint; D1 plus the cursor remains the durable stream. Signed-in account requests to `/events` and `/subscribe` are authorized first from the account-owned, still-installed repository binding in D1, avoiding one GitHub REST access check per poll/reconnect; legacy requests without a matching ADE account binding fall back to the GitHub-token authorization path. The `RepoEventsDurableObject` (`src/repoEventsDurableObject.ts`, one hibernating instance per lowercased `owner/repo`, bound as `REPO_EVENTS` in `wrangler.jsonc` with a SQLite-class migration) coalesces a repo's webhook burst into at most one `{"t":"github_delivery","repo":"owner/repo"}` frame per ~1s debounce, carries no payload or cursor, closes each socket after ~4h with code `4401` to force credential revalidation, and answers an app-level `ping` with `pong` at the edge without waking. A webhook write's `notifyRepoEvents` failure is swallowed (the safety poll recovers) so a committed delivery never becomes a GitHub retry. Storage is bounded three ways: default event retention dropped from 30 to **7 days** (`DEFAULT_RETENTION_DAYS`, overridable via `EVENT_RETENTION_DAYS`); `slimGitHubPayloadForStorage` strips the avatar-heavy top-level `sender`/`organization`/`enterprise` duplicates and `check_run.output` from stored `check_run`/`check_suite`/`workflow_run`/`status` payloads (idempotency still hashes the raw body); and a 5-minute isolate-scoped cache of write-level repo-access verdicts (keyed by token digest, never the token) cuts repeat GitHub authorization round-trips on the legacy token path — admin-level checks are never cached. Two repo-scoped webhook-maintenance routes back drift recovery and diagnostics: `POST /github/repos/:owner/:repo/webhook/heal` (repo-**admin** gated) re-syncs the GitHub App's webhook secret to the Worker's own `GITHUB_WEBHOOK_SECRET` via `PATCH /app/hook/config` — the recovery path when a rotated secret causes signature-mismatch drift, and idempotent because it can only converge on the Worker's current secret; `GET /github/repos/:owner/:repo/webhook/deliveries` (push/write gated) proxies the GitHub App delivery log filtered to the caller's repository, failing closed (a repo-scoped delivery is dropped unless its `repository_id` matches the authorized repo; app-level ping/meta deliveries with no repository are kept). The shared `assertGitHubRepoAuthorized` gate takes a `write | admin` access level and returns the `repositoryId` used for that filter. Legacy `/projects/:projectId/github/...` project-token routes remain for self-hosted deployments. See `apps/webhook-relay/README.md` for deploy/setup. - `apps/desktop/src/main/services/github/githubRelayConfig.ts` — resolves the relay base URL and auth mode. Defaults to the hosted Worker (`DEFAULT_GITHUB_RELAY_API_BASE_URL`) with `usesHostedDefault`; `fetchGitHubAppInstallationStatus` authenticates the hosted repo status route with a GitHub App user access token via `resolveHostedGitHubRelayAuthToken` (never the user's general GitHub token), falling back to the legacy project-token route only when `shouldUseLegacyGitHubRelayProjectRoute` (non-default base URL + project id + access token). Also exposes `createGitHubRelayAuthAuditLog`, a dedup wrapper that emits one `github.hosted_relay_auth_token_used` audit line per (event, route, repo, token source). - `apps/desktop/src/main/services/github/githubAppUserAuth.ts` — raw GitHub device-flow HTTP helpers: `startGitHubAppDeviceFlow`, `pollGitHubAppDeviceFlow`, and `refreshGitHubAppUserToken` against GitHub's OAuth device endpoints, plus the `ADE_GITHUB_APP_CLIENT_ID` constant and the `GitHubAppUserTokenRecord` shape. No storage or lifecycle logic — pure request/response mapping. - `apps/desktop/src/main/services/github/githubAppUserAuthService.ts` — `createGitHubAppUserAuthService`, the shared factory that owns the App user token store (`github.appUserToken.v1` in the credential store), device-auth session lifecycle (`startDeviceAuth` / `pollDeviceAuth` / `clearAuth`), single-flight refresh with an `authEpoch` guard so a clear can't re-persist an in-flight refresh, and `getValidTokenForRelay` (refreshes within a 2-minute skew). Consumed by both desktop `githubService` and the ade-cli headless services. @@ -148,7 +148,7 @@ Automations accept inbound events from four sources (`AutomationIngressSource`): - `local-webhook` — `automationIngressService` opens an HTTP endpoint. - `github-webhook` events verify HMAC-SHA256 via `safeCompareSignature` (timing-safe). Secret read from `automations.githubWebhook.secret`. - `webhook` events are custom inbound webhooks with optional shared-secret verification. -- `github-relay` — the default hosted path. A Cloudflare Worker (`apps/webhook-relay/`) receives GitHub App webhooks, verifies the GitHub HMAC signature, and writes each delivery into D1. ADE connects to the **repo-scoped** `GET /github/repos/:owner/:repo/subscribe` WebSocket and drains `GET /github/repos/:owner/:repo/events?after=&order=asc&limit=100` on connect and each `github_delivery` frame. Returned pages are processed oldest-first without reversal, and `hasMore` immediately continues the drain. The durable cursor is persisted once per page, after every event in it has been attempted. Linked PR ids are attached to that commit boundary: a successful drain batches them into one targeted PR refresh, while a later page/transport failure still flushes ids from pages whose cursors were already committed. A **per-event** ingest or dispatch failure does not stall the drain: it is caught, logged (`automations.github_relay_pr_ingest_failed` / `automations.github_relay_dispatch_failed`), and the cursor still advances past that event — the delivery is already durably recorded relay-side and the background PR poller corrects PR state independently, so a single poison event can no longer replay from the same cursor forever and freeze all ingest for the repo. Only a **page/transport** failure (non-OK response, fetch abort, or a `nextCursor` that fails to advance) throws out of the loop, leaving the durable cursor at the prior page so that page replays on the next drain. Socket reconnects use jittered exponential backoff with fresh auth and a catch-up drain. The interval poll remains a safety net: the configured 30-second cadence applies while the socket is down, stretching to five minutes while connected. Hosted relay reads and subscriptions use either an expiring GitHub App user access token created through GitHub device flow or the existing ADE account-token path, never the user's general ADE GitHub PAT/OAuth/`gh auth` token. The relay uses an app-limited token only to ask GitHub whether the authenticated user has push/write, maintain, or admin access, and rejects read-only public-repo callers with 403. The same Worker also exposes `GET .../status` plus two repo-scoped webhook-maintenance routes for drift recovery and diagnostics — `POST .../webhook/heal` (admin-gated re-sync of the App's webhook secret) and `GET .../webhook/deliveries` (push-gated, repo-filtered proxy of the App delivery log); see the source file map above. The relay base URL defaults to `DEFAULT_GITHUB_RELAY_API_BASE_URL`. The legacy `automations.githubRelay.apiBaseUrl` + `remoteProjectId` + `accessToken` **project-token** routes (`/projects/:projectId/github/...`) remain poll-only for self-hosted relays — chosen only when a non-default base URL plus project id and access token are all set (`shouldUseLegacyGitHubRelayProjectRoute`) and do not require GitHub App user authorization. +- `github-relay` — the default hosted path. A Cloudflare Worker (`apps/webhook-relay/`) receives GitHub App webhooks, verifies the GitHub HMAC signature, and writes each delivery into D1. ADE connects to the **repo-scoped** `GET /github/repos/:owner/:repo/subscribe` WebSocket and drains `GET /github/repos/:owner/:repo/events?after=&order=asc&limit=100` on connect and each `github_delivery` frame. Returned pages are processed oldest-first without reversal, and `hasMore` immediately continues the drain. The durable cursor is persisted once per page, after every event in it has been attempted. Linked PR ids are attached to that commit boundary: a successful drain batches them into one targeted PR refresh, while a later page/transport failure still flushes ids from pages whose cursors were already committed. A **per-event** ingest or dispatch failure does not stall the drain: it is caught, logged (`automations.github_relay_pr_ingest_failed` / `automations.github_relay_dispatch_failed`), and the cursor still advances past that event — the delivery is already durably recorded relay-side and the background PR poller corrects PR state independently, so a single poison event can no longer replay from the same cursor forever and freeze all ingest for the repo. Only a **page/transport** failure (non-OK response, fetch abort, or a `nextCursor` that fails to advance) throws out of the loop, leaving the durable cursor at the prior page so that page replays on the next drain. Such failures mark relay health false, respect `Retry-After`, and enter an exponential 30-second-to-15-minute poll cooldown; a successful drain clears the cooldown and marks the relay healthy again. Socket reconnects use their own jittered exponential backoff with fresh auth and a catch-up drain. The interval poll remains a safety net: the configured 30-second cadence applies while the socket is down, stretching to five minutes while connected, subject to the failure cooldown. Hosted relay reads and subscriptions use either an expiring GitHub App user access token created through GitHub device flow or the existing ADE account-token path, never the user's general ADE GitHub PAT/OAuth/`gh auth` token. Account-authenticated event/subscription requests use the account's installed repository binding without a GitHub API round-trip; the App user token path remains the legacy fallback and requires push/write, maintain, or admin access. Read-only public-repo callers are rejected with 403. The same Worker also exposes `GET .../status` plus two repo-scoped webhook-maintenance routes for drift recovery and diagnostics — `POST .../webhook/heal` (admin-gated re-sync of the App's webhook secret) and `GET .../webhook/deliveries` (push-gated, repo-filtered proxy of the App delivery log); see the source file map above. The relay base URL defaults to `DEFAULT_GITHUB_RELAY_API_BASE_URL`. The legacy `automations.githubRelay.apiBaseUrl` + `remoteProjectId` + `accessToken` **project-token** routes (`/projects/:projectId/github/...`) remain poll-only for self-hosted relays — chosen only when a non-default base URL plus project id and access token are all set (`shouldUseLegacyGitHubRelayProjectRoute`) and do not require GitHub App user authorization. - `linear-relay` — Linear event relay for automation triggers; Linear triggers here are context-only. Two delivery modes share the relay: a per-workspace webhook created by `linearIngressService.setup()` (requires a workspace-admin credential; per-org signing secret registered in the Worker's D1), or the ADE Linear OAuth app (`linearAppClient.ts` — client id bundled, PKCE, `read,write,admin` scope), whose webhook Linear auto-provisions on authorization and signs with the app-level `LINEAR_APP_WEBHOOK_SECRET` the Worker holds. App-connected projects self-configure on the first poll (`isAdeAppConnection` dep) — no manual connect step; teardown never deletes the app's webhook. - `github-polling` — `githubPollingService` polls the GitHub REST API directly for the origin repo and any `extraRepos`, diffing per-poll snapshots to synthesize `github.issue_*` / `github.pr_*` events (opened / edited / labeled / closed / commented, and PR merged). No relay or webhook infra required. Cursor is a `=|=` string stored via `automationService.setIngressCursor({ source: "github-polling" })`; default interval is 30s, but each tick returns before network access when no enabled rule has a `github.*` trigger. @@ -199,7 +199,7 @@ Automations route outputs based on `outputs.disposition`: - **Cron sanity-check before installing.** `cron.validate(expr)` plus the 5-field split is the safety net; otherwise `node-cron` throws. - **Webhook secret verification is timing-safe.** Don't refactor `safeCompareSignature` into a plain string compare. - **Legacy relay polling must respect the access token ref.** `automations.githubRelay.accessToken` is an env ref for self-hosted/project-token relays; resolve via `automationSecretService`, never hard-coded. -- **Relay wake-ups are not deliveries.** The repo WebSocket only requests a cursor drain. Coalesce concurrent drain requests with one dirty rerun, process ascending pages in returned order, and persist each `nextCursor` after that page's events have all been attempted. Collect linked PR ids at the same page-commit boundary and reconcile them once per successful drain; on a later page failure, flush only ids from already committed pages. A per-event ingest/dispatch throw is caught and the cursor advances past it (a poison event must not freeze the repo's ingest); only a page fetch/transport failure or a non-advancing cursor aborts the drain and leaves the durable cursor pointing at the prior page. Config/repo changes and service shutdown must close the old socket and clear every reconnect/connect/poll timer. +- **Relay wake-ups are not deliveries.** The repo WebSocket only requests a cursor drain. Coalesce concurrent drain requests with one dirty rerun, process ascending pages in returned order, and persist each `nextCursor` after that page's events have all been attempted. Collect linked PR ids at the same page-commit boundary and reconcile them once per successful drain; on a later page failure, flush only ids from already committed pages. A per-event ingest/dispatch throw is caught and the cursor advances past it (a poison event must not freeze the repo's ingest); only a page fetch/transport failure or a non-advancing cursor aborts the drain and leaves the durable cursor pointing at the prior page. Those failures must also clear relay health and respect the poll cooldown so the PR poller returns to direct GitHub fallback without hammering the relay. Config/repo changes and service shutdown must close the old socket, clear health, and clear every reconnect/connect/poll timer. - **Confidence threshold is `0.65` baseline.** Rules that explicitly raise the threshold penalize confidence proportionally — document this in rule descriptions so operators understand scoring. - **Shared-config trust only blocks shared rules.** `runRuleNow` throws when `projectConfig.trust.requiresSharedTrust` is true **and** the rule's `id` appears in `projectConfig.shared.automations` (i.e. it is defined in `.ade/ade.yaml`). A rule authored in local config still runs when the shared config is untrusted. The Automations-tab banner and its `Trust config` CTA (which calls `projectConfig.confirmTrust`) likewise appear only when the rule list actually contains a non-`local` rule, so a project with no shared automations never sees a trust prompt. ## Cross-links diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index a3751edb9..baf5fba82 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -71,18 +71,30 @@ Main process: - `apps/desktop/src/main/services/onboarding/onboardingSuggestedConfig.ts` — pure GitHub Actions workflow parsing and suggested test/automation/provider config generation for `.ade/ade.yaml`. -- `apps/desktop/src/main/services/github/githubService.ts` and - `githubRateLimit.ts` — GitHub App, environment, PAT, and GitHub CLI - credential discovery; `/user` and repository probes; structured - auth-failure classification; and REST quota parsing. Explicit environment - tokens override all stored credentials for automation. Otherwise REST - operations prefer local GitHub CLI auth and then a stored PAT. The ADE GitHub - App remains a separate, read-only credential used only for webhook-backed - real-time PR updates. - `GitHubStatus.authFailure` - distinguishes rate limiting, - invalid credentials, network failures, and unknown validation errors so - clients do not flatten every failed probe into missing permissions. +- `apps/desktop/src/main/services/github/githubService.ts`, + `githubCredentialHealth.ts`, and `githubRateLimit.ts` — GitHub App, + environment, PAT, and GitHub CLI credential discovery; `/user` and repository + probes; REST and GraphQL failure/quota classification; per-resource credential + cooldowns; and the shared-account primary-quota circuit breaker. Reads use + environment → ADE GitHub App → GitHub CLI → stored PAT. Writes use environment + → GitHub CLI → stored PAT because the App user credential is intentionally + read-only. Request-level 401/403/429 responses, GraphQL rate/permission errors, + and 403/404 repository-probe denials advance to the next compatible source. + Invalid or permission-denied credentials cool down for five minutes; rate + limits wait at least one minute and honor GitHub's reset. ADE then retries the + preferred source automatically. GitHub's primary user quota is shared by App + user, OAuth, and PAT credentials for the same account, so an exhausted primary + bucket pauses known credentials for that account instead of cycling tokens. + `GitHubStatus.authFailure` distinguishes rate limiting, invalid credentials, + permission denial, network failures, and unknown validation errors so clients + do not flatten every failed probe into missing permissions. +- `apps/desktop/src/shared/githubOperationCredential.ts` — the capability-aware + read/write credential order, App read-only rule, and duplicate-token removal + used by desktop and runtime-side GitHub services. +- `apps/ade-cli/src/headlessLinearServices.ts` — runtime-owned mirror of the + GitHub request/status path. It applies the same candidate order, cooldowns, + GraphQL classification, conditional-request cache isolation, and read/write + status fields when a packaged or remote-bound window uses `ade serve`. - `apps/desktop/src/main/services/config/projectConfigService.ts` — YAML config read/merge/save, AI mode migration, lane env init, Linear sync resolver. ~3,150 lines, the largest service. @@ -105,9 +117,11 @@ Shared types and IPC: `DEFAULT_AUTO_UPDATE_PREFERENCES` (`automaticInstall: false`, `onlyWhenIdle: true`) plus the renderer-visible update snapshot contract. - `apps/desktop/src/shared/types/git.ts` — `GitHubStatus`, - `GitHubAuthFailure`, and `GitHubRateLimitState`. The failure/quota fields are - optional so a newer client can remain compatible with an older remote - runtime. + `GitHubAuthFailure`, `GitHubRateLimitState`, and the credential source, + capability, state, and fallback contracts. `writeAuthSource`, + `credentialStates`, `credentialFallback`, and + `backgroundRefreshPausedUntil` are optional so a newer client remains + compatible with an older remote runtime. - `apps/desktop/src/shared/ipc.ts` — channels: - `ade.onboarding.*` (status, detectDefaults, detectExistingLanes, applySuggestedConfig, complete, setDismissed) @@ -258,17 +272,20 @@ Renderer — settings: - `apps/desktop/src/renderer/components/settings/GitHubIntegrationSection.tsx` and `GitHubSection.tsx` — ADE GitHub App / environment / GitHub CLI / PAT auth, credential-specific permission diagnostics, structured validation - failures, and the latest GitHub REST quota. Embedded inside General. Classic + failures, and automatic fallback state. Embedded inside Integrations. Classic PATs and CLI OAuth tokens show their detected scopes; fine-grained PATs show repository-permission guidance; App user tokens show installation-backed repository metadata access and never report missing classic `repo` / `workflow` scopes, because GitHub Apps do not use those OAuth scopes. The App - panel also states that the App is intentionally read-only and separate from - operation credentials, and documents the environment → GitHub CLI → PAT - order. A rate-limited - credential renders **Rate limited**, the reset time/quota, and no auth - command; only a missing, invalid, or genuinely under-scoped credential shows - login/refresh instructions. The App-installation card also classifies relay + panel states that the App is intentionally read-only and documents the + environment → App → GitHub CLI → PAT read order plus the write-capable + subset. Healthy connections do not expose quota bookkeeping. When ADE is + compensating for a problem, Settings names the paused source and temporary + replacement, plus the retry time when GitHub supplied one. An exhausted shared + account bucket renders the reset time and explains that background refresh is + paused automatically; it never asks the user to re-authenticate. When no + fallback remains, a missing, invalid, or genuinely under-scoped credential + shows login/refresh instructions. The App-installation card also classifies relay rate-limit responses as a concise cooldown state instead of displaying GitHub's raw request-id / scraping-policy error. Raw network/unknown validation errors stay in Settings rather than the global banner. The shared @@ -313,9 +330,18 @@ Renderer — settings: - `apps/desktop/src/renderer/lib/githubIntegrationStatus.ts` — pure, two-axis derivation of GitHub App integration health (account user-token axis vs. per-repo install axis), the `deriveGithubRealtimeBlock` - top-blocker picker (account problems outrank repo problems), and the shared - banner/Settings copy for the account, repo, and gh-CLI/token sub-states. - Imported by both `GitHubAppInstallPanel` and `IntegrationBannerHost`. + top-blocker picker (account problems outrank repo problems), + `githubStatusHasWriteCredential` for capability-gating mutations, and the + shared banner/Settings copy for the account, repo, and gh-CLI/token + sub-states. Imported by `GitHubAppInstallPanel`, `IntegrationBannerHost`, and + write surfaces such as `FeedbackReporterModal`; App-only read connectivity + therefore keeps PR data live while still prompting for GitHub CLI or a PAT + before a mutation. +- `apps/desktop/src/renderer/components/app/IntegrationBannerHost.tsx` and + `FeedbackReporterModal.tsx` — consume the shared read/write distinction. The + app shell raises a write-access banner for an otherwise connected App-only + status, and feedback submission requires a write-capable credential rather + than treating read connectivity as sufficient. - `apps/desktop/src/renderer/components/settings/LinearIntegrationSection.tsx` and `LinearSection.tsx` — Linear OAuth / API key, workspace status, and GitHub autolink setup. Embedded inside General. diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 31f6b27d8..7b257e77d 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -47,7 +47,7 @@ runtime. In packaged / installed builds the desktop window is runtime-bound, so the ADE daemon owns the `prPollingService` instance (created, started, and disposed in `apps/ade-cli/src/bootstrap.ts`) whose ticks emit the PR events consumers render as `prs-updated`; the - daemon also starts the automation ingress relay subscriber/drain loop there, which feeds +daemon also starts the automation ingress relay subscriber/drain loop there, which feeds `prService.ingestGithubWebhook` for webhook-driven freshness (see [automations](../automations/README.md#runtime-ownership)). Without this the desktop main process no longer hosts the loop in production, @@ -80,7 +80,7 @@ Service files (`apps/desktop/src/main/services/prs/`): | `prService.test.ts` | Feature-level service coverage, including mobile snapshot aggregation, paged GitHub history and exact state totals, webhook invalidation, unmapped mobile detail, and integration proposal behavior. | | `prAsync.test.ts` | Shared bounded-concurrency and async helper coverage, plus the `prMergeAutoSettlementService` regression suite. | | `pullRequestRowCleanup.ts` | The only writer of the detach columns. `detachPullRequestRowsForLane` (lane delete) and `detachPullRequestRowsByIds` (branch switch / rename-with-branch-change) stamp `detached_at` + the frozen lane identity and provenance, lift `commit_count` / `changed_files` off the snapshot, null the bulky snapshot JSON columns, and drop lane-scoped group membership. `countLaneProvenance` must run *before* the caller deletes the lane's sessions / artifacts / checkpoints. `deletePullRequestRowsByIds` remains for the genuinely destructive paths. See [Detached PR rows](#detached-pr-rows). | -| `prPollingService.ts` | 60 s fallback polling loop, fingerprint-based change detection, notification emission, targeted webhook reconciliation, and GitHub rate-limit backoff. `reconcilePrs(prIds)` coalesces webhook-linked PR ids and refreshes only those rows immediately; ordinary `poke()` still requests a normal tick. User-driven hot windows poll affected PRs every 5 s for the first minute and 15 s until the three-minute cap, but poll results cannot start or restart a hot window. Writes `last_polled_at` per PR so callers can run delta polls on the next tick. The ADE daemon owns an instance (created + started + disposed in `apps/ade-cli/src/bootstrap.ts`) so background polling and PR events run for runtime-bound windows; the desktop main process still owns one for local-bound windows. When zero PRs are tracked yet, the forced full-snapshot `discoverLanePullRequests` fetch is throttled to a 10-minute cadence instead of running every tick — user-driven surfaces discover PRs on their own reads anyway | +| `prPollingService.ts` | Webhook-first PR freshness plus the direct-GitHub safety net. `reconcilePrs(prIds)` coalesces webhook-linked ids and refreshes only those rows immediately. A healthy relay suppresses hot polling and reduces broad refreshes to a 15-minute safety sweep; an unhealthy relay uses the configurable 60 s fallback (clamped to 5 s–5 min) and user-driven hot windows of 15 s for the first minute, then 30 s until the three-minute cap. Empty-cache discovery runs at most every 30 minutes with a healthy relay or 10 minutes without one. Before every network refresh, the poller honors credential cooldown/reset state and preserves the final 500 core/GraphQL requests for foreground actions. It writes `last_polled_at` per PR for delta polling. The ADE daemon owns an instance (created + started + disposed in `apps/ade-cli/src/bootstrap.ts`) for runtime-bound windows; the desktop main process owns the local-bound instance. | | `prMergeAutoSettlementService.ts` | Applies the enabled lane-PR merge settlement policy after each polling snapshot. It files eligible, unblocked chat and tracked-agent-CLI sessions for a newly discovered merged PR, but emits `pr-sessions-auto-settled` only when the preceding in-memory snapshot contained that PR as open or draft. A first-sight merge — including backfilled history from another machine or the first snapshot after restart — is filed silently, so an imported history cannot generate merge toasts or push notifications. | | `prChatCards.ts` | Converts bounded PR polling transitions into durable `ade_card` episodes for linked Work chats: CI completion/failure, review received, merge ready, conflicts, and merged. CI jobs are failure-first, capped at three visible rows with `rowsTruncated`, and report an honest `degradedReason` + Retry action when both job/check detail sources fail instead of rendering an empty success state. Desktop-main and daemon-owned pollers call the same emitter, and failures are isolated per PR/session so one cold or malformed chat cannot stop the poll loop. | | `prSummaryService.ts` | AI PR summary generator; caches `PrAiSummary` per `(prId, headSha)` in `pull_request_ai_summaries` so pushes invalidate the cache | @@ -93,6 +93,16 @@ Service files (`apps/desktop/src/main/services/prs/`): | `prRebaseResolver.ts` | Builds rebase-resolution prompts, launches chat session | | `resolverUtils.ts` | Shared permission-mode mapping, recent commit reading, comment noise filter, and the `looksLikeResolutionAck` heuristic that flags resolved-looking replies on unresolved review threads | +GitHub access and relay dependencies: + +| File | Responsibility | +|------|---------------| +| `apps/desktop/src/main/services/github/githubService.ts`, `apps/ade-cli/src/headlessLinearServices.ts` | Desktop-local and runtime-owned GitHub request paths. Both build the environment → App → GitHub CLI → PAT read chain, skip the read-only App for writes, retry compatible credentials after auth/permission/rate failures, and expose the active/fallback sources through `GitHubStatus`. | +| `apps/desktop/src/main/services/github/githubCredentialHealth.ts`, `githubRateLimit.ts` | Token-digest health keyed by REST/GraphQL resource, five-minute invalid/permission cooldowns, rate-limit reset handling, same-account primary-quota propagation, and the 500-request background reserve. | +| `apps/desktop/src/shared/githubOperationCredential.ts`, `apps/desktop/src/shared/types/git.ts` | Capability-aware credential order and the optional status DTOs for source state, fallback, write availability, and background-pause time. | +| `apps/desktop/src/main/services/automations/automationIngressService.ts`, `apps/ade-cli/src/bootstrap.ts`, `apps/desktop/src/main/main.ts` | Relay cursor drain and targeted reconciliation, relay-health tracking, and injection of relay/quota state into the runtime-owned or desktop-local PR poller. | +| `apps/webhook-relay/src/relay.ts` | Hosted event/subscription authorization. Signed-in ADE account requests use the installed repository binding in D1 first; legacy clients fall back to a GitHub-token repository-access check. | + Branch-scoped `gh` lookup (`apps/desktop/src/main/services/git/`): | File | Responsibility | @@ -164,7 +174,7 @@ Shared contracts: | File | Responsibility | |------|---------------| | `apps/desktop/src/shared/types/prs.ts` | PR DTOs and integration proposal contracts, including `preferredIntegrationLaneId`, `mergeIntoHeadSha`, `integrationLaneOrigin`, and `additionalInstructions` fields. `PrSummary.unmapped?: true` flags a projection-synthesized summary with no `pull_requests` row. `syntheticGithubPrId(coords)` / `parseSyntheticGithubPrId(id)` are the single source of the `gh:owner/repo#num` id format — both the service (projection-only summaries, coordinate fetches) and the renderer (keying unmapped GitHub-tab rows) import them from here instead of re-deriving the string. `MergeStateStatus` (lowercase mirror of GitHub's GraphQL merge-box enum) and `PrReviewDecision` drive the merge checklist; `PrStatus` carries `mergeStateStatus`, `reviewDecision`, `approvalsCount` / `requiredApprovals`, `mergeabilityComputing`, `canBypass`, and `headSha`. `LandPrArgs` adds `commitTitle` / `commitBody` (editable merge-commit message) and `expectedHeadSha` (stale-head guard) alongside `bypassRules`, which opts the merge into a `gh pr merge --admin` retry when GitHub rejects the standard merge. `UpdateBranchArgs` / `UpdateBranchResult` back the `merge` / `rebase` update-branch flow. `PrActionCapabilities` adds `mergeStateStatus`, `canBypass`, and `canUpdateBranch` so mobile renders the same merge state. `PrTimelineEvent` carries a `pr_opened` variant plus `lifecycle`, `cross_reference`, `renamed`, `branch_ref`, `assignment`, expanded `review_request`, and `review_dismissed` variants so the timeline reaches GitHub event parity; review-thread events now carry the full `comments` list (with `diffHunk`) and force-push commit events carry before/after SHAs. `PrEventPayload` adds a `pr-reconcile` variant (`state: "running" | "idle"`, `polledAt`) emitted around a catch-up reconcile so the renderer can show a "syncing…" affordance. `PrDetachedLane` (`at`, `laneName`, `laneColor`, `chats`, `artifacts`, `checkpoints`) and `PrMergedBy` (`login`, `avatarUrl`) back the merged view; `PrSummary` and `GitHubPrListItem` both carry `detached`, `mergedBy`, `mergeMethod`, `commitCount`, `changedFiles` (all optional and null-tolerant, because rows predating this feature have none). | -| `apps/desktop/src/shared/types/git.ts` | `BranchPullRequest` (branch / prNumber / title / state / url / author / updatedAt) — the lightweight PR shape returned by `prService.listOpenPullRequests` and consumed by the branch picker without going through `PrSummary`. `GitHubAutolink` (id / keyPrefix / urlTemplate / isAlphanumeric) backs the new `ade.github.listRepoAutolinks` / `ade.github.createRepoAutolink` IPC channels. | +| `apps/desktop/src/shared/types/git.ts` | `BranchPullRequest` (branch / prNumber / title / state / url / author / updatedAt) — the lightweight PR shape returned by `prService.listOpenPullRequests` and consumed by the branch picker without going through `PrSummary`. `GitHubAutolink` (id / keyPrefix / urlTemplate / isAlphanumeric) backs `ade.github.listRepoAutolinks` / `ade.github.createRepoAutolink`. The same module owns the optional `GitHubStatus` credential-chain fields described in [GitHub connectivity model](#github-connectivity-model). | | `apps/desktop/src/shared/types/conflicts.ts` | Conflict resolver DTOs; `PrepareResolverSessionArgs.additionalInstructions` is appended to generated resolver prompts. | | `apps/desktop/src/shared/linearMagicWords.ts` | Pure helpers for PR/commit Linear references. `linearPrMagicWord` / `buildLinearPrReference` / `ensureLinearPrReference` (single-issue magic word in the PR body), `dedupeLinearPrIssueReferences` / `ensureLinearPrReferences` (multi-issue dedupe + injection), and `renderLinearPrIssueLinkSection` / `ensureLinearPrIssueLinkSection` (the ``-fenced "Linked Linear issues" markdown block appended to PR bodies by `prService.applyLinearPrLinkage`). | | `apps/desktop/src/shared/prMarkdownText.ts` | `normalizeEscapedMarkdownNewlines(text)` — unescapes literal `\n` / `\r\n` / `\r` / `\t` sequences that arrive in PR bodies after GitHub round-trips them through JSON. Used by `PrMarkdown` before handing the string to ReactMarkdown so escaped newlines render as paragraph breaks. | @@ -570,26 +580,38 @@ for PRs merged before this shipped, and every surface renders them as optional. ## GitHub connectivity model `getStatus()` in `apps/desktop/src/main/services/github/githubService.ts` -returns a `GitHubStatus` shaped to be the single source of truth for -"GitHub is usable here" — UI banners and badges read `status.connected` -rather than re-deriving from individual fields. +returns a `GitHubStatus` shaped to be the single source of truth for GitHub +read and write availability. UI banners and badges read `status.connected` for +read access and `writeAuthSource` for mutations rather than inferring either +from token-storage fields. Fields: - `tokenStored`, `tokenDecryptionFailed`, `tokenType` — `classic` | - `fine-grained` | `unknown`. Set from token prefix on save. + `fine-grained` | `oauth` | `unknown`, detected from the active token. - `userLogin`, `scopes`, `checkedAt` — outcome of `validateToken` (calls `GET /user`). Classic tokens populate `scopes` from `x-oauth-scopes`; fine-grained tokens never return that header so `scopes` is empty. +- `authSource` — the credential selected for reads, using environment → ADE + GitHub App → GitHub CLI → stored PAT. The App credential is read-only. +- `writeAuthSource` — the first usable environment, GitHub CLI, or stored PAT + credential. `none` means reads may remain connected through the App while + create/update/merge actions remain unavailable. +- `credentialStates`, `credentialFallback` — optional per-source availability, + capabilities, active roles, cooldown/failure state, and the active read + fallback transition. Different sources that resolve to the same token are + attempted once. - `authFailure` — optional structured validation failure for compatibility - with older runtimes: `rate_limited`, `invalid_token`, `network`, or - `unknown`, with the original message and optional retry time. A present - failure means ADE found a credential but could not finish validating it; - clients must not reinterpret that as missing scopes. -- `rateLimit` — the latest GitHub REST quota headers (`limit`, `remaining`, - `used`, `resetAt`, and `resource`) from either the user validation request - or the fine-grained repository probe. + with older runtimes: `rate_limited`, `invalid_token`, `permission_denied`, + `network`, or `unknown`, with the original message and optional retry time. A + present failure means ADE found credentials but could not finish validating + a usable read path; clients must not reinterpret that as missing scopes. +- `rateLimit` — the latest quota headers (`limit`, `remaining`, `used`, + `resetAt`, and `resource`) from the active status probe. +- `backgroundRefreshPausedUntil` — optional reset time exposed when the core or + GraphQL quota of an available project credential reaches the 500-request + reserve. Search's smaller independent bucket does not pause PR refresh. - `repo` — auto-detected origin owner/name. - `repoAccessOk: boolean | null`, `repoAccessError: string | null` — result of an explicit `GET /repos/{owner}/{name}` probe @@ -597,20 +619,21 @@ Fields: probe, or `getStatus` returned early on a token-error path). - `connected: boolean` — computed by `computeConnected`: - `false` if token is missing or `userLogin` is null. + - For the App: requires the repository probe to pass (or no repo to probe). - For `fine-grained` tokens: requires the repo probe to pass (or no repo to probe). This is the only reliable check because fine-grained permissions are not introspectable from headers; a token can authenticate as a user yet 403 every PR-tab call. - - For `classic` tokens: requires `getGitHubTokenAccessState(scopes)` + - For `classic` / `oauth` tokens: requires + `getGitHubTokenAccessState(scopes)` to report `hasRequiredAccess`. - For `unknown` token prefixes: best-effort — `userLogin` is enough. -Status is cached in-memory for 30 s. The cache is bypassed when the -caller passes `getStatus({ forceRefresh: true })` (Settings' -"REFRESH" button does this so the user can fix permissions on -github.com and immediately re-check). When the cache is hit but the -auto-detected `repo` has changed, `repoAccessOk` is reset to `null` -because the cached probe no longer applies. +Status is cached in-memory for 30 s. The cache is invalidated and re-probed when +the auto-detected repository or the head credential changes. The cache is also +bypassed when the caller passes `getStatus({ forceRefresh: true })` (Settings' +"REFRESH" button); forced refresh retries invalid/permission cooldowns so a user +can verify a repair immediately, but it still honors rate-limit resets. Status changes broadcast through the `ade.github.statusChanged` IPC channel (`window.ade.github.onStatusChanged`) every time @@ -624,31 +647,39 @@ CONNECTED while the AppShell banner still said disconnected. - `tokenAuthenticated` — token decrypted and `userLogin` is populated. - `isConnected` (`status.connected` from the backend) — the actual - "GitHub is usable" gate. Drives the connected / needs-permission / - not-connected presentation and any saved-and-verified notice. + "GitHub reads are usable" gate. Drives the connected / needs-permission / + not-connected presentation and allows App-only PR snapshots. Write actions + use `writeAuthSource`; when it is `none`, ADE keeps reads live and asks the + user to connect GitHub CLI or a PAT before changing GitHub. - A structured auth failure takes precedence over permission inference. - Rate limits render as **Rate limited**, show the API quota and reset time, - and explicitly say that no authentication command is needed. Invalid - credentials render a reconnect action; network and unknown validation - failures render a retry/status action, with the raw error confined to - Settings. + Healthy connections do not expose GitHub quota bookkeeping. If one + credential is temporarily unavailable, Settings names the paused connection + and the connection ADE is using instead, plus the retry time when GitHub + supplied one. When no fallback remains, invalid credentials render a + reconnect action; network and unknown validation failures render a + retry/status action, with the raw error confined to Settings. - A repo-probe-failed inline error renders when the token authenticated but the probe came back 403/404, with copy that asks the user to grant Contents (Read), Pull requests (Read and write), and Metadata (Read) on the active repo (fine-grained tokens) or to make sure the classic token has access to the repo. -The App Shell banner uses the same shared presentation helper as Settings, so -it cannot advertise a reconnect/permission command while GitHub has merely -rate-limited a valid credential. +The App Shell banner uses the same shared presentation helper as Settings. It +stays quiet when a fallback keeps reads and writes usable, distinguishes +App-only read access from a write-capable connection, and never advertises a +reconnect command for an account-level rate-limit pause. ## Background polling -`prPollingService` runs inside the process that backs the window's -runtime — the ADE daemon for runtime-bound (packaged) windows, the -desktop main process for local-bound windows (see -[Where this runs](#where-this-runs)). It runs at a 60 s default interval -(clamped to 5 s–5 min, jittered ±10%). Each tick: +`prPollingService` runs inside the process that backs the window's runtime — +the ADE daemon for runtime-bound (packaged) windows, the desktop main process +for local-bound windows (see [Where this runs](#where-this-runs)). With a +healthy GitHub App relay, webhook deliveries drive targeted refreshes and the +poller performs only a 15-minute safety sweep (30 minutes for empty-project +discovery). Relay health means the most recent cursor drain completed +successfully; a disabled or failed drain clears that signal so the poller's +next run uses the direct-GitHub fallback. Without a healthy relay the poller uses the configured 60 s default +interval (clamped to 5 s–5 min, jittered ±10%). Each sweep: 1. Pulls the current PR list via `prService`. 2. Computes a fingerprint per PR (excluding volatile timing fields: @@ -665,26 +696,29 @@ scheduled tick. The service coalesces those ids and runs one targeted one immediate follow-up. This preserves the real-time webhook feel without turning each delivery into a broad repository refresh. -Hot refresh is reserved for service-owned activity that is expected to cause -near-term GitHub transitions, such as merge-queue progress, PR mutations, or a -newly mapped PR row. -It is strictly bounded: 5 s reads for the first minute, 15 s reads until three -minutes, then the normal cadence resumes. Re-marking an already-hot PR retains -the original start time, and fingerprint changes discovered by the poller do -not mark PRs hot. This prevents active CI from self-rearming an unbounded -five-second loop. - -GitHub REST failures that carry a primary or secondary rate-limit reset are -typed with `rateLimitResetAtMs`. The poller waits until that reset plus a small -buffer and does not let webhook pokes bypass the pause. +When the relay is unavailable, hot refresh is reserved for service-owned +activity expected to cause near-term GitHub transitions, such as merge-queue +progress, PR mutations, or a newly mapped PR row. It is strictly bounded: 15 s +reads for the first minute, 30 s reads until three minutes, then the normal +cadence resumes. A healthy relay suppresses the hot loop because webhook +reconciliation owns the fast path. Re-marking an already-hot PR retains the +original start time, and fingerprint changes discovered by the poller do not +mark PRs hot. + +GitHub REST or GraphQL failures that carry a primary or secondary rate-limit +reset are typed with `rateLimitResetAtMs`. The poller waits until that reset plus +a small buffer and does not let webhook pokes bypass the pause. It also stops +when an available project credential's core or GraphQL bucket reaches 500 +remaining, leaving that reserve for explicit user actions, and resumes +automatically after reset. A low search bucket does not pause PR polling. When `prService` reports zero tracked PRs, the tick can force a full repo-snapshot discovery (`discoverLanePullRequests`). Because that is -far heavier than a tracked-PR delta poll, it is throttled to at most -once every 10 minutes for projects that have no PRs yet (new users, -non-PR projects) — user-driven surfaces still discover PRs on their own -reads. The throttle seeds from epoch, not "never", so the first tick -after start still discovers. +far heavier than a tracked-PR delta poll, it is throttled to at most once every +30 minutes with a healthy relay or 10 minutes without one for projects that +have no PRs yet (new users, non-PR projects). User-driven surfaces still +discover PRs on their own reads. The throttle seeds from epoch, not "never", so +the first tick after start still discovers. Notification titles are generic (not PR-specific) so they display well as system notifications. The event payload includes `prTitle`, @@ -713,13 +747,21 @@ PR state stays current through complementary layers: relay page is durably cursor-committed, linked PR ids are coalesced into one targeted REST reconciliation. A successful multi-page drain emits one reconciliation batch; if a later page fails, ids from already committed - pages are still flushed. Webhook results never start a hot-poll window. + pages are still flushed. A successful drain marks the relay healthy. Failed + relay polls mark it unhealthy, respect `Retry-After` when present, and use an + exponential 30 s–15 min retry cooldown; `pollNow()` clears that cooldown for + an explicit retry. Webhook results never start a hot-poll window. Signed-in + account event reads and subscriptions authorize from the relay's installed + repository binding without spending a GitHub REST request; the GitHub-token + repository check remains as the legacy-client fallback. 2. **Background polling** — the safety net for missed or unavailable webhooks. - The runtime-owned `prPollingService` runs at the normal 60 s cadence, with - bounded hot windows only around service-owned changes expected to produce - near-term GitHub transitions, plus rate-limit-aware backoff. Poll results can - notify consumers but cannot re-arm the hot window, so active CI does not - amplify itself into a quota-exhausting loop. + A healthy relay reduces broad reconciliation to a 15-minute safety sweep; + the 60 s cadence is retained only when webhook delivery is unavailable. + When the relay is unavailable, bounded hot windows run only around + service-owned changes expected to produce near-term GitHub transitions, with + rate-limit-aware backoff and a 500-request reserve for direct user work. Poll + results can notify consumers but cannot re-arm the hot window, so active CI + does not amplify itself into a quota-exhausting loop. 3. **Reconcile-on-focus** — the broader catch-up path for a project that was dormant, unfocused, or missed enough events to require a snapshot sweep. `prService.reconcileOnFocus()` runs on project open From 49d6e50698d658bec2536af7fd9fdaafaaf2ad78 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:07:20 -0400 Subject: [PATCH 02/12] ship: apply initial quality revalidation --- apps/ade-cli/src/headlessLinearServices.ts | 14 +++++++--- .../github/githubCredentialHealth.test.ts | 12 ++++++++ .../services/github/githubCredentialHealth.ts | 7 ++--- .../main/services/github/githubRateLimit.ts | 28 ++++++++++++------- .../src/main/services/github/githubService.ts | 14 +++++++--- .../src/shared/githubOperationCredential.ts | 15 ++++++---- 6 files changed, 62 insertions(+), 28 deletions(-) diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index d58e85809..8b4afc9bd 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -763,7 +763,11 @@ export function createHeadlessGitHubService( ): Promise => { const inventory = await readCredentialInventoryAsync(); const candidates = githubOperationCredentialCandidates(inventory.candidates, capability); - return candidates.find((candidate) => !githubCredentialCooldown(candidate)) + return candidates.find((candidate) => !githubCredentialCooldown( + candidate, + Date.now(), + { resource: "core" }, + )) ?? candidates[0] ?? { token: null, @@ -938,7 +942,6 @@ export function createHeadlessGitHubService( capability?: GithubOperationCredentialCapability; }): Promise<{ data: T; response: Response | null; linkHeader?: string | null }> => { const capability = args.capability ?? (args.method === "GET" ? "read" : "write"); - const inventory = args.token ? null : await readCredentialInventoryAsync(); const explicitToken = args.token?.trim() ?? ""; const candidates: HeadlessGitHubTokenCandidate[] = explicitToken ? [{ @@ -949,7 +952,10 @@ export function createHeadlessGitHubService( ghAuthError: null, capabilities: [capability], }] - : githubOperationCredentialCandidates(inventory!.candidates, capability); + : githubOperationCredentialCandidates( + (await readCredentialInventoryAsync()).candidates, + capability, + ); if (candidates.length === 0) { throw new Error( "GitHub auth missing. Set ADE_GITHUB_TOKEN/GITHUB_TOKEN, run `gh auth login -h github.com -s repo -s workflow`, or add a PAT in Settings.", @@ -1338,7 +1344,7 @@ export function createHeadlessGitHubService( const statusCooldown = (candidate: HeadlessGitHubTokenCandidate) => githubCredentialCooldown( candidate, Date.now(), - { ignoreNonRateLimit: opts.forceRefresh === true }, + { resource: "core", ignoreNonRateLimit: opts.forceRefresh === true }, ); const primaryCandidate = readCandidates[0] ?? null; if (!primaryCandidate) { diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts index 81b660382..b0d43e8ce 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts @@ -81,6 +81,18 @@ describe("githubCredentialHealth", () => { })); expect(githubBackgroundRequestPauseUntilMs()).toBe(Date.parse(reserveResetAt)); + recordGithubCredentialFailure(ghCandidate, failure, { + limit: 30, + remaining: 0, + used: 30, + resetAt: retryAt, + resource: "search", + }); + expect(githubCredentialCooldown(ghCandidate, Date.now(), { resource: "search" })) + .not.toBeNull(); + expect(githubCredentialCooldown(ghCandidate, Date.now(), { resource: "core" })) + .toBeNull(); + const otherCandidate: GithubCredentialCandidate = { source: "pat", token: "ghp_other_account", diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.ts index 4c8e3da4d..5794fb3b1 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.ts @@ -207,10 +207,9 @@ export function githubCredentialStates(args: { const health = candidate ? healthFor(candidate) : null; const cooling = candidate ? githubCredentialCooldown(candidate) : null; const capabilities = [...githubOperationCredentialCapabilities(source)]; - const activeFor: GitHubCredentialCapability[] = [ - ...(args.activeReadSource === source ? ["read" as const] : []), - ...(args.activeWriteSource === source ? ["write" as const] : []), - ]; + const activeFor: GitHubCredentialCapability[] = []; + if (args.activeReadSource === source) activeFor.push("read"); + if (args.activeWriteSource === source) activeFor.push("write"); return { source, available: args.availableSources.has(source), diff --git a/apps/desktop/src/main/services/github/githubRateLimit.ts b/apps/desktop/src/main/services/github/githubRateLimit.ts index 953ab4cfb..8943adbe7 100644 --- a/apps/desktop/src/main/services/github/githubRateLimit.ts +++ b/apps/desktop/src/main/services/github/githubRateLimit.ts @@ -124,22 +124,30 @@ export function classifyGitHubGraphqlCredentialFailure( authFailure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null; } | null { - if (!payload || typeof payload !== "object" || !Array.isArray((payload as { errors?: unknown }).errors)) { - return null; - } - const errors = (payload as { errors: unknown[] }).errors; + if ( + !payload + || typeof payload !== "object" + || !("errors" in payload) + || !Array.isArray(payload.errors) + ) return null; + const errors: unknown[] = payload.errors; const messages = errors.flatMap((error) => { if (!error || typeof error !== "object") return []; - const message = (error as { message?: unknown }).message; + const message = "message" in error ? error.message : null; return typeof message === "string" && message.trim() ? [message.trim()] : []; }); const errorTypes = errors.flatMap((error) => { if (!error || typeof error !== "object") return []; - const record = error as { - type?: unknown; - extensions?: { code?: unknown; type?: unknown }; - }; - return [record.type, record.extensions?.code, record.extensions?.type] + const extensions = "extensions" in error + && error.extensions + && typeof error.extensions === "object" + ? error.extensions + : null; + return [ + "type" in error ? error.type : null, + extensions && "code" in extensions ? extensions.code : null, + extensions && "type" in extensions ? extensions.type : null, + ] .filter((value): value is string => typeof value === "string") .map((value) => value.toUpperCase()); }); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 6876533bf..2e30dd65d 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -809,7 +809,11 @@ export function createGithubService({ ): Promise => { const inventory = await readCredentialInventory(); const candidates = githubOperationCredentialCandidates(inventory.candidates, capability); - const selected = candidates.find((candidate) => !githubCredentialCooldown(candidate)) + const selected = candidates.find((candidate) => !githubCredentialCooldown( + candidate, + Date.now(), + { resource: "core" }, + )) ?? candidates[0] ?? null; return selected ?? { @@ -1138,7 +1142,6 @@ export function createGithubService({ accept?: string; }): Promise<{ data: T; response: Response | null; linkHeader?: string | null }> => { const capability = args.capability ?? (args.method === "GET" ? "read" : "write"); - const inventory = args.token ? null : await readCredentialInventory(); const explicitToken = args.token?.trim() ?? ""; const candidates: GitHubTokenCandidate[] = explicitToken ? [{ @@ -1149,7 +1152,10 @@ export function createGithubService({ ghAuthError: null, capabilities: [capability], }] - : githubOperationCredentialCandidates(inventory!.candidates, capability); + : githubOperationCredentialCandidates( + (await readCredentialInventory()).candidates, + capability, + ); if (candidates.length === 0) { throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); } @@ -1409,7 +1415,7 @@ export function createGithubService({ const statusCooldown = (candidate: GitHubTokenCandidate) => githubCredentialCooldown( candidate, Date.now(), - { ignoreNonRateLimit: opts.forceRefresh === true }, + { resource: "core", ignoreNonRateLimit: opts.forceRefresh === true }, ); const currentWriteCandidate = writeCandidates.find((candidate) => !statusCooldown(candidate)) ?? null; diff --git a/apps/desktop/src/shared/githubOperationCredential.ts b/apps/desktop/src/shared/githubOperationCredential.ts index 7631195d3..f3f8f7c0c 100644 --- a/apps/desktop/src/shared/githubOperationCredential.ts +++ b/apps/desktop/src/shared/githubOperationCredential.ts @@ -16,13 +16,16 @@ export const GITHUB_OPERATION_CREDENTIALS = [ capabilities: readonly GithubOperationCredentialCapability[]; }[]; -export const GITHUB_OPERATION_CREDENTIAL_PRECEDENCE = GITHUB_OPERATION_CREDENTIALS.map( - ({ source }) => source, -); +export const GITHUB_OPERATION_CREDENTIAL_PRECEDENCE: + readonly GithubOperationCredentialSource[] = GITHUB_OPERATION_CREDENTIALS.map( + ({ source }) => source, + ); -const GITHUB_WRITE_CREDENTIAL_PRECEDENCE = GITHUB_OPERATION_CREDENTIAL_PRECEDENCE.filter( - (source): source is Exclude => source !== "app", -); +const GITHUB_WRITE_CREDENTIAL_PRECEDENCE: + readonly Exclude[] = + GITHUB_OPERATION_CREDENTIAL_PRECEDENCE.filter( + (source): source is Exclude => source !== "app", + ); export function githubOperationCredentialPrecedence( capability: GithubOperationCredentialCapability, From a4ccebdfb8458bb94468bf441e2d5a61dc79e704 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 05:25:33 -0400 Subject: [PATCH 03/12] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20addre?= =?UTF-8?q?ss=20GitHub=20auth=20fallback=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/headlessLinearServices.test.ts | 165 ++++++- apps/ade-cli/src/headlessLinearServices.ts | 403 +++++++++++------- .../services/github/githubCredentialHealth.ts | 55 ++- .../services/github/githubService.test.ts | 186 +++++++- .../src/main/services/github/githubService.ts | 325 +++++++------- apps/desktop/src/shared/githubApiPath.test.ts | 51 +++ apps/desktop/src/shared/githubApiPath.ts | 58 +++ .../shared/githubOperationCredential.test.ts | 57 +++ .../src/shared/githubOperationCredential.ts | 158 +++++++ apps/desktop/src/shared/types/git.ts | 4 +- 10 files changed, 1123 insertions(+), 339 deletions(-) create mode 100644 apps/desktop/src/shared/githubApiPath.test.ts create mode 100644 apps/desktop/src/shared/githubApiPath.ts create mode 100644 apps/desktop/src/shared/githubOperationCredential.test.ts diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index f0093d598..5edfa20d7 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -675,7 +675,7 @@ describe("headlessLinearServices", () => { status: 200, headers: { "content-type": "application/json", - "x-oauth-scopes": "repo, workflow", + "x-oauth-scopes": "repo", }, })) as unknown as typeof fetch; globalThis.fetch = fetchImpl; @@ -689,6 +689,7 @@ describe("headlessLinearServices", () => { await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ authSource: "environment", connected: true, + writeAuthSource: "none", patTokenStored: true, userLogin: "octocat", }); @@ -877,7 +878,64 @@ describe("headlessLinearServices", () => { } }); - it("keeps App-only headless reads connected while writes remain unavailable", async () => { + it("limits headless 404 fallback to repository-scoped requests", async () => { + const previousAdeHome = process.env.ADE_HOME; + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-404-")); + const machineCredentialStore = new EncryptedFileCredentialStore(); + machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "octocat", + updatedAt: new Date().toISOString(), + })); + const authorizations: string[] = []; + globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + authorizations.push(new Headers(init?.headers).get("authorization") ?? ""); + if (authorizations.length === 2) { + return new Response(JSON.stringify([{ id: 1 }]), { status: 200 }); + } + if (authorizations.length === 3) { + return new Response(JSON.stringify([{ number: 2 }]), { status: 200 }); + } + return new Response(JSON.stringify({ message: "Not Found" }), { status: 404 }); + }) as unknown as typeof fetch; + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + try { + await expect(githubService.apiRequest({ method: "GET", path: "/repos/acme/ade/issues" })) + .resolves.toMatchObject({ data: [{ id: 1 }] }); + await expect(githubService.apiRequest({ method: "GET", path: "/repos/acme/ade/pulls" })) + .resolves.toMatchObject({ data: [{ number: 2 }] }); + await expect(githubService.apiRequest({ method: "GET", path: "/user/emails" })) + .rejects.toThrow("Not Found"); + expect(authorizations).toEqual([ + "Bearer ghu_app_user_token", + "Bearer gho_cli_token", + "Bearer ghu_app_user_token", + "Bearer ghu_app_user_token", + ]); + } finally { + globalThis.fetch = previousFetch; + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + } + }); + + it("keeps App reads connected without advertising an invalid GitHub CLI writer", async () => { const previousAdeHome = process.env.ADE_HOME; const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; const previousGitHubToken = process.env.GITHUB_TOKEN; @@ -900,15 +958,26 @@ describe("headlessLinearServices", () => { userLogin: "octocat", updatedAt: new Date().toISOString(), })); - globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ login: "octocat" }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch; + const authorizations: string[] = []; + globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get("authorization") ?? ""; + authorizations.push(authorization); + return authorization === "Bearer ghu_app_user_token" + ? new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + : new Response(JSON.stringify({ message: "Bad credentials" }), { status: 401 }); + }) as unknown as typeof fetch; const githubService = createHeadlessGitHubService( "/tmp/ade-project", { debug() {}, info() {}, warn() {}, error() {} } as any, { - ghAuthTokenProvider: () => ({ token: null, ghCliPath: null, ghAuthError: null }), + ghAuthTokenProvider: () => ({ + token: "gho_invalid_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), }, ); @@ -919,7 +988,10 @@ describe("headlessLinearServices", () => { connected: true, userLogin: "octocat", }); - await expect(githubService.getTokenOrThrowAsync()).rejects.toThrow("GitHub auth missing"); + expect(authorizations).toEqual([ + "Bearer ghu_app_user_token", + "Bearer gho_invalid_cli_token", + ]); } finally { globalThis.fetch = previousFetch; if (previousAdeHome == null) delete process.env.ADE_HOME; @@ -935,6 +1007,83 @@ describe("headlessLinearServices", () => { } }); + it("drops a cached headless writer when that credential disappears", async () => { + const previousAdeHome = process.env.ADE_HOME; + const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; + const previousGitHubToken = process.env.GITHUB_TOKEN; + const previousGhToken = process.env.GH_TOKEN; + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-cache-")); + delete process.env.ADE_GITHUB_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + const machineCredentialStore = new EncryptedFileCredentialStore(); + machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "octocat", + updatedAt: new Date().toISOString(), + })); + let ghToken: string | null = "gho_cli_token"; + const authorizations: string[] = []; + globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + authorizations.push(new Headers(init?.headers).get("authorization") ?? ""); + return new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { + "content-type": "application/json", + "x-oauth-scopes": authorizations.at(-1) === "Bearer gho_cli_token" + ? "repo, workflow" + : "", + }, + }); + }) as unknown as typeof fetch; + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: ghToken, + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + await expect(githubService.getStatus()).resolves.toMatchObject({ + authSource: "app", + writeAuthSource: "gh", + connected: true, + }); + ghToken = null; + await expect(githubService.getStatus()).resolves.toMatchObject({ + authSource: "app", + writeAuthSource: "none", + connected: true, + }); + expect(authorizations).toEqual([ + "Bearer ghu_app_user_token", + "Bearer gho_cli_token", + "Bearer ghu_app_user_token", + ]); + } finally { + globalThis.fetch = previousFetch; + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN; + else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken; + if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = previousGitHubToken; + if (previousGhToken == null) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = previousGhToken; + } + }); + it("falls back from a rejected GitHub App token to GitHub CLI", async () => { const previousAdeHome = process.env.ADE_HOME; const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 8b4afc9bd..753f321f0 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -23,10 +23,7 @@ import { getRuntimeModelRefForDescriptor, resolveModelAlias, } from "../../desktop/src/shared/modelRegistry"; -import { - getGitHubTokenAccessState, - parseGitHubScopeHeaders, -} from "../../desktop/src/shared/githubScopes"; +import { parseGitHubScopeHeaders } from "../../desktop/src/shared/githubScopes"; import type { GitHubAuthFailure, GitHubAppDeviceAuthPollResult, @@ -65,18 +62,27 @@ import { createPrService as createPrServiceImpl } from "../../desktop/src/main/s import { createAutomationSecretService as createAutomationSecretServiceImpl } from "../../desktop/src/main/services/automations/automationSecretService"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import { + evaluateGithubCredentialCapabilities, githubOperationCredentialCandidates, + resolveGithubStatusCredentials, selectGithubOperationCredential, type GithubOperationCredentialCapability, } from "../../desktop/src/shared/githubOperationCredential"; +import { + classifyGitHubRepositoryApiPath, + createGithubRepositoryRequestFallback, +} from "../../desktop/src/shared/githubApiPath"; import { clearGithubCredentialHealth, githubBackgroundRequestPauseUntilMs, githubCredentialCooldown, + githubCredentialInventoryKey, + githubCredentialRepositoryAccess, githubCredentialStates, githubCredentialTokenDigest, recordGithubCredentialFailure, recordGithubCredentialProbeSuccess, + recordGithubCredentialRepositoryAccess, recordGithubCredentialSuccess, registerGithubCredentialIdentity, type GithubCredentialCandidate, @@ -629,17 +635,20 @@ export function createHeadlessGitHubService( ReturnType > | null = null; let cachedAt = 0; + let cachedStatusBinding: string | null = null; let tokenOverride: string | null = null; let tokenDecryptionFailed = false; let statusLookupGeneration = 0; let statusLookupInFlight: { generation: number; + binding: string; promise: Promise; } | null = null; const invalidateStatusCache = (): void => { cachedStatus = null; cachedAt = 0; + cachedStatusBinding = null; statusLookupGeneration += 1; }; @@ -780,7 +789,7 @@ export function createHeadlessGitHubService( const getToken = (): string => readToken().token ?? ""; - const getTokenType = (token: string): HeadlessGitHubStatus["tokenType"] => { + const getTokenType = (token: string): NonNullable => { if (token.startsWith("github_pat_")) return "fine-grained"; if (token.startsWith("ghp_")) return "classic"; if (/^gh[ousr]_/.test(token)) return "oauth"; @@ -797,33 +806,12 @@ export function createHeadlessGitHubService( } return fallback; }; - const computeConnected = (args: { - tokenStored: boolean; - userLogin: string | null; - authSource: HeadlessGitHubStatus["authSource"]; - tokenType: HeadlessGitHubStatus["tokenType"]; - scopes: string[]; - repo: { owner: string; name: string } | null; - repoAccessOk: boolean | null; - }): boolean => { - if (!args.tokenStored || !args.userLogin) return false; - if (args.authSource === "app") { - return args.repo ? args.repoAccessOk === true : true; - } - if (args.tokenType === "fine-grained") { - return args.repo ? args.repoAccessOk === true : true; - } - if (args.tokenType === "classic" || args.tokenType === "oauth" || args.scopes.length > 0) { - return getGitHubTokenAccessState(args.scopes).hasRequiredAccess; - } - return true; - }; const validateToken = async ( token: string, ): Promise<{ userLogin: string | null; scopes: string[]; - tokenType: HeadlessGitHubStatus["tokenType"]; + tokenType: NonNullable; rateLimit: GitHubRateLimitState | null; }> => { const response = await fetchGitHub("https://api.github.com/user", { @@ -861,15 +849,62 @@ export function createHeadlessGitHubService( : null; return { userLogin, scopes, tokenType: getTokenType(token), rateLimit }; }; + + type HeadlessGithubStatusProbe = { + validated: Awaited>; + repoAccessOk: boolean | null; + repoAccessError: string | null; + }; + type HeadlessGithubStatusProbeResult = + | { ok: true; value: HeadlessGithubStatusProbe } + | { + ok: false; + error: string; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + value?: HeadlessGithubStatusProbe; + }; + + const validatedCredentialCapabilities = ( + candidate: HeadlessGitHubTokenCandidate, + probe: HeadlessGithubStatusProbe, + repo: { owner: string; name: string } | null, + ) => evaluateGithubCredentialCapabilities({ + source: candidate.source, + tokenType: probe.validated.tokenType, + scopes: probe.validated.scopes, + userLogin: probe.validated.userLogin, + repositoryPresent: repo != null, + repositoryReadValidated: probe.repoAccessOk, + }); + const probeRepoAccess = async ( - token: string, + candidate: HeadlessGitHubTokenCandidate, repo: { owner: string; name: string }, + forceRefresh = false, ): Promise<{ ok: boolean; error: string | null; authFailure: GitHubAuthFailure | null; rateLimit: GitHubRateLimitState | null; }> => { + const cachedAccess = forceRefresh + ? null + : githubCredentialRepositoryAccess(candidate, repo); + if (cachedAccess != null) { + return { + ok: cachedAccess, + error: cachedAccess ? null : `This credential cannot access ${repo.owner}/${repo.name}.`, + authFailure: cachedAccess + ? null + : { + kind: "permission_denied", + message: `This credential cannot access ${repo.owner}/${repo.name}.`, + retryAt: null, + }, + rateLimit: null, + }; + } try { const response = await fetchGitHub( `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`, @@ -877,12 +912,13 @@ export function createHeadlessGitHubService( method: "GET", headers: { accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, + authorization: `Bearer ${candidate.token}`, "user-agent": "ade-cli", }, }, ); if (response.ok) { + recordGithubCredentialRepositoryAccess(candidate, repo, true); return { ok: true, error: null, @@ -907,6 +943,9 @@ export function createHeadlessGitHubService( : failure.authFailure.kind === "unknown" ? null : failure.authFailure; + if (authFailure?.kind === "permission_denied") { + recordGithubCredentialRepositoryAccess(candidate, repo, false); + } return { ok: false, error: `${response.status}: ${message}`, @@ -925,6 +964,40 @@ export function createHeadlessGitHubService( } }; + const probeCandidate = async ( + candidate: HeadlessGitHubTokenCandidate, + repo: { owner: string; name: string } | null, + forceRefresh: boolean, + ): Promise => { + try { + const validated = await validateToken(candidate.token); + let repoAccessOk: boolean | null = null; + let repoAccessError: string | null = null; + if (repo && (candidate.source === "app" || validated.tokenType === "fine-grained")) { + const repoProbe = await probeRepoAccess(candidate, repo, forceRefresh); + validated.rateLimit = repoProbe.rateLimit ?? validated.rateLimit; + repoAccessOk = repoProbe.ok; + repoAccessError = repoProbe.error; + if (repoProbe.authFailure) { + return { + ok: false, + error: repoProbe.error ?? repoProbe.authFailure.message, + authFailure: repoProbe.authFailure, + rateLimit: repoProbe.rateLimit, + value: { validated, repoAccessOk, repoAccessError }, + }; + } + } + return { ok: true, value: { validated, repoAccessOk, repoAccessError } }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const classified = error instanceof HeadlessGitHubTokenValidationError + ? { authFailure: error.authFailure, rateLimit: error.rateLimit } + : classifyGitHubAuthFailure({ message }); + return { ok: false, error: message, ...classified }; + } + }; + const etagCache = new Map({ + path: repositoryPath, + readAccess: githubCredentialRepositoryAccess, + recordAccess: recordGithubCredentialRepositoryAccess, + }); let firstUnavailable: { failure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null; @@ -975,6 +1054,22 @@ export function createHeadlessGitHubService( let lastAttemptError: HeadlessGithubCredentialAttemptError | null = null; for (const candidate of candidates) { + if ( + !args.token + && repositoryPath + && repositoryFallback.shouldSkip(candidate) + ) { + lastAttemptError = new HeadlessGithubCredentialAttemptError( + `This credential cannot access ${repositoryPath.owner}/${repositoryPath.name}.`, + { + kind: "permission_denied", + message: `This credential cannot access ${repositoryPath.owner}/${repositoryPath.name}.`, + retryAt: null, + }, + null, + ); + continue; + } const cooldown = args.token ? null : githubCredentialCooldown(candidate, Date.now(), { resource: rateLimitResource }); @@ -1002,6 +1097,7 @@ export function createHeadlessGitHubService( const cached = etagCache.get(cacheKey); if (cached) { recordGithubCredentialSuccess(candidate, response.headers); + repositoryFallback.recordSuccess(candidate); return { data: cached.data as T, response, linkHeader: cached.linkHeader }; } } @@ -1022,7 +1118,11 @@ export function createHeadlessGitHubService( message, headers: response.headers, }); - recordGithubCredentialFailure(candidate, failure.authFailure, failure.rateLimit); + const { repositoryNotFound, ambiguousRepositoryNotFound } = + repositoryFallback.classifyFailure(candidate, response.status); + if (!repositoryNotFound) { + recordGithubCredentialFailure(candidate, failure.authFailure, failure.rateLimit); + } const attemptError = new HeadlessGithubCredentialAttemptError( message, failure.authFailure, @@ -1030,8 +1130,15 @@ export function createHeadlessGitHubService( ); lastAttemptError = attemptError; const canTryNext = !args.token - && (response.status === 401 || response.status === 403 || response.status === 429); - if (canTryNext) continue; + && ( + response.status === 401 + || response.status === 403 + || response.status === 429 + || ambiguousRepositoryNotFound + ); + if (canTryNext) { + continue; + } if (attemptError.authFailure.kind === "rate_limited") { const resetAtMs = githubRateLimitRetryAtMs(attemptError.authFailure, attemptError.rateLimit); const resetDetail = resetAtMs == null @@ -1070,6 +1177,7 @@ export function createHeadlessGitHubService( } recordGithubCredentialSuccess(candidate, response.headers); + repositoryFallback.recordSuccess(candidate); if (candidate !== candidates[0]) { logger.info("github.credential_fallback_used", { capability, @@ -1320,25 +1428,82 @@ export function createHeadlessGitHubService( service = { async getStatus(opts: { forceRefresh?: boolean } = {}) { if (opts.forceRefresh) { - if (statusLookupInFlight?.generation === statusLookupGeneration) { - return await statusLookupInFlight.promise; - } invalidateStatusCache(); } + const [origin, inventory] = await Promise.all([ + readGitOriginAsync(projectRoot), + readCredentialInventoryAsync(), + ]); + const repo = parseGitHubRepoFromRemoteUrl(origin ?? ""); + const hasOrigin = Boolean(origin); + const binding = `${githubCredentialInventoryKey(inventory.candidates)}:${repo?.owner ?? ""}/${repo?.name ?? ""}`; const now = Date.now(); - if (cachedStatus && now - cachedAt < 30_000) return cachedStatus; + if ( + !opts.forceRefresh + && cachedStatus + && cachedStatusBinding === binding + && now - cachedAt < 30_000 + ) { + const cachedReadCandidate = cachedStatus.authSource === "none" + ? null + : inventory.candidates.find( + (candidate) => candidate.source === cachedStatus?.authSource, + ) ?? null; + const cachedWriteSource = cachedStatus.writeAuthSource + && cachedStatus.writeAuthSource !== "none" + ? cachedStatus.writeAuthSource + : null; + const cachedWriteCandidate = cachedWriteSource == null + ? null + : inventory.candidates.find((candidate) => candidate.source === cachedWriteSource) ?? null; + const cachedReadUnavailable = cachedStatus.authSource !== "none" + && (!cachedReadCandidate || githubCredentialCooldown( + cachedReadCandidate, + now, + { resource: "core" }, + ) != null); + const cachedWriteUnavailable = cachedWriteSource != null + && (!cachedWriteCandidate || githubCredentialCooldown( + cachedWriteCandidate, + now, + { resource: "core" }, + ) != null); + if (!cachedReadUnavailable && !cachedWriteUnavailable) { + const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); + const pauseUntilMs = githubBackgroundRequestPauseUntilMs(now, readCandidates); + return { + ...cachedStatus, + repo, + hasOrigin, + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath ?? cachedStatus.ghCliPath, + ghAuthError: inventory.ghAuthError, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + activeReadSource: cachedStatus.authSource === "none" + ? null + : cachedStatus.authSource, + activeWriteSource: cachedWriteSource, + }), + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + }; + } + cachedStatus = null; + cachedAt = 0; + cachedStatusBinding = null; + } const generation = statusLookupGeneration; - if (statusLookupInFlight?.generation === generation) { + if ( + statusLookupInFlight?.generation === generation + && statusLookupInFlight.binding === binding + ) { return await statusLookupInFlight.promise; } const lookup = (async (): Promise => { - const [origin, inventory] = await Promise.all([ - readGitOriginAsync(projectRoot), - readCredentialInventoryAsync(), - ]); - const repo = parseGitHubRepoFromRemoteUrl(origin ?? ""); - const hasOrigin = Boolean(origin); const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); const statusCooldown = (candidate: HeadlessGitHubTokenCandidate) => githubCredentialCooldown( @@ -1379,106 +1544,53 @@ export function createHeadlessGitHubService( }; } - const failures: Array<{ - candidate: HeadlessGitHubTokenCandidate; - error: string; - authFailure: GitHubAuthFailure; - rateLimit: GitHubRateLimitState | null; - }> = []; - let active: { - candidate: HeadlessGitHubTokenCandidate; - validated: Awaited>; - repoAccessOk: boolean | null; - repoAccessError: string | null; - } | null = null; - - for (const [candidateIndex, candidate] of readCandidates.entries()) { - const cooldown = statusCooldown(candidate); - if (cooldown) { - failures.push({ - candidate, - error: cooldown.failure.message, - authFailure: cooldown.failure, - rateLimit: cooldown.rateLimit, - }); - continue; - } - try { - const validated = await validateToken(candidate.token); - let repoAccessOk: boolean | null = null; - let repoAccessError: string | null = null; - if (repo && (candidate.source === "app" || validated.tokenType === "fine-grained")) { - const probe = await probeRepoAccess(candidate.token, repo); - repoAccessOk = probe.ok; - repoAccessError = probe.error; - validated.rateLimit = probe.rateLimit ?? validated.rateLimit; - if (!probe.ok) { - logger.warn("github.repo_probe_failed", { - source: candidate.source, - repo: `${repo.owner}/${repo.name}`, - tokenType: validated.tokenType, - error: probe.error, - }); - if (probe.authFailure) { - const hasFallbackCandidate = readCandidates - .slice(candidateIndex + 1) - .some((nextCandidate) => !statusCooldown(nextCandidate)); - if (probe.authFailure.kind === "permission_denied" && !hasFallbackCandidate) { - registerGithubCredentialIdentity(candidate, validated.userLogin); - active = { candidate, validated, repoAccessOk, repoAccessError }; - break; - } - recordGithubCredentialFailure(candidate, probe.authFailure, probe.rateLimit); - failures.push({ - candidate, - error: probe.error ?? probe.authFailure.message, - authFailure: probe.authFailure, - rateLimit: probe.rateLimit, - }); - continue; - } - } + const { active, activeWriteSource, failures } = await resolveGithubStatusCredentials({ + readCandidates, + writeCandidates, + cooldown: statusCooldown, + probe: (candidate) => probeCandidate(candidate, repo, opts.forceRefresh === true), + capabilities: (candidate, value) => validatedCredentialCapabilities( + candidate, + value, + repo, + ), + isRepositoryAccessFailure: (result) => result.authFailure.kind === "permission_denied" + && result.value?.repoAccessOk === false, + onAcceptedProbe: (candidate, value, validated) => { + registerGithubCredentialIdentity(candidate, value.validated.userLogin); + if (validated) { + recordGithubCredentialProbeSuccess( + candidate, + value.validated.rateLimit, + value.validated.userLogin, + ); } - registerGithubCredentialIdentity(candidate, validated.userLogin); - recordGithubCredentialProbeSuccess(candidate, validated.rateLimit, validated.userLogin); - active = { candidate, validated, repoAccessOk, repoAccessError }; - break; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const classified = error instanceof HeadlessGitHubTokenValidationError - ? { authFailure: error.authFailure, rateLimit: error.rateLimit } - : classifyGitHubAuthFailure({ message }); - recordGithubCredentialFailure(candidate, classified.authFailure, classified.rateLimit); - failures.push({ candidate, error: message, ...classified }); - logger.warn("github.token_validation_failed", { - source: candidate.source, - error: message, - kind: classified.authFailure.kind, - retryAt: classified.authFailure.retryAt, - }); - if ( - classified.authFailure.kind === "network" - || classified.authFailure.kind === "unknown" - ) { - break; + }, + onRejectedProbe: (candidate, result, context) => { + if (!context.repositoryAccessFailure) { + recordGithubCredentialFailure(candidate, result.authFailure, result.rateLimit); } - } - } - - const currentWriteCandidate = writeCandidates.find((candidate) => !statusCooldown(candidate)) - ?? null; + if (context.phase === "read") { + logger.warn("github.token_validation_failed", { + source: candidate.source, + error: result.error, + kind: result.authFailure.kind, + retryAt: result.authFailure.retryAt, + }); + } + }, + }); const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); if (active) { - const { candidate, validated, repoAccessOk, repoAccessError } = active; + const { candidate, value } = active; + const { validated, repoAccessOk, repoAccessError } = value; return { tokenStored: true, patTokenStored: inventory.patTokenStored, tokenDecryptionFailed: false, storageScope: "app", authSource: candidate.source, - writeAuthSource: currentWriteCandidate?.source === "app" - ? "none" - : currentWriteCandidate?.source ?? "none", + writeAuthSource: activeWriteSource ?? "none", tokenType: validated.tokenType, repo, hasOrigin, @@ -1493,9 +1605,7 @@ export function createHeadlessGitHubService( candidates: inventory.candidates, availableSources: inventory.availableSources, activeReadSource: candidate.source, - activeWriteSource: currentWriteCandidate?.source === "app" - ? null - : currentWriteCandidate?.source ?? null, + activeWriteSource, }), credentialFallback: failures[0] && failures[0].candidate.source !== candidate.source ? { @@ -1511,15 +1621,7 @@ export function createHeadlessGitHubService( : new Date(pauseUntilMs).toISOString(), repoAccessOk, repoAccessError, - connected: computeConnected({ - tokenStored: true, - userLogin: validated.userLogin, - authSource: candidate.source, - tokenType: validated.tokenType, - scopes: validated.scopes, - repo, - repoAccessOk, - }), + connected: validatedCredentialCapabilities(candidate, value, repo).read, }; } @@ -1537,9 +1639,7 @@ export function createHeadlessGitHubService( tokenDecryptionFailed: false, storageScope: "app", authSource: primaryCandidate.source, - writeAuthSource: currentWriteCandidate?.source === "app" - ? "none" - : currentWriteCandidate?.source ?? "none", + writeAuthSource: "none", tokenType: getTokenType(primaryCandidate.token), repo, hasOrigin, @@ -1554,9 +1654,7 @@ export function createHeadlessGitHubService( candidates: inventory.candidates, availableSources: inventory.availableSources, activeReadSource: null, - activeWriteSource: currentWriteCandidate?.source === "app" - ? null - : currentWriteCandidate?.source ?? null, + activeWriteSource: null, }), credentialFallback: null, backgroundRefreshPausedUntil: pauseUntilMs == null @@ -1567,12 +1665,13 @@ export function createHeadlessGitHubService( connected: false, }; })(); - statusLookupInFlight = { generation, promise: lookup }; + statusLookupInFlight = { generation, binding, promise: lookup }; try { const status = await lookup; if (statusLookupGeneration === generation) { cachedStatus = status; cachedAt = Date.now(); + cachedStatusBinding = binding; } return status; } finally { diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.ts index 5794fb3b1..bcd05ebbe 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.ts @@ -5,6 +5,7 @@ import type { GitHubCredentialSource, GitHubCredentialState, GitHubRateLimitState, + GitHubRepoRef, } from "../../../shared/types"; import { githubRateLimitRetryAtMs, @@ -17,6 +18,7 @@ import { const FALLBACK_COOLDOWN_MS = 5 * 60_000; const SECONDARY_RATE_LIMIT_COOLDOWN_MS = 60_000; +const REPOSITORY_ACCESS_TTL_MS = 2 * 60_000; export const GITHUB_BACKGROUND_RATE_LIMIT_RESERVE = 500; export type GithubCredentialCandidate = { @@ -38,11 +40,57 @@ type CredentialHealth = { }; const healthByTokenDigest = new Map(); +const repositoryAccessByTokenAndRepo = new Map(); export function githubCredentialTokenDigest(token: string): string { return createHash("sha256").update(token).digest("hex"); } +export function githubCredentialInventoryKey( + candidates: readonly GithubCredentialCandidate[], +): string { + return candidates + .map((candidate) => `${candidate.source}:${githubCredentialTokenDigest(candidate.token)}`) + .join("|"); +} + +function repositoryAccessKey( + candidate: GithubCredentialCandidate, + repo: GitHubRepoRef, +): string { + return `${githubCredentialTokenDigest(candidate.token)}:${repo.owner.trim().toLowerCase()}/${repo.name.trim().toLowerCase()}`; +} + +export function githubCredentialRepositoryAccess( + candidate: GithubCredentialCandidate, + repo: GitHubRepoRef, + nowMs = Date.now(), +): boolean | null { + const key = repositoryAccessKey(candidate, repo); + const entry = repositoryAccessByTokenAndRepo.get(key); + if (!entry) return null; + if (entry.expiresAtMs <= nowMs) { + repositoryAccessByTokenAndRepo.delete(key); + return null; + } + return entry.accessible; +} + +export function recordGithubCredentialRepositoryAccess( + candidate: GithubCredentialCandidate, + repo: GitHubRepoRef, + accessible: boolean, + nowMs = Date.now(), +): void { + repositoryAccessByTokenAndRepo.set(repositoryAccessKey(candidate, repo), { + accessible, + expiresAtMs: nowMs + REPOSITORY_ACCESS_TTL_MS, + }); +} + function normalizedLogin(login: string | null | undefined): string | null { const value = login?.trim().toLowerCase() ?? ""; return value || null; @@ -188,10 +236,15 @@ export function githubCredentialCooldown( export function clearGithubCredentialHealth(token?: string): void { if (token) { - healthByTokenDigest.delete(githubCredentialTokenDigest(token)); + const digest = githubCredentialTokenDigest(token); + healthByTokenDigest.delete(digest); + for (const key of repositoryAccessByTokenAndRepo.keys()) { + if (key.startsWith(`${digest}:`)) repositoryAccessByTokenAndRepo.delete(key); + } return; } healthByTokenDigest.clear(); + repositoryAccessByTokenAndRepo.clear(); } export function githubCredentialStates(args: { diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 82ff9b542..49c38fdbc 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -420,6 +420,86 @@ describe("githubService.apiRequest", () => { .toBe("Bearer gho_cli_token"); }); + it("falls back on repository-scoped 404s without retrying unrelated 404s", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch + .mockResolvedValueOnce(jsonResponse(404, { message: "Not Found" })) + .mockResolvedValueOnce(jsonResponse(200, [{ id: 1 }])) + .mockResolvedValueOnce(jsonResponse(200, [{ number: 2 }])) + .mockResolvedValueOnce(jsonResponse(404, { message: "Not Found" })); + const service = makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + + await expect(service.apiRequest({ method: "GET", path: "/repos/acme/ade/issues" })) + .resolves.toMatchObject({ data: [{ id: 1 }] }); + await expect(service.apiRequest({ method: "GET", path: "/repos/acme/ade/pulls" })) + .resolves.toMatchObject({ data: [{ number: 2 }] }); + await expect(service.apiRequest({ method: "GET", path: "/user/emails" })) + .rejects.toThrow("Not Found"); + + expect(mockFetch).toHaveBeenCalledTimes(4); + expect((mockFetch.mock.calls[0]?.[1]?.headers as Record).authorization) + .toBe("Bearer ghu_app_user_token"); + expect((mockFetch.mock.calls[1]?.[1]?.headers as Record).authorization) + .toBe("Bearer gho_cli_token"); + expect((mockFetch.mock.calls[2]?.[1]?.headers as Record).authorization) + .toBe("Bearer ghu_app_user_token"); + expect((mockFetch.mock.calls[3]?.[1]?.headers as Record).authorization) + .toBe("Bearer ghu_app_user_token"); + }); + + it("does not fan out a nested 404 after repository access is known", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { full_name: "acme/ade" })) + .mockResolvedValueOnce(jsonResponse(404, { message: "Issue not found" })); + const service = makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + + await expect(service.apiRequest({ method: "GET", path: "/repos/acme/ade" })) + .resolves.toMatchObject({ data: { full_name: "acme/ade" } }); + await expect(service.apiRequest({ method: "GET", path: "/repos/acme/ade/issues/404" })) + .rejects.toThrow("Issue not found"); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect((mockFetch.mock.calls[1]?.[1]?.headers as Record).authorization) + .toBe("Bearer ghu_app_user_token"); + }); + it("skips the read-only GitHub App for write requests", async () => { delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; const credentialStore = new MemoryCredentialStore(); @@ -846,40 +926,22 @@ describe("githubService.getStatus", () => { }); } - it("classic token with required scopes is connected (no repo probe needed)", async () => { + it("keeps repo-capable classic tokens connected while withholding write access", async () => { stubOriginRemote(); process.env.GITHUB_TOKEN = "ghp_classic"; - const credentialStore = new MemoryCredentialStore(); - credentialStore.setSync("github.token.v1", "ghp_stored_token"); - credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ - accessToken: "ghu_app_user_token", - tokenType: "bearer", - scope: null, - expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), - refreshToken: null, - refreshTokenExpiresAt: null, - userLogin: "alice", - updatedAt: new Date().toISOString(), - })); mockFetch.mockResolvedValueOnce( - jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), + jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo" }), ); - const status = await makeService({ - credentialStore, - ghAuthTokenProvider: () => ({ - token: "gho_cli_token", - ghCliPath: "/opt/homebrew/bin/gh", - ghAuthError: null, - }), - }).getStatus(); + const status = await makeService().getStatus(); expect(status.tokenStored).toBe(true); expect(status.authSource).toBe("environment"); expect(status.tokenType).toBe("classic"); expect(status.userLogin).toBe("alice"); - expect(status.scopes).toEqual(["repo", "workflow"]); + expect(status.scopes).toEqual(["repo"]); expect(status.repoAccessOk).toBeNull(); expect(status.connected).toBe(true); + expect(status.writeAuthSource).toBe("none"); expect(mockFetch).toHaveBeenCalledTimes(1); }); @@ -914,6 +976,42 @@ describe("githubService.getStatus", () => { await expect(service.getTokenOrThrowAsync()).rejects.toThrow("GitHub auth missing"); }); + it("does not advertise an unvalidated lower-precedence write credential", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { login: "alice" })) + .mockResolvedValueOnce(jsonResponse(200, { full_name: "acme/ade" })) + .mockResolvedValueOnce(jsonResponse(401, { message: "Bad credentials" })); + + const status = await makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_invalid_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }).getStatus(); + + expect(status).toMatchObject({ + authSource: "app", + writeAuthSource: "none", + connected: true, + }); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + it("reports an exhausted GitHub API quota as rate limited instead of missing permissions", async () => { stubOriginRemote(); process.env.GITHUB_TOKEN = "ghp_classic"; @@ -1303,6 +1401,48 @@ describe("githubService.getStatus", () => { resource: null, }); expect(status.connected).toBe(true); + expect(status.writeAuthSource).toBe("environment"); + }); + + it("drops a cached writer when that credential disappears", async () => { + stubOriginRemote(); + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.token.v1", "ghp_stored_token"); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { login: "alice" })) + .mockResolvedValueOnce(jsonResponse(200, { full_name: "acme/ade" })) + .mockResolvedValueOnce(jsonResponse( + 200, + { login: "alice" }, + { "x-oauth-scopes": "repo, workflow" }, + )) + .mockResolvedValueOnce(jsonResponse(200, { login: "alice" })); + const service = makeService({ + credentialStore, + }); + + await expect(service.getStatus()).resolves.toMatchObject({ + authSource: "app", + writeAuthSource: "pat", + connected: true, + }); + credentialStore.deleteSync("github.token.v1"); + await expect(service.getStatus()).resolves.toMatchObject({ + authSource: "app", + writeAuthSource: "none", + connected: true, + }); + expect(mockFetch).toHaveBeenCalledTimes(4); }); it("reports rate limiting during a fine-grained repo probe instead of missing repo access", async () => { diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 2e30dd65d..2b5bb647b 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -19,13 +19,19 @@ import type { } from "../../../shared/types"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import { parseGithubRemoteUrl } from "../../../shared/githubRemote"; -import { getGitHubTokenAccessState, parseGitHubScopeHeaders } from "../../../shared/githubScopes"; +import { parseGitHubScopeHeaders } from "../../../shared/githubScopes"; import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/credentials/credentialStore"; import { + evaluateGithubCredentialCapabilities, githubOperationCredentialCandidates, + resolveGithubStatusCredentials, selectGithubOperationCredential, type GithubOperationCredentialCapability, } from "../../../shared/githubOperationCredential"; +import { + classifyGitHubRepositoryApiPath, + createGithubRepositoryRequestFallback, +} from "../../../shared/githubApiPath"; import { mergePathEntries, resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; import { fetchGitHubAppInstallationStatus, type GitHubRelaySecretReader } from "./githubRelayConfig"; import { createGitHubAppUserAuthService } from "./githubAppUserAuthService"; @@ -43,10 +49,13 @@ import { clearGithubCredentialHealth, githubBackgroundRequestPauseUntilMs, githubCredentialCooldown, + githubCredentialInventoryKey, + githubCredentialRepositoryAccess, githubCredentialStates, githubCredentialTokenDigest, recordGithubCredentialFailure, recordGithubCredentialProbeSuccess, + recordGithubCredentialRepositoryAccess, recordGithubCredentialSuccess, registerGithubCredentialIdentity, type GithubCredentialCandidate, @@ -93,7 +102,7 @@ type SharedGithubStatusProbe = { validated: { userLogin: string | null; scopes: string[]; - tokenType: GitHubStatus["tokenType"]; + tokenType: NonNullable; rateLimit: GitHubRateLimitState | null; }; repoAccessOk: boolean | null; @@ -396,7 +405,7 @@ async function fetchGitHub(input: string | URL, init: RequestInit): Promise { if (token.startsWith("github_pat_")) return "fine-grained"; if (token.startsWith("ghp_")) return "classic"; if (/^gh[ousr]_/.test(token)) return "oauth"; @@ -541,7 +550,7 @@ export function createGithubService({ const ghAuthProvider = ghAuthTokenProvider ?? readGitHubCliAuthToken; const sharedGhAuth = processGithubAuthState(ghAuthProvider); let statusInFlight: Promise | null = null; - let cachedStatusTokenDigest: string | null = null; + let cachedStatusCredentialInventoryKey: string | null = null; const readMachineToken = (): string | null => { if (!credentialStore) return null; @@ -908,7 +917,7 @@ export function createGithubService({ const validateToken = async (token: string): Promise<{ userLogin: string | null; scopes: string[]; - tokenType: GitHubStatus["tokenType"]; + tokenType: NonNullable; rateLimit: GitHubRateLimitState | null; }> => { const response = await fetchGitHub("https://api.github.com/user", { @@ -949,14 +958,32 @@ export function createGithubService({ // the only reliable connectivity check for fine-grained tokens, which never // return x-oauth-scopes and so cannot be introspected via headers. const probeRepoAccess = async ( - token: string, + candidate: GitHubTokenCandidate, repo: GitHubRepoRef, + forceRefresh = false, ): Promise<{ ok: boolean; error: string | null; authFailure: GitHubAuthFailure | null; rateLimit: GitHubRateLimitState | null; }> => { + const cachedAccess = forceRefresh + ? null + : githubCredentialRepositoryAccess(candidate, repo); + if (cachedAccess != null) { + return { + ok: cachedAccess, + error: cachedAccess ? null : `This credential cannot access ${repo.owner}/${repo.name}.`, + authFailure: cachedAccess + ? null + : { + kind: "permission_denied", + message: `This credential cannot access ${repo.owner}/${repo.name}.`, + retryAt: null, + }, + rateLimit: null, + }; + } try { const response = await fetchGitHub( `https://api.github.com/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`, @@ -964,13 +991,14 @@ export function createGithubService({ method: "GET", headers: { accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, + authorization: `Bearer ${candidate.token}`, "user-agent": "ade-desktop", "x-github-api-version": GITHUB_REST_API_VERSION, }, }, ); if (response.ok) { + recordGithubCredentialRepositoryAccess(candidate, repo, true); releaseGitHubResponse(response); return { ok: true, @@ -996,6 +1024,9 @@ export function createGithubService({ : failure.authFailure.kind === "unknown" ? null : failure.authFailure; + if (authFailure?.kind === "permission_denied") { + recordGithubCredentialRepositoryAccess(candidate, repo, false); + } return { ok: false, error: `${response.status}: ${message}`, @@ -1015,16 +1046,16 @@ export function createGithubService({ }; const computeGithubStatusProbe = async ( - token: string, + candidate: GitHubTokenCandidate, repo: GitHubRepoRef | null, - authSource: GitHubStatus["authSource"], + forceRefresh = false, ): Promise => { try { - const validated = await validateToken(token); + const validated = await validateToken(candidate.token); let repoAccessOk: boolean | null = null; let repoAccessError: string | null = null; - if (repo && (authSource === "app" || validated.tokenType === "fine-grained")) { - const probe = await probeRepoAccess(token, repo); + if (repo && (candidate.source === "app" || validated.tokenType === "fine-grained")) { + const probe = await probeRepoAccess(candidate, repo, forceRefresh); validated.rateLimit = probe.rateLimit ?? validated.rateLimit; if (probe.authFailure) { return { @@ -1060,11 +1091,11 @@ export function createGithubService({ }; const readSharedGithubStatusProbe = async ( - token: string, + candidate: GitHubTokenCandidate, repo: GitHubRepoRef | null, forceRefresh: boolean, ): Promise => { - const key = githubStatusProbeKey(token, repo); + const key = githubStatusProbeKey(candidate.token, repo); if (forceRefresh) { const existing = sharedGhAuth.statusInFlight.get(key); if (existing) await existing.catch(() => {}); @@ -1078,7 +1109,7 @@ export function createGithubService({ if (inFlight) return await inFlight; } - const work = computeGithubStatusProbe(token, repo, "gh"); + const work = computeGithubStatusProbe(candidate, repo, forceRefresh); sharedGhAuth.statusInFlight.set(key, work); try { const result = await work; @@ -1094,7 +1125,7 @@ export function createGithubService({ : GH_AUTH_TOKEN_CACHE_TTL_MS), result, }); - if (isNetworkFailure && sharedGhAuth.authCache?.token === token) { + if (isNetworkFailure && sharedGhAuth.authCache?.token === candidate.token) { sharedGhAuth.authCache.expiresAt = Math.max( sharedGhAuth.authCache.expiresAt, Date.now() + GITHUB_STATUS_FAILURE_COOLDOWN_MS, @@ -1169,6 +1200,12 @@ export function createGithubService({ const accept = args.accept?.trim() || "application/vnd.github+json"; const rateLimitResource = githubRateLimitResourceForPath(args.path); + const repositoryPath = classifyGitHubRepositoryApiPath(args.path); + const repositoryFallback = createGithubRepositoryRequestFallback({ + path: repositoryPath, + readAccess: githubCredentialRepositoryAccess, + recordAccess: recordGithubCredentialRepositoryAccess, + }); let firstUnavailable: { failure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null; @@ -1176,6 +1213,22 @@ export function createGithubService({ let lastAttemptError: GithubCredentialAttemptError | null = null; for (const candidate of candidates) { + if ( + !args.token + && repositoryPath + && repositoryFallback.shouldSkip(candidate) + ) { + lastAttemptError = new GithubCredentialAttemptError( + `This credential cannot access ${repositoryPath.owner}/${repositoryPath.name}.`, + { + kind: "permission_denied", + message: `This credential cannot access ${repositoryPath.owner}/${repositoryPath.name}.`, + retryAt: null, + }, + null, + ); + continue; + } const cooldown = args.token ? null : githubCredentialCooldown(candidate, Date.now(), { resource: rateLimitResource }); @@ -1217,6 +1270,7 @@ export function createGithubService({ const cached = etagCache.get(cacheKey); if (cached) { recordGithubCredentialSuccess(candidate, response.headers); + repositoryFallback.recordSuccess(candidate); releaseGitHubResponse(response); return { data: cached.data as T, response, linkHeader: cached.linkHeader }; } @@ -1249,7 +1303,11 @@ export function createGithubService({ message, headers: response.headers, }); - recordGithubCredentialFailure(candidate, failure.authFailure, failure.rateLimit); + const { repositoryNotFound, ambiguousRepositoryNotFound } = + repositoryFallback.classifyFailure(candidate, response.status); + if (!repositoryNotFound) { + recordGithubCredentialFailure(candidate, failure.authFailure, failure.rateLimit); + } const attemptError = new GithubCredentialAttemptError( message + detail, failure.authFailure, @@ -1257,8 +1315,15 @@ export function createGithubService({ ); lastAttemptError = attemptError; const canTryNext = !args.token - && (response.status === 401 || response.status === 403 || response.status === 429); - if (canTryNext) continue; + && ( + response.status === 401 + || response.status === 403 + || response.status === 429 + || ambiguousRepositoryNotFound + ); + if (canTryNext) { + continue; + } if (attemptError.authFailure.kind === "rate_limited") { const resetAtMs = githubRateLimitRetryAtMs(attemptError.authFailure, attemptError.rateLimit); const resetDetail = resetAtMs == null @@ -1297,6 +1362,7 @@ export function createGithubService({ } recordGithubCredentialSuccess(candidate, response.headers); + repositoryFallback.recordSuccess(candidate); if (candidate !== candidates[0]) { logger.info("github.credential_fallback_used", { capability, @@ -1365,41 +1431,24 @@ export function createGithubService({ let cachedStatus: GitHubStatus | null = null; let cachedAt = 0; - // Decides whether the saved token is actually usable for the project. Classic - // tokens require both required scopes; fine-grained tokens require a successful - // repo probe (since their permissions are not introspectable via headers). - const computeConnected = (args: { - tokenStored: boolean; - userLogin: string | null; - authSource: GitHubStatus["authSource"]; - tokenType: GitHubStatus["tokenType"]; - scopes: string[]; - repo: GitHubRepoRef | null; - repoAccessOk: boolean | null; - }): boolean => { - if (!args.tokenStored || !args.userLogin) return false; - if (args.authSource === "app") { - return args.repo ? args.repoAccessOk === true : true; - } - if (args.tokenType === "fine-grained") { - // No repo to probe (e.g. project without a GitHub remote): a fine-grained - // token that authenticates as a user is the best signal we have. - if (!args.repo) return true; - return args.repoAccessOk === true; - } - if (args.tokenType === "classic" || args.tokenType === "oauth" || args.scopes.length > 0) { - const access = getGitHubTokenAccessState(args.scopes); - return access.hasRequiredAccess; - } - // Unknown prefix — fall back to "user lookup worked" (best-effort). - return Boolean(args.userLogin); - }; + const validatedCredentialCapabilities = ( + candidate: GitHubTokenCandidate, + value: SharedGithubStatusProbe, + repo: GitHubRepoRef | null, + ) => evaluateGithubCredentialCapabilities({ + source: candidate.source, + tokenType: value.validated.tokenType, + scopes: value.validated.scopes, + userLogin: value.validated.userLogin, + repositoryPresent: repo != null, + repositoryReadValidated: value.repoAccessOk, + }); const computeStatus = async (opts: { forceRefresh?: boolean } = {}): Promise => { if (opts.forceRefresh) { cachedStatus = null; cachedAt = 0; - cachedStatusTokenDigest = null; + cachedStatusCredentialInventoryKey = null; sharedGhAuth.authCache = null; sharedGhAuth.statusCache.clear(); processGhHostsTokenCache.clear(); @@ -1412,13 +1461,12 @@ export function createGithubService({ const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); const primaryCandidate = readCandidates[0] ?? null; const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); + const credentialInventoryKey = githubCredentialInventoryKey(inventory.candidates); const statusCooldown = (candidate: GitHubTokenCandidate) => githubCredentialCooldown( candidate, Date.now(), { resource: "core", ignoreNonRateLimit: opts.forceRefresh === true }, ); - const currentWriteCandidate = writeCandidates.find((candidate) => !statusCooldown(candidate)) - ?? null; if (!primaryCandidate) { cachedStatus = { tokenStored: false, @@ -1450,25 +1498,40 @@ export function createGithubService({ connected: false, }; cachedAt = Date.now(); - cachedStatusTokenDigest = null; + cachedStatusCredentialInventoryKey = credentialInventoryKey; return cachedStatus; } const now = Date.now(); - const primaryTokenDigest = githubCredentialTokenDigest(primaryCandidate.token); if (cachedStatus && now - cachedAt < 30_000 && cachedStatus.tokenStored) { const repoChanged = (cachedStatus.repo?.owner ?? null) !== (repo?.owner ?? null) || (cachedStatus.repo?.name ?? null) !== (repo?.name ?? null); - if (repoChanged || cachedStatusTokenDigest !== primaryTokenDigest) { + const cachedReadCandidate = cachedStatus.authSource === "none" + ? null + : readCandidates.find((candidate) => candidate.source === cachedStatus?.authSource) ?? null; + const cachedWriteCandidate = cachedStatus.writeAuthSource === "none" + ? null + : writeCandidates.find((candidate) => candidate.source === cachedStatus?.writeAuthSource) ?? null; + const cachedReadUnavailable = cachedStatus.authSource !== "none" + && (!cachedReadCandidate || statusCooldown(cachedReadCandidate) != null); + const cachedWriteUnavailable = cachedStatus.writeAuthSource !== "none" + && (!cachedWriteCandidate || statusCooldown(cachedWriteCandidate) != null); + if ( + repoChanged + || cachedStatusCredentialInventoryKey !== credentialInventoryKey + || cachedReadUnavailable + || cachedWriteUnavailable + ) { cachedStatus = null; cachedAt = 0; - cachedStatusTokenDigest = null; + cachedStatusCredentialInventoryKey = null; } else { const activeReadSource = cachedStatus.authSource === "none" ? null : cachedStatus.authSource; - const activeWriteSource = currentWriteCandidate?.source === "app" - ? null - : currentWriteCandidate?.source ?? null; + const activeWriteSource = cachedStatus.writeAuthSource + && cachedStatus.writeAuthSource !== "none" + ? cachedStatus.writeAuthSource + : null; const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); return { ...cachedStatus, @@ -1491,72 +1554,43 @@ export function createGithubService({ } } - const failures: Array<{ - candidate: GitHubTokenCandidate; - error: string; - authFailure: GitHubAuthFailure; - rateLimit: GitHubRateLimitState | null; - }> = []; - let active: { - candidate: GitHubTokenCandidate; - value: SharedGithubStatusProbe; - } | null = null; - - for (const [candidateIndex, candidate] of readCandidates.entries()) { - const cooldown = statusCooldown(candidate); - if (cooldown) { - failures.push({ - candidate, - error: cooldown.failure.message, - authFailure: cooldown.failure, - rateLimit: cooldown.rateLimit, - }); - continue; - } - const statusProbe = candidate.source === "gh" - ? await readSharedGithubStatusProbe(candidate.token, repo, opts.forceRefresh === true) - : await computeGithubStatusProbe(candidate.token, repo, candidate.source); - if (!statusProbe.ok) { - const hasFallbackCandidate = readCandidates - .slice(candidateIndex + 1) - .some((nextCandidate) => !statusCooldown(nextCandidate)); - if ( - statusProbe.authFailure.kind === "permission_denied" - && statusProbe.value - && !hasFallbackCandidate - ) { - active = { candidate, value: statusProbe.value }; - registerGithubCredentialIdentity(candidate, statusProbe.value.validated.userLogin); - break; + const probeCandidate = async (candidate: GitHubTokenCandidate): Promise => ( + candidate.source === "gh" + ? await readSharedGithubStatusProbe(candidate, repo, opts.forceRefresh === true) + : await computeGithubStatusProbe(candidate, repo, opts.forceRefresh === true) + ); + const { active, activeWriteSource, failures } = await resolveGithubStatusCredentials({ + readCandidates, + writeCandidates, + cooldown: statusCooldown, + probe: probeCandidate, + capabilities: (candidate, value) => validatedCredentialCapabilities(candidate, value, repo), + isRepositoryAccessFailure: (result) => result.authFailure.kind === "permission_denied" + && result.value?.repoAccessOk === false, + onAcceptedProbe: (candidate, value, validated) => { + registerGithubCredentialIdentity(candidate, value.validated.userLogin); + if (validated) { + recordGithubCredentialProbeSuccess( + candidate, + value.validated.rateLimit, + value.validated.userLogin, + ); } - recordGithubCredentialFailure(candidate, statusProbe.authFailure, statusProbe.rateLimit); - failures.push({ candidate, ...statusProbe }); - logger.warn("github.token_validation_failed", { - source: candidate.source, - error: statusProbe.error, - kind: statusProbe.authFailure.kind, - retryAt: statusProbe.authFailure.retryAt, - }); - if ( - statusProbe.authFailure.kind === "network" - || statusProbe.authFailure.kind === "unknown" - ) { - break; + }, + onRejectedProbe: (candidate, result, context) => { + if (!context.repositoryAccessFailure) { + recordGithubCredentialFailure(candidate, result.authFailure, result.rateLimit); } - continue; - } - active = { candidate, value: statusProbe.value }; - registerGithubCredentialIdentity(candidate, statusProbe.value.validated.userLogin); - recordGithubCredentialProbeSuccess( - candidate, - statusProbe.value.validated.rateLimit, - statusProbe.value.validated.userLogin, - ); - break; - } - - const activeWriteCandidate = writeCandidates.find((candidate) => !statusCooldown(candidate)) - ?? null; + if (context.phase === "read") { + logger.warn("github.token_validation_failed", { + source: candidate.source, + error: result.error, + kind: result.authFailure.kind, + retryAt: result.authFailure.retryAt, + }); + } + }, + }); const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); if (active) { const { candidate, value } = active; @@ -1570,24 +1604,14 @@ export function createGithubService({ error: repoAccessError, }); } - const connected = computeConnected({ - tokenStored: true, - userLogin: validated.userLogin, - authSource: candidate.source, - tokenType: validated.tokenType, - scopes: validated.scopes, - repo, - repoAccessOk, - }); - cachedStatus = { + const connected = validatedCredentialCapabilities(candidate, value, repo).read; + const status: GitHubStatus = { tokenStored: true, patTokenStored: inventory.patTokenStored, tokenDecryptionFailed: false, storageScope: "app", authSource: candidate.source, - writeAuthSource: activeWriteCandidate?.source === "app" - ? "none" - : activeWriteCandidate?.source ?? "none", + writeAuthSource: activeWriteSource ?? "none", tokenType: validated.tokenType, repo, hasOrigin, @@ -1602,9 +1626,7 @@ export function createGithubService({ candidates: inventory.candidates, availableSources: inventory.availableSources, activeReadSource: candidate.source, - activeWriteSource: activeWriteCandidate?.source === "app" - ? null - : activeWriteCandidate?.source ?? null, + activeWriteSource, }), credentialFallback: failures[0] && failures[0].candidate.source !== candidate.source ? { @@ -1622,11 +1644,10 @@ export function createGithubService({ repoAccessError, connected, }; + cachedStatus = status; cachedAt = now; - // Track the precedence head, not the fallback, so a source appearing or - // disappearing invalidates this cache immediately. - cachedStatusTokenDigest = primaryTokenDigest; - return cachedStatus; + cachedStatusCredentialInventoryKey = credentialInventoryKey; + return status; } const failure = failures.find((entry) => entry.authFailure.kind === "rate_limited") @@ -1643,9 +1664,7 @@ export function createGithubService({ tokenDecryptionFailed: false, storageScope: "app", authSource: primaryCandidate.source, - writeAuthSource: activeWriteCandidate?.source === "app" - ? "none" - : activeWriteCandidate?.source ?? "none", + writeAuthSource: "none", tokenType: detectGitHubTokenType(primaryCandidate.token), repo, hasOrigin, @@ -1660,9 +1679,7 @@ export function createGithubService({ candidates: inventory.candidates, availableSources: inventory.availableSources, activeReadSource: null, - activeWriteSource: activeWriteCandidate?.source === "app" - ? null - : activeWriteCandidate?.source ?? null, + activeWriteSource: null, }), credentialFallback: null, backgroundRefreshPausedUntil: pauseUntilMs == null @@ -1673,7 +1690,7 @@ export function createGithubService({ connected: false, }; cachedAt = now; - cachedStatusTokenDigest = primaryTokenDigest; + cachedStatusCredentialInventoryKey = credentialInventoryKey; return cachedStatus; }; @@ -2106,7 +2123,7 @@ export function createGithubService({ cachedStatus = null; cachedAt = 0; - cachedStatusTokenDigest = null; + cachedStatusCredentialInventoryKey = null; return { state: resultState, @@ -2144,7 +2161,7 @@ export function createGithubService({ clearGithubCredentialHealth(); cachedStatus = null; cachedAt = 0; - cachedStatusTokenDigest = null; + cachedStatusCredentialInventoryKey = null; } return result; }, @@ -2154,7 +2171,7 @@ export function createGithubService({ clearGithubCredentialHealth(); cachedStatus = null; cachedAt = 0; - cachedStatusTokenDigest = null; + cachedStatusCredentialInventoryKey = null; return status; }, @@ -2163,7 +2180,7 @@ export function createGithubService({ tokenDecryptionFailed = false; cachedStatus = null; cachedAt = 0; - cachedStatusTokenDigest = null; + cachedStatusCredentialInventoryKey = null; sharedGhAuth.authCache = null; sharedGhAuth.statusCache.clear(); clearGithubCredentialHealth(); @@ -2174,7 +2191,7 @@ export function createGithubService({ tokenDecryptionFailed = false; cachedStatus = null; cachedAt = 0; - cachedStatusTokenDigest = null; + cachedStatusCredentialInventoryKey = null; sharedGhAuth.authCache = null; sharedGhAuth.statusCache.clear(); clearGithubCredentialHealth(); diff --git a/apps/desktop/src/shared/githubApiPath.test.ts b/apps/desktop/src/shared/githubApiPath.test.ts new file mode 100644 index 000000000..91229c28a --- /dev/null +++ b/apps/desktop/src/shared/githubApiPath.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { + classifyGitHubRepositoryApiPath, + createGithubRepositoryRequestFallback, +} from "./githubApiPath"; + +describe("githubApiPath", () => { + it("classifies repository roots and nested paths", () => { + expect(classifyGitHubRepositoryApiPath("/repos/acme/ade")).toEqual({ + owner: "acme", + name: "ade", + isRepositoryRoot: true, + }); + expect(classifyGitHubRepositoryApiPath("/repos/acme%20org/ade/issues?state=open")) + .toEqual({ owner: "acme org", name: "ade", isRepositoryRoot: false }); + expect(classifyGitHubRepositoryApiPath("/user/emails")).toBeNull(); + }); + + it("persists root misses without poisoning a repo after a nested 404", () => { + const candidate = { token: "app" }; + const access = new Map(); + const readAccess = vi.fn(() => access.get(candidate.token) ?? null); + const recordAccess = vi.fn((_candidate, _repo, accessible: boolean) => { + access.set(candidate.token, accessible); + }); + const nested = createGithubRepositoryRequestFallback({ + path: classifyGitHubRepositoryApiPath("/repos/acme/ade/issues/404"), + readAccess, + recordAccess, + }); + expect(nested.classifyFailure(candidate, 404)).toEqual({ + repositoryNotFound: true, + ambiguousRepositoryNotFound: true, + }); + expect(recordAccess).not.toHaveBeenCalled(); + expect(nested.shouldSkip(candidate)).toBe(false); + + const root = createGithubRepositoryRequestFallback({ + path: classifyGitHubRepositoryApiPath("/repos/acme/ade"), + readAccess, + recordAccess, + }); + root.classifyFailure(candidate, 404); + expect(recordAccess).toHaveBeenLastCalledWith( + candidate, + expect.objectContaining({ owner: "acme", name: "ade" }), + false, + ); + expect(root.shouldSkip(candidate)).toBe(true); + }); +}); diff --git a/apps/desktop/src/shared/githubApiPath.ts b/apps/desktop/src/shared/githubApiPath.ts new file mode 100644 index 000000000..5f7a6a376 --- /dev/null +++ b/apps/desktop/src/shared/githubApiPath.ts @@ -0,0 +1,58 @@ +import type { GitHubRepoRef } from "./types/git"; + +export type GitHubRepositoryApiPath = GitHubRepoRef & { + isRepositoryRoot: boolean; +}; + +export function classifyGitHubRepositoryApiPath(path: string): GitHubRepositoryApiPath | null { + const pathname = path.split(/[?#]/, 1)[0] ?? ""; + const match = pathname.match(/^\/repos\/([^/]+)\/([^/]+)(\/.*)?$/); + if (!match) return null; + try { + const nestedPath = match[3] ?? ""; + return { + owner: decodeURIComponent(match[1] ?? ""), + name: decodeURIComponent(match[2] ?? ""), + isRepositoryRoot: nestedPath === "" || nestedPath === "/", + }; + } catch { + return null; + } +} + +export function createGithubRepositoryRequestFallback(args: { + path: GitHubRepositoryApiPath | null; + readAccess: (candidate: Candidate, repo: GitHubRepoRef) => boolean | null; + recordAccess: ( + candidate: Candidate, + repo: GitHubRepoRef, + accessible: boolean, + ) => void; +}) { + return { + shouldSkip(candidate: Candidate): boolean { + return args.path != null && args.readAccess(candidate, args.path) === false; + }, + classifyFailure(candidate: Candidate, status: number): { + repositoryNotFound: boolean; + ambiguousRepositoryNotFound: boolean; + } { + if (status !== 404 || !args.path) { + return { repositoryNotFound: false, ambiguousRepositoryNotFound: false }; + } + const knownAccess = args.readAccess(candidate, args.path); + if (args.path.isRepositoryRoot) { + args.recordAccess(candidate, args.path, false); + } + return { + repositoryNotFound: true, + ambiguousRepositoryNotFound: !( + knownAccess === true && args.path.isRepositoryRoot === false + ), + }; + }, + recordSuccess(candidate: Candidate): void { + if (args.path) args.recordAccess(candidate, args.path, true); + }, + }; +} diff --git a/apps/desktop/src/shared/githubOperationCredential.test.ts b/apps/desktop/src/shared/githubOperationCredential.test.ts new file mode 100644 index 000000000..88307ad58 --- /dev/null +++ b/apps/desktop/src/shared/githubOperationCredential.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import { + evaluateGithubCredentialCapabilities, + resolveGithubStatusCredentials, +} from "./githubOperationCredential"; + +describe("githubOperationCredential", () => { + it("keeps a repo-validated fine-grained token available for writes", () => { + expect(evaluateGithubCredentialCapabilities({ + source: "pat", + tokenType: "fine-grained", + scopes: [], + userLogin: "alice", + repositoryPresent: true, + repositoryReadValidated: true, + })).toEqual({ read: true, write: true }); + }); + + it("resolves read fallback and validates the selected writer once", async () => { + const app = { source: "app" as const, token: "app" }; + const gh = { source: "gh" as const, token: "gh" }; + const accepted = vi.fn(); + const rejected = vi.fn(); + const result = await resolveGithubStatusCredentials({ + readCandidates: [app, gh], + writeCandidates: [gh], + cooldown: () => null, + probe: async (candidate) => candidate.source === "app" + ? { + ok: false as const, + error: "Not Found", + authFailure: { + kind: "permission_denied" as const, + message: "Not Found", + retryAt: null, + }, + rateLimit: null, + value: { repoAccessOk: false, write: false }, + } + : { ok: true as const, value: { repoAccessOk: true, write: true } }, + capabilities: (_candidate, probe) => ({ read: probe.repoAccessOk, write: probe.write }), + isRepositoryAccessFailure: (probe) => probe.value?.repoAccessOk === false, + onAcceptedProbe: accepted, + onRejectedProbe: rejected, + }); + + expect(result.active?.candidate.source).toBe("gh"); + expect(result.activeWriteSource).toBe("gh"); + expect(result.failures.map((failure) => failure.candidate.source)).toEqual(["app"]); + expect(rejected).toHaveBeenCalledWith( + app, + expect.objectContaining({ ok: false }), + { repositoryAccessFailure: true, phase: "read" }, + ); + expect(accepted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/shared/githubOperationCredential.ts b/apps/desktop/src/shared/githubOperationCredential.ts index f3f8f7c0c..e57fa2c52 100644 --- a/apps/desktop/src/shared/githubOperationCredential.ts +++ b/apps/desktop/src/shared/githubOperationCredential.ts @@ -1,7 +1,11 @@ import type { + GitHubAuthFailure, GitHubCredentialCapability, GitHubCredentialSource, + GitHubRateLimitState, + GitHubTokenType, } from "./types/git"; +import { getGitHubTokenAccessState } from "./githubScopes"; export type GithubOperationCredentialSource = GitHubCredentialSource; export type GithubOperationCredentialCapability = GitHubCredentialCapability; @@ -65,6 +69,160 @@ export function githubOperationCredentialCandidates< }); } +export function evaluateGithubCredentialCapabilities(args: { + source: GithubOperationCredentialSource; + tokenType: GitHubTokenType; + scopes: readonly string[]; + userLogin: string | null; + repositoryPresent: boolean; + repositoryReadValidated: boolean | null; +}): Readonly<{ read: boolean; write: boolean }> { + if (!args.userLogin) return { read: false, write: false }; + + const repositoryReadAvailable = !args.repositoryPresent + || args.repositoryReadValidated === true; + if (args.source === "app") { + return { read: repositoryReadAvailable, write: false }; + } + if (args.tokenType === "fine-grained") { + // GitHub does not expose fine-grained token permissions during validation. + // A successful repository probe establishes that the user-selected token + // can target this repo; actual write requests still fail over on 403. + return { read: repositoryReadAvailable, write: repositoryReadAvailable }; + } + if ( + args.tokenType === "classic" + || args.tokenType === "oauth" + || args.scopes.length > 0 + ) { + const access = getGitHubTokenAccessState(args.scopes); + return { + read: access.requirements.repo.present, + write: access.hasRequiredAccess, + }; + } + return { read: true, write: true }; +} + +export type GithubStatusCredentialProbeResult = + | { ok: true; value: Probe } + | { + ok: false; + error: string; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + value?: Probe; + }; + +export async function resolveGithubStatusCredentials< + Candidate extends { + source: GithubOperationCredentialSource; + token: string; + }, + Probe, +>(args: { + readCandidates: readonly Candidate[]; + writeCandidates: readonly Candidate[]; + cooldown: (candidate: Candidate) => { + failure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + } | null; + probe: (candidate: Candidate) => Promise>; + capabilities: ( + candidate: Candidate, + probe: Probe, + ) => Readonly<{ read: boolean; write: boolean }>; + isRepositoryAccessFailure: ( + result: Extract, { ok: false }>, + ) => boolean; + onAcceptedProbe: ( + candidate: Candidate, + probe: Probe, + validated: boolean, + ) => void; + onRejectedProbe: ( + candidate: Candidate, + result: Extract, { ok: false }>, + context: { repositoryAccessFailure: boolean; phase: "read" | "write" }, + ) => void; +}): Promise<{ + active: { candidate: Candidate; value: Probe } | null; + activeWriteSource: Exclude | null; + failures: Array<{ + candidate: Candidate; + error: string; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + }>; +}> { + const failures: Array<{ + candidate: Candidate; + error: string; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + }> = []; + const successfulProbes = new Map(); + let active: { candidate: Candidate; value: Probe } | null = null; + + for (const [candidateIndex, candidate] of args.readCandidates.entries()) { + const cooldown = args.cooldown(candidate); + if (cooldown) { + failures.push({ + candidate, + error: cooldown.failure.message, + authFailure: cooldown.failure, + rateLimit: cooldown.rateLimit, + }); + continue; + } + const result = await args.probe(candidate); + if (!result.ok) { + const repositoryAccessFailure = args.isRepositoryAccessFailure(result); + const hasFallback = args.readCandidates + .slice(candidateIndex + 1) + .some((fallback) => !args.cooldown(fallback)); + if (repositoryAccessFailure && result.value && !hasFallback) { + active = { candidate, value: result.value }; + successfulProbes.set(candidate.token, result.value); + args.onAcceptedProbe(candidate, result.value, false); + break; + } + failures.push({ candidate, ...result }); + args.onRejectedProbe(candidate, result, { repositoryAccessFailure, phase: "read" }); + if (result.authFailure.kind === "network" || result.authFailure.kind === "unknown") break; + continue; + } + active = { candidate, value: result.value }; + successfulProbes.set(candidate.token, result.value); + args.onAcceptedProbe(candidate, result.value, true); + break; + } + + let activeWriteSource: Exclude | null = null; + if (active) { + for (const candidate of args.writeCandidates) { + if (candidate.source === "app" || args.cooldown(candidate)) continue; + const existingProbe = successfulProbes.get(candidate.token); + const result = existingProbe + ? { ok: true as const, value: existingProbe } + : await args.probe(candidate); + if (!result.ok) { + const repositoryAccessFailure = args.isRepositoryAccessFailure(result); + args.onRejectedProbe(candidate, result, { repositoryAccessFailure, phase: "write" }); + continue; + } + successfulProbes.set(candidate.token, result.value); + if (!existingProbe) args.onAcceptedProbe(candidate, result.value, true); + if (args.capabilities(candidate, result.value).write) { + activeWriteSource = candidate.source; + break; + } + } + } + + return { active, activeWriteSource, failures }; +} + type CredentialResolvers = Record< GithubOperationCredentialSource, () => T | null diff --git a/apps/desktop/src/shared/types/git.ts b/apps/desktop/src/shared/types/git.ts index 86c8f6476..de186a1e5 100644 --- a/apps/desktop/src/shared/types/git.ts +++ b/apps/desktop/src/shared/types/git.ts @@ -335,13 +335,15 @@ export type GitHubCredentialFallback = { retryAt: string | null; }; +export type GitHubTokenType = "classic" | "fine-grained" | "oauth" | "unknown"; + export type GitHubStatus = { tokenStored: boolean; patTokenStored: boolean; tokenDecryptionFailed: boolean; storageScope: "app"; authSource: "app" | "pat" | "environment" | "gh" | "none"; - tokenType?: "classic" | "fine-grained" | "oauth" | "unknown"; + tokenType?: GitHubTokenType; repo: GitHubRepoRef | null; // True when the project has any `origin` remote, even non-GitHub. Distinct // from `repo != null`, which is only true for GitHub origins. The Publish From 9c92db4f86540f30aa01a05ed171746d438291e8 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:33:03 -0400 Subject: [PATCH 04/12] fix(github): harden credential fallback review paths --- apps/ade-cli/src/bootstrap.ts | 2 +- .../src/headlessLinearServices.test.ts | 130 ++++++++++++-- apps/ade-cli/src/headlessLinearServices.ts | 110 ++++++++---- apps/ade-cli/src/multiProjectRpcServer.ts | 2 +- apps/desktop/src/main/main.ts | 2 +- .../automationIngressService.test.ts | 119 +++++++++++++ .../automations/automationIngressService.ts | 84 +++++++-- .../github/githubCredentialHealth.test.ts | 4 +- .../services/github/githubCredentialHealth.ts | 9 +- .../main/services/github/githubRateLimit.ts | 30 +++- .../services/github/githubService.test.ts | 161 +++++++++++++++++- .../src/main/services/github/githubService.ts | 104 ++++++----- .../src/main/services/ipc/registerIpc.ts | 2 +- .../projects/projectScaffoldService.test.ts | 9 +- .../projects/projectScaffoldService.ts | 4 +- .../src/main/services/prs/prAsync.test.ts | 84 +++++++++ .../src/main/services/prs/prPollingService.ts | 10 +- .../src/main/services/prs/prService.test.ts | 2 + .../src/main/services/prs/prService.ts | 5 +- .../lib/githubIntegrationStatus.test.ts | 11 ++ .../renderer/lib/githubIntegrationStatus.ts | 2 +- .../githubConditionalRequestCache.test.ts | 24 +++ .../shared/githubConditionalRequestCache.ts | 57 +++++++ .../src/shared/githubOperationCredential.ts | 15 ++ apps/webhook-relay/src/relay.ts | 10 +- apps/webhook-relay/test/account.test.ts | 1 + 26 files changed, 863 insertions(+), 130 deletions(-) create mode 100644 apps/desktop/src/shared/githubConditionalRequestCache.test.ts create mode 100644 apps/desktop/src/shared/githubConditionalRequestCache.ts diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index f449c473c..7365ca574 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1221,7 +1221,7 @@ export async function createAdeRuntime(args: { logger, appVersion: "ade-cli", getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv, - getLocalGitHubToken: () => headlessLinearServices.githubService.getTokenOrThrowAsync(), + getLocalGitHubToken: () => headlessLinearServices.githubService.getGitTransportTokenOrThrowAsync(), onLinearIssueChatLinked: publishLinearChatLink, onEvent: (event) => { pushEvent("runtime", event as unknown as Record); diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 5edfa20d7..66a9d40de 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -37,6 +37,50 @@ import { EncryptedFileCredentialStore } from "./services/credentials/credentialS import { createHeadlessGitHubService, createHeadlessLinearServices } from "./headlessLinearServices"; import { clearGithubCredentialHealth } from "../../desktop/src/main/services/github/githubCredentialHealth"; +const HEADLESS_GITHUB_ENV_KEYS = [ + "ADE_HOME", + "ADE_GITHUB_TOKEN", + "GITHUB_TOKEN", + "GH_TOKEN", + "GH_CONFIG_DIR", +] as const; + +function isolateHeadlessGithubAuth(prefix: string, options: { emptyGhConfig?: boolean } = {}) { + const previousEnvironment = new Map( + HEADLESS_GITHUB_ENV_KEYS.map((key) => [key, process.env[key]]), + ); + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + delete process.env.ADE_GITHUB_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GH_TOKEN; + if (options.emptyGhConfig) { + process.env.GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}gh-`)); + } + return { + restore(): void { + globalThis.fetch = previousFetch; + for (const [key, value] of previousEnvironment) { + if (value == null) delete process.env[key]; + else process.env[key] = value; + } + }, + }; +} + +function storeHeadlessAppUserToken(token = "ghu_app_user_token"): void { + new EncryptedFileCredentialStore().setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: token, + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "octocat", + updatedAt: new Date().toISOString(), + })); +} + function createDeps(overrides: Record = {}) { const projectRoot = overrides.projectRoot ?? "/tmp/ade-project"; const adeDir = overrides.adeDir ?? path.join(projectRoot, ".ade"); @@ -878,21 +922,73 @@ describe("headlessLinearServices", () => { } }); + it("falls back for headless repository NOT_FOUND and skips the known-negative credential", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-graphql-not-found-"); + storeHeadlessAppUserToken(); + const fallbackData = { data: { repository: { name: "ade" } } }; + const authorizations: string[] = []; + globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get("authorization") ?? ""; + authorizations.push(authorization); + if (authorizations.length === 1) { + return new Response(JSON.stringify({ + data: { repository: null }, + errors: [{ type: "NOT_FOUND", message: "Could not resolve to a Repository with the name 'ade'." }], + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (authorizations.length === 2) { + return new Response(JSON.stringify({ data: { repository: { name: "ade" } } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify(fallbackData), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + await expect(githubService.apiRequest({ + method: "POST", + path: "/graphql", + capability: "read", + repo: { owner: "acme", name: "ade" }, + body: { query: "query { repository(owner: \"acme\", name: \"ade\") { name } }" }, + })).resolves.toMatchObject({ data: { data: { repository: { name: "ade" } } } }); + await expect(githubService.apiRequest({ + method: "POST", + path: "/graphql", + capability: "read", + repo: { owner: "acme", name: "ade" }, + body: { query: "query { repository(owner: \"acme\", name: \"ade\") { name } }" }, + })).resolves.toMatchObject({ data: fallbackData }); + expect(authorizations).toEqual([ + "Bearer ghu_app_user_token", + "Bearer gho_cli_token", + "Bearer gho_cli_token", + ]); + } finally { + environment.restore(); + } + }); + it("limits headless 404 fallback to repository-scoped requests", async () => { - const previousAdeHome = process.env.ADE_HOME; - const previousFetch = globalThis.fetch; - process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-404-")); - const machineCredentialStore = new EncryptedFileCredentialStore(); - machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ - accessToken: "ghu_app_user_token", - tokenType: "bearer", - scope: null, - expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), - refreshToken: null, - refreshTokenExpiresAt: null, - userLogin: "octocat", - updatedAt: new Date().toISOString(), - })); + const environment = isolateHeadlessGithubAuth("ade-headless-github-404-", { + emptyGhConfig: true, + }); + storeHeadlessAppUserToken(); const authorizations: string[] = []; globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { authorizations.push(new Headers(init?.headers).get("authorization") ?? ""); @@ -929,9 +1025,7 @@ describe("headlessLinearServices", () => { "Bearer ghu_app_user_token", ]); } finally { - globalThis.fetch = previousFetch; - if (previousAdeHome == null) delete process.env.ADE_HOME; - else process.env.ADE_HOME = previousAdeHome; + environment.restore(); } }); @@ -992,6 +1086,8 @@ describe("headlessLinearServices", () => { "Bearer ghu_app_user_token", "Bearer gho_invalid_cli_token", ]); + await expect(githubService.getTokenOrThrowAsync()).rejects.toThrow("GitHub write access is unavailable"); + await expect(githubService.getReadTokenOrThrowAsync()).resolves.toBe("ghu_app_user_token"); } finally { globalThis.fetch = previousFetch; if (previousAdeHome == null) delete process.env.ADE_HOME; diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 753f321f0..89735fe46 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -29,6 +29,7 @@ import type { GitHubAppDeviceAuthPollResult, GitHubAppDeviceAuthStartResult, GitHubAppUserAuthStatus, + GitHubRepoRef, GitHubRateLimitState, GitHubStatus, } from "../../desktop/src/shared/types"; @@ -64,6 +65,7 @@ import { EncryptedFileCredentialStore } from "./services/credentials/credentialS import { evaluateGithubCredentialCapabilities, githubOperationCredentialCandidates, + resolveGithubOperationCredentialCandidate, resolveGithubStatusCredentials, selectGithubOperationCredential, type GithubOperationCredentialCapability, @@ -72,6 +74,7 @@ import { classifyGitHubRepositoryApiPath, createGithubRepositoryRequestFallback, } from "../../desktop/src/shared/githubApiPath"; +import { createGithubConditionalRequestCache } from "../../desktop/src/shared/githubConditionalRequestCache"; import { clearGithubCredentialHealth, githubBackgroundRequestPauseUntilMs, @@ -769,15 +772,18 @@ export function createHeadlessGitHubService( const readTokenAsync = async ( capability: GithubOperationCredentialCapability = "write", + failurePolicy: "all" | "non-rate-limit-only" = "all", ): Promise => { const inventory = await readCredentialInventoryAsync(); - const candidates = githubOperationCredentialCandidates(inventory.candidates, capability); - return candidates.find((candidate) => !githubCredentialCooldown( - candidate, - Date.now(), - { resource: "core" }, - )) - ?? candidates[0] + return resolveGithubOperationCredentialCandidate({ + candidates: inventory.candidates, + capability, + isAvailable: (candidate) => !githubCredentialCooldown( + candidate, + Date.now(), + { resource: "core", failurePolicy }, + ), + }) ?? { token: null, source: "none", @@ -998,12 +1004,7 @@ export function createHeadlessGitHubService( } }; - const etagCache = new Map(); - const ETAG_CACHE_MAX_SIZE = 200; + const conditionalRequestCache = createGithubConditionalRequestCache(); const apiRequest = async (args: { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; @@ -1013,6 +1014,7 @@ export function createHeadlessGitHubService( token?: string; accept?: string; capability?: GithubOperationCredentialCapability; + repo?: GitHubRepoRef; }): Promise<{ data: T; response: Response | null; linkHeader?: string | null }> => { const capability = args.capability ?? (args.method === "GET" ? "read" : "write"); const explicitToken = args.token?.trim() ?? ""; @@ -1041,7 +1043,8 @@ export function createHeadlessGitHubService( } const accept = args.accept?.trim() || "application/vnd.github+json"; const rateLimitResource = githubRateLimitResourceForPath(args.path); - const repositoryPath = classifyGitHubRepositoryApiPath(args.path); + const repositoryPath = classifyGitHubRepositoryApiPath(args.path) + ?? (args.repo ? { ...args.repo, isRepositoryRoot: true } : null); const repositoryFallback = createGithubRepositoryRequestFallback({ path: repositoryPath, readAccess: githubCredentialRepositoryAccess, @@ -1084,22 +1087,37 @@ export function createHeadlessGitHubService( "user-agent": "ade-cli", ...(args.body == null ? {} : { "content-type": "application/json" }), }; + let releaseConditionalRequest: (() => void) | null = null; if (args.method === "GET") { - const cached = etagCache.get(cacheKey); - if (cached) headers["if-none-match"] = cached.etag; + const conditional = conditionalRequestCache.begin(cacheKey); + if (conditional) { + headers["if-none-match"] = conditional.entry.etag; + releaseConditionalRequest = conditional.release; + } + } + let response: Response; + try { + response = await fetchGitHub(url, { + method: args.method, + headers, + body: args.body == null ? undefined : JSON.stringify(args.body), + }); + } finally { + releaseConditionalRequest?.(); } - const response = await fetchGitHub(url, { - method: args.method, - headers, - body: args.body == null ? undefined : JSON.stringify(args.body), - }); if (response.status === 304) { - const cached = etagCache.get(cacheKey); + const cached = conditionalRequestCache.get(cacheKey); if (cached) { recordGithubCredentialSuccess(candidate, response.headers); repositoryFallback.recordSuccess(candidate); return { data: cached.data as T, response, linkHeader: cached.linkHeader }; } + delete headers["if-none-match"]; + response = await fetchGitHub(url, { + method: args.method, + headers, + body: args.body == null ? undefined : JSON.stringify(args.body), + }); } const text = await response.text(); let data: unknown = text; @@ -1157,18 +1175,26 @@ export function createHeadlessGitHubService( ? classifyGitHubGraphqlCredentialFailure(data, response.headers) : null; if (graphqlFailure) { - recordGithubCredentialFailure( + const { repositoryNotFound } = repositoryFallback.classifyFailure( candidate, - graphqlFailure.authFailure, - graphqlFailure.rateLimit, + graphqlFailure.status, ); + if (!repositoryNotFound) { + recordGithubCredentialFailure( + candidate, + graphqlFailure.authFailure, + graphqlFailure.rateLimit, + ); + } const attemptError = new HeadlessGithubCredentialAttemptError( graphqlFailure.message, graphqlFailure.authFailure, graphqlFailure.rateLimit, ); lastAttemptError = attemptError; - if (!args.token) continue; + const canTryNext = !args.token + && (capability === "read" || !graphqlFailure.hasData); + if (canTryNext) continue; if (attemptError.authFailure.kind === "rate_limited") { const resetAtMs = githubRateLimitRetryAtMs(attemptError.authFailure, attemptError.rateLimit); throw new GitHubRateLimitError(attemptError.message, resetAtMs, attemptError.rateLimit); @@ -1189,12 +1215,7 @@ export function createHeadlessGitHubService( if (args.method === "GET") { const etag = response.headers.get("etag"); if (etag) { - while (etagCache.size >= ETAG_CACHE_MAX_SIZE && !etagCache.has(cacheKey)) { - const oldest = etagCache.keys().next().value as string | undefined; - if (!oldest) break; - etagCache.delete(oldest); - } - etagCache.set(cacheKey, { etag, data, linkHeader }); + conditionalRequestCache.store(cacheKey, { etag, data, linkHeader }); } } return { data: data as T, response, linkHeader }; @@ -1509,7 +1530,10 @@ export function createHeadlessGitHubService( const statusCooldown = (candidate: HeadlessGitHubTokenCandidate) => githubCredentialCooldown( candidate, Date.now(), - { resource: "core", ignoreNonRateLimit: opts.forceRefresh === true }, + { + resource: "core", + failurePolicy: opts.forceRefresh === true ? "rate-limit-only" : "all", + }, ); const primaryCandidate = readCandidates[0] ?? null; if (!primaryCandidate) { @@ -1751,7 +1775,25 @@ export function createHeadlessGitHubService( return token; }, async getTokenOrThrowAsync() { - const token = (await readTokenAsync()).token ?? ""; + const token = (await readTokenAsync("write")).token ?? ""; + if (!token) { + throw new Error( + "GitHub write access is unavailable. Set ADE_GITHUB_TOKEN/GITHUB_TOKEN, connect GitHub CLI, or add a PAT in Settings.", + ); + } + return token; + }, + async getReadTokenOrThrowAsync() { + const token = (await readTokenAsync("read")).token ?? ""; + if (!token) { + throw new Error( + "GitHub auth missing. Set ADE_GITHUB_TOKEN/GITHUB_TOKEN, run `gh auth login -h github.com -s repo -s workflow`, or add a PAT in Settings.", + ); + } + return token; + }, + async getGitTransportTokenOrThrowAsync() { + const token = (await readTokenAsync("read", "non-rate-limit-only")).token ?? ""; if (!token) { throw new Error( "GitHub auth missing. Set ADE_GITHUB_TOKEN/GITHUB_TOKEN, run `gh auth login -h github.com -s repo -s workflow`, or add a PAT in Settings.", diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 5aa16cc1e..51c79b082 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -594,7 +594,7 @@ async function inspectHandoffStorage(params: Record) { let destinationAuthHeader = ""; if (githubService.parseGitHubRepoFromRemoteUrl(originUrl) && /^https:\/\//i.test(originUrl)) { try { - const token = await githubService.getTokenOrThrowAsync(); + const token = await githubService.getGitTransportTokenOrThrowAsync(); const basic = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64"); destinationAuthHeader = `AUTHORIZATION: basic ${basic}`; } catch { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 8f515b339..2f6b5f060 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3254,7 +3254,7 @@ app.whenReady().then(async () => { logger, appVersion: app.getVersion(), getAdeCliAgentEnv: adeCliService.agentEnv, - getLocalGitHubToken: () => githubService.getTokenOrThrowAsync(), + getLocalGitHubToken: () => githubService.getGitTransportTokenOrThrowAsync(), onLinearIssueChatLinked: publishLinearChatLink, onEvent: (event) => { emitProjectEvent(projectRoot, IPC.agentChatEvent, event); diff --git a/apps/desktop/src/main/services/automations/automationIngressService.test.ts b/apps/desktop/src/main/services/automations/automationIngressService.test.ts index e91867098..6931dd156 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.test.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.test.ts @@ -993,6 +993,125 @@ describe("automationIngressService", () => { })); }); + it("caps relay Retry-After cooldowns and clears them on restart", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-01T12:00:00.000Z")); + const logger = makeLogger(); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("slow down", { + status: 429, + headers: { "retry-after": "86400" }, + })) + .mockResolvedValue(new Response(JSON.stringify({ events: [], nextCursor: null, hasMore: false }), { + headers: { "content-type": "application/json" }, + })); + + service = createAutomationIngressService({ + logger: logger as never, + automationService: null, + secretService: { getSecret: () => null } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getAppUserTokenForRelay: vi.fn(async () => "ghu_app_user_token"), + }, + listRules: () => [], + ingressCursorStore: { get: () => null, set: () => {} }, + }); + + await service.pollNow(); + + expect(logger.warn).toHaveBeenCalledWith( + "automations.github_relay_poll_failed", + expect.objectContaining({ retryAt: "2026-08-01T12:15:00.000Z" }), + ); + + service.stop(); + await service.start(); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("preserves committed reconciliation when a later relay page is superseded", async () => { + const updates: Array> = []; + const logger = makeLogger(); + const cursors = new Map([["github-relay", "seq:2"]]); + const onPrStateIngested = vi.fn(); + let resolveOldSecondPage: ((response: Response) => void) | null = null; + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ + events: [ + { cursor: "seq:3", eventId: "delivery-3", githubEvent: "pull_request", payload: {} }, + ], + nextCursor: "seq:3", + hasMore: true, + }), { headers: { "content-type": "application/json" } })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveOldSecondPage = resolve; + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + events: [], + nextCursor: "seq:3", + hasMore: false, + }), { headers: { "content-type": "application/json" } })); + + service = createAutomationIngressService({ + logger: logger as never, + automationService: { + updateIngressStatus: (patch: Record) => updates.push(patch), + dispatchIngressTrigger: vi.fn(), + getIngressCursor: (source: string) => cursors.get(source) ?? null, + setIngressCursor: ({ source, cursor }: { source: string; cursor: string | null }) => { + cursors.set(source, cursor); + }, + getIngressStatus: () => ({}), + } as never, + prService: { + ingestGithubWebhook: vi.fn(async () => ({ + processed: true, + duplicate: false, + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 3, + linkedPrIds: ["pr-3"], + reason: null, + })), + } as never, + secretService: { getSecret: () => null } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getAppUserTokenForRelay: vi.fn(async () => "ghu_app_user_token"), + }, + onPrStateIngested, + listRules: () => [], + }); + + const firstStart = service.start(); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2)); + expect(cursors.get("github-relay")).toBe("seq:3"); + expect(onPrStateIngested).toHaveBeenCalledOnce(); + expect(onPrStateIngested).toHaveBeenCalledWith(["pr-3"]); + + service.stop(); + const secondStart = service.start(); + const resolveStalePage = resolveOldSecondPage as ((response: Response) => void) | null; + if (!resolveStalePage) throw new Error("Expected the old second page to be in flight"); + resolveStalePage(new Response("stale failure", { status: 500 })); + await Promise.all([firstStart, secondStart]); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(onPrStateIngested).toHaveBeenCalledOnce(); + expect(logger.warn).not.toHaveBeenCalledWith( + "automations.github_relay_poll_failed", + expect.anything(), + ); + expect(updates).not.toContainEqual(expect.objectContaining({ + githubRelay: expect.objectContaining({ status: "error" }), + })); + expect(updates.at(-1)).toEqual(expect.objectContaining({ + githubRelay: expect.objectContaining({ healthy: true, status: "ready" }), + })); + }); + it("polls immediately for subscription wake-up frames", async () => { const webSockets = makeWebSocketHarness(); const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index a72060fae..3460725d8 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -88,6 +88,34 @@ class GithubRelayPollError extends Error { } } +class GithubRelayPollSupersededError extends Error { + constructor() { + super("GitHub relay poll was superseded by a newer service lifecycle."); + this.name = "GithubRelayPollSupersededError"; + } +} + +function createGithubRelayPollRun(args: { + generation: number; + currentGeneration: () => number; + isStopped: () => boolean; +}) { + const isCurrent = (): boolean => + args.generation === args.currentGeneration() && !args.isStopped(); + const assertCurrent = (): void => { + if (!isCurrent()) throw new GithubRelayPollSupersededError(); + }; + return { + isCurrent, + assertCurrent, + async wait(work: PromiseLike): Promise { + const value = await work; + assertCurrent(); + return value; + }, + }; +} + function relayRetryAtMs(headers: Pick): number | null { const value = headers.get("retry-after")?.trim() ?? ""; if (!value) return null; @@ -340,6 +368,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg let pollInFlight: Promise | null = null; let pollRerunRequested = false; let pollAbortController: AbortController | null = null; + let relayPollGeneration = 0; let started = false; let stopped = false; let subscriptionSocket: WebSocket | null = null; @@ -740,12 +769,18 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg }); }; - const pollGithubRelay = async () => { + const pollGithubRelay = async (generation: number) => { + const run = createGithubRelayPollRun({ + generation, + currentGeneration: () => relayPollGeneration, + isStopped: () => stopped, + }); + if (!run.isCurrent()) return; if (Date.now() < relayPollCooldownUntilMs) return; const config = buildGithubRelayConfig(); const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config); const accountAccessToken = args.getAccountAccessToken - ? (await args.getAccountAccessToken().catch(() => null))?.trim() || null + ? (await run.wait(args.getAccountAccessToken().catch(() => null)))?.trim() || null : null; // Account auth may become available during the GitHub-token cooldown, so // it is checked first. Without either credential, neither HTTP nor socket @@ -790,7 +825,9 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg // new config later fails. disableRelaySubscription(); } - const repo = useLegacyProjectRoute ? null : await args.githubService?.detectRepo(); + const repo = useLegacyProjectRoute + ? null + : await run.wait(Promise.resolve(args.githubService?.detectRepo())); const eventsUrl = useLegacyProjectRoute ? new URL(`${baseUrl}/projects/${encodeURIComponent(config.remoteProjectId!)}/github/events`) : repo @@ -816,8 +853,11 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg let githubAppUserToken: string | null = null; if (!useLegacyProjectRoute) { try { - githubAppUserToken = ((await args.githubService?.getAppUserTokenForRelay()) ?? "").trim() || null; + githubAppUserToken = ((await run.wait(Promise.resolve( + args.githubService?.getAppUserTokenForRelay(), + ))) ?? "").trim() || null; } catch (error) { + if (error instanceof GithubRelayPollSupersededError) throw error; if (!accountAccessToken) { enterHostedAuthPending(error instanceof Error ? error.message : String(error)); return; @@ -891,15 +931,15 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg pollAbortController = controller; const timeout = setTimeout(() => controller.abort(), GITHUB_RELAY_POLL_TIMEOUT_MS); timeout.unref?.(); - const response = await fetch(pageUrl.toString(), { + const response = await run.wait(fetch(pageUrl.toString(), { headers: requestHeaders, signal: controller.signal, }).finally(() => { clearTimeout(timeout); if (pollAbortController === controller) pollAbortController = null; - }); + })); if (!response.ok) { - const responseText = await response.text().catch(() => ""); + const responseText = await run.wait(response.text().catch(() => "")); let responseMessage = responseText.trim(); try { const parsed = JSON.parse(responseText) as { error?: unknown; message?: unknown }; @@ -918,7 +958,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg relayRetryAtMs(response.headers), ); } - const payload = await response.json() as { + const payload = await run.wait(response.json()) as { events?: Array>; nextCursor?: unknown; cursorExpired?: unknown; @@ -942,7 +982,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg // direct GitHub poller corrects PR state independently. A throwing // event would otherwise replay from the same cursor forever and // freeze all ingest for the repo. - const ingested = await args.prService?.ingestGithubWebhook({ + const ingested = await run.wait(Promise.resolve(args.prService?.ingestGithubWebhook({ eventName: githubEvent, deliveryId: eventId, payload: rawPayload, @@ -953,7 +993,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg error: error instanceof Error ? error.message : String(error), }); return null; - }); + }))); // Same as the local-webhook path: a relay-delivered PR change should // refresh the poller immediately instead of waiting for its next tick. // Batch every delivery in this drain into one targeted reconciliation @@ -962,7 +1002,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg for (const prId of ingested.linkedPrIds) pageIngestedPrIds.add(prId); } try { - await args.automationService?.dispatchIngressTrigger({ + await run.wait(Promise.resolve(args.automationService?.dispatchIngressTrigger({ source: "github-relay", eventKey: eventId, triggerType: "github-webhook", @@ -971,8 +1011,9 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg cursor: eventCursor ?? eventId, keywords: summary.split(/\s+/g).filter(Boolean), rawPayload, - }); + }))); } catch (error) { + if (error instanceof GithubRelayPollSupersededError) throw error; args.logger.warn("automations.github_relay_dispatch_failed", { githubEvent, eventId, @@ -986,7 +1027,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg : null; if (responseCursor) pageLastCursor = responseCursor; if (payload.cursorExpired === true && repo) { - await args.prService?.reconcileGithubStacks(repo); + await run.wait(Promise.resolve(args.prService?.reconcileGithubStacks(repo))); } // Commit only after every event and any cursor-expiry repair in this // page have completed. A failed page is replayed from its previous @@ -994,6 +1035,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg if (pageLastCursor && pageLastCursor !== pageCursor) { setIngressCursor({ source: "github-relay", cursor: pageLastCursor }); for (const prId of pageIngestedPrIds) committedIngestedPrIds.add(prId); + flushCommittedPrReconciliation(); } lastSeenCursor = pageLastCursor; if (useLegacyProjectRoute || payload.hasMore !== true) break; @@ -1002,6 +1044,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg } pageCursor = pageLastCursor; } + run.assertCurrent(); flushCommittedPrReconciliation(); relayPollFailureCount = 0; relayPollCooldownUntilMs = 0; @@ -1014,6 +1057,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg lastError: null, }); } catch (error) { + if (error instanceof GithubRelayPollSupersededError || !run.isCurrent()) return; flushCommittedPrReconciliation(); if (stopped && error instanceof Error && error.name === "AbortError") return; relayPollFailureCount += 1; @@ -1021,9 +1065,13 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg GITHUB_RELAY_POLL_BACKOFF_CAP_MS, GITHUB_RELAY_POLL_BACKOFF_BASE_MS * 2 ** Math.max(0, relayPollFailureCount - 1), ); + const nowMs = Date.now(); relayPollCooldownUntilMs = Math.max( - Date.now() + backoffMs, - error instanceof GithubRelayPollError ? error.retryAtMs ?? 0 : 0, + nowMs + backoffMs, + Math.min( + nowMs + GITHUB_RELAY_POLL_BACKOFF_CAP_MS, + error instanceof GithubRelayPollError ? error.retryAtMs ?? 0 : 0, + ), ); args.logger.warn("automations.github_relay_poll_failed", { error: error instanceof Error ? error.message : String(error), @@ -1046,7 +1094,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg pollInFlight = (async () => { do { pollRerunRequested = false; - await pollGithubRelay(); + await pollGithubRelay(relayPollGeneration); } while (pollRerunRequested && !stopped && Date.now() >= relayPollCooldownUntilMs); })().finally(() => { pollInFlight = null; @@ -1059,6 +1107,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg if (started && !stopped) return; started = true; stopped = false; + relayPollGeneration += 1; // The local webhook server exists to receive automation webhooks; in // PR-freshness-only mode (no automation service) only the relay poll runs. if (!server && args.automationService) { @@ -1110,7 +1159,10 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg stop() { stopped = true; started = false; + relayPollGeneration += 1; githubRelayHealthy = false; + relayPollCooldownUntilMs = 0; + relayPollFailureCount = 0; if (pollTimer) { clearInterval(pollTimer); pollTimer = null; diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts index b0d43e8ce..859723b69 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts @@ -127,7 +127,7 @@ describe("githubCredentialHealth", () => { retryAt: null, }, null); expect(githubCredentialCooldown(appCandidate)).not.toBeNull(); - expect(githubCredentialCooldown(appCandidate, Date.now(), { ignoreNonRateLimit: true })) + expect(githubCredentialCooldown(appCandidate, Date.now(), { failurePolicy: "rate-limit-only" })) .toBeNull(); recordGithubCredentialFailure(appCandidate, { @@ -141,7 +141,7 @@ describe("githubCredentialHealth", () => { resetAt: new Date(Date.now() + 60_000).toISOString(), resource: "core", }); - expect(githubCredentialCooldown(appCandidate, Date.now(), { ignoreNonRateLimit: true }) + expect(githubCredentialCooldown(appCandidate, Date.now(), { failurePolicy: "rate-limit-only" }) ?.failure.kind).toBe("rate_limited"); }); }); diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.ts index bcd05ebbe..e605369eb 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.ts @@ -214,7 +214,7 @@ export function githubCredentialCooldown( nowMs = Date.now(), options: { resource?: string | null; - ignoreNonRateLimit?: boolean; + failurePolicy?: "all" | "rate-limit-only" | "non-rate-limit-only"; } = {}, ): { failure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null } | null { const health = healthFor(candidate); @@ -223,11 +223,16 @@ export function githubCredentialCooldown( const entries = requestedResource ? [health.resources.get(requestedResource), health.resources.get("unknown")] : [...health.resources.values()]; + const failurePolicy = options.failurePolicy ?? "all"; const cooling = entries .filter((entry): entry is NonNullable => Boolean( entry?.failure && entry.cooldownUntilMs > nowMs - && (!options.ignoreNonRateLimit || entry.failure.kind === "rate_limited"), + && ( + failurePolicy === "all" + || (failurePolicy === "rate-limit-only" && entry.failure.kind === "rate_limited") + || (failurePolicy === "non-rate-limit-only" && entry.failure.kind !== "rate_limited") + ), )) .sort((left, right) => right.cooldownUntilMs - left.cooldownUntilMs)[0]; if (!cooling?.failure) return null; diff --git a/apps/desktop/src/main/services/github/githubRateLimit.ts b/apps/desktop/src/main/services/github/githubRateLimit.ts index 8943adbe7..232d72fbc 100644 --- a/apps/desktop/src/main/services/github/githubRateLimit.ts +++ b/apps/desktop/src/main/services/github/githubRateLimit.ts @@ -119,8 +119,9 @@ export function classifyGitHubGraphqlCredentialFailure( payload: unknown, headers: Pick, ): { - status: 403 | 429; + status: 403 | 404 | 429; message: string; + hasData: boolean; authFailure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null; } | null { @@ -130,6 +131,16 @@ export function classifyGitHubGraphqlCredentialFailure( || !("errors" in payload) || !Array.isArray(payload.errors) ) return null; + const data = "data" in payload ? payload.data : null; + const hasMeaningfulData = (value: unknown): boolean => { + if (value == null) return false; + if (Array.isArray(value)) return value.some(hasMeaningfulData); + if (typeof value === "object") { + return Object.values(value as Record).some(hasMeaningfulData); + } + return true; + }; + const hasData = hasMeaningfulData(data); const errors: unknown[] = payload.errors; const messages = errors.flatMap((error) => { if (!error || typeof error !== "object") return []; @@ -160,6 +171,7 @@ export function classifyGitHubGraphqlCredentialFailure( return { status: 429, message, + hasData, ...classifyGitHubAuthFailure({ status: 429, message, headers }), }; } @@ -169,9 +181,25 @@ export function classifyGitHubGraphqlCredentialFailure( return { status: 403, message, + hasData, ...classifyGitHubAuthFailure({ status: 403, message, headers }), }; } + const repositoryNotFound = errorTypes.includes("NOT_FOUND") + && /(?:could not resolve to a|not found).*repository|repository.*not found/i.test(message); + if (repositoryNotFound) { + return { + status: 404, + message, + hasData, + rateLimit, + authFailure: { + kind: "permission_denied", + message, + retryAt: null, + }, + }; + } return null; } diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 49c38fdbc..5f4a059e0 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -293,6 +293,25 @@ describe("githubService.apiRequest", () => { expect(thrownError.rateLimitResetAtMs).toBe(resetTimestamp * 1000); }); + it("keeps Git transport auth usable while REST core access is rate limited", async () => { + process.env.GITHUB_TOKEN = "ghp_rate_limited_but_valid"; + mockFetch.mockResolvedValueOnce(jsonResponse(403, { + message: "API rate limit exceeded", + }, { + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(Math.floor(Date.now() / 1_000) + 3600), + "x-ratelimit-resource": "core", + })); + const service = makeService(); + + await expect(service.apiRequest({ method: "GET", path: "/repos/acme/ade" })) + .rejects.toThrow("API rate limit exceeded"); + await expect(service.getReadTokenOrThrowAsync()).rejects.toThrow("GitHub auth missing"); + await expect(service.getGitTransportTokenOrThrowAsync()) + .resolves.toBe("ghp_rate_limited_but_valid"); + }); + it.each([ { name: "when GitHub also returns a primary reset", @@ -420,6 +439,131 @@ describe("githubService.apiRequest", () => { .toBe("Bearer gho_cli_token"); }); + it("falls back for repository NOT_FOUND and skips the known-negative credential", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + const fallbackData = { data: { repository: { name: "ade" } } }; + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { + data: { repository: null }, + errors: [{ type: "NOT_FOUND", message: "Could not resolve to a Repository with the name 'ade'." }], + })) + .mockResolvedValueOnce(jsonResponse(200, { data: { repository: { name: "ade" } } })) + .mockResolvedValueOnce(jsonResponse(200, fallbackData)); + const service = makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + + await expect(service.apiRequest({ + method: "POST", + path: "/graphql", + capability: "read", + repo: { owner: "acme", name: "ade" }, + body: { query: "query { repository(owner: \"acme\", name: \"ade\") { name } }" }, + })).resolves.toMatchObject({ data: { data: { repository: { name: "ade" } } } }); + await expect(service.apiRequest({ + method: "POST", + path: "/graphql", + capability: "read", + repo: { owner: "acme", name: "ade" }, + body: { query: "query { repository(owner: \"acme\", name: \"ade\") { name } }" }, + })).resolves.toMatchObject({ data: fallbackData }); + + expect(mockFetch).toHaveBeenCalledTimes(3); + expect((mockFetch.mock.calls[0]?.[1]?.headers as Record).authorization) + .toBe("Bearer ghu_app_user_token"); + expect((mockFetch.mock.calls[1]?.[1]?.headers as Record).authorization) + .toBe("Bearer gho_cli_token"); + expect((mockFetch.mock.calls[2]?.[1]?.headers as Record).authorization) + .toBe("Bearer gho_cli_token"); + }); + + it("retries partial credential errors for GraphQL reads", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { + data: { repository: { name: "ade" } }, + errors: [{ type: "FORBIDDEN", message: "One field is not accessible" }], + })) + .mockResolvedValueOnce(jsonResponse(200, { + data: { repository: { name: "ade", mergeQueue: { entries: [] } } }, + })); + + const result = await makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }).apiRequest({ + method: "POST", + path: "/graphql", + capability: "read", + repo: { owner: "acme", name: "ade" }, + body: { query: "query { repository(owner: \"acme\", name: \"ade\") { name } }" }, + }); + + expect(result.data).toEqual({ + data: { repository: { name: "ade", mergeQueue: { entries: [] } } }, + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect((mockFetch.mock.calls[1]?.[1]?.headers as Record).authorization) + .toBe("Bearer gho_cli_token"); + }); + + it("does not replay GraphQL mutations after a partial credential error", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + process.env.GITHUB_TOKEN = "ghp_environment_token"; + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + data: { updatePullRequest: { pullRequest: { id: "PR_1" } } }, + errors: [{ type: "FORBIDDEN", message: "One field is not accessible" }], + })); + + await expect(makeService({ + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }).apiRequest({ + method: "POST", + path: "/graphql", + capability: "write", + body: { query: "mutation { updatePullRequest(input: {}) { pullRequest { id } } }" }, + })).rejects.toThrow("One field is not accessible"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect((mockFetch.mock.calls[0]?.[1]?.headers as Record).authorization) + .toBe("Bearer ghp_environment_token"); + }); + it("falls back on repository-scoped 404s without retrying unrelated 404s", async () => { delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; const credentialStore = new MemoryCredentialStore(); @@ -895,6 +1039,20 @@ describe("githubService issue-domain helpers", () => { await expect(conditional).resolves.toMatchObject({ data: { value: "cached" } }); }); + it("retries an uncached 304 response without a conditional header", async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(304, {})) + .mockResolvedValueOnce(jsonResponse(200, { value: "fresh" })); + const service = makeService(); + + await expect(service.apiRequest({ method: "GET", path: "/uncached", token: "ghp_test123" })) + .resolves.toMatchObject({ data: { value: "fresh" } }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0]?.[1]?.headers).not.toMatchObject({ "if-none-match": expect.anything() }); + expect(mockFetch.mock.calls[1]?.[1]?.headers).not.toMatchObject({ "if-none-match": expect.anything() }); + }); + it("URL-encodes owner/name so special characters don't break the path", async () => { mockFetch.mockResolvedValueOnce(jsonResponse(200, [])); const service = makeService(); @@ -973,7 +1131,8 @@ describe("githubService.getStatus", () => { userLogin: "alice", }); expect(mockFetch).toHaveBeenCalledTimes(2); - await expect(service.getTokenOrThrowAsync()).rejects.toThrow("GitHub auth missing"); + await expect(service.getTokenOrThrowAsync()).rejects.toThrow("GitHub write access is unavailable"); + await expect(service.getReadTokenOrThrowAsync()).resolves.toBe("ghu_app_user_token"); }); it("does not advertise an unvalidated lower-precedence write credential", async () => { diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 2b5bb647b..639c16d7a 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -24,6 +24,7 @@ import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/cr import { evaluateGithubCredentialCapabilities, githubOperationCredentialCandidates, + resolveGithubOperationCredentialCandidate, resolveGithubStatusCredentials, selectGithubOperationCredential, type GithubOperationCredentialCapability, @@ -32,6 +33,7 @@ import { classifyGitHubRepositoryApiPath, createGithubRepositoryRequestFallback, } from "../../../shared/githubApiPath"; +import { createGithubConditionalRequestCache } from "../../../shared/githubConditionalRequestCache"; import { mergePathEntries, resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; import { fetchGitHubAppInstallationStatus, type GitHubRelaySecretReader } from "./githubRelayConfig"; import { createGitHubAppUserAuthService } from "./githubAppUserAuthService"; @@ -815,17 +817,19 @@ export function createGithubService({ const readAuthToken = async ( capability: GithubOperationCredentialCapability = "read", + failurePolicy: "all" | "non-rate-limit-only" = "all", ): Promise => { const inventory = await readCredentialInventory(); - const candidates = githubOperationCredentialCandidates(inventory.candidates, capability); - const selected = candidates.find((candidate) => !githubCredentialCooldown( - candidate, - Date.now(), - { resource: "core" }, - )) - ?? candidates[0] - ?? null; - return selected ?? { + const resolved = resolveGithubOperationCredentialCandidate({ + candidates: inventory.candidates, + capability, + isAvailable: (candidate) => !githubCredentialCooldown( + candidate, + Date.now(), + { resource: "core", failurePolicy }, + ), + }); + return resolved ?? { token: null, source: "none", patTokenStored: inventory.patTokenStored, @@ -1146,17 +1150,7 @@ export function createGithubService({ // ETag cache for conditional GET requests. Responses that return 304 Not Modified // don't count against GitHub's rate limit, so this dramatically reduces API usage. - const etagCache = new Map(); - const ETAG_CACHE_MAX_SIZE = 200; - const inFlightConditionalGetKeys = new Set(); - - const evictOldestEtagCacheEntry = (protectedKeys: ReadonlySet): void => { - for (const key of etagCache.keys()) { - if (protectedKeys.has(key)) continue; - etagCache.delete(key); - return; - } - }; + const conditionalRequestCache = createGithubConditionalRequestCache(); const apiRequest = async (args: { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; @@ -1165,6 +1159,7 @@ export function createGithubService({ body?: unknown; token?: string; capability?: GithubOperationCredentialCapability; + repo?: GitHubRepoRef; /** * Override the default `Accept` header. Used to request GitHub schema * previews (e.g. `application/vnd.github.merge-info-preview+json` for the @@ -1200,7 +1195,8 @@ export function createGithubService({ const accept = args.accept?.trim() || "application/vnd.github+json"; const rateLimitResource = githubRateLimitResourceForPath(args.path); - const repositoryPath = classifyGitHubRepositoryApiPath(args.path); + const repositoryPath = classifyGitHubRepositoryApiPath(args.path) + ?? (args.repo ? { ...args.repo, isRepositoryRoot: true } : null); const repositoryFallback = createGithubRepositoryRequestFallback({ path: repositoryPath, readAccess: githubCredentialRepositoryAccess, @@ -1245,13 +1241,12 @@ export function createGithubService({ "user-agent": "ade-desktop", "x-github-api-version": GITHUB_REST_API_VERSION, }; - let sentConditionalGet = false; + let releaseConditionalRequest: (() => void) | null = null; if (args.method === "GET") { - const cached = etagCache.get(cacheKey); - if (cached) { - headers["if-none-match"] = cached.etag; - sentConditionalGet = true; - inFlightConditionalGetKeys.add(cacheKey); + const conditional = conditionalRequestCache.begin(cacheKey); + if (conditional) { + headers["if-none-match"] = conditional.entry.etag; + releaseConditionalRequest = conditional.release; } } @@ -1263,17 +1258,24 @@ export function createGithubService({ body: args.body != null ? JSON.stringify(args.body) : undefined, }); } finally { - if (sentConditionalGet) inFlightConditionalGetKeys.delete(cacheKey); + releaseConditionalRequest?.(); } if (response.status === 304) { - const cached = etagCache.get(cacheKey); + const cached = conditionalRequestCache.get(cacheKey); if (cached) { recordGithubCredentialSuccess(candidate, response.headers); repositoryFallback.recordSuccess(candidate); releaseGitHubResponse(response); return { data: cached.data as T, response, linkHeader: cached.linkHeader }; } + releaseGitHubResponse(response); + delete headers["if-none-match"]; + response = await fetchGitHub(url.toString(), { + method: args.method, + headers, + body: args.body != null ? JSON.stringify(args.body) : undefined, + }); } const text = await response.text(); @@ -1342,18 +1344,26 @@ export function createGithubService({ ? classifyGitHubGraphqlCredentialFailure(data, response.headers) : null; if (graphqlFailure) { - recordGithubCredentialFailure( + const { repositoryNotFound } = repositoryFallback.classifyFailure( candidate, - graphqlFailure.authFailure, - graphqlFailure.rateLimit, + graphqlFailure.status, ); + if (!repositoryNotFound) { + recordGithubCredentialFailure( + candidate, + graphqlFailure.authFailure, + graphqlFailure.rateLimit, + ); + } const attemptError = new GithubCredentialAttemptError( graphqlFailure.message, graphqlFailure.authFailure, graphqlFailure.rateLimit, ); lastAttemptError = attemptError; - if (!args.token) continue; + const canTryNext = !args.token + && (capability === "read" || !graphqlFailure.hasData); + if (canTryNext) continue; if (attemptError.authFailure.kind === "rate_limited") { const resetAtMs = githubRateLimitRetryAtMs(attemptError.authFailure, attemptError.rateLimit); throw new GitHubRateLimitError(attemptError.message, resetAtMs, attemptError.rateLimit); @@ -1374,12 +1384,7 @@ export function createGithubService({ if (args.method === "GET") { const etag = response.headers.get("etag"); if (etag) { - while (etagCache.size >= ETAG_CACHE_MAX_SIZE && !etagCache.has(cacheKey)) { - const before = etagCache.size; - evictOldestEtagCacheEntry(inFlightConditionalGetKeys); - if (etagCache.size === before) break; - } - etagCache.set(cacheKey, { etag, data, linkHeader }); + conditionalRequestCache.store(cacheKey, { etag, data, linkHeader }); } } return { data: data as T, response, linkHeader }; @@ -1465,7 +1470,10 @@ export function createGithubService({ const statusCooldown = (candidate: GitHubTokenCandidate) => githubCredentialCooldown( candidate, Date.now(), - { resource: "core", ignoreNonRateLimit: opts.forceRefresh === true }, + { + resource: "core", + failurePolicy: opts.forceRefresh === true ? "rate-limit-only" : "all", + }, ); if (!primaryCandidate) { cachedStatus = { @@ -1745,9 +1753,7 @@ export function createGithubService({ is_alphanumeric: args.isAlphanumeric === true, }, }); - for (const cacheKey of etagCache.keys()) { - if (cacheKey.includes(autolinksPath)) etagCache.delete(cacheKey); - } + conditionalRequestCache.deleteWhere((cacheKey) => cacheKey.includes(autolinksPath)); return normalizeAutolink(data); }; @@ -2211,6 +2217,18 @@ export function createGithubService({ async getTokenOrThrowAsync(): Promise { const token = (await readAuthToken("write")).token; + if (!token) throw new Error("GitHub write access is unavailable. Connect GitHub CLI or add a personal access token in Settings."); + return token; + }, + + async getReadTokenOrThrowAsync(): Promise { + const token = (await readAuthToken("read")).token; + if (!token) throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); + return token; + }, + + async getGitTransportTokenOrThrowAsync(): Promise { + const token = (await readAuthToken("read", "non-rate-limit-only")).token; if (!token) throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); return token; }, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 9af99cbb5..31745b099 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -4304,7 +4304,7 @@ export function registerIpc({ bindRemoteProject, getGitHubTokenForRemoteClone: async () => { try { - return await getCtx().githubService.getTokenOrThrowAsync(); + return await getCtx().githubService.getGitTransportTokenOrThrowAsync(); } catch { return null; } diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts index ed7e2129e..d53574eea 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.test.ts @@ -30,6 +30,8 @@ function makeGithubServiceStub(overrides: Partial<{ apiRequest: overrides.apiRequest ?? vi.fn(), getTokenOrThrow, getTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), + getReadTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), + getGitTransportTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), parseGitHubRepoFromRemoteUrl: overrides.parseGitHubRepoFromRemoteUrl ?? vi.fn((url: string) => { @@ -544,9 +546,12 @@ describe("listMyGitHubRepos", () => { .mockResolvedValueOnce({ data: fullPage, response: null }) .mockResolvedValueOnce({ data: partialPage, response: null }); + const githubService = makeGithubServiceStub({ apiRequest }); + githubService.getReadTokenOrThrowAsync.mockResolvedValue("ghp_healthy_rest_fallback"); + githubService.getGitTransportTokenOrThrowAsync.mockResolvedValue("ghp_rate_limited_primary"); const service = createProjectScaffoldService({ logger: makeLogger(), - githubService: makeGithubServiceStub({ apiRequest }), + githubService, }); const result = await service.listMyGitHubRepos({}); @@ -565,6 +570,8 @@ describe("listMyGitHubRepos", () => { expect(apiRequest.mock.calls[1]?.[0]).toMatchObject({ query: expect.objectContaining({ page: 2 }), }); + expect(githubService.getReadTokenOrThrowAsync).toHaveBeenCalledOnce(); + expect(githubService.getGitTransportTokenOrThrowAsync).not.toHaveBeenCalled(); expect(result.repos).toHaveLength(102); expect(result.repos[0]).toMatchObject({ owner: "alice", diff --git a/apps/desktop/src/main/services/projects/projectScaffoldService.ts b/apps/desktop/src/main/services/projects/projectScaffoldService.ts index 066128428..3159447bf 100644 --- a/apps/desktop/src/main/services/projects/projectScaffoldService.ts +++ b/apps/desktop/src/main/services/projects/projectScaffoldService.ts @@ -215,7 +215,7 @@ export function createProjectScaffoldService({ let authHeader = (input.githubAuthHeader ?? "").trim(); if (!authHeader) { try { - const storedToken = await githubService.getTokenOrThrowAsync(); + const storedToken = await githubService.getGitTransportTokenOrThrowAsync(); const basic = Buffer.from(`x-access-token:${storedToken}`, "utf8").toString("base64"); authHeader = `basic ${basic}`; } catch { @@ -260,7 +260,7 @@ export function createProjectScaffoldService({ const listMyGitHubRepos = async (input: ListMyGitHubReposInput): Promise => { let token: string; try { - token = await githubService.getTokenOrThrowAsync(); + token = await githubService.getReadTokenOrThrowAsync(); } catch (err) { const wrapped = new Error("GitHub is not connected. Run gh auth login or add a PAT in Settings.") as Error & { code?: string }; wrapped.code = "github_not_connected"; diff --git a/apps/desktop/src/main/services/prs/prAsync.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts index 83830047f..00816d6af 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -12,6 +12,10 @@ import { import { createPrMergeAutoSettlementService } from "./prMergeAutoSettlementService"; import { createPrPollingService } from "./prPollingService"; import { buildPrSummaryPrompt, createPrSummaryService, parsePrSummaryJson } from "./prSummaryService"; +import { + clearGithubCredentialHealth, + recordGithubCredentialSuccess, +} from "../github/githubCredentialHealth"; // --------------------------------------------------------------------------- // Shared helpers @@ -81,6 +85,7 @@ describe("prPollingService", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + clearGithubCredentialHealth(); }); it("refreshes only hot PRs and ignores updatedAt-only churn", async () => { @@ -207,6 +212,85 @@ describe("prPollingService", () => { expect(refresh).toHaveBeenLastCalledWith(); }); + it("does not inherit a global pause when the project-scoped provider returns no pause", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-24T12:00:00.000Z")); + vi.spyOn(Math, "random").mockReturnValue(0.5); + recordGithubCredentialSuccess({ + source: "gh", + token: "gho_unrelated_project", + capabilities: ["read", "write"], + }, new Headers({ + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "100", + "x-ratelimit-used": "4900", + "x-ratelimit-reset": String(Date.parse("2026-03-24T13:00:00.000Z") / 1_000), + "x-ratelimit-resource": "core", + })); + + const summary = createSummary(); + const refresh = vi.fn(async () => [summary]); + const getGithubBackgroundPauseUntilMs = vi.fn(() => null); + const service = createPrPollingService({ + logger: createLogger() as any, + prService: { + listAll: () => [summary], + refresh, + getHotRefreshDelayMs: () => null, + getHotRefreshPrIds: () => [], + } as any, + projectConfigService: { get: () => ({ effective: {} }) } as any, + getGithubBackgroundPauseUntilMs, + onEvent: vi.fn(), + }); + + service.start(); + await vi.advanceTimersByTimeAsync(12_000); + + expect(getGithubBackgroundPauseUntilMs).toHaveBeenCalledTimes(1); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("backs off a failed relay safety sweep without retrying it every second", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-24T12:00:00.000Z")); + vi.spyOn(Math, "random").mockReturnValue(0.5); + + const summary = createSummary(); + const listAll = vi.fn(() => [summary]); + const refresh = vi.fn(async () => { + throw new Error("safety sweep failed"); + }); + const service = createPrPollingService({ + logger: createLogger() as any, + prService: { + listAll, + refresh, + getHotRefreshDelayMs: () => null, + getHotRefreshPrIds: () => [], + } as any, + projectConfigService: { + get: () => ({ effective: { github: { prPollingIntervalSeconds: 5 } } }), + } as any, + isGithubRelayHealthy: () => true, + onEvent: vi.fn(), + }); + + service.start(); + await vi.advanceTimersByTimeAsync(12_000); + expect(refresh).toHaveBeenCalledTimes(1); + expect(listAll).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(9_999); + expect(listAll).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(listAll).toHaveBeenCalledTimes(3); + expect(refresh).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(15 * 60_000 - 10_000); + expect(refresh).toHaveBeenCalledTimes(2); + }); + it("discovers lane PRs when the local PR cache starts empty", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00.000Z")); diff --git a/apps/desktop/src/main/services/prs/prPollingService.ts b/apps/desktop/src/main/services/prs/prPollingService.ts index 46002d947..d80d3a77a 100644 --- a/apps/desktop/src/main/services/prs/prPollingService.ts +++ b/apps/desktop/src/main/services/prs/prPollingService.ts @@ -250,7 +250,9 @@ export function createPrPollingService({ const polledAt = nowIso(); try { const backgroundPauseUntilMs = await Promise.resolve( - getGithubBackgroundPauseUntilMs?.() ?? githubBackgroundRequestPauseUntilMs(), + getGithubBackgroundPauseUntilMs + ? getGithubBackgroundPauseUntilMs() + : githubBackgroundRequestPauseUntilMs(), ); if (backgroundPauseUntilMs != null && backgroundPauseUntilMs > Date.now()) { const untilReset = Math.max(10_000, backgroundPauseUntilMs - Date.now() + 5_000); @@ -305,8 +307,8 @@ export function createPrPollingService({ await prService.refresh({ prIds: targetedPrIds }); } else if (relayHealthy) { if (Date.now() - lastRelaySafetySweepAtMs >= RELAY_SAFETY_SWEEP_INTERVAL_MS) { - await prService.refresh(); lastRelaySafetySweepAtMs = Date.now(); + await prService.refresh(); } } else if (hotPrIds.length > 0) { await prService.refresh({ prIds: hotPrIds }); @@ -489,7 +491,9 @@ export function createPrPollingService({ const relaySafetyDelay = relayHealthy ? Math.max(1_000, RELAY_SAFETY_SWEEP_INTERVAL_MS - (Date.now() - lastRelaySafetySweepAtMs)) : null; - const base = hotDelay ?? relaySafetyDelay ?? computeBackoffMs(); + const base = consecutiveFailures > 0 + ? computeBackoffMs() + : hotDelay ?? relaySafetyDelay ?? computeBackoffMs(); const delay = jitterMs(Math.max(base, nextDelayOverrideMs ?? 0)); nextDelayOverrideMs = null; if (rateLimitResumeAtMs > 0 && Date.now() >= rateLimitResumeAtMs) { diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index a641ea374..06634ad6b 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -244,6 +244,8 @@ function makeGithubService(overrides?: Record) { clearToken: vi.fn(), getTokenOrThrow, getTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), + getReadTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), + getGitTransportTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), ...remainingOverrides, } as any; } diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 0fd94c925..8e2692f6b 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -4206,6 +4206,8 @@ export function createPrService({ variables: Record, options: { accept?: string } = {}, ): Promise => { + const owner = typeof variables.owner === "string" ? variables.owner.trim() : ""; + const name = typeof variables.name === "string" ? variables.name.trim() : ""; const { data: payload } = await githubService.apiRequest<{ data?: T; errors?: Array<{ message?: unknown }>; @@ -4213,6 +4215,7 @@ export function createPrService({ method: "POST", path: "/graphql", capability: /^\s*mutation\b/i.test(query) ? "write" : "read", + ...(owner && name ? { repo: { owner, name } } : {}), body: { query, variables }, ...(options.accept ? { accept: options.accept } : {}), }); @@ -5744,7 +5747,7 @@ export function createPrService({ repo: GitHubRepoRef; jobId: number; }): Promise<{ text: string; truncated: boolean } | null> => { - const token = await githubService.getTokenOrThrowAsync(); + const token = await githubService.getReadTokenOrThrowAsync(); const apiUrl = `https://api.github.com/repos/${args.repo.owner}/${args.repo.name}/actions/jobs/${args.jobId}/logs`; const headers = { accept: "application/vnd.github+json", diff --git a/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts b/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts index 959aeb917..fbfaf1719 100644 --- a/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts +++ b/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts @@ -148,6 +148,17 @@ describe("describeGithubCliBanner", () => { expect(banner.action).toBe("Fix GitHub auth"); }); + it("treats an omitted write source as no write credential for App-only status", () => { + const banner = describeGithubCliBanner(makeCliStatus({ + authSource: "app", + writeAuthSource: undefined, + connected: true, + })); + + expect(banner.subState).toBe("no-write-credential"); + expect(banner.title).toBe("GitHub write access isn't connected"); + }); + it("keeps raw validation errors in Settings without leaking them into the banner", () => { const copy = describeGithubAuthFailure(makeCliStatus({ authFailure: { diff --git a/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts b/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts index 7d27b4c74..da13ae9e1 100644 --- a/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts +++ b/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts @@ -260,7 +260,7 @@ export function describeGithubCliBanner(status: GitHubStatus): { action: "Connect GitHub", }; } - if (status.writeAuthSource === "none") { + if (status.connected && !githubStatusHasWriteCredential(status)) { return { subState: "no-write-credential", title: "GitHub write access isn't connected", diff --git a/apps/desktop/src/shared/githubConditionalRequestCache.test.ts b/apps/desktop/src/shared/githubConditionalRequestCache.test.ts new file mode 100644 index 000000000..7d981eda9 --- /dev/null +++ b/apps/desktop/src/shared/githubConditionalRequestCache.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { createGithubConditionalRequestCache } from "./githubConditionalRequestCache"; + +describe("githubConditionalRequestCache", () => { + it("keeps an entry protected until every same-key request releases it", () => { + const cache = createGithubConditionalRequestCache(1); + const entry = { etag: '"one"', data: { value: "one" }, linkHeader: null }; + cache.store("one", entry); + + const first = cache.begin("one"); + const second = cache.begin("one"); + expect(first?.entry).toBe(entry); + expect(second?.entry).toBe(entry); + + first?.release(); + cache.store("two", { etag: '"two"', data: { value: "two" }, linkHeader: null }); + expect(cache.get("one")).toBe(entry); + + second?.release(); + cache.store("three", { etag: '"three"', data: { value: "three" }, linkHeader: null }); + expect(cache.get("one")).toBeNull(); + expect(cache.get("three")?.data).toEqual({ value: "three" }); + }); +}); diff --git a/apps/desktop/src/shared/githubConditionalRequestCache.ts b/apps/desktop/src/shared/githubConditionalRequestCache.ts new file mode 100644 index 000000000..094b39279 --- /dev/null +++ b/apps/desktop/src/shared/githubConditionalRequestCache.ts @@ -0,0 +1,57 @@ +export type GithubConditionalRequestCacheEntry = { + etag: string; + data: unknown; + linkHeader: string | null; +}; + +export function createGithubConditionalRequestCache(maxSize = 200) { + const entries = new Map(); + const activeConditionalRequests = new Map(); + + const release = (key: string): void => { + const count = activeConditionalRequests.get(key) ?? 0; + if (count <= 1) activeConditionalRequests.delete(key); + else activeConditionalRequests.set(key, count - 1); + }; + + return { + begin(key: string): { + entry: GithubConditionalRequestCacheEntry; + release: () => void; + } | null { + const entry = entries.get(key); + if (!entry) return null; + activeConditionalRequests.set(key, (activeConditionalRequests.get(key) ?? 0) + 1); + let released = false; + return { + entry, + release: () => { + if (released) return; + released = true; + release(key); + }, + }; + }, + get(key: string): GithubConditionalRequestCacheEntry | null { + return entries.get(key) ?? null; + }, + deleteWhere(predicate: (key: string) => boolean): void { + for (const key of entries.keys()) { + if (predicate(key)) entries.delete(key); + } + }, + store(key: string, entry: GithubConditionalRequestCacheEntry): void { + while (entries.size >= maxSize && !entries.has(key)) { + let evictable: string | null = null; + for (const candidate of entries.keys()) { + if (activeConditionalRequests.has(candidate)) continue; + evictable = candidate; + break; + } + if (!evictable) break; + entries.delete(evictable); + } + entries.set(key, entry); + }, + }; +} diff --git a/apps/desktop/src/shared/githubOperationCredential.ts b/apps/desktop/src/shared/githubOperationCredential.ts index e57fa2c52..24a5bcb18 100644 --- a/apps/desktop/src/shared/githubOperationCredential.ts +++ b/apps/desktop/src/shared/githubOperationCredential.ts @@ -69,6 +69,21 @@ export function githubOperationCredentialCandidates< }); } +export function resolveGithubOperationCredentialCandidate< + T extends { + source: GithubOperationCredentialSource; + token: string; + capabilities: readonly GithubOperationCredentialCapability[]; + }, +>(args: { + candidates: readonly T[]; + capability: GithubOperationCredentialCapability; + isAvailable: (candidate: T) => boolean; +}): T | null { + return githubOperationCredentialCandidates(args.candidates, args.capability) + .find(args.isAvailable) ?? null; +} + export function evaluateGithubCredentialCapabilities(args: { source: GithubOperationCredentialSource; tokenType: GitHubTokenType; diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index 7ba28c2e1..1a5001c0c 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -1523,8 +1523,14 @@ async function authorizeRepoEventRead( // re-proving repository access. The GitHub-token path remains for legacy // clients that do not send an ADE account token. const accountId = await authenticateAccount(request, env); - if (accountId && await githubRepositoryAccountMatches(env, repo, accountId)) { - return { authorized: true, accountId }; + if (accountId) { + if (await githubRepositoryAccountMatches(env, repo, accountId)) { + return { authorized: true, accountId }; + } + return { + authorized: false, + response: json({ ok: false, error: "unauthorized" }, { status: 401 }), + }; } const auth = await assertGitHubRepoAuthorized(request, env, repo); diff --git a/apps/webhook-relay/test/account.test.ts b/apps/webhook-relay/test/account.test.ts index 98eb64611..61a578258 100644 --- a/apps/webhook-relay/test/account.test.ts +++ b/apps/webhook-relay/test/account.test.ts @@ -672,6 +672,7 @@ describe("account integration re-keying", () => { const providerCallsBeforeRead = vi.mocked(fetch).mock.calls.length; const response = await handleRequest(request("/github/repos/acme/repo/events", { + authorization: "Bearer ghp_repo_token", accountToken, }), env); From 222a4aa480fbd4887b92d34632d31992d3825cfa Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:49:28 -0400 Subject: [PATCH 05/12] fix(github): harden credential failover --- .../src/headlessLinearServices.test.ts | 350 ++++++++++++++++-- apps/ade-cli/src/headlessLinearServices.ts | 66 +++- .../automationIngressService.test.ts | 76 ++++ .../automations/automationIngressService.ts | 29 +- .../github/githubAppUserAuthService.ts | 2 + .../main/services/github/githubRawRequest.ts | 186 ++++++++++ .../services/github/githubService.test.ts | 326 +++++++++++++++- .../src/main/services/github/githubService.ts | 145 ++++++-- .../src/main/services/prs/prAsync.test.ts | 7 +- .../src/main/services/prs/prService.test.ts | 169 +++++++++ .../src/main/services/prs/prService.ts | 42 +-- apps/desktop/src/shared/githubApiPath.ts | 8 + .../githubConditionalRequestCache.test.ts | 13 + .../shared/githubConditionalRequestCache.ts | 13 +- .../shared/githubOperationCredential.test.ts | 37 ++ .../src/shared/githubOperationCredential.ts | 1 - apps/webhook-relay/src/relay.ts | 35 +- apps/webhook-relay/test/account.test.ts | 24 ++ 18 files changed, 1442 insertions(+), 87 deletions(-) create mode 100644 apps/desktop/src/main/services/github/githubRawRequest.ts diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 66a9d40de..172625f06 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; const originalDisableGhAuthFallback = process.env.ADE_DISABLE_GH_AUTH_FALLBACK; @@ -35,7 +36,12 @@ vi.mock("../../desktop/src/main/services/automations/automationSecretService", ( import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import { createHeadlessGitHubService, createHeadlessLinearServices } from "./headlessLinearServices"; -import { clearGithubCredentialHealth } from "../../desktop/src/main/services/github/githubCredentialHealth"; +import { + clearGithubCredentialHealth, + githubCredentialCooldown, + githubCredentialRepositoryAccess, + recordGithubCredentialFailure, +} from "../../desktop/src/main/services/github/githubCredentialHealth"; const HEADLESS_GITHUB_ENV_KEYS = [ "ADE_HOME", @@ -50,12 +56,15 @@ function isolateHeadlessGithubAuth(prefix: string, options: { emptyGhConfig?: bo HEADLESS_GITHUB_ENV_KEYS.map((key) => [key, process.env[key]]), ); const previousFetch = globalThis.fetch; - process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const temporaryDirectories = [fs.mkdtempSync(path.join(os.tmpdir(), prefix))]; + process.env.ADE_HOME = temporaryDirectories[0]; delete process.env.ADE_GITHUB_TOKEN; delete process.env.GITHUB_TOKEN; delete process.env.GH_TOKEN; if (options.emptyGhConfig) { - process.env.GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}gh-`)); + const ghConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}gh-`)); + temporaryDirectories.push(ghConfigDir); + process.env.GH_CONFIG_DIR = ghConfigDir; } return { restore(): void { @@ -64,6 +73,9 @@ function isolateHeadlessGithubAuth(prefix: string, options: { emptyGhConfig?: bo if (value == null) delete process.env[key]; else process.env[key] = value; } + for (const temporaryDirectory of temporaryDirectories) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } }, }; } @@ -219,6 +231,85 @@ describe("headlessLinearServices", () => { } }); + it("clears only changed PAT health when headless credentials change", () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-pat-health-", { + emptyGhConfig: true, + }); + const credentialStore = new EncryptedFileCredentialStore(); + credentialStore.setSync("github.token.v1", "ghp_old_token"); + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + ); + const environmentCandidate = { + source: "environment" as const, + token: "ghp_environment_token", + capabilities: ["read", "write"] as const, + }; + const oldPatCandidate = { + source: "pat" as const, + token: "ghp_old_token", + capabilities: ["read", "write"] as const, + }; + const newPatCandidate = { + source: "pat" as const, + token: "ghp_new_token", + capabilities: ["read", "write"] as const, + }; + const invalid = { kind: "invalid_token" as const, message: "Bad credentials", retryAt: null }; + + try { + recordGithubCredentialFailure(environmentCandidate, invalid, null); + recordGithubCredentialFailure(oldPatCandidate, invalid, null); + recordGithubCredentialFailure(newPatCandidate, invalid, null); + + githubService.setToken("ghp_new_token"); + expect(githubCredentialCooldown(oldPatCandidate)).toBeNull(); + expect(githubCredentialCooldown(newPatCandidate)).toBeNull(); + expect(githubCredentialCooldown(environmentCandidate)).not.toBeNull(); + + recordGithubCredentialFailure(newPatCandidate, invalid, null); + githubService.clearToken(); + expect(githubCredentialCooldown(newPatCandidate)).toBeNull(); + expect(githubCredentialCooldown(environmentCandidate)).not.toBeNull(); + } finally { + environment.restore(); + } + }); + + it("clears only App health when headless App authorization is removed", () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-app-health-", { + emptyGhConfig: true, + }); + storeHeadlessAppUserToken(); + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + ); + const appCandidate = { + source: "app" as const, + token: "ghu_app_user_token", + capabilities: ["read"] as const, + }; + const environmentCandidate = { + source: "environment" as const, + token: "ghp_environment_token", + capabilities: ["read", "write"] as const, + }; + const invalid = { kind: "invalid_token" as const, message: "Bad credentials", retryAt: null }; + + try { + recordGithubCredentialFailure(appCandidate, invalid, null); + recordGithubCredentialFailure(environmentCandidate, invalid, null); + + githubService.clearAppUserAuth(); + expect(githubCredentialCooldown(appCandidate)).toBeNull(); + expect(githubCredentialCooldown(environmentCandidate)).not.toBeNull(); + } finally { + environment.restore(); + } + }); + it("coalesces concurrent forced GitHub status lookups", async () => { const previousAdeHome = process.env.ADE_HOME; const previousFetch = globalThis.fetch; @@ -922,6 +1013,211 @@ describe("headlessLinearServices", () => { } }); + it("preserves an earlier headless REST rate limit after fallback permission failure", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-rest-rate-precedence-", { + emptyGhConfig: true, + }); + storeHeadlessAppUserToken(); + const resetAt = Math.floor(Date.now() / 1_000) + 3_600; + globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => ( + new Headers(init?.headers).get("authorization") === "Bearer ghu_app_user_token" + ? new Response(JSON.stringify({ message: "API rate limit exceeded" }), { + status: 403, + headers: { + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetAt), + "x-ratelimit-resource": "core", + }, + }) + : new Response(JSON.stringify({ message: "Resource not accessible" }), { status: 403 }) + )) as unknown as typeof fetch; + const service = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + await expect(service.apiRequest({ method: "GET", path: "/repos/acme/ade" })) + .rejects.toMatchObject({ + name: "GitHubRateLimitError", + rateLimitResetAtMs: resetAt * 1_000, + }); + } finally { + environment.restore(); + } + }); + + it("preserves an earlier headless GraphQL rate limit after fallback permission failure", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-graphql-rate-precedence-", { + emptyGhConfig: true, + }); + storeHeadlessAppUserToken(); + const resetAt = Math.floor(Date.now() / 1_000) + 3_600; + globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => ( + new Headers(init?.headers).get("authorization") === "Bearer ghu_app_user_token" + ? new Response(JSON.stringify({ + data: null, + errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }], + }), { + status: 200, + headers: { + "content-type": "application/json", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetAt), + "x-ratelimit-resource": "graphql", + }, + }) + : new Response(JSON.stringify({ + data: null, + errors: [{ type: "FORBIDDEN", message: "Resource not accessible" }], + }), { status: 200, headers: { "content-type": "application/json" } }) + )) as unknown as typeof fetch; + const service = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + await expect(service.apiRequest({ + method: "POST", + path: "/graphql", + capability: "read", + body: { query: "query { viewer { login } }" }, + })).rejects.toMatchObject({ + name: "GitHubRateLimitError", + rateLimitResetAtMs: resetAt * 1_000, + }); + } finally { + environment.restore(); + } + }); + + it("retries raw repository redirects after an ambiguous App 404", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-raw-404-", { + emptyGhConfig: true, + }); + storeHeadlessAppUserToken(); + const authorizations: string[] = []; + globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get("authorization") ?? ""; + authorizations.push(authorization); + if (authorization === "Bearer ghu_app_user_token") { + return new Response(JSON.stringify({ message: "Not Found" }), { status: 404 }); + } + return new Response(null, { + status: 302, + headers: { + location: "https://productionresultssa.blob.core.windows.net/actions-results/job.zip", + }, + }); + }) as unknown as typeof fetch; + const service = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + const response = await service.requestRawWithCredentialFallback({ + url: "https://api.github.com/repos/acme/ade/actions/jobs/123/logs", + redirect: "manual", + repo: { owner: "acme", name: "ade" }, + }); + + expect(response.status).toBe(302); + expect(authorizations).toEqual([ + "Bearer ghu_app_user_token", + "Bearer gho_cli_token", + ]); + expect(githubCredentialRepositoryAccess({ + source: "app", + token: "ghu_app_user_token", + capabilities: ["read"], + }, { owner: "acme", name: "ade" })).toBeNull(); + } finally { + environment.restore(); + } + }); + + it("does not cache a generic headless repo-probe 403 as repository denial", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-generic-403-", { + emptyGhConfig: true, + }); + storeHeadlessAppUserToken(); + const projectRoot = path.join(process.env.ADE_HOME!, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + execFileSync("git", ["init", projectRoot]); + execFileSync("git", ["-C", projectRoot, "remote", "add", "origin", "https://github.com/acme/ade.git"]); + const authorizations: string[] = []; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get("authorization") ?? ""; + authorizations.push(authorization); + const url = String(input); + if (authorization === "Bearer ghu_app_user_token" && url.endsWith("/user")) { + return new Response(JSON.stringify({ login: "octocat" }), { status: 200 }); + } + if (authorization === "Bearer ghu_app_user_token" && url.endsWith("/repos/acme/ade")) { + return new Response(JSON.stringify({ + message: "Resource protected by organization SAML enforcement.", + }), { status: 403 }); + } + if (authorization === "Bearer gho_cli_token" && url.endsWith("/user")) { + return new Response(JSON.stringify({ login: "fallback-user" }), { + status: 200, + headers: { "x-oauth-scopes": "repo, workflow" }, + }); + } + return new Response(JSON.stringify([{ id: 1 }]), { status: 200 }); + }) as unknown as typeof fetch; + const service = createHeadlessGitHubService( + projectRoot, + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + await expect(service.getStatus()).resolves.toMatchObject({ + authSource: "gh", + userLogin: "fallback-user", + }); + await expect(service.apiRequest({ + method: "GET", + path: "/repos/acme/ade/issues", + })).resolves.toMatchObject({ data: [{ id: 1 }] }); + expect(authorizations.at(-1)).toBe("Bearer ghu_app_user_token"); + } finally { + environment.restore(); + } + }); + it("falls back for headless repository NOT_FOUND and skips the known-negative credential", async () => { const environment = isolateHeadlessGithubAuth("ade-headless-github-graphql-not-found-"); storeHeadlessAppUserToken(); @@ -1088,6 +1384,7 @@ describe("headlessLinearServices", () => { ]); await expect(githubService.getTokenOrThrowAsync()).rejects.toThrow("GitHub write access is unavailable"); await expect(githubService.getReadTokenOrThrowAsync()).resolves.toBe("ghu_app_user_token"); + await expect(githubService.getGitTransportTokenOrThrowAsync()).rejects.toThrow("GitHub auth missing"); } finally { globalThis.fetch = previousFetch; if (previousAdeHome == null) delete process.env.ADE_HOME; @@ -1103,16 +1400,35 @@ describe("headlessLinearServices", () => { } }); + it("uses a stored PAT instead of the App token for headless Git transport", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-transport-", { + emptyGhConfig: true, + }); + storeHeadlessAppUserToken(); + new EncryptedFileCredentialStore().setSync("github.token.v1", "github_pat_read_only_contents"); + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: null, + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + await expect(githubService.getReadTokenOrThrowAsync()).resolves.toBe("ghu_app_user_token"); + await expect(githubService.getGitTransportTokenOrThrowAsync()) + .resolves.toBe("github_pat_read_only_contents"); + } finally { + environment.restore(); + } + }); + it("drops a cached headless writer when that credential disappears", async () => { - const previousAdeHome = process.env.ADE_HOME; - const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; - const previousGitHubToken = process.env.GITHUB_TOKEN; - const previousGhToken = process.env.GH_TOKEN; - const previousFetch = globalThis.fetch; - process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-cache-")); - delete process.env.ADE_GITHUB_TOKEN; - delete process.env.GITHUB_TOKEN; - delete process.env.GH_TOKEN; + const environment = isolateHeadlessGithubAuth("ade-headless-github-cache-"); const machineCredentialStore = new EncryptedFileCredentialStore(); machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ accessToken: "ghu_app_user_token", @@ -1168,15 +1484,7 @@ describe("headlessLinearServices", () => { "Bearer ghu_app_user_token", ]); } finally { - globalThis.fetch = previousFetch; - if (previousAdeHome == null) delete process.env.ADE_HOME; - else process.env.ADE_HOME = previousAdeHome; - if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN; - else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken; - if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN; - else process.env.GITHUB_TOKEN = previousGitHubToken; - if (previousGhToken == null) delete process.env.GH_TOKEN; - else process.env.GH_TOKEN = previousGhToken; + environment.restore(); } }); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 89735fe46..72567b749 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -46,6 +46,10 @@ import { type GitHubRelaySecretReader, } from "../../desktop/src/main/services/github/githubRelayConfig"; import { createGitHubAppUserAuthService } from "../../desktop/src/main/services/github/githubAppUserAuthService"; +import { + requestGithubRawWithCredentialFallback, + type GithubRawRequestArgs, +} from "../../desktop/src/main/services/github/githubRawRequest"; import { classifyGitHubAuthFailure, classifyGitHubGraphqlCredentialFailure, @@ -73,6 +77,7 @@ import { import { classifyGitHubRepositoryApiPath, createGithubRepositoryRequestFallback, + isGithubRepositorySpecificAccessDenial, } from "../../desktop/src/shared/githubApiPath"; import { createGithubConditionalRequestCache } from "../../desktop/src/shared/githubConditionalRequestCache"; import { @@ -598,6 +603,10 @@ const GITHUB_API_TIMEOUT_MS = 20_000; async function fetchGitHub(input: string | URL, init: RequestInit): Promise { const controller = new AbortController(); + const upstreamSignal = init.signal; + const abortFromUpstream = (): void => controller.abort(upstreamSignal?.reason); + if (upstreamSignal?.aborted) abortFromUpstream(); + else upstreamSignal?.addEventListener("abort", abortFromUpstream, { once: true }); const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS); try { return await fetch(input, { ...init, signal: controller.signal }); @@ -610,6 +619,7 @@ async function fetchGitHub(input: string | URL, init: RequestInit): Promise => await requestGithubRawWithCredentialFallback({ + ...args, + candidates: (await readCredentialInventoryAsync()).candidates, + fetchImpl: fetchGitHub, + userAgent: "ade-cli", + authMissingMessage: "GitHub auth missing. Set ADE_GITHUB_TOKEN/GITHUB_TOKEN, run `gh auth login -h github.com -s repo -s workflow`, or add a PAT in Settings.", + onFallback: ({ capability, fromSource, toSource }) => { + logger.info("github.credential_fallback_used", { + capability, + fromSource, + toSource, + }); + }, + }); + const apiRequest = async (args: { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; path: string; @@ -1055,6 +1086,7 @@ export function createHeadlessGitHubService( rateLimit: GitHubRateLimitState | null; } | null = null; let lastAttemptError: HeadlessGithubCredentialAttemptError | null = null; + let firstRateLimitError: HeadlessGithubCredentialAttemptError | null = null; for (const candidate of candidates) { if ( @@ -1147,6 +1179,9 @@ export function createHeadlessGitHubService( failure.rateLimit, ); lastAttemptError = attemptError; + if (attemptError.authFailure.kind === "rate_limited") { + firstRateLimitError ??= attemptError; + } const canTryNext = !args.token && ( response.status === 401 @@ -1192,6 +1227,9 @@ export function createHeadlessGitHubService( graphqlFailure.rateLimit, ); lastAttemptError = attemptError; + if (attemptError.authFailure.kind === "rate_limited") { + firstRateLimitError ??= attemptError; + } const canTryNext = !args.token && (capability === "read" || !graphqlFailure.hasData); if (canTryNext) continue; @@ -1221,13 +1259,17 @@ export function createHeadlessGitHubService( return { data: data as T, response, linkHeader }; } - const exhausted = lastAttemptError ?? (firstUnavailable + const unavailableError = firstUnavailable ? new HeadlessGithubCredentialAttemptError( firstUnavailable.failure.message, firstUnavailable.failure, firstUnavailable.rateLimit, ) - : null); + : null; + const exhausted = firstRateLimitError + ?? (unavailableError?.authFailure.kind === "rate_limited" ? unavailableError : null) + ?? lastAttemptError + ?? unavailableError; if (exhausted?.authFailure.kind === "rate_limited") { const resetAtMs = githubRateLimitRetryAtMs(exhausted.authFailure, exhausted.rateLimit); const resetDetail = resetAtMs == null @@ -1745,16 +1787,20 @@ export function createHeadlessGitHubService( return await appUserAuth.startDeviceAuth(); }, async pollAppUserDeviceAuth(args: { sessionId: string }): Promise { + const previousToken = appUserAuth.getStoredTokenForHealth(); const result = await appUserAuth.pollDeviceAuth(args); if (result.status === "authorized") { - clearGithubCredentialHealth(); + const currentToken = appUserAuth.getStoredTokenForHealth(); + if (previousToken) clearGithubCredentialHealth(previousToken); + if (currentToken && currentToken !== previousToken) clearGithubCredentialHealth(currentToken); invalidateStatusCache(); } return result; }, clearAppUserAuth(): GitHubAppUserAuthStatus { + const previousToken = appUserAuth.getStoredTokenForHealth(); const status = appUserAuth.clearAuth(); - clearGithubCredentialHealth(); + if (previousToken) clearGithubCredentialHealth(previousToken); invalidateStatusCache(); return status; }, @@ -1793,7 +1839,7 @@ export function createHeadlessGitHubService( return token; }, async getGitTransportTokenOrThrowAsync() { - const token = (await readTokenAsync("read", "non-rate-limit-only")).token ?? ""; + const token = (await readTokenAsync("write", "non-rate-limit-only")).token ?? ""; if (!token) { throw new Error( "GitHub auth missing. Set ADE_GITHUB_TOKEN/GITHUB_TOKEN, run `gh auth login -h github.com -s repo -s workflow`, or add a PAT in Settings.", @@ -1808,6 +1854,7 @@ export function createHeadlessGitHubService( parseNextLink: parseNextGitHubLink, setToken(nextToken: string) { const clean = nextToken.trim(); + const previousToken = readStoredPatToken(); tokenOverride = clean || null; if (clean) { credentialStore.setSync(tokenKey, clean); @@ -1815,18 +1862,21 @@ export function createHeadlessGitHubService( credentialStore.deleteSync(tokenKey); } tokenDecryptionFailed = false; - clearGithubCredentialHealth(); + if (previousToken) clearGithubCredentialHealth(previousToken); + if (clean && clean !== previousToken) clearGithubCredentialHealth(clean); invalidateStatusCache(); emitStatusChanged(); }, clearToken() { + const previousToken = readStoredPatToken(); tokenOverride = null; credentialStore.deleteSync(tokenKey); tokenDecryptionFailed = false; - clearGithubCredentialHealth(); + if (previousToken) clearGithubCredentialHealth(previousToken); invalidateStatusCache(); emitStatusChanged(); }, + requestRawWithCredentialFallback, apiRequest, createRepository, getRepository, diff --git a/apps/desktop/src/main/services/automations/automationIngressService.test.ts b/apps/desktop/src/main/services/automations/automationIngressService.test.ts index 6931dd156..65d2017ed 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.test.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.test.ts @@ -993,6 +993,82 @@ describe("automationIngressService", () => { })); }); + it.each([ + ["plain text", (message: string) => message, {}], + ["a parsed JSON error", (message: string) => JSON.stringify({ error: message }), { "content-type": "application/json" }], + ])("bounds %s from a failed relay response before logging or publishing status", async (_, bodyFor, headers) => { + const responseMessage = "relay failure ".repeat(80); + const expectedError = `GitHub relay poll failed (502): ${responseMessage.slice(0, 500)}`; + const logger = makeLogger(); + const updates: Array> = []; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(bodyFor(responseMessage), { + status: 502, + headers, + })); + + service = createAutomationIngressService({ + logger: logger as never, + automationService: { + updateIngressStatus: (patch: Record) => updates.push(patch), + dispatchIngressTrigger: vi.fn(), + getIngressCursor: () => null, + setIngressCursor: vi.fn(), + getIngressStatus: () => ({}), + } as never, + secretService: { getSecret: () => null } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getAppUserTokenForRelay: vi.fn(async () => "ghu_app_user_token"), + }, + listRules: () => [], + }); + + await service.pollNow(); + + expect(logger.warn).toHaveBeenCalledWith( + "automations.github_relay_poll_failed", + expect.objectContaining({ error: expectedError }), + ); + expect(updates).toContainEqual(expect.objectContaining({ + githubRelay: expect.objectContaining({ lastError: expectedError }), + })); + }); + + it("retries a failed connected relay drain when its cooldown expires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-01T12:00:00.000Z")); + const webSockets = makeWebSocketHarness(); + const fetchSpy = vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response("upstream unavailable", { status: 503 })) + .mockResolvedValue(new Response(JSON.stringify({ events: [], nextCursor: null, hasMore: false }), { + headers: { "content-type": "application/json" }, + })); + + service = createAutomationIngressService({ + logger: makeLogger() as never, + automationService: null, + secretService: { getSecret: () => null } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getAppUserTokenForRelay: vi.fn(async () => "ghu_app_user_token"), + }, + listRules: () => [], + ingressCursorStore: { get: () => null, set: () => {} }, + pollIntervalMs: GITHUB_RELAY_MIN_POLL_INTERVAL_MS, + webSocketFactory: webSockets.factory, + }); + + await service.start(); + webSockets.sockets[0]!.open(); + webSockets.sockets[0]!.receive({ t: "github_delivery" }); + + await vi.advanceTimersByTimeAsync(GITHUB_RELAY_MIN_POLL_INTERVAL_MS - 1); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + it("caps relay Retry-After cooldowns and clears them on restart", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-08-01T12:00:00.000Z")); diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index 3460725d8..bcc23bf17 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -77,6 +77,7 @@ export const GITHUB_RELAY_SUBSCRIPTION_BACKOFF_BASE_MS = 1_000; export const GITHUB_RELAY_SUBSCRIPTION_BACKOFF_CAP_MS = 60_000; const GITHUB_RELAY_POLL_BACKOFF_BASE_MS = 30_000; const GITHUB_RELAY_POLL_BACKOFF_CAP_MS = 15 * 60_000; +const GITHUB_RELAY_ERROR_MESSAGE_MAX_LENGTH = 500; class GithubRelayPollError extends Error { constructor( @@ -365,6 +366,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg } let server: http.Server | null = null; let pollTimer: NodeJS.Timeout | null = null; + let relayPollRetryTimer: NodeJS.Timeout | null = null; let pollInFlight: Promise | null = null; let pollRerunRequested = false; let pollAbortController: AbortController | null = null; @@ -616,6 +618,23 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg pollTimer.unref?.(); }; + const clearRelayPollRetryTimer = (): void => { + if (!relayPollRetryTimer) return; + clearTimeout(relayPollRetryTimer); + relayPollRetryTimer = null; + }; + + const scheduleRelayPollRetry = (): void => { + clearRelayPollRetryTimer(); + if (!started || stopped) return; + const delayMs = Math.max(0, relayPollCooldownUntilMs - Date.now()); + relayPollRetryTimer = setTimeout(() => { + relayPollRetryTimer = null; + void pollGithubRelayOnce(); + }, delayMs); + relayPollRetryTimer.unref?.(); + }; + const clearSubscriptionReconnectTimer = (): void => { if (!subscriptionReconnectTimer) return; clearTimeout(subscriptionReconnectTimer); @@ -776,7 +795,10 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg isStopped: () => stopped, }); if (!run.isCurrent()) return; - if (Date.now() < relayPollCooldownUntilMs) return; + if (Date.now() < relayPollCooldownUntilMs) { + scheduleRelayPollRetry(); + return; + } const config = buildGithubRelayConfig(); const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config); const accountAccessToken = args.getAccountAccessToken @@ -951,6 +973,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg } catch { // Keep the plain-text response. } + responseMessage = responseMessage.trim().slice(0, GITHUB_RELAY_ERROR_MESSAGE_MAX_LENGTH); throw new GithubRelayPollError( responseMessage ? `GitHub relay poll failed (${response.status}): ${responseMessage}` @@ -1048,6 +1071,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg flushCommittedPrReconciliation(); relayPollFailureCount = 0; relayPollCooldownUntilMs = 0; + clearRelayPollRetryTimer(); updateGithubRelayStatus({ healthy: true, status: "ready", @@ -1073,6 +1097,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg error instanceof GithubRelayPollError ? error.retryAtMs ?? 0 : 0, ), ); + scheduleRelayPollRetry(); args.logger.warn("automations.github_relay_poll_failed", { error: error instanceof Error ? error.message : String(error), retryAt: new Date(relayPollCooldownUntilMs).toISOString(), @@ -1151,6 +1176,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg // Explicit polls (e.g. right after the user authorizes the GitHub App) // bypass the auth-pending cooldown. hostedAuthPendingUntilMs = 0; + clearRelayPollRetryTimer(); relayPollCooldownUntilMs = 0; relayPollFailureCount = 0; await pollGithubRelayOnce(); @@ -1167,6 +1193,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg clearInterval(pollTimer); pollTimer = null; } + clearRelayPollRetryTimer(); clearSubscriptionReconnectTimer(); clearSubscriptionConnectTimer(); pollAbortController?.abort(); diff --git a/apps/desktop/src/main/services/github/githubAppUserAuthService.ts b/apps/desktop/src/main/services/github/githubAppUserAuthService.ts index e3c42cffa..0c5ae5b46 100644 --- a/apps/desktop/src/main/services/github/githubAppUserAuthService.ts +++ b/apps/desktop/src/main/services/github/githubAppUserAuthService.ts @@ -49,6 +49,7 @@ export function createGitHubAppUserAuthService(args: { startDeviceAuth(): Promise; pollDeviceAuth(args: { sessionId: string }): Promise; clearAuth(): GitHubAppUserAuthStatus; + getStoredTokenForHealth(): string | null; getValidTokenForRelay(): Promise; auditLog: GitHubRelayAuthAuditLog; } { @@ -286,6 +287,7 @@ export function createGitHubAppUserAuthService(args: { startDeviceAuth, pollDeviceAuth, clearAuth, + getStoredTokenForHealth: () => readAppUserTokenRecord()?.accessToken ?? null, getValidTokenForRelay: getValidAppUserTokenForRelay, auditLog, }; diff --git a/apps/desktop/src/main/services/github/githubRawRequest.ts b/apps/desktop/src/main/services/github/githubRawRequest.ts new file mode 100644 index 000000000..b63d1e060 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubRawRequest.ts @@ -0,0 +1,186 @@ +import { + githubOperationCredentialCandidates, + type GithubOperationCredentialCapability, +} from "../../../shared/githubOperationCredential"; +import { + classifyGitHubRepositoryApiPath, + createGithubRepositoryRequestFallback, +} from "../../../shared/githubApiPath"; +import type { + GitHubAuthFailure, + GitHubRateLimitState, + GitHubRepoRef, +} from "../../../shared/types/git"; +import { + githubCredentialCooldown, + githubCredentialRepositoryAccess, + recordGithubCredentialFailure, + recordGithubCredentialRepositoryAccess, + recordGithubCredentialSuccess, + type GithubCredentialCandidate, +} from "./githubCredentialHealth"; +import { + classifyGitHubAuthFailure, + GitHubRateLimitError, + githubRateLimitResourceForPath, + githubRateLimitRetryAtMs, +} from "./githubRateLimit"; + +export type GithubRawRequestArgs = { + url: string; + method?: string; + headers?: Record; + redirect?: RequestRedirect; + signal?: AbortSignal; + capability?: GithubOperationCredentialCapability; + repo?: GitHubRepoRef; +}; + +class GithubRawCredentialAttemptError extends Error { + constructor( + message: string, + readonly authFailure: GitHubAuthFailure, + readonly rateLimit: GitHubRateLimitState | null, + ) { + super(message); + this.name = "GithubCredentialAttemptError"; + } +} + +function responseMessage(text: string, status: number): string { + const fallback = `GitHub API request failed (HTTP ${status})`; + const trimmed = text.trim(); + if (!trimmed) return fallback; + try { + const payload = JSON.parse(trimmed) as unknown; + if (payload && typeof payload === "object") { + const record = payload as Record; + if (typeof record.message === "string" && record.message.trim()) return record.message.trim(); + if (typeof record.error === "string" && record.error.trim()) return record.error.trim(); + } + return fallback; + } catch { + return trimmed; + } +} + +export async function requestGithubRawWithCredentialFallback(args: GithubRawRequestArgs & { + candidates: readonly GithubCredentialCandidate[]; + fetchImpl: (input: string, init: RequestInit) => Promise; + userAgent: string; + defaultHeaders?: Record; + authMissingMessage: string; + onFallback?: (args: { + capability: GithubOperationCredentialCapability; + fromSource: GithubCredentialCandidate["source"] | null; + toSource: GithubCredentialCandidate["source"]; + }) => void; +}): Promise { + const capability = args.capability ?? "read"; + const candidates = githubOperationCredentialCandidates(args.candidates, capability); + if (candidates.length === 0) throw new Error(args.authMissingMessage); + + const parsedUrl = new URL(args.url); + const rateLimitResource = githubRateLimitResourceForPath(parsedUrl.pathname); + const repositoryPath = classifyGitHubRepositoryApiPath(parsedUrl.pathname) + ?? (args.repo ? { ...args.repo, isRepositoryRoot: true } : null); + const repositoryFallback = createGithubRepositoryRequestFallback({ + path: repositoryPath, + readAccess: githubCredentialRepositoryAccess, + recordAccess: recordGithubCredentialRepositoryAccess, + }); + let firstUnavailable: { + failure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + } | null = null; + let lastAttemptError: GithubRawCredentialAttemptError | null = null; + let firstRateLimitError: GithubRawCredentialAttemptError | null = null; + + for (const candidate of candidates) { + if (repositoryFallback.shouldSkip(candidate)) { + const message = `This credential cannot access ${repositoryPath?.owner}/${repositoryPath?.name}.`; + lastAttemptError = new GithubRawCredentialAttemptError( + message, + { kind: "permission_denied", message, retryAt: null }, + null, + ); + continue; + } + const cooldown = githubCredentialCooldown(candidate, Date.now(), { + resource: rateLimitResource, + }); + if (cooldown) { + firstUnavailable ??= cooldown; + continue; + } + + const headers = new Headers(args.headers); + for (const [key, value] of Object.entries(args.defaultHeaders ?? {})) headers.set(key, value); + headers.set("authorization", `Bearer ${candidate.token}`); + headers.set("user-agent", args.userAgent); + const response = await args.fetchImpl(args.url, { + method: args.method ?? "GET", + headers, + redirect: args.redirect, + signal: args.signal, + }); + const manualRedirect = args.redirect === "manual" + && response.status >= 300 + && response.status < 400; + if (response.ok || manualRedirect) { + recordGithubCredentialSuccess(candidate, response.headers); + repositoryFallback.recordSuccess(candidate); + if (candidate !== candidates[0]) { + args.onFallback?.({ + capability, + fromSource: candidates[0]?.source ?? null, + toSource: candidate.source, + }); + } + return response; + } + + const { repositoryNotFound, ambiguousRepositoryNotFound } = + repositoryFallback.classifyFailure(candidate, response.status); + const canTryNext = response.status === 401 + || response.status === 403 + || response.status === 429 + || ambiguousRepositoryNotFound; + if (!canTryNext) return response; + + const message = responseMessage(await response.text().catch(() => ""), response.status); + const failure = classifyGitHubAuthFailure({ + status: response.status, + message, + headers: response.headers, + }); + if (!repositoryNotFound) { + recordGithubCredentialFailure(candidate, failure.authFailure, failure.rateLimit); + } + const attemptError = new GithubRawCredentialAttemptError( + message, + failure.authFailure, + failure.rateLimit, + ); + lastAttemptError = attemptError; + if (attemptError.authFailure.kind === "rate_limited") firstRateLimitError ??= attemptError; + } + + const unavailableError = firstUnavailable + ? new GithubRawCredentialAttemptError( + firstUnavailable.failure.message, + firstUnavailable.failure, + firstUnavailable.rateLimit, + ) + : null; + const exhausted = firstRateLimitError + ?? (unavailableError?.authFailure.kind === "rate_limited" ? unavailableError : null) + ?? lastAttemptError + ?? unavailableError; + if (exhausted?.authFailure.kind === "rate_limited") { + const resetAtMs = githubRateLimitRetryAtMs(exhausted.authFailure, exhausted.rateLimit); + throw new GitHubRateLimitError(exhausted.message, resetAtMs, exhausted.rateLimit); + } + if (exhausted) throw exhausted; + throw new Error("No usable GitHub credential is available for this operation."); +} diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 5f4a059e0..3fca57788 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -55,7 +55,12 @@ import { createGithubService, fetchAdeLatestRelease, } from "./githubService"; -import { clearGithubCredentialHealth } from "./githubCredentialHealth"; +import { + clearGithubCredentialHealth, + githubCredentialCooldown, + githubCredentialRepositoryAccess, + recordGithubCredentialFailure, +} from "./githubCredentialHealth"; // --------------------------------------------------------------------------- // Helpers @@ -312,6 +317,186 @@ describe("githubService.apiRequest", () => { .resolves.toBe("ghp_rate_limited_but_valid"); }); + it("does not use the read-only GitHub App token for Git transport", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + const service = makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + + await expect(service.getReadTokenOrThrowAsync()).resolves.toBe("ghu_app_user_token"); + await expect(service.getGitTransportTokenOrThrowAsync()).resolves.toBe("gho_cli_token"); + }); + + it("keeps a stored PAT eligible for Git transport when the App is connected", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.token.v1", "github_pat_read_only_contents"); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + + await expect(makeService({ credentialStore }).getGitTransportTokenOrThrowAsync()) + .resolves.toBe("github_pat_read_only_contents"); + }); + + it("memoizes credential inventory until authentication changes", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + const readStoredCredential = vi.spyOn(credentialStore, "getSync"); + const service = makeService({ credentialStore }); + + await service.getReadTokenOrThrowAsync(); + const readsAfterFirstLookup = readStoredCredential.mock.calls.length; + await service.getReadTokenOrThrowAsync(); + expect(readStoredCredential).toHaveBeenCalledTimes(readsAfterFirstLookup); + + service.setToken("ghp_new_token"); + const readsAfterMutation = readStoredCredential.mock.calls.length; + await service.getReadTokenOrThrowAsync(); + expect(readStoredCredential.mock.calls.length).toBeGreaterThan(readsAfterMutation); + }); + + it("preserves a primary rate-limit signal when a fallback is forbidden", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + process.env.GITHUB_TOKEN = "ghp_environment_token"; + const resetAtSec = Math.floor(Date.now() / 1_000) + 3_600; + mockFetch + .mockResolvedValueOnce(jsonResponse(403, { message: "API rate limit exceeded" }, { + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": String(resetAtSec), + "x-ratelimit-resource": "core", + })) + .mockResolvedValueOnce(jsonResponse(403, { message: "Resource not accessible" })); + const service = makeService({ + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + + await expect(service.apiRequest({ + method: "GET", + path: "/repos/acme/ade", + token: "ghp_environment_token", + })).rejects.toMatchObject({ name: "GitHubRateLimitError" }); + await expect(service.apiRequest({ method: "GET", path: "/repos/acme/ade" })) + .rejects.toMatchObject({ + name: "GitHubRateLimitError", + rateLimitResetAtMs: resetAtSec * 1_000, + }); + }); + + it("retries manual-redirect requests with the next healthy credential", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch + .mockResolvedValueOnce(jsonResponse(404, { message: "Not Found" })) + .mockResolvedValueOnce(jsonResponse(302, {}, { + location: "https://productionresultssa.blob.core.windows.net/actions-results/job.zip", + })); + const service = makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + + const response = await service.requestRawWithCredentialFallback({ + url: "https://api.github.com/repos/acme/ade/actions/jobs/123/logs", + method: "GET", + headers: { accept: "application/vnd.github+json" }, + redirect: "manual", + capability: "read", + repo: { owner: "acme", name: "ade" }, + }); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("actions-results/job.zip"); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(new Headers(mockFetch.mock.calls[0]?.[1]?.headers).get("authorization")) + .toBe("Bearer ghu_app_user_token"); + expect(new Headers(mockFetch.mock.calls[1]?.[1]?.headers).get("authorization")) + .toBe("Bearer gho_cli_token"); + expect(mockFetch.mock.calls[1]?.[1]?.redirect).toBe("manual"); + await response.text(); + }); + + it("does not mark a repository accessible after a raw server error", async () => { + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch.mockResolvedValueOnce(jsonResponse(503, { message: "Service unavailable" })); + const repo = { owner: "acme", name: "ade" }; + const candidate = { + source: "app" as const, + token: "ghu_app_user_token", + capabilities: ["read"] as const, + }; + + const response = await makeService({ credentialStore }).requestRawWithCredentialFallback({ + url: "https://api.github.com/repos/acme/ade/actions/jobs/123/logs", + redirect: "manual", + repo, + }); + + expect(response.status).toBe(503); + expect(githubCredentialRepositoryAccess(candidate, repo)).toBeNull(); + await response.text(); + }); + it.each([ { name: "when GitHub also returns a primary reset", @@ -564,6 +749,42 @@ describe("githubService.apiRequest", () => { .toBe("Bearer ghp_environment_token"); }); + it("falls back for zero-data repository-scoped GraphQL mutations", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + process.env.GITHUB_TOKEN = "ghp_environment_token"; + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { + data: null, + errors: [{ type: "FORBIDDEN", message: "Repository write is not accessible" }], + })) + .mockResolvedValueOnce(jsonResponse(200, { + data: { resolveReviewThread: { thread: { isResolved: true } } }, + })); + const service = makeService({ + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + + await expect(service.apiRequest({ + method: "POST", + path: "/graphql", + capability: "write", + repo: { owner: "acme", name: "ade" }, + body: { query: "mutation { resolveReviewThread(input: {}) { thread { isResolved } } }" }, + })).resolves.toMatchObject({ + data: { data: { resolveReviewThread: { thread: { isResolved: true } } } }, + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(new Headers(mockFetch.mock.calls[0]?.[1]?.headers).get("authorization")) + .toBe("Bearer ghp_environment_token"); + expect(new Headers(mockFetch.mock.calls[1]?.[1]?.headers).get("authorization")) + .toBe("Bearer gho_cli_token"); + }); + it("falls back on repository-scoped 404s without retrying unrelated 404s", async () => { delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; const credentialStore = new MemoryCredentialStore(); @@ -695,13 +916,39 @@ describe("githubService.apiRequest", () => { it("stores and clears GitHub PATs in the shared machine credential store", () => { const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.token.v1", "ghp_old_token"); const service = makeService({ credentialStore }); + const environment = { + source: "environment" as const, + token: "ghp_environment_token", + capabilities: ["read", "write"] as const, + }; + const oldPat = { + source: "pat" as const, + token: "ghp_old_token", + capabilities: ["read", "write"] as const, + }; + const newPat = { + source: "pat" as const, + token: "ghp_saved_token", + capabilities: ["read", "write"] as const, + }; + const invalid = { kind: "invalid_token" as const, message: "Bad credentials", retryAt: null }; + recordGithubCredentialFailure(environment, invalid, null); + recordGithubCredentialFailure(oldPat, invalid, null); + recordGithubCredentialFailure(newPat, invalid, null); service.setToken("ghp_saved_token"); expect(credentialStore.getSync("github.token.v1")).toBe("ghp_saved_token"); + expect(githubCredentialCooldown(oldPat)).toBeNull(); + expect(githubCredentialCooldown(newPat)).toBeNull(); + expect(githubCredentialCooldown(environment)).not.toBeNull(); + recordGithubCredentialFailure(newPat, invalid, null); service.clearToken(); expect(credentialStore.getSync("github.token.v1")).toBeNull(); + expect(githubCredentialCooldown(newPat)).toBeNull(); + expect(githubCredentialCooldown(environment)).not.toBeNull(); }); }); @@ -1482,6 +1729,51 @@ describe("githubService.getStatus", () => { .toBe("Bearer ghp_environment_token"); }); + it("does not cache generic 403 responses as repository-specific denial", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { login: "alice" })) + .mockResolvedValueOnce(jsonResponse(403, { + message: "Resource protected by organization SAML enforcement.", + })) + .mockResolvedValueOnce(jsonResponse( + 200, + { login: "fallback-user" }, + { "x-oauth-scopes": "repo, workflow" }, + )) + .mockResolvedValueOnce(jsonResponse(200, [{ id: 1 }])); + const service = makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + + await expect(service.getStatus()).resolves.toMatchObject({ + authSource: "gh", + userLogin: "fallback-user", + }); + await expect(service.apiRequest({ method: "GET", path: "/repos/acme/ade/issues" })) + .resolves.toMatchObject({ data: [{ id: 1 }] }); + + expect(new Headers(mockFetch.mock.calls[3]?.[1]?.headers).get("authorization")) + .toBe("Bearer ghu_app_user_token"); + }); + it("clearing a stored PAT falls back to gh auth", async () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; @@ -1595,7 +1887,7 @@ describe("githubService.getStatus", () => { writeAuthSource: "pat", connected: true, }); - credentialStore.deleteSync("github.token.v1"); + service.clearToken(); await expect(service.getStatus()).resolves.toMatchObject({ authSource: "app", writeAuthSource: "none", @@ -2372,6 +2664,19 @@ describe("githubService GitHub App user authorization", () => { it("stores the GitHub App user token returned by device flow polling", async () => { const credentialStore = new MemoryCredentialStore(); const service = makeService({ credentialStore }); + const appCandidate = { + source: "app" as const, + token: "ghu_app_user_token", + capabilities: ["read"] as const, + }; + const environmentCandidate = { + source: "environment" as const, + token: "ghp_environment_token", + capabilities: ["read", "write"] as const, + }; + const invalid = { kind: "invalid_token" as const, message: "Bad credentials", retryAt: null }; + recordGithubCredentialFailure(appCandidate, invalid, null); + recordGithubCredentialFailure(environmentCandidate, invalid, null); mockFetch .mockResolvedValueOnce(jsonResponse(200, { device_code: "device-code", @@ -2410,6 +2715,8 @@ describe("githubService GitHub App user authorization", () => { refreshToken: "ghr_refresh_token", userLogin: "octocat", }); + expect(githubCredentialCooldown(appCandidate)).toBeNull(); + expect(githubCredentialCooldown(environmentCandidate)).not.toBeNull(); }); it("refreshes an expiring GitHub App user token before using it for the relay", async () => { @@ -2504,6 +2811,19 @@ describe("githubService GitHub App user authorization", () => { }); const service = makeService({ credentialStore }); + const appCandidate = { + source: "app" as const, + token: "ghu_old_token", + capabilities: ["read"] as const, + }; + const environmentCandidate = { + source: "environment" as const, + token: "ghp_environment_token", + capabilities: ["read", "write"] as const, + }; + const invalid = { kind: "invalid_token" as const, message: "Bad credentials", retryAt: null }; + recordGithubCredentialFailure(appCandidate, invalid, null); + recordGithubCredentialFailure(environmentCandidate, invalid, null); const tokenPromise = service.getAppUserTokenForRelay(); service.clearAppUserAuth(); resolveRefresh(jsonResponse(200, { @@ -2519,6 +2839,8 @@ describe("githubService GitHub App user authorization", () => { ); expect(credentialStore.getSync("github.appUserToken.v1")).toBeNull(); expect(service.getAppUserAuthStatus()).toMatchObject({ tokenStored: false, userLogin: null }); + expect(githubCredentialCooldown(appCandidate)).toBeNull(); + expect(githubCredentialCooldown(environmentCandidate)).not.toBeNull(); }); }); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 639c16d7a..bd241a4b1 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -32,12 +32,17 @@ import { import { classifyGitHubRepositoryApiPath, createGithubRepositoryRequestFallback, + isGithubRepositorySpecificAccessDenial, } from "../../../shared/githubApiPath"; import { createGithubConditionalRequestCache } from "../../../shared/githubConditionalRequestCache"; import { mergePathEntries, resolveExecutableFromKnownLocations } from "../ai/cliExecutableResolver"; import { fetchGitHubAppInstallationStatus, type GitHubRelaySecretReader } from "./githubRelayConfig"; import { createGitHubAppUserAuthService } from "./githubAppUserAuthService"; import { GITHUB_REST_API_VERSION } from "./githubApiVersion"; +import { + requestGithubRawWithCredentialFallback, + type GithubRawRequestArgs, +} from "./githubRawRequest"; import { classifyGitHubAuthFailure, classifyGitHubGraphqlCredentialFailure, @@ -72,6 +77,7 @@ 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); @@ -126,6 +132,7 @@ type ProcessGithubAuthState = { authInFlight: Promise | null; statusCache: Map; statusInFlight: Map>; + credentialInventoryRevision: number; }; const processGithubAuthStates = new WeakMap(); @@ -149,6 +156,7 @@ function processGithubAuthState(provider: GitHubCliAuthProvider): ProcessGithubA authInFlight: null, statusCache: new Map(), statusInFlight: new Map(), + credentialInventoryRevision: 0, }; processGithubAuthStates.set(provider, created); return created; @@ -553,6 +561,37 @@ 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; + + const invalidateCredentialInventory = (): void => { + credentialInventoryCache = null; + sharedGhAuth.credentialInventoryRevision += 1; + }; + + const invalidateStatusCache = (): void => { + cachedStatus = null; + cachedAt = 0; + cachedStatusCredentialInventoryKey = null; + }; + + const credentialsChanged = (args: { + tokensToClear: Array; + clearGhCaches?: boolean; + }): void => { + for (const token of new Set(args.tokensToClear.filter((value): value is string => Boolean(value)))) { + clearGithubCredentialHealth(token); + } + if (args.clearGhCaches) { + sharedGhAuth.authCache = null; + sharedGhAuth.statusCache.clear(); + } + invalidateCredentialInventory(); + invalidateStatusCache(); + }; const readMachineToken = (): string | null => { if (!credentialStore) return null; @@ -757,7 +796,7 @@ export function createGithubService({ : null; }; - const readCredentialInventory = async (): Promise => { + const buildCredentialInventory = async (): Promise => { const patLookup = readPatAuthToken(); const patTokenStored = Boolean(patLookup); const environment = readEnvironmentAuthToken(); @@ -815,6 +854,30 @@ export function createGithubService({ }; }; + 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 readAuthToken = async ( capability: GithubOperationCredentialCapability = "read", failurePolicy: "all" | "non-rate-limit-only" = "all", @@ -1018,6 +1081,10 @@ export function createGithubService({ message, headers: response.headers, }); + const repositoryAccessDenied = isGithubRepositorySpecificAccessDenial( + response.status, + message, + ); const authFailure = failure.authFailure.kind === "unknown" && (response.status === 403 || response.status === 404) ? { @@ -1028,7 +1095,7 @@ export function createGithubService({ : failure.authFailure.kind === "unknown" ? null : failure.authFailure; - if (authFailure?.kind === "permission_denied") { + if (authFailure?.kind === "permission_denied" && repositoryAccessDenied) { recordGithubCredentialRepositoryAccess(candidate, repo, false); } return { @@ -1152,6 +1219,24 @@ export function createGithubService({ // don't count against GitHub's rate limit, so this dramatically reduces API usage. const conditionalRequestCache = createGithubConditionalRequestCache(); + const requestRawWithCredentialFallback = async ( + args: GithubRawRequestArgs, + ): Promise => await requestGithubRawWithCredentialFallback({ + ...args, + candidates: (await readCredentialInventory()).candidates, + fetchImpl: fetchGitHub, + userAgent: "ade-desktop", + defaultHeaders: { "x-github-api-version": GITHUB_REST_API_VERSION }, + authMissingMessage: "GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings.", + onFallback: ({ capability, fromSource, toSource }) => { + logger.info("github.credential_fallback_used", { + capability, + fromSource, + toSource, + }); + }, + }); + const apiRequest = async (args: { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; path: string; @@ -1207,6 +1292,7 @@ export function createGithubService({ rateLimit: GitHubRateLimitState | null; } | null = null; let lastAttemptError: GithubCredentialAttemptError | null = null; + let firstRateLimitError: GithubCredentialAttemptError | null = null; for (const candidate of candidates) { if ( @@ -1316,6 +1402,9 @@ export function createGithubService({ failure.rateLimit, ); lastAttemptError = attemptError; + if (attemptError.authFailure.kind === "rate_limited") { + firstRateLimitError ??= attemptError; + } const canTryNext = !args.token && ( response.status === 401 @@ -1361,6 +1450,9 @@ export function createGithubService({ graphqlFailure.rateLimit, ); lastAttemptError = attemptError; + if (attemptError.authFailure.kind === "rate_limited") { + firstRateLimitError ??= attemptError; + } const canTryNext = !args.token && (capability === "read" || !graphqlFailure.hasData); if (canTryNext) continue; @@ -1390,13 +1482,17 @@ export function createGithubService({ return { data: data as T, response, linkHeader }; } - const exhausted = lastAttemptError ?? (firstUnavailable + const unavailableError = firstUnavailable ? new GithubCredentialAttemptError( firstUnavailable.failure.message, firstUnavailable.failure, firstUnavailable.rateLimit, ) - : null); + : null; + const exhausted = firstRateLimitError + ?? (unavailableError?.authFailure.kind === "rate_limited" ? unavailableError : null) + ?? lastAttemptError + ?? unavailableError; if (exhausted?.authFailure.kind === "rate_limited") { const resetAtMs = githubRateLimitRetryAtMs(exhausted.authFailure, exhausted.rateLimit); const resetDetail = resetAtMs == null @@ -1457,6 +1553,7 @@ export function createGithubService({ sharedGhAuth.authCache = null; sharedGhAuth.statusCache.clear(); processGhHostsTokenCache.clear(); + invalidateCredentialInventory(); } const [inventory, origin] = await Promise.all([ readCredentialInventory(), @@ -2162,45 +2259,41 @@ export function createGithubService({ }, async pollAppUserDeviceAuth(args: { sessionId: string }): Promise { + const previousToken = appUserAuth.getStoredTokenForHealth(); const result = await appUserAuth.pollDeviceAuth(args); if (result.status === "authorized") { - clearGithubCredentialHealth(); - cachedStatus = null; - cachedAt = 0; - cachedStatusCredentialInventoryKey = null; + const currentToken = appUserAuth.getStoredTokenForHealth(); + credentialsChanged({ tokensToClear: [previousToken, currentToken] }); } return result; }, clearAppUserAuth(): GitHubAppUserAuthStatus { + const previousToken = appUserAuth.getStoredTokenForHealth(); const status = appUserAuth.clearAuth(); - clearGithubCredentialHealth(); - cachedStatus = null; - cachedAt = 0; - cachedStatusCredentialInventoryKey = null; + credentialsChanged({ tokensToClear: [previousToken] }); return status; }, setToken(token: string): void { + const previousToken = readStoredPatToken(); persistToken(token); tokenDecryptionFailed = false; - cachedStatus = null; - cachedAt = 0; - cachedStatusCredentialInventoryKey = null; - sharedGhAuth.authCache = null; - sharedGhAuth.statusCache.clear(); - clearGithubCredentialHealth(); + const currentToken = token.trim(); + credentialsChanged({ + tokensToClear: [previousToken, currentToken], + clearGhCaches: true, + }); }, clearToken(): void { + const previousToken = readStoredPatToken(); persistToken(null); tokenDecryptionFailed = false; - cachedStatus = null; - cachedAt = 0; - cachedStatusCredentialInventoryKey = null; - sharedGhAuth.authCache = null; - sharedGhAuth.statusCache.clear(); - clearGithubCredentialHealth(); + credentialsChanged({ + tokensToClear: [previousToken], + clearGhCaches: true, + }); }, async getRepoOrThrow(): Promise { @@ -2228,11 +2321,13 @@ export function createGithubService({ }, async getGitTransportTokenOrThrowAsync(): Promise { - const token = (await readAuthToken("read", "non-rate-limit-only")).token; + const token = (await readAuthToken("write", "non-rate-limit-only")).token; if (!token) throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); return token; }, + requestRawWithCredentialFallback, + async getAppUserTokenForRelay(): Promise { return await appUserAuth.getValidTokenForRelay(); }, diff --git a/apps/desktop/src/main/services/prs/prAsync.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts index 00816d6af..13b09b370 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -17,6 +17,10 @@ import { recordGithubCredentialSuccess, } from "../github/githubCredentialHealth"; +afterEach(() => { + clearGithubCredentialHealth(); +}); + // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- @@ -85,7 +89,6 @@ describe("prPollingService", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - clearGithubCredentialHealth(); }); it("refreshes only hot PRs and ignores updatedAt-only churn", async () => { @@ -284,6 +287,8 @@ describe("prPollingService", () => { await vi.advanceTimersByTimeAsync(9_999); expect(listAll).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); + // The backoff tick reads once to choose work and once to publish the + // unchanged snapshot; the failed safety sweep itself is not retried. expect(listAll).toHaveBeenCalledTimes(3); expect(refresh).toHaveBeenCalledTimes(1); diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 06634ad6b..f92baf0da 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -246,6 +246,7 @@ function makeGithubService(overrides?: Record) { getTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), getReadTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), getGitTransportTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), + requestRawWithCredentialFallback: vi.fn(), ...remainingOverrides, } as any; } @@ -878,6 +879,89 @@ describe("prService.getForLane", () => { }); }); +describe("prService repository-scoped GraphQL mutations", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes the owning repository for every node-only review mutation", async () => { + const row = makePrRow(); + const db = makeMockDb(); + installPullRequestRowStore(db, [row]); + const githubService = makeGithubService({ + apiRequest: vi.fn(async (args: { path: string; body?: unknown }) => { + if (args.path !== "/graphql") throw new Error(`Unexpected GitHub API path: ${args.path}`); + const query = String((args.body as { query?: unknown } | undefined)?.query ?? ""); + if (query.includes("query AdePullRequestReviewThreads")) { + return { + data: { + data: { + repository: { + pullRequest: { + reviewThreads: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ + id: "thread-1", + isResolved: false, + isOutdated: false, + comments: { nodes: [] }, + }], + }, + }, + }, + }, + }, + }; + } + if (query.includes("addPullRequestReviewThreadReply")) { + return { + data: { + data: { + addPullRequestReviewThreadReply: { + comment: { id: "comment-1", body: "reply", author: { login: "octocat" } }, + }, + }, + }, + }; + } + if (query.includes("resolveReviewThread")) { + return { + data: { + data: { resolveReviewThread: { thread: { id: "thread-1", isResolved: true } } }, + }, + }; + } + if (query.includes("addReaction")) { + return { + data: { data: { addReaction: { reaction: { id: "reaction-1", content: "THUMBS_UP" } } } }, + }; + } + throw new Error("Unexpected GraphQL operation"); + }), + }); + const { service } = buildService({ db, githubService }); + + await service.replyToReviewThread({ prId: row.id, threadId: "thread-1", body: "reply" }); + await service.resolveReviewThread({ prId: row.id, threadId: "thread-1" }); + await service.postReviewComment({ prId: row.id, threadId: "thread-1", body: "reply" }); + await service.setReviewThreadResolved({ prId: row.id, threadId: "thread-1", resolved: true }); + await service.reactToComment({ prId: row.id, commentId: "comment-1", content: "+1" }); + + const mutationCalls = githubService.apiRequest.mock.calls + .map(([args]: [{ body?: unknown }]) => args) + .filter((args: { body?: unknown }) => /^\s*mutation\b/i.test( + String((args.body as { query?: unknown } | undefined)?.query ?? ""), + )); + expect(mutationCalls).toHaveLength(5); + for (const call of mutationCalls) { + expect(call).toEqual(expect.objectContaining({ + capability: "write", + repo: REPO, + })); + } + }); +}); + describe("prService.getGithubSnapshot", () => { beforeEach(() => { vi.clearAllMocks(); @@ -3632,6 +3716,91 @@ describe("prService.getCheckLog", () => { vi.unstubAllGlobals(); } }); + + it("downloads the API redirect through credential fallback without authenticating the blob request", async () => { + const row = makePrRow({ id: "pr-actions", github_pr_number: 90 }); + const db = makeMockDb(); + db.get.mockImplementation((sql: string, params: unknown[]) => { + const text = String(sql); + if (text.includes("from pull_requests") && text.includes("where id = ?")) { + return params[0] === row.id ? row : null; + } + return null; + }); + const redirectResponse = new Response(null, { + status: 302, + headers: { location: "https://pipelines.actions.githubusercontent.com/log.txt" }, + }); + const discardRedirectBody = vi.spyOn(redirectResponse, "arrayBuffer"); + const requestRawWithCredentialFallback = vi.fn(async () => redirectResponse); + const githubService = makeGithubService({ + requestRawWithCredentialFallback, + apiRequest: vi.fn(async (args: { path: string }) => { + if (args.path === "/repos/test-owner/test-repo/pulls/90") { + return { data: makeGitHubPull({ number: 90, head: { ref: "my-feature", sha: "head-sha" } }) }; + } + if (args.path === "/repos/test-owner/test-repo/actions/runs") { + return { + data: { + workflow_runs: [{ + id: 7, + name: "CI", + status: "completed", + conclusion: "failure", + head_sha: "head-sha", + html_url: "https://github.com/test-owner/test-repo/actions/runs/7", + created_at: "2026-07-27T11:55:00.000Z", + updated_at: "2026-07-27T11:59:00.000Z", + }], + }, + }; + } + if (args.path === "/repos/test-owner/test-repo/actions/runs/7/jobs") { + return { + data: { + jobs: [{ + id: 111, + name: "build", + status: "completed", + conclusion: "failure", + steps: [{ number: 1, name: "test", status: "completed", conclusion: "failure" }], + }], + }, + }; + } + throw new Error(`Unexpected GitHub API path: ${args.path}`); + }), + }); + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response("test failed\n", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + try { + const { service } = buildService({ db, githubService }); + + const excerpt = await service.getCheckLog({ prId: "pr-actions", jobId: 111 }); + + expect(excerpt).toMatchObject({ jobId: 111, jobName: "build" }); + expect(requestRawWithCredentialFallback).toHaveBeenCalledWith({ + url: "https://api.github.com/repos/test-owner/test-repo/actions/jobs/111/logs", + method: "GET", + headers: { accept: "application/vnd.github+json" }, + redirect: "manual", + signal: expect.any(AbortSignal), + capability: "read", + repo: REPO, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [blobUrl, blobInit] = fetchMock.mock.calls[0] ?? []; + expect(String(blobUrl)).toBe("https://pipelines.actions.githubusercontent.com/log.txt"); + expect(blobInit).toEqual({ + method: "GET", + signal: expect.any(AbortSignal), + }); + expect(discardRedirectBody).toHaveBeenCalledOnce(); + } finally { + vi.unstubAllGlobals(); + } + }); }); describe("prService.rerunChecks", () => { diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 8e2692f6b..3763eb6da 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -4204,10 +4204,11 @@ export function createPrService({ const graphqlRequest = async ( query: string, variables: Record, - options: { accept?: string } = {}, + options: { accept?: string; repo?: GitHubRepoRef } = {}, ): Promise => { const owner = typeof variables.owner === "string" ? variables.owner.trim() : ""; const name = typeof variables.name === "string" ? variables.name.trim() : ""; + const repo = options.repo ?? (owner && name ? { owner, name } : null); const { data: payload } = await githubService.apiRequest<{ data?: T; errors?: Array<{ message?: unknown }>; @@ -4215,7 +4216,7 @@ export function createPrService({ method: "POST", path: "/graphql", capability: /^\s*mutation\b/i.test(query) ? "write" : "read", - ...(owner && name ? { repo: { owner, name } } : {}), + ...(repo ? { repo } : {}), body: { query, variables }, ...(options.accept ? { accept: options.accept } : {}), }); @@ -5747,23 +5748,20 @@ export function createPrService({ repo: GitHubRepoRef; jobId: number; }): Promise<{ text: string; truncated: boolean } | null> => { - const token = await githubService.getReadTokenOrThrowAsync(); const apiUrl = `https://api.github.com/repos/${args.repo.owner}/${args.repo.name}/actions/jobs/${args.jobId}/logs`; - const headers = { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "user-agent": "ade-desktop", - }; - - const redirect = await fetch(apiUrl, { + const redirect = await githubService.requestRawWithCredentialFallback({ + url: apiUrl, method: "GET", - headers, + headers: { accept: "application/vnd.github+json" }, redirect: "manual", signal: AbortSignal.timeout(CHECK_LOG_FETCH_TIMEOUT_MS), + capability: "read", + repo: args.repo, }); let blobResponse: Response; if (redirect.status >= 300 && redirect.status < 400) { const location = redirect.headers.get("location"); + await redirect.arrayBuffer().catch(() => undefined); if (!location) return null; let blobUrl: URL; try { @@ -5781,6 +5779,7 @@ export function createPrService({ } else if (redirect.ok) { blobResponse = redirect; } else { + await redirect.arrayBuffer().catch(() => undefined); return null; } if (!blobResponse.ok || !blobResponse.body) return null; @@ -10926,6 +10925,7 @@ export function createPrService({ threadId: args.threadId, body: args.body, }, + { repo }, ); const comment = data.addPullRequestReviewThreadReply?.comment; @@ -10962,11 +10962,12 @@ export function createPrService({ } `, { threadId: args.threadId }, + { repo }, ); }, async postReviewComment(args: PostPrReviewCommentArgs): Promise { - await assertThreadBelongsToPr(args.prId, args.threadId); + const { repo } = await assertThreadBelongsToPr(args.prId, args.threadId); const data = await graphqlRequest<{ addPullRequestReviewThreadReply?: { comment?: { @@ -10994,6 +10995,7 @@ export function createPrService({ } `, { threadId: args.threadId, body: args.body }, + { repo }, ); const comment = data.addPullRequestReviewThreadReply?.comment; if (!comment) { @@ -11011,7 +11013,7 @@ export function createPrService({ }, async setReviewThreadResolved(args: SetPrReviewThreadResolvedArgs): Promise { - await assertThreadBelongsToPr(args.prId, args.threadId); + const { repo } = await assertThreadBelongsToPr(args.prId, args.threadId); if (args.resolved) { const data = await graphqlRequest<{ resolveReviewThread?: { thread?: { id?: unknown; isResolved?: unknown } | null } | null; @@ -11024,6 +11026,7 @@ export function createPrService({ } `, { threadId: args.threadId }, + { repo }, ); const thread = data.resolveReviewThread?.thread ?? null; return { @@ -11042,6 +11045,7 @@ export function createPrService({ } `, { threadId: args.threadId }, + { repo }, ); const thread = data.unresolveReviewThread?.thread ?? null; return { @@ -11051,14 +11055,9 @@ export function createPrService({ }, async reactToComment(args: ReactToPrCommentArgs): Promise { - // requireRow gates the caller's access to the PR, but the commentId is - // trusted from the UI: reactions can target review comments, issue - // comments, or review threads — validating ownership for every node type - // would require an extra GraphQL round-trip per click and offers little - // defense given the user already has write access to the PR's comments. - // Unmapped GitHub-tab PRs carry a synthetic "gh:" id with no row — the - // reaction keys on the global commentId, so skip the row gate for those. - if (!parseSyntheticGithubPrId(args.prId)) requireRow(args.prId); + // Node ids are global, but the repository context lets GitHub credential + // failover skip tokens that cannot write to this PR's repository. + const { repo } = resolvePrThreadTarget(args.prId); const contentEnum = reactionToGraphqlEnum(args.content); await graphqlRequest( ` @@ -11069,6 +11068,7 @@ export function createPrService({ } `, { subjectId: args.commentId, content: contentEnum }, + { repo }, ); }, diff --git a/apps/desktop/src/shared/githubApiPath.ts b/apps/desktop/src/shared/githubApiPath.ts index 5f7a6a376..b801fbb17 100644 --- a/apps/desktop/src/shared/githubApiPath.ts +++ b/apps/desktop/src/shared/githubApiPath.ts @@ -4,6 +4,14 @@ export type GitHubRepositoryApiPath = GitHubRepoRef & { isRepositoryRoot: boolean; }; +export function isGithubRepositorySpecificAccessDenial( + status: number, + message: string, +): boolean { + return status === 404 + || /repository (?:is )?not accessible|cannot access (?:this )?repository|does not have access to (?:this )?repository/i.test(message); +} + export function classifyGitHubRepositoryApiPath(path: string): GitHubRepositoryApiPath | null { const pathname = path.split(/[?#]/, 1)[0] ?? ""; const match = pathname.match(/^\/repos\/([^/]+)\/([^/]+)(\/.*)?$/); diff --git a/apps/desktop/src/shared/githubConditionalRequestCache.test.ts b/apps/desktop/src/shared/githubConditionalRequestCache.test.ts index 7d981eda9..4cc147749 100644 --- a/apps/desktop/src/shared/githubConditionalRequestCache.test.ts +++ b/apps/desktop/src/shared/githubConditionalRequestCache.test.ts @@ -21,4 +21,17 @@ describe("githubConditionalRequestCache", () => { expect(cache.get("one")).toBeNull(); expect(cache.get("three")?.data).toEqual({ value: "three" }); }); + + it("evicts the least recently used inactive entry", () => { + const cache = createGithubConditionalRequestCache(2); + cache.store("one", { etag: '"one"', data: 1, linkHeader: null }); + cache.store("two", { etag: '"two"', data: 2, linkHeader: null }); + + expect(cache.get("one")?.data).toBe(1); + cache.store("three", { etag: '"three"', data: 3, linkHeader: null }); + + expect(cache.get("one")?.data).toBe(1); + expect(cache.get("two")).toBeNull(); + expect(cache.get("three")?.data).toBe(3); + }); }); diff --git a/apps/desktop/src/shared/githubConditionalRequestCache.ts b/apps/desktop/src/shared/githubConditionalRequestCache.ts index 094b39279..e48a971bd 100644 --- a/apps/desktop/src/shared/githubConditionalRequestCache.ts +++ b/apps/desktop/src/shared/githubConditionalRequestCache.ts @@ -14,12 +14,20 @@ export function createGithubConditionalRequestCache(maxSize = 200) { else activeConditionalRequests.set(key, count - 1); }; + const touch = (key: string): GithubConditionalRequestCacheEntry | null => { + const entry = entries.get(key); + if (!entry) return null; + entries.delete(key); + entries.set(key, entry); + return entry; + }; + return { begin(key: string): { entry: GithubConditionalRequestCacheEntry; release: () => void; } | null { - const entry = entries.get(key); + const entry = touch(key); if (!entry) return null; activeConditionalRequests.set(key, (activeConditionalRequests.get(key) ?? 0) + 1); let released = false; @@ -33,7 +41,7 @@ export function createGithubConditionalRequestCache(maxSize = 200) { }; }, get(key: string): GithubConditionalRequestCacheEntry | null { - return entries.get(key) ?? null; + return touch(key); }, deleteWhere(predicate: (key: string) => boolean): void { for (const key of entries.keys()) { @@ -51,6 +59,7 @@ export function createGithubConditionalRequestCache(maxSize = 200) { if (!evictable) break; entries.delete(evictable); } + entries.delete(key); entries.set(key, entry); }, }; diff --git a/apps/desktop/src/shared/githubOperationCredential.test.ts b/apps/desktop/src/shared/githubOperationCredential.test.ts index 88307ad58..ab328a572 100644 --- a/apps/desktop/src/shared/githubOperationCredential.test.ts +++ b/apps/desktop/src/shared/githubOperationCredential.test.ts @@ -3,6 +3,7 @@ import { evaluateGithubCredentialCapabilities, resolveGithubStatusCredentials, } from "./githubOperationCredential"; +import type { GithubStatusCredentialProbeResult } from "./githubOperationCredential"; describe("githubOperationCredential", () => { it("keeps a repo-validated fine-grained token available for writes", () => { @@ -54,4 +55,40 @@ describe("githubOperationCredential", () => { ); expect(accepted).toHaveBeenCalledTimes(1); }); + + it("re-probes an accepted but unvalidated read credential before using it for writes", async () => { + const gh = { source: "gh" as const, token: "shared" }; + type Probe = { repoAccessOk: boolean; write: boolean }; + const probe = vi.fn<[typeof gh], Promise>>() + .mockResolvedValueOnce({ + ok: false as const, + error: "Not Found", + authFailure: { + kind: "permission_denied" as const, + message: "Not Found", + retryAt: null, + }, + rateLimit: null, + value: { repoAccessOk: false, write: false }, + }) + .mockResolvedValueOnce({ + ok: true as const, + value: { repoAccessOk: true, write: true }, + }); + + const result = await resolveGithubStatusCredentials({ + readCandidates: [gh], + writeCandidates: [gh], + cooldown: () => null, + probe, + capabilities: (_candidate, value) => ({ read: value.repoAccessOk, write: value.write }), + isRepositoryAccessFailure: (result) => result.value?.repoAccessOk === false, + onAcceptedProbe: vi.fn(), + onRejectedProbe: vi.fn(), + }); + + expect(result.active?.candidate).toBe(gh); + expect(result.activeWriteSource).toBe("gh"); + expect(probe).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/desktop/src/shared/githubOperationCredential.ts b/apps/desktop/src/shared/githubOperationCredential.ts index 24a5bcb18..97957dbf0 100644 --- a/apps/desktop/src/shared/githubOperationCredential.ts +++ b/apps/desktop/src/shared/githubOperationCredential.ts @@ -198,7 +198,6 @@ export async function resolveGithubStatusCredentials< .some((fallback) => !args.cooldown(fallback)); if (repositoryAccessFailure && result.value && !hasFallback) { active = { candidate, value: result.value }; - successfulProbes.set(candidate.token, result.value); args.onAcceptedProbe(candidate, result.value, false); break; } diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index 1a5001c0c..5405ab5fc 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -548,15 +548,22 @@ async function authenticateAccount(request: Request, env: RelayEnv): Promise { - const row = await env.DB +): Promise { + return await env.DB .prepare("select account_id from github_app_repositories where repository_key = ? and installed = 1 limit 1") .bind(`${repo.owner}/${repo.name}`.toLowerCase()) .first(); +} + +async function githubRepositoryAccountMatches( + env: RelayEnv, + repo: { owner: string; name: string }, + accountId: string, +): Promise { + const row = await readInstalledGitHubRepositoryAccount(env, repo); return row?.account_id === accountId; } @@ -1524,9 +1531,27 @@ async function authorizeRepoEventRead( // clients that do not send an ADE account token. const accountId = await authenticateAccount(request, env); if (accountId) { - if (await githubRepositoryAccountMatches(env, repo, accountId)) { + const mapping = await readInstalledGitHubRepositoryAccount(env, repo); + if (mapping?.account_id === accountId) { + return { authorized: true, accountId }; + } + if (!mapping) { + return { + authorized: false, + response: json({ ok: false, error: "unauthorized" }, { status: 401 }), + }; + } + const auth = await assertGitHubRepoAuthorized(request, env, repo); + if (!auth.authorized) return { authorized: false, response: auth.response }; + + const repositoryKey = `${repo.owner}/${repo.name}`.toLowerCase(); + if (await associateGitHubRepositoryWithAccount(env, repositoryKey, repositoryKey, accountId)) { return { authorized: true, accountId }; } + + // A repository claimed by another account or explicitly unlinked from this + // one must remain terminal for account-authenticated event reads. Legacy + // callers without an ADE account token still use the provider path below. return { authorized: false, response: json({ ok: false, error: "unauthorized" }, { status: 401 }), diff --git a/apps/webhook-relay/test/account.test.ts b/apps/webhook-relay/test/account.test.ts index 61a578258..9a825114a 100644 --- a/apps/webhook-relay/test/account.test.ts +++ b/apps/webhook-relay/test/account.test.ts @@ -479,6 +479,25 @@ describe("account integration re-keying", () => { expect(env.DB.linearOrganizations[0]?.account_id).toBeNull(); }); + it("binds an unassociated repository when the first account-authenticated request is an event poll", async () => { + const env = makeEnv(); + seedRepository(env.DB, null); + stubLegacyApis(); + const accountToken = await mintToken("user_1"); + + const response = await handleRequest(request("/github/repos/acme/repo/events", { + authorization: "Bearer ghu_app_user_token", + accountToken, + }), env); + + expect(response.status).toBe(200); + expect(fetch).toHaveBeenCalledTimes(1); + expect(env.DB.repositories[0]?.account_id).toBe("user_1"); + expect(env.DB.githubEvents[0]?.account_id).toBe("user_1"); + expect((await response.json() as { events: Array<{ eventId: string }> }).events) + .toEqual([expect.objectContaining({ eventId: "github-delivery-1" })]); + }); + it("stamps account mappings, isolates account lists, supports both auth keys, and revokes only account access", async () => { const env = makeEnv(); seedRepository(env.DB, null); @@ -619,8 +638,13 @@ describe("account integration re-keying", () => { expect(env.DB.linearEvents[0]?.account_id).toBeNull(); const revokedGitHub = await handleRequest(request("/github/repos/acme/repo/events", { accountToken }), env); + const revokedGitHubWithProvider = await handleRequest(request("/github/repos/acme/repo/events", { + authorization: "Bearer ghp_repo_token", + accountToken, + }), env); const revokedLinear = await handleRequest(request("/linear/orgs/org-1/events", { accountToken }), env); expect(revokedGitHub.status).toBe(401); + expect(revokedGitHubWithProvider.status).toBe(401); expect(revokedLinear.status).toBe(401); expect((await handleRequest(request("/github/repos/acme/repo/events", { authorization: "Bearer ghp_repo_token", From 33350ed7d826959d3261d436cc0ab8b713a538de Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:27:21 -0400 Subject: [PATCH 06/12] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20rebas?= =?UTF-8?q?e=20and=20address=20#3695557116=20#3695557118?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/headlessLinearServices.test.ts | 47 ++++++++ apps/ade-cli/src/headlessLinearServices.ts | 58 ++++++---- .../automations/automationIngressService.ts | 12 +- .../github/githubCredentialHealth.test.ts | 16 ++- .../services/github/githubCredentialHealth.ts | 105 +++++++++++++----- .../main/services/github/githubRawRequest.ts | 4 +- .../services/github/githubService.test.ts | 31 ++++++ .../src/main/services/github/githubService.ts | 58 ++++++---- .../components/settings/GitHubSection.tsx | 23 ++-- .../shared/githubOperationCredential.test.ts | 52 ++++++++- .../src/shared/githubOperationCredential.ts | 47 ++++++-- 11 files changed, 351 insertions(+), 102 deletions(-) diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 172625f06..5c47c8fee 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -1055,6 +1055,53 @@ describe("headlessLinearServices", () => { } }); + it("retries a headless credential on later operations after an endpoint-level 403", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-operation-403-", { + emptyGhConfig: true, + }); + process.env.GITHUB_TOKEN = "ghp_environment_token"; + const authorizations: string[] = []; + let environmentAttempts = 0; + globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get("authorization") ?? ""; + authorizations.push(authorization); + if (authorization === "Bearer ghp_environment_token" && environmentAttempts++ === 0) { + return new Response(JSON.stringify({ + message: "Resource protected by organization policy", + }), { status: 403 }); + } + return new Response(JSON.stringify([{ id: authorizations.length }]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + const service = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + await expect(service.apiRequest({ method: "GET", path: "/user/emails" })) + .resolves.toMatchObject({ data: [{ id: 2 }] }); + await expect(service.apiRequest({ method: "GET", path: "/user/emails" })) + .resolves.toMatchObject({ data: [{ id: 3 }] }); + expect(authorizations).toEqual([ + "Bearer ghp_environment_token", + "Bearer gho_cli_token", + "Bearer ghp_environment_token", + ]); + } finally { + environment.restore(); + } + }); + it("preserves an earlier headless GraphQL rate limit after fallback permission failure", async () => { const environment = isolateHeadlessGithubAuth("ade-headless-github-graphql-rate-precedence-", { emptyGhConfig: true, diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 72567b749..762e77f3a 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -84,11 +84,14 @@ import { clearGithubCredentialHealth, githubBackgroundRequestPauseUntilMs, githubCredentialCooldown, + githubCredentialNonRateLimitCooldown, + githubCredentialRateLimitCooldown, githubCredentialInventoryKey, githubCredentialRepositoryAccess, githubCredentialStates, githubCredentialTokenDigest, recordGithubCredentialFailure, + recordGithubOperationFailure, recordGithubCredentialProbeSuccess, recordGithubCredentialRepositoryAccess, recordGithubCredentialSuccess, @@ -782,7 +785,6 @@ export function createHeadlessGitHubService( const readTokenAsync = async ( capability: GithubOperationCredentialCapability = "write", - failurePolicy: "all" | "non-rate-limit-only" = "all", ): Promise => { const inventory = await readCredentialInventoryAsync(); return resolveGithubOperationCredentialCandidate({ @@ -791,7 +793,7 @@ export function createHeadlessGitHubService( isAvailable: (candidate) => !githubCredentialCooldown( candidate, Date.now(), - { resource: "core", failurePolicy }, + { resource: "core" }, ), }) ?? { @@ -803,6 +805,25 @@ export function createHeadlessGitHubService( }; }; + const readGitTransportTokenAsync = async (): Promise => { + const inventory = await readCredentialInventoryAsync(); + return resolveGithubOperationCredentialCandidate({ + candidates: inventory.candidates, + capability: "write", + isAvailable: (candidate) => !githubCredentialNonRateLimitCooldown( + candidate, + Date.now(), + { resource: "core" }, + ), + }) ?? { + token: null, + source: "none", + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + }; + }; + const getToken = (): string => readToken().token ?? ""; const getTokenType = (token: string): NonNullable => { @@ -1171,7 +1192,7 @@ export function createHeadlessGitHubService( const { repositoryNotFound, ambiguousRepositoryNotFound } = repositoryFallback.classifyFailure(candidate, response.status); if (!repositoryNotFound) { - recordGithubCredentialFailure(candidate, failure.authFailure, failure.rateLimit); + recordGithubOperationFailure(candidate, failure.authFailure, failure.rateLimit); } const attemptError = new HeadlessGithubCredentialAttemptError( message, @@ -1215,7 +1236,7 @@ export function createHeadlessGitHubService( graphqlFailure.status, ); if (!repositoryNotFound) { - recordGithubCredentialFailure( + recordGithubOperationFailure( candidate, graphqlFailure.authFailure, graphqlFailure.rateLimit, @@ -1569,14 +1590,9 @@ export function createHeadlessGitHubService( const lookup = (async (): Promise => { const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); - const statusCooldown = (candidate: HeadlessGitHubTokenCandidate) => githubCredentialCooldown( - candidate, - Date.now(), - { - resource: "core", - failurePolicy: opts.forceRefresh === true ? "rate-limit-only" : "all", - }, - ); + const statusCooldown = (candidate: HeadlessGitHubTokenCandidate) => opts.forceRefresh === true + ? githubCredentialRateLimitCooldown(candidate, Date.now(), { resource: "core" }) + : githubCredentialCooldown(candidate, Date.now(), { resource: "core" }); const primaryCandidate = readCandidates[0] ?? null; if (!primaryCandidate) { return { @@ -1622,15 +1638,15 @@ export function createHeadlessGitHubService( ), isRepositoryAccessFailure: (result) => result.authFailure.kind === "permission_denied" && result.value?.repoAccessOk === false, - onAcceptedProbe: (candidate, value, validated) => { + onAuthenticatedProbe: (candidate, value) => { registerGithubCredentialIdentity(candidate, value.validated.userLogin); - if (validated) { - recordGithubCredentialProbeSuccess( - candidate, - value.validated.rateLimit, - value.validated.userLogin, - ); - } + }, + onUsableProbe: (candidate, value) => { + recordGithubCredentialProbeSuccess( + candidate, + value.validated.rateLimit, + value.validated.userLogin, + ); }, onRejectedProbe: (candidate, result, context) => { if (!context.repositoryAccessFailure) { @@ -1839,7 +1855,7 @@ export function createHeadlessGitHubService( return token; }, async getGitTransportTokenOrThrowAsync() { - const token = (await readTokenAsync("write", "non-rate-limit-only")).token ?? ""; + const token = (await readGitTransportTokenAsync()).token ?? ""; if (!token) { throw new Error( "GitHub auth missing. Set ADE_GITHUB_TOKEN/GITHUB_TOKEN, run `gh auth login -h github.com -s repo -s workflow`, or add a PAT in Settings.", diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index bcc23bf17..20e20e920 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -934,10 +934,13 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg hostedAuth?.ok ? "github-app-user-token" : "account-token", ); } + let remoteProjectId: string | null = null; + if (useLegacyProjectRoute) remoteProjectId = config.remoteProjectId; + else if (repo) remoteProjectId = `${repo.owner}/${repo.name}`; updateGithubRelayStatus({ configured: true, apiBaseUrl: config.apiBaseUrl, - remoteProjectId: useLegacyProjectRoute ? config.remoteProjectId : repo ? `${repo.owner}/${repo.name}` : null, + remoteProjectId, status: "polling", }); @@ -965,11 +968,8 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg let responseMessage = responseText.trim(); try { const parsed = JSON.parse(responseText) as { error?: unknown; message?: unknown }; - responseMessage = typeof parsed.error === "string" - ? parsed.error - : typeof parsed.message === "string" - ? parsed.message - : responseMessage; + if (typeof parsed.error === "string") responseMessage = parsed.error; + else if (typeof parsed.message === "string") responseMessage = parsed.message; } catch { // Keep the plain-text response. } diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts index 859723b69..bdb16e9e8 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts @@ -4,7 +4,9 @@ import { clearGithubCredentialHealth, githubBackgroundRequestPauseUntilMs, githubCredentialCooldown, + githubCredentialRateLimitCooldown, recordGithubCredentialFailure, + recordGithubOperationFailure, recordGithubCredentialSuccess, registerGithubCredentialIdentity, type GithubCredentialCandidate, @@ -127,7 +129,7 @@ describe("githubCredentialHealth", () => { retryAt: null, }, null); expect(githubCredentialCooldown(appCandidate)).not.toBeNull(); - expect(githubCredentialCooldown(appCandidate, Date.now(), { failurePolicy: "rate-limit-only" })) + expect(githubCredentialRateLimitCooldown(appCandidate, Date.now())) .toBeNull(); recordGithubCredentialFailure(appCandidate, { @@ -141,7 +143,17 @@ describe("githubCredentialHealth", () => { resetAt: new Date(Date.now() + 60_000).toISOString(), resource: "core", }); - expect(githubCredentialCooldown(appCandidate, Date.now(), { failurePolicy: "rate-limit-only" }) + expect(githubCredentialRateLimitCooldown(appCandidate, Date.now()) ?.failure.kind).toBe("rate_limited"); }); + + it("does not globally cool a credential after an operation-level permission denial", () => { + recordGithubOperationFailure(appCandidate, { + kind: "permission_denied", + message: "Resource protected by organization policy", + retryAt: null, + }, null); + + expect(githubCredentialCooldown(appCandidate)).toBeNull(); + }); }); diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.ts index e605369eb..00e0305b9 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.ts @@ -163,21 +163,15 @@ export function recordGithubCredentialProbeSuccess( }); } -export function recordGithubCredentialFailure( +function recordGithubFailure( candidate: GithubCredentialCandidate, failure: GitHubAuthFailure, rateLimit: GitHubRateLimitState | null, + cooldownUntilMs: number, ): void { - const now = Date.now(); const digest = githubCredentialTokenDigest(candidate.token); const existing = healthByTokenDigest.get(digest); const userLogin = normalizedLogin(candidate.userLogin ?? existing?.userLogin); - const retryAtMs = githubRateLimitRetryAtMs(failure, rateLimit); - const cooldownUntilMs = failure.kind === "rate_limited" - ? Math.max(now + SECONDARY_RATE_LIMIT_COOLDOWN_MS, retryAtMs ?? 0) - : failure.kind === "invalid_token" || failure.kind === "permission_denied" - ? now + FALLBACK_COOLDOWN_MS - : 0; const next: CredentialHealth = { resources: updateResourceHealth(existing, rateLimit, (current) => ({ failure, @@ -209,13 +203,45 @@ export function recordGithubCredentialFailure( } } -export function githubCredentialCooldown( +export function recordGithubCredentialFailure( + candidate: GithubCredentialCandidate, + failure: GitHubAuthFailure, + rateLimit: GitHubRateLimitState | null, +): void { + const now = Date.now(); + const cooldownUntilMs = failure.kind === "rate_limited" + ? Math.max( + now + SECONDARY_RATE_LIMIT_COOLDOWN_MS, + githubRateLimitRetryAtMs(failure, rateLimit) ?? 0, + ) + : failure.kind === "invalid_token" || failure.kind === "permission_denied" + ? now + FALLBACK_COOLDOWN_MS + : 0; + recordGithubFailure(candidate, failure, rateLimit, cooldownUntilMs); +} + +export function recordGithubOperationFailure( + candidate: GithubCredentialCandidate, + failure: GitHubAuthFailure, + rateLimit: GitHubRateLimitState | null, +): void { + const now = Date.now(); + const cooldownUntilMs = failure.kind === "rate_limited" + ? Math.max( + now + SECONDARY_RATE_LIMIT_COOLDOWN_MS, + githubRateLimitRetryAtMs(failure, rateLimit) ?? 0, + ) + : failure.kind === "invalid_token" + ? now + FALLBACK_COOLDOWN_MS + : 0; + recordGithubFailure(candidate, failure, rateLimit, cooldownUntilMs); +} + +function githubCredentialCooldownMatching( candidate: GithubCredentialCandidate, nowMs = Date.now(), - options: { - resource?: string | null; - failurePolicy?: "all" | "rate-limit-only" | "non-rate-limit-only"; - } = {}, + options: { resource?: string | null } = {}, + matches: (failure: GitHubAuthFailure) => boolean, ): { failure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null } | null { const health = healthFor(candidate); if (!health) return null; @@ -223,22 +249,51 @@ export function githubCredentialCooldown( const entries = requestedResource ? [health.resources.get(requestedResource), health.resources.get("unknown")] : [...health.resources.values()]; - const failurePolicy = options.failurePolicy ?? "all"; const cooling = entries .filter((entry): entry is NonNullable => Boolean( entry?.failure && entry.cooldownUntilMs > nowMs - && ( - failurePolicy === "all" - || (failurePolicy === "rate-limit-only" && entry.failure.kind === "rate_limited") - || (failurePolicy === "non-rate-limit-only" && entry.failure.kind !== "rate_limited") - ), + && matches(entry.failure), )) .sort((left, right) => right.cooldownUntilMs - left.cooldownUntilMs)[0]; if (!cooling?.failure) return null; return { failure: cooling.failure, rateLimit: cooling.rateLimit }; } +export function githubCredentialCooldown( + candidate: GithubCredentialCandidate, + nowMs = Date.now(), + options: { resource?: string | null } = {}, +): { failure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null } | null { + return githubCredentialCooldownMatching(candidate, nowMs, options, () => true); +} + +export function githubCredentialRateLimitCooldown( + candidate: GithubCredentialCandidate, + nowMs = Date.now(), + options: { resource?: string | null } = {}, +): { failure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null } | null { + return githubCredentialCooldownMatching( + candidate, + nowMs, + options, + (failure) => failure.kind === "rate_limited", + ); +} + +export function githubCredentialNonRateLimitCooldown( + candidate: GithubCredentialCandidate, + nowMs = Date.now(), + options: { resource?: string | null } = {}, +): { failure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null } | null { + return githubCredentialCooldownMatching( + candidate, + nowMs, + options, + (failure) => failure.kind !== "rate_limited", + ); +} + export function clearGithubCredentialHealth(token?: string): void { if (token) { const digest = githubCredentialTokenDigest(token); @@ -268,18 +323,16 @@ export function githubCredentialStates(args: { const activeFor: GitHubCredentialCapability[] = []; if (args.activeReadSource === source) activeFor.push("read"); if (args.activeWriteSource === source) activeFor.push("write"); + let state: GitHubCredentialState["state"] = "unavailable"; + if (args.availableSources.has(source)) state = "ready"; + if (cooling) state = "cooldown"; + if (activeFor.length > 0) state = "active"; return { source, available: args.availableSources.has(source), capabilities, activeFor, - state: activeFor.length > 0 - ? "active" - : cooling - ? "cooldown" - : args.availableSources.has(source) - ? "ready" - : "unavailable", + state, failure: cooling?.failure ?? null, rateLimit: cooling?.rateLimit ?? [...(health?.resources.values() ?? [])].find((entry) => entry.rateLimit)?.rateLimit diff --git a/apps/desktop/src/main/services/github/githubRawRequest.ts b/apps/desktop/src/main/services/github/githubRawRequest.ts index b63d1e060..e64b04b7d 100644 --- a/apps/desktop/src/main/services/github/githubRawRequest.ts +++ b/apps/desktop/src/main/services/github/githubRawRequest.ts @@ -14,7 +14,7 @@ import type { import { githubCredentialCooldown, githubCredentialRepositoryAccess, - recordGithubCredentialFailure, + recordGithubOperationFailure, recordGithubCredentialRepositoryAccess, recordGithubCredentialSuccess, type GithubCredentialCandidate, @@ -155,7 +155,7 @@ export async function requestGithubRawWithCredentialFallback(args: GithubRawRequ headers: response.headers, }); if (!repositoryNotFound) { - recordGithubCredentialFailure(candidate, failure.authFailure, failure.rateLimit); + recordGithubOperationFailure(candidate, failure.authFailure, failure.rateLimit); } const attemptError = new GithubRawCredentialAttemptError( message, diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 3fca57788..51474231e 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -419,6 +419,37 @@ describe("githubService.apiRequest", () => { }); }); + it("retries a credential on later operations after an endpoint-level 403", async () => { + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + process.env.GITHUB_TOKEN = "ghp_environment_token"; + mockFetch + .mockResolvedValueOnce(jsonResponse(403, { + message: "Resource protected by organization policy", + })) + .mockResolvedValueOnce(jsonResponse(200, [{ id: 1 }])) + .mockResolvedValueOnce(jsonResponse(200, [{ id: 2 }])); + const service = makeService({ + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + + await expect(service.apiRequest({ method: "GET", path: "/user/emails" })) + .resolves.toMatchObject({ data: [{ id: 1 }] }); + await expect(service.apiRequest({ method: "GET", path: "/user/emails" })) + .resolves.toMatchObject({ data: [{ id: 2 }] }); + + expect(mockFetch.mock.calls.map(([, init]) => ( + (init?.headers as Record | undefined)?.authorization + ))).toEqual([ + "Bearer ghp_environment_token", + "Bearer gho_cli_token", + "Bearer ghp_environment_token", + ]); + }); + it("retries manual-redirect requests with the next healthy credential", async () => { delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; const credentialStore = new MemoryCredentialStore(); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index bd241a4b1..1051f3069 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -56,11 +56,14 @@ import { clearGithubCredentialHealth, githubBackgroundRequestPauseUntilMs, githubCredentialCooldown, + githubCredentialNonRateLimitCooldown, + githubCredentialRateLimitCooldown, githubCredentialInventoryKey, githubCredentialRepositoryAccess, githubCredentialStates, githubCredentialTokenDigest, recordGithubCredentialFailure, + recordGithubOperationFailure, recordGithubCredentialProbeSuccess, recordGithubCredentialRepositoryAccess, recordGithubCredentialSuccess, @@ -880,7 +883,6 @@ export function createGithubService({ const readAuthToken = async ( capability: GithubOperationCredentialCapability = "read", - failurePolicy: "all" | "non-rate-limit-only" = "all", ): Promise => { const inventory = await readCredentialInventory(); const resolved = resolveGithubOperationCredentialCandidate({ @@ -889,7 +891,7 @@ export function createGithubService({ isAvailable: (candidate) => !githubCredentialCooldown( candidate, Date.now(), - { resource: "core", failurePolicy }, + { resource: "core" }, ), }); return resolved ?? { @@ -901,6 +903,25 @@ export function createGithubService({ }; }; + const readGitTransportAuthToken = async (): Promise => { + const inventory = await readCredentialInventory(); + return resolveGithubOperationCredentialCandidate({ + candidates: inventory.candidates, + capability: "write", + isAvailable: (candidate) => !githubCredentialNonRateLimitCooldown( + candidate, + Date.now(), + { resource: "core" }, + ), + }) ?? { + token: null, + source: "none", + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + }; + }; + const readAuthTokenSync = (): GitHubTokenLookup => { const patLookup = readPatAuthToken(); const patTokenStored = Boolean(patLookup); @@ -1394,7 +1415,7 @@ export function createGithubService({ const { repositoryNotFound, ambiguousRepositoryNotFound } = repositoryFallback.classifyFailure(candidate, response.status); if (!repositoryNotFound) { - recordGithubCredentialFailure(candidate, failure.authFailure, failure.rateLimit); + recordGithubOperationFailure(candidate, failure.authFailure, failure.rateLimit); } const attemptError = new GithubCredentialAttemptError( message + detail, @@ -1438,7 +1459,7 @@ export function createGithubService({ graphqlFailure.status, ); if (!repositoryNotFound) { - recordGithubCredentialFailure( + recordGithubOperationFailure( candidate, graphqlFailure.authFailure, graphqlFailure.rateLimit, @@ -1564,14 +1585,9 @@ export function createGithubService({ const primaryCandidate = readCandidates[0] ?? null; const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); const credentialInventoryKey = githubCredentialInventoryKey(inventory.candidates); - const statusCooldown = (candidate: GitHubTokenCandidate) => githubCredentialCooldown( - candidate, - Date.now(), - { - resource: "core", - failurePolicy: opts.forceRefresh === true ? "rate-limit-only" : "all", - }, - ); + const statusCooldown = (candidate: GitHubTokenCandidate) => opts.forceRefresh === true + ? githubCredentialRateLimitCooldown(candidate, Date.now(), { resource: "core" }) + : githubCredentialCooldown(candidate, Date.now(), { resource: "core" }); if (!primaryCandidate) { cachedStatus = { tokenStored: false, @@ -1672,15 +1688,15 @@ export function createGithubService({ capabilities: (candidate, value) => validatedCredentialCapabilities(candidate, value, repo), isRepositoryAccessFailure: (result) => result.authFailure.kind === "permission_denied" && result.value?.repoAccessOk === false, - onAcceptedProbe: (candidate, value, validated) => { + onAuthenticatedProbe: (candidate, value) => { registerGithubCredentialIdentity(candidate, value.validated.userLogin); - if (validated) { - recordGithubCredentialProbeSuccess( - candidate, - value.validated.rateLimit, - value.validated.userLogin, - ); - } + }, + onUsableProbe: (candidate, value) => { + recordGithubCredentialProbeSuccess( + candidate, + value.validated.rateLimit, + value.validated.userLogin, + ); }, onRejectedProbe: (candidate, result, context) => { if (!context.repositoryAccessFailure) { @@ -2321,7 +2337,7 @@ export function createGithubService({ }, async getGitTransportTokenOrThrowAsync(): Promise { - const token = (await readAuthToken("write", "non-rate-limit-only")).token; + const token = (await readGitTransportAuthToken()).token; if (!token) throw new Error("GitHub auth missing. Run `gh auth login -h github.com -s repo -s workflow` or add a personal access token in Settings."); return token; }, diff --git a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx index 38118df8a..fe25e5aeb 100644 --- a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx @@ -102,6 +102,12 @@ function credentialStateLabel(state: GitHubCredentialState): string { return state.available ? "Fallback" : "Not set up"; } +function credentialStateColor(state: GitHubCredentialState): string { + if (state.state === "active") return COLORS.success; + if (state.state === "cooldown") return COLORS.warning; + return state.available ? COLORS.textSecondary : COLORS.textDim; +} + export function GitHubSection({ embedded = false }: { embedded?: boolean }) { const [actionError, setActionError] = useState(null); const [saveNotice, setSaveNotice] = useState(null); @@ -215,6 +221,9 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { && tokenAuthenticated && hasInspectableScopes && !accessState.hasRequiredAccess; + let readsWithLabel = authSourceLabel(githubStatus); + if (authFailure?.kind === "rate_limited") readsWithLabel = "Paused"; + if (activeReadCredential) readsWithLabel = credentialSourceLabel(activeReadCredential.source); let statusColor: string; let statusLabel: string; if (isConnected && credentialFallback) { @@ -391,11 +400,7 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { > {summaryCell("USER", githubStatus?.userLogin ?? null)} {summaryCell("REPOSITORY", githubStatus?.repo ? `${githubStatus.repo.owner}/${githubStatus.repo.name}` : null)} - {summaryCell("READS WITH", activeReadCredential - ? credentialSourceLabel(activeReadCredential.source) - : authFailure?.kind === "rate_limited" - ? "Paused" - : authSourceLabel(githubStatus))} + {summaryCell("READS WITH", readsWithLabel)} {summaryCell("WRITES WITH", credentialSourceLabel(effectiveWriteAuthSource))} @@ -427,13 +432,7 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) {
CONNECTION ORDER
{credentialStates.map((credential, index) => { - const stateColor = credential.state === "active" - ? COLORS.success - : credential.state === "cooldown" - ? COLORS.warning - : credential.available - ? COLORS.textSecondary - : COLORS.textDim; + const stateColor = credentialStateColor(credential); return (
{ it("resolves read fallback and validates the selected writer once", async () => { const app = { source: "app" as const, token: "app" }; const gh = { source: "gh" as const, token: "gh" }; - const accepted = vi.fn(); + const authenticated = vi.fn(); + const usable = vi.fn(); const rejected = vi.fn(); const result = await resolveGithubStatusCredentials({ readCandidates: [app, gh], @@ -41,7 +42,8 @@ describe("githubOperationCredential", () => { : { ok: true as const, value: { repoAccessOk: true, write: true } }, capabilities: (_candidate, probe) => ({ read: probe.repoAccessOk, write: probe.write }), isRepositoryAccessFailure: (probe) => probe.value?.repoAccessOk === false, - onAcceptedProbe: accepted, + onAuthenticatedProbe: authenticated, + onUsableProbe: usable, onRejectedProbe: rejected, }); @@ -53,7 +55,8 @@ describe("githubOperationCredential", () => { expect.objectContaining({ ok: false }), { repositoryAccessFailure: true, phase: "read" }, ); - expect(accepted).toHaveBeenCalledTimes(1); + expect(authenticated).toHaveBeenCalledTimes(1); + expect(usable).toHaveBeenCalledTimes(1); }); it("re-probes an accepted but unvalidated read credential before using it for writes", async () => { @@ -83,7 +86,8 @@ describe("githubOperationCredential", () => { probe, capabilities: (_candidate, value) => ({ read: value.repoAccessOk, write: value.write }), isRepositoryAccessFailure: (result) => result.value?.repoAccessOk === false, - onAcceptedProbe: vi.fn(), + onAuthenticatedProbe: vi.fn(), + onUsableProbe: vi.fn(), onRejectedProbe: vi.fn(), }); @@ -91,4 +95,44 @@ describe("githubOperationCredential", () => { expect(result.activeWriteSource).toBe("gh"); expect(probe).toHaveBeenCalledTimes(2); }); + + it("continues to the next credential when an authenticated probe lacks read access", async () => { + const environment = { source: "environment" as const, token: "environment" }; + const app = { source: "app" as const, token: "app" }; + const authenticated = vi.fn(); + const usable = vi.fn(); + const rejected = vi.fn(); + + const result = await resolveGithubStatusCredentials({ + readCandidates: [environment, app], + writeCandidates: [environment], + cooldown: () => null, + probe: async (candidate) => ({ + ok: true as const, + value: { read: candidate.source === "app", write: false }, + }), + capabilities: (_candidate, value) => value, + isRepositoryAccessFailure: () => false, + onAuthenticatedProbe: authenticated, + onUsableProbe: usable, + onRejectedProbe: rejected, + }); + + expect(result.active?.candidate).toBe(app); + expect(result.failures).toEqual([ + expect.objectContaining({ + candidate: environment, + authFailure: expect.objectContaining({ kind: "permission_denied" }), + }), + ]); + expect(authenticated).toHaveBeenNthCalledWith(1, environment, expect.anything()); + expect(authenticated).toHaveBeenNthCalledWith(2, app, expect.anything()); + expect(usable).toHaveBeenCalledOnce(); + expect(usable).toHaveBeenCalledWith(app, expect.anything()); + expect(rejected).toHaveBeenCalledWith( + environment, + expect.objectContaining({ ok: false }), + { repositoryAccessFailure: false, phase: "read" }, + ); + }); }); diff --git a/apps/desktop/src/shared/githubOperationCredential.ts b/apps/desktop/src/shared/githubOperationCredential.ts index 97957dbf0..3f2b7899a 100644 --- a/apps/desktop/src/shared/githubOperationCredential.ts +++ b/apps/desktop/src/shared/githubOperationCredential.ts @@ -150,11 +150,8 @@ export async function resolveGithubStatusCredentials< isRepositoryAccessFailure: ( result: Extract, { ok: false }>, ) => boolean; - onAcceptedProbe: ( - candidate: Candidate, - probe: Probe, - validated: boolean, - ) => void; + onAuthenticatedProbe: (candidate: Candidate, probe: Probe) => void; + onUsableProbe: (candidate: Candidate, probe: Probe) => void; onRejectedProbe: ( candidate: Candidate, result: Extract, { ok: false }>, @@ -198,7 +195,7 @@ export async function resolveGithubStatusCredentials< .some((fallback) => !args.cooldown(fallback)); if (repositoryAccessFailure && result.value && !hasFallback) { active = { candidate, value: result.value }; - args.onAcceptedProbe(candidate, result.value, false); + args.onAuthenticatedProbe(candidate, result.value); break; } failures.push({ candidate, ...result }); @@ -206,9 +203,40 @@ export async function resolveGithubStatusCredentials< if (result.authFailure.kind === "network" || result.authFailure.kind === "unknown") break; continue; } + const candidateCapabilities = args.capabilities(candidate, result.value); + if (!candidateCapabilities.read) { + successfulProbes.set(candidate.token, result.value); + const hasFallback = args.readCandidates + .slice(candidateIndex + 1) + .some((fallback) => !args.cooldown(fallback)); + if (!hasFallback) { + active = { candidate, value: result.value }; + args.onAuthenticatedProbe(candidate, result.value); + break; + } + const capabilityFailure = { + ok: false as const, + error: "This credential does not grant GitHub repository read access.", + authFailure: { + kind: "permission_denied" as const, + message: "This credential does not grant GitHub repository read access.", + retryAt: null, + }, + rateLimit: null, + value: result.value, + }; + failures.push({ candidate, ...capabilityFailure }); + args.onAuthenticatedProbe(candidate, result.value); + args.onRejectedProbe(candidate, capabilityFailure, { + repositoryAccessFailure: false, + phase: "read", + }); + continue; + } active = { candidate, value: result.value }; successfulProbes.set(candidate.token, result.value); - args.onAcceptedProbe(candidate, result.value, true); + args.onAuthenticatedProbe(candidate, result.value); + args.onUsableProbe(candidate, result.value); break; } @@ -226,7 +254,10 @@ export async function resolveGithubStatusCredentials< continue; } successfulProbes.set(candidate.token, result.value); - if (!existingProbe) args.onAcceptedProbe(candidate, result.value, true); + if (!existingProbe) { + args.onAuthenticatedProbe(candidate, result.value); + args.onUsableProbe(candidate, result.value); + } if (args.capabilities(candidate, result.value).write) { activeWriteSource = candidate.source; break; From 5b931d4a0b4b8cc8f3677b82cdfacb515226d408 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:01:26 -0400 Subject: [PATCH 07/12] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20fix?= =?UTF-8?q?=20test-desktop=20(1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/services/cto/linearAuth.test.ts | 82 +++++++++++++ .../main/services/cto/linearOAuthService.ts | 116 ++++++++++++++---- 2 files changed, 177 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 0f0c7e1ed..bbc7ba6c5 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -642,6 +642,88 @@ describe("linearOAuthService", () => { expect(firstStatus.error).toContain("Superseded"); }); + it("coalesces concurrent session starts behind one callback listener", async () => { + const service = createLinearOAuthService({ + credentials: createCredentialsMock() as any, + logger: createLogger(), + }); + activeServices.push(service); + + const firstStart = service.startSession(); + const secondStart = service.startSession(); + + expect(secondStart).toBe(firstStart); + const [first, second] = await Promise.all([firstStart, secondStart]); + expect(second).toEqual(first); + expect(service.getSession(first.sessionId).status).toBe("pending"); + }); + + it("aborts an active token exchange before replacing its callback listener", async () => { + const credentials = createCredentialsMock(); + let markExchangeStarted: (() => void) | null = null; + const exchangeStarted = new Promise((resolve) => { + markExchangeStarted = resolve; + }); + const mockFetch = vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => { + markExchangeStarted?.(); + const signal = init?.signal; + if (signal?.aborted) { + reject(signal.reason); + return; + } + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + })) as any; + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: mockFetch, + }); + activeServices.push(service); + + const first = await service.startSession(); + const state = new URL(first.authUrl).searchParams.get("state")!; + const callback = httpGet(`${first.redirectUri}?code=slow-code&state=${state}`); + await exchangeStarted; + + const second = await Promise.race([ + service.startSession(), + waitMs(1_000).then(() => { + throw new Error("Replacement OAuth session timed out"); + }), + ]); + await callback; + + expect(service.getSession(first.sessionId)).toMatchObject({ + status: "expired", + error: expect.stringContaining("Superseded"), + }); + expect(service.getSession(second.sessionId).status).toBe("pending"); + expect(credentials.setOAuthToken).not.toHaveBeenCalled(); + }); + + it("does not bind a callback listener after disposal interrupts startup", async () => { + const service = createLinearOAuthService({ + credentials: createCredentialsMock() as any, + logger: createLogger(), + }); + activeServices.push(service); + + const interruptedStart = service.startSession(); + await Promise.resolve(); + service.dispose(); + + await expect(interruptedStart).rejects.toThrow("no longer active"); + + const replacement = createLinearOAuthService({ + credentials: createCredentialsMock() as any, + logger: createLogger(), + }); + activeServices.push(replacement); + await expect(replacement.startSession()).resolves.toMatchObject({ + redirectUri: expect.stringContaining(":19836/oauth/callback"), + }); + }); + it("dispose clears all sessions and closes servers", async () => { const credentials = createCredentialsMock(); const service = createLinearOAuthService({ diff --git a/apps/desktop/src/main/services/cto/linearOAuthService.ts b/apps/desktop/src/main/services/cto/linearOAuthService.ts index 9c80d5182..31a8a4d17 100644 --- a/apps/desktop/src/main/services/cto/linearOAuthService.ts +++ b/apps/desktop/src/main/services/cto/linearOAuthService.ts @@ -30,6 +30,7 @@ type LinearOAuthSessionState = { status: CtoGetLinearOAuthSessionResult["status"]; error: string | null; server: http.Server; + abortController: AbortController; }; type LinearExternalOAuthSessionState = { @@ -69,6 +70,15 @@ function createOAuthPortInUseError(): Error { return error; } +function closeServerAndWait(server: http.Server): Promise { + return new Promise((resolve) => { + try { + server.close(() => resolve()); + } catch { + resolve(); + } + }); +} export function createLinearOAuthService(args: { credentials: LinearCredentialService; @@ -78,25 +88,30 @@ export function createLinearOAuthService(args: { const fetchImpl = args.fetchImpl ?? fetch; const sessions = new Map(); const externalSessions = new Map(); + let disposed = false; + let startingServer: http.Server | null = null; + + const assertActive = (): void => { + if (disposed) throw new Error("Linear OAuth service is no longer active."); + }; const finalizeSession = (session: LinearOAuthSessionState, patch: { status: LinearOAuthSessionState["status"]; error?: string | null; - }) => { + }): Promise => { session.status = patch.status; session.error = patch.error ?? null; - try { - session.server.close(); - } catch { - // best effort - } + session.abortController.abort(); + const closed = closeServerAndWait(session.server); + if (patch.status === "expired") session.server.closeAllConnections(); + return closed; }; const pruneExpiredSessions = () => { const now = Date.now(); for (const session of sessions.values()) { if (session.status === "pending" && now - session.createdAt > LOOPBACK_SESSION_TTL_MS) { - finalizeSession(session, { + void finalizeSession(session, { status: "expired", error: "Linear OAuth session expired before the callback completed.", }); @@ -150,6 +165,7 @@ export function createLinearOAuthService(args: { const exchangeCode = async ( session: Pick, code: string, + signal?: AbortSignal, ): Promise => { const oauthClient = args.credentials.getOAuthClientCredentials(); if (!oauthClient) { @@ -175,6 +191,7 @@ export function createLinearOAuthService(args: { "content-type": "application/x-www-form-urlencoded", }, body: body.toString(), + signal, }); const payload = await response.json().catch(() => ({})) as { @@ -184,6 +201,11 @@ export function createLinearOAuthService(args: { error?: string; error_description?: string; }; + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new Error("Linear OAuth session was cancelled."); + } if (!response.ok || typeof payload.access_token !== "string" || !payload.access_token.trim()) { throw new Error(payload.error_description ?? payload.error ?? `Linear OAuth token exchange failed (HTTP ${response.status}).`); @@ -201,16 +223,23 @@ export function createLinearOAuthService(args: { }); }; - const startSession = async (): Promise => { + const startSessionOnce = async (): Promise => { + assertActive(); pruneExpiredSessions(); // Close any leftover pending sessions so the fixed port is available. // This handles the case where the user closed the browser tab without // completing or cancelling the previous OAuth flow. + const supersededSessions: Promise[] = []; for (const prev of sessions.values()) { if (prev.status === "pending") { - finalizeSession(prev, { status: "expired", error: "Superseded by a new OAuth attempt." }); + supersededSessions.push(finalizeSession(prev, { + status: "expired", + error: "Superseded by a new OAuth attempt.", + })); } } + await Promise.all(supersededSessions); + assertActive(); const oauthClient = args.credentials.getOAuthClientCredentials(); if (!oauthClient) { throw new Error("Linear OAuth is not configured. Configure it in Settings > Linear."); @@ -246,7 +275,7 @@ export function createLinearOAuthService(args: { } if (error) { - finalizeSession(session, { + void finalizeSession(session, { status: "failed", error: errorDescription ?? error, }); @@ -256,7 +285,7 @@ export function createLinearOAuthService(args: { } if (!code) { - finalizeSession(session, { + void finalizeSession(session, { status: "failed", error: "Linear OAuth callback did not include an authorization code.", }); @@ -265,13 +294,15 @@ export function createLinearOAuthService(args: { return; } - await exchangeCode(session, code); - finalizeSession(session, { status: "completed" }); + await exchangeCode(session, code, session.abortController.signal); + if (session.status !== "pending") return; + void finalizeSession(session, { status: "completed" }); res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end("Linear connected. You can close this window and return to ADE."); } catch (error) { + if (session.status !== "pending") return; const message = error instanceof Error ? error.message : "OAuth callback failed."; - finalizeSession(session, { status: "failed", error: message }); + void finalizeSession(session, { status: "failed", error: message }); args.logger?.warn("linear_sync.oauth_callback_failed", { error: message, }); @@ -279,22 +310,39 @@ export function createLinearOAuthService(args: { res.end(message); } }); + startingServer = server; try { await new Promise((resolve, reject) => { - server.once("error", reject); + const cleanup = () => { + server.off("error", onError); + server.off("close", onClose); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onClose = () => { + cleanup(); + reject(new Error("Linear OAuth callback server closed before it started.")); + }; + server.once("error", onError); + server.once("close", onClose); server.listen(OAUTH_PORT, OAUTH_HOST, () => { - server.off("error", reject); + cleanup(); resolve(); }); }); } catch (error) { + if (startingServer === server) startingServer = null; try { server.close(); } catch { // best effort } + if (disposed) assertActive(); + if (isAddressInUseError(error)) { args.logger?.warn("linear_sync.oauth_callback_port_in_use", { host: OAUTH_HOST, @@ -305,6 +353,13 @@ export function createLinearOAuthService(args: { } throw error; } + if (disposed) { + if (startingServer === server) startingServer = null; + const closed = closeServerAndWait(server); + server.closeAllConnections(); + await closed; + assertActive(); + } const address = server.address(); if (!address || typeof address === "string") { @@ -330,8 +385,10 @@ export function createLinearOAuthService(args: { status: "pending", error: null, server, + abortController: new AbortController(), }; sessions.set(sessionId, session); + if (startingServer === server) startingServer = null; return { sessionId, @@ -340,6 +397,17 @@ export function createLinearOAuthService(args: { }; }; + let startSessionInFlight: Promise | null = null; + const startSession = (): Promise => { + if (disposed) return Promise.reject(new Error("Linear OAuth service is no longer active.")); + if (startSessionInFlight) return startSessionInFlight; + const work = startSessionOnce().finally(() => { + if (startSessionInFlight === work) startSessionInFlight = null; + }); + startSessionInFlight = work; + return work; + }; + const getSession = (sessionId: string): CtoGetLinearOAuthSessionResult => { pruneExpiredSessions(); const session = sessions.get(sessionId); @@ -435,12 +503,18 @@ export function createLinearOAuthService(args: { startExternalSession, completeExternalSession, dispose() { + disposed = true; + if (startingServer) { + const server = startingServer; + startingServer = null; + void closeServerAndWait(server); + server.closeAllConnections(); + } for (const session of sessions.values()) { - try { - session.server.close(); - } catch { - // best effort - } + void finalizeSession(session, { + status: "expired", + error: "Linear OAuth service stopped.", + }); } sessions.clear(); externalSessions.clear(); From 2684d5cff4287a5f699c8435ab6e4a5e43dd1577 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:56:48 -0400 Subject: [PATCH 08/12] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20addre?= =?UTF-8?q?ss=20terminal=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/ade-cli/src/bootstrap.ts | 2 +- .../src/headlessLinearServices.test.ts | 72 +------- .../automationIngressService.test.ts | 51 +++++ .../automations/automationIngressService.ts | 6 +- .../src/main/services/cto/linearAuth.test.ts | 143 ++++++++++++-- .../main/services/cto/linearOAuthService.ts | 174 +++++++++++++++--- .../main/services/github/githubRawRequest.ts | 2 + .../src/main/services/ipc/registerIpc.ts | 21 ++- .../src/main/services/prs/prService.test.ts | 5 +- .../src/main/services/prs/prService.ts | 16 +- 10 files changed, 363 insertions(+), 129 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 7365ca574..e014b319d 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1981,7 +1981,7 @@ export async function createAdeRuntime(args: { swallow(() => iosSimulatorService?.dispose()); swallow(() => appControlService?.dispose()); swallow(() => builtInBrowserBridge?.dispose()); - swallow(() => linearOAuthService.dispose()); + void linearOAuthService.dispose().catch(() => {}); swallow(() => headlessLinearServices.dispose()); swallow(() => agentChatService?.forceDisposeAll?.()); swallow(() => testService.disposeAll()); diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 5c47c8fee..391495797 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -932,26 +932,10 @@ describe("headlessLinearServices", () => { }); it("falls back when headless GraphQL returns rate-limit errors with HTTP 200", async () => { - const previousAdeHome = process.env.ADE_HOME; - const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; - const previousGitHubToken = process.env.GITHUB_TOKEN; - const previousGhToken = process.env.GH_TOKEN; - const previousFetch = globalThis.fetch; - process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-graphql-")); - delete process.env.ADE_GITHUB_TOKEN; - delete process.env.GITHUB_TOKEN; - delete process.env.GH_TOKEN; - const machineCredentialStore = new EncryptedFileCredentialStore(); - machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ - accessToken: "ghu_app_user_token", - tokenType: "bearer", - scope: null, - expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), - refreshToken: null, - refreshTokenExpiresAt: null, - userLogin: "octocat", - updatedAt: new Date().toISOString(), - })); + const environment = isolateHeadlessGithubAuth("ade-headless-github-graphql-", { + emptyGhConfig: true, + }); + storeHeadlessAppUserToken(); const authorizations: string[] = []; const resetAt = Math.floor(Date.now() / 1_000) + 3600; globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { @@ -1001,15 +985,7 @@ describe("headlessLinearServices", () => { "Bearer gho_cli_token", ]); } finally { - globalThis.fetch = previousFetch; - if (previousAdeHome == null) delete process.env.ADE_HOME; - else process.env.ADE_HOME = previousAdeHome; - if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN; - else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken; - if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN; - else process.env.GITHUB_TOKEN = previousGitHubToken; - if (previousGhToken == null) delete process.env.GH_TOKEN; - else process.env.GH_TOKEN = previousGhToken; + environment.restore(); } }); @@ -1373,28 +1349,10 @@ describe("headlessLinearServices", () => { }); it("keeps App reads connected without advertising an invalid GitHub CLI writer", async () => { - const previousAdeHome = process.env.ADE_HOME; - const previousAdeGitHubToken = process.env.ADE_GITHUB_TOKEN; - const previousGitHubToken = process.env.GITHUB_TOKEN; - const previousGhToken = process.env.GH_TOKEN; - const previousGhConfigDir = process.env.GH_CONFIG_DIR; - const previousFetch = globalThis.fetch; - process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-app-only-")); - process.env.GH_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-gh-empty-")); - delete process.env.ADE_GITHUB_TOKEN; - delete process.env.GITHUB_TOKEN; - delete process.env.GH_TOKEN; - const machineCredentialStore = new EncryptedFileCredentialStore(); - machineCredentialStore.setSync("github.appUserToken.v1", JSON.stringify({ - accessToken: "ghu_app_user_token", - tokenType: "bearer", - scope: null, - expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), - refreshToken: null, - refreshTokenExpiresAt: null, - userLogin: "octocat", - updatedAt: new Date().toISOString(), - })); + const environment = isolateHeadlessGithubAuth("ade-headless-github-app-only-", { + emptyGhConfig: true, + }); + storeHeadlessAppUserToken(); const authorizations: string[] = []; globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const authorization = new Headers(init?.headers).get("authorization") ?? ""; @@ -1433,17 +1391,7 @@ describe("headlessLinearServices", () => { await expect(githubService.getReadTokenOrThrowAsync()).resolves.toBe("ghu_app_user_token"); await expect(githubService.getGitTransportTokenOrThrowAsync()).rejects.toThrow("GitHub auth missing"); } finally { - globalThis.fetch = previousFetch; - if (previousAdeHome == null) delete process.env.ADE_HOME; - else process.env.ADE_HOME = previousAdeHome; - if (previousAdeGitHubToken == null) delete process.env.ADE_GITHUB_TOKEN; - else process.env.ADE_GITHUB_TOKEN = previousAdeGitHubToken; - if (previousGitHubToken == null) delete process.env.GITHUB_TOKEN; - else process.env.GITHUB_TOKEN = previousGitHubToken; - if (previousGhToken == null) delete process.env.GH_TOKEN; - else process.env.GH_TOKEN = previousGhToken; - if (previousGhConfigDir == null) delete process.env.GH_CONFIG_DIR; - else process.env.GH_CONFIG_DIR = previousGhConfigDir; + environment.restore(); } }); diff --git a/apps/desktop/src/main/services/automations/automationIngressService.test.ts b/apps/desktop/src/main/services/automations/automationIngressService.test.ts index 65d2017ed..90a1b6f7d 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.test.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.test.ts @@ -1188,6 +1188,57 @@ describe("automationIngressService", () => { })); }); + it("swallows a superseded account-auth lookup and runs the queued lifecycle poll", async () => { + let resolveFirstAccountLookup!: (token: string | null) => void; + const getAccountAccessToken = vi.fn() + .mockImplementationOnce(() => new Promise((resolve) => { + resolveFirstAccountLookup = resolve; + })) + .mockResolvedValue(null); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ events: [], nextCursor: null, hasMore: false }), { + headers: { "content-type": "application/json" }, + }), + ); + service = createAutomationIngressService({ + logger: makeLogger() as never, + automationService: null, + secretService: { getSecret: () => null } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getAppUserTokenForRelay: vi.fn(async () => "ghu_app_user_token"), + }, + getAccountAccessToken, + listRules: () => [], + ingressCursorStore: { get: () => null, set: () => {} }, + }); + + const firstStart = service.start(); + await vi.waitFor(() => expect(getAccountAccessToken).toHaveBeenCalledOnce()); + service.stop(); + const secondStart = service.start(); + resolveFirstAccountLookup(null); + + await expect(Promise.all([firstStart, secondStart])).resolves.toEqual([undefined, undefined]); + expect(getAccountAccessToken).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenCalledOnce(); + }); + + it("propagates non-supersession errors raised before relay polling starts", async () => { + service = createAutomationIngressService({ + logger: makeLogger() as never, + automationService: null, + secretService: { getSecret: () => null } as never, + getAccountAccessToken: () => { + throw new Error("account lookup exploded"); + }, + listRules: () => [], + ingressCursorStore: { get: () => null, set: () => {} }, + }); + + await expect(service.pollNow()).rejects.toThrow("account lookup exploded"); + }); + it("polls immediately for subscription wake-up frames", async () => { const webSockets = makeWebSocketHarness(); const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index 20e20e920..2479f77ae 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -1119,7 +1119,11 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg pollInFlight = (async () => { do { pollRerunRequested = false; - await pollGithubRelay(relayPollGeneration); + try { + await pollGithubRelay(relayPollGeneration); + } catch (error) { + if (!(error instanceof GithubRelayPollSupersededError)) throw error; + } } while (pollRerunRequested && !stopped && Date.now() >= relayPollCooldownUntilMs); })().finally(() => { pollInFlight = null; diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index bbc7ba6c5..66e6561c6 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -295,15 +295,7 @@ function createCredentialsMock(overrides?: { }; } -/** - * HTTP GET that tolerates early server close. - * - * The OAuth service calls `server.close()` immediately after writing - * its response in error paths. Node's http client may see a socket - * hang-up before the response is fully consumed. We capture whatever - * status code was received; if none, resolve with statusCode 0 so - * tests can still assert on session state via `getSession`. - */ +/** HTTP GET with an independent socket so callback-concurrency tests are real. */ function httpGet(url: string): Promise<{ statusCode: number; body: string }> { return new Promise((resolve) => { const parsed = new URL(url); @@ -317,6 +309,7 @@ function httpGet(url: string): Promise<{ statusCode: number; body: string }> { port: parsed.port, path: `${parsed.pathname}${parsed.search}`, method: "GET", + agent: false, }, (res) => { statusCode = res.statusCode ?? 0; @@ -363,11 +356,8 @@ async function waitForSessionStatus( } afterEach(async () => { - for (const svc of activeServices) { - svc.dispose(); - } + await Promise.all(activeServices.map((service) => service.dispose())); activeServices.length = 0; - await waitMs(50); }); describe("linearOAuthService", () => { @@ -521,6 +511,95 @@ describe("linearOAuthService", () => { const session = service.getSession(sessionId); expect(session.status).toBe("completed"); expect(session.error).toBeNull(); + + await expect(service.startSession()).resolves.toMatchObject({ + redirectUri: expect.stringContaining(":19836/oauth/callback"), + }); + }); + + it("ends concurrent callbacks after one completes the OAuth session", async () => { + const credentials = createCredentialsMock(); + const exchangeResolvers: Array<(response: { + ok: boolean; + status: number; + json: () => Promise<{ access_token: string }>; + }) => void> = []; + const mockFetch = vi.fn(() => new Promise((resolve) => { + exchangeResolvers.push(resolve); + })) as any; + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: mockFetch, + }); + activeServices.push(service); + + const { authUrl, redirectUri } = await service.startSession(); + const stateParam = new URL(authUrl).searchParams.get("state")!; + const callbackUrl = `${redirectUri}?code=test-code&state=${stateParam}`; + const firstCallback = httpGet(callbackUrl); + const secondCallback = httpGet(callbackUrl); + await vi.waitFor(() => expect(exchangeResolvers).toHaveLength(2)); + + for (const resolveExchange of exchangeResolvers) { + resolveExchange({ + ok: true, + status: 200, + json: async () => ({ access_token: "linear-access-token-123" }), + }); + } + + const responses = await Promise.race([ + Promise.all([firstCallback, secondCallback]), + waitMs(1_000).then(() => { + throw new Error("Concurrent OAuth callbacks did not finish"); + }), + ]); + expect(responses.map((response) => response.statusCode).sort()).toEqual([200, 409]); + }); + + it("ends an exchanging callback when a concurrent error callback finishes the session", async () => { + const credentials = createCredentialsMock(); + let markExchangeStarted!: () => void; + const exchangeStarted = new Promise((resolve) => { + markExchangeStarted = resolve; + }); + let releaseExchange!: (response: { + ok: boolean; + status: number; + json: () => Promise<{ access_token: string }>; + }) => void; + const mockFetch = vi.fn(() => new Promise((resolve) => { + releaseExchange = resolve; + markExchangeStarted(); + })) as any; + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: mockFetch, + }); + activeServices.push(service); + + const { sessionId, authUrl, redirectUri } = await service.startSession(); + const stateParam = new URL(authUrl).searchParams.get("state")!; + const exchangingCallback = httpGet(`${redirectUri}?code=test-code&state=${stateParam}`); + await exchangeStarted; + + const errorCallback = httpGet( + `${redirectUri}?error=access_denied&error_description=User+declined&state=${stateParam}`, + ); + await expect(errorCallback).resolves.toMatchObject({ statusCode: 400 }); + + releaseExchange({ + ok: true, + status: 200, + json: async () => ({ access_token: "linear-access-token-123" }), + }); + await expect(exchangingCallback).resolves.toMatchObject({ statusCode: 409 }); + expect(service.getSession(sessionId)).toMatchObject({ + status: "failed", + error: "User declined", + }); }); it("handles OAuth callback with error parameter from Linear", async () => { @@ -702,17 +781,45 @@ describe("linearOAuthService", () => { }); it("does not bind a callback listener after disposal interrupts startup", async () => { + let markPortBound!: () => void; + const portBound = new Promise((resolve) => { + markPortBound = resolve; + }); + let releaseListeningCallback!: () => void; + const listeningCallbackReleased = new Promise((resolve) => { + releaseListeningCallback = resolve; + }); + const originalListen = http.Server.prototype.listen; + const listenSpy = vi.spyOn(http.Server.prototype, "listen").mockImplementation(function ( + this: http.Server, + ...listenArgs: unknown[] + ) { + const callback = typeof listenArgs.at(-1) === "function" + ? listenArgs.pop() as () => void + : null; + return Reflect.apply(originalListen, this, [...listenArgs, () => { + markPortBound(); + void listeningCallbackReleased.then(() => callback?.()); + }]) as http.Server; + } as typeof originalListen); const service = createLinearOAuthService({ credentials: createCredentialsMock() as any, logger: createLogger(), }); activeServices.push(service); - const interruptedStart = service.startSession(); - await Promise.resolve(); - service.dispose(); + try { + const interruptedStart = service.startSession(); + await portBound; + const disposal = service.dispose(); + releaseListeningCallback(); - await expect(interruptedStart).rejects.toThrow("no longer active"); + await expect(interruptedStart).rejects.toThrow("no longer active"); + await disposal; + } finally { + releaseListeningCallback(); + listenSpy.mockRestore(); + } const replacement = createLinearOAuthService({ credentials: createCredentialsMock() as any, @@ -732,7 +839,7 @@ describe("linearOAuthService", () => { }); const { sessionId } = await service.startSession(); - service.dispose(); + await service.dispose(); const session = service.getSession(sessionId); expect(session.status).toBe("expired"); diff --git a/apps/desktop/src/main/services/cto/linearOAuthService.ts b/apps/desktop/src/main/services/cto/linearOAuthService.ts index 31a8a4d17..6843b4b26 100644 --- a/apps/desktop/src/main/services/cto/linearOAuthService.ts +++ b/apps/desktop/src/main/services/cto/linearOAuthService.ts @@ -31,6 +31,7 @@ type LinearOAuthSessionState = { error: string | null; server: http.Server; abortController: AbortController; + closePromise: Promise | null; }; type LinearExternalOAuthSessionState = { @@ -80,6 +81,28 @@ function closeServerAndWait(server: http.Server): Promise { }); } +function writeResponse( + response: http.ServerResponse, + status: number, + contentType: string, + body: string, +): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + response.off("finish", finish); + response.off("close", finish); + resolve(); + }; + response.once("finish", finish); + response.once("close", finish); + response.writeHead(status, { "content-type": contentType }); + response.end(body); + }); +} + export function createLinearOAuthService(args: { credentials: LinearCredentialService; logger?: Logger | null; @@ -90,23 +113,77 @@ export function createLinearOAuthService(args: { const externalSessions = new Map(); let disposed = false; let startingServer: http.Server | null = null; + let disposeInFlight: Promise | null = null; const assertActive = (): void => { if (disposed) throw new Error("Linear OAuth service is no longer active."); }; - const finalizeSession = (session: LinearOAuthSessionState, patch: { + const markSessionTerminal = (session: LinearOAuthSessionState, patch: { status: LinearOAuthSessionState["status"]; error?: string | null; - }): Promise => { + }): void => { session.status = patch.status; session.error = patch.error ?? null; session.abortController.abort(); + }; + + const beginServerClose = (session: LinearOAuthSessionState): Promise => { const closed = closeServerAndWait(session.server); - if (patch.status === "expired") session.server.closeAllConnections(); + session.server.closeIdleConnections(); + return closed; + }; + + const closeSessionServer = (session: LinearOAuthSessionState): Promise => { + if (!session.closePromise) session.closePromise = beginServerClose(session); + return session.closePromise; + }; + + const forceCloseSessionServer = (session: LinearOAuthSessionState): Promise => { + const closed = closeSessionServer(session); + session.server.closeAllConnections(); return closed; }; + const finalizeSession = (session: LinearOAuthSessionState, patch: { + status: LinearOAuthSessionState["status"]; + error?: string | null; + }): Promise => { + markSessionTerminal(session, patch); + return patch.status === "expired" + ? forceCloseSessionServer(session) + : closeSessionServer(session); + }; + + const respondAndFinalizeSession = ( + session: LinearOAuthSessionState, + patch: { + status: LinearOAuthSessionState["status"]; + error?: string | null; + }, + response: http.ServerResponse, + reply: { + status: number; + contentType: string; + body: string; + }, + ): Promise => { + markSessionTerminal(session, patch); + const work = writeResponse(response, reply.status, reply.contentType, reply.body) + .then(() => beginServerClose(session)); + session.closePromise = work; + return work; + }; + + const respondAlreadyFinished = (response: http.ServerResponse): Promise => ( + writeResponse( + response, + 409, + "text/plain; charset=utf-8", + "This Linear sign-in has already finished. Return to ADE to continue.", + ) + ); + const pruneExpiredSessions = () => { const now = Date.now(); for (const session of sessions.values()) { @@ -236,6 +313,8 @@ export function createLinearOAuthService(args: { status: "expired", error: "Superseded by a new OAuth attempt.", })); + } else if (prev.closePromise) { + supersededSessions.push(prev.closePromise); } } await Promise.all(supersededSessions); @@ -274,40 +353,76 @@ export function createLinearOAuthService(args: { return; } + if (session.status !== "pending") { + await respondAlreadyFinished(res); + return; + } + if (error) { - void finalizeSession(session, { - status: "failed", - error: errorDescription ?? error, - }); - res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); - res.end("Linear authorization was declined."); + await respondAndFinalizeSession( + session, + { status: "failed", error: errorDescription ?? error }, + res, + { + status: 400, + contentType: "text/plain; charset=utf-8", + body: "Linear authorization was declined.", + }, + ); return; } if (!code) { - void finalizeSession(session, { - status: "failed", - error: "Linear OAuth callback did not include an authorization code.", - }); - res.writeHead(400, { "content-type": "text/plain; charset=utf-8" }); - res.end("Missing authorization code."); + await respondAndFinalizeSession( + session, + { + status: "failed", + error: "Linear OAuth callback did not include an authorization code.", + }, + res, + { + status: 400, + contentType: "text/plain; charset=utf-8", + body: "Missing authorization code.", + }, + ); return; } await exchangeCode(session, code, session.abortController.signal); - if (session.status !== "pending") return; - void finalizeSession(session, { status: "completed" }); - res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - res.end("Linear connected. You can close this window and return to ADE."); + if (session.status !== "pending") { + await respondAlreadyFinished(res); + return; + } + await respondAndFinalizeSession( + session, + { status: "completed" }, + res, + { + status: 200, + contentType: "text/html; charset=utf-8", + body: "Linear connected. You can close this window and return to ADE.", + }, + ); } catch (error) { - if (session.status !== "pending") return; + if (session.status !== "pending") { + await respondAlreadyFinished(res); + return; + } const message = error instanceof Error ? error.message : "OAuth callback failed."; - void finalizeSession(session, { status: "failed", error: message }); args.logger?.warn("linear_sync.oauth_callback_failed", { error: message, }); - res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); - res.end(message); + await respondAndFinalizeSession( + session, + { status: "failed", error: message }, + res, + { + status: 500, + contentType: "text/plain; charset=utf-8", + body: message, + }, + ); } }); startingServer = server; @@ -386,6 +501,7 @@ export function createLinearOAuthService(args: { error: null, server, abortController: new AbortController(), + closePromise: null, }; sessions.set(sessionId, session); if (startingServer === server) startingServer = null; @@ -502,22 +618,26 @@ export function createLinearOAuthService(args: { getSession, startExternalSession, completeExternalSession, - dispose() { + dispose(): Promise { + if (disposeInFlight) return disposeInFlight; disposed = true; + const closePromises: Promise[] = []; if (startingServer) { const server = startingServer; startingServer = null; - void closeServerAndWait(server); + closePromises.push(closeServerAndWait(server)); server.closeAllConnections(); } for (const session of sessions.values()) { - void finalizeSession(session, { + closePromises.push(finalizeSession(session, { status: "expired", error: "Linear OAuth service stopped.", - }); + })); } sessions.clear(); externalSessions.clear(); + disposeInFlight = Promise.all(closePromises).then(() => undefined); + return disposeInFlight; }, }; } diff --git a/apps/desktop/src/main/services/github/githubRawRequest.ts b/apps/desktop/src/main/services/github/githubRawRequest.ts index e64b04b7d..26e8a075b 100644 --- a/apps/desktop/src/main/services/github/githubRawRequest.ts +++ b/apps/desktop/src/main/services/github/githubRawRequest.ts @@ -173,6 +173,8 @@ export async function requestGithubRawWithCredentialFallback(args: GithubRawRequ firstUnavailable.rateLimit, ) : null; + // Preserve the most actionable exhausted result: a real rate limit includes + // retry timing, then prefer the latest attempted response, then local absence. const exhausted = firstRateLimitError ?? (unavailableError?.authFailure.kind === "rate_limited" ? unavailableError : null) ?? lastAttemptError diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 31745b099..096774243 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1642,6 +1642,7 @@ export function registerIpc({ const watcherCleanupBoundSenders = new Set(); let linearOAuthService: LinearOAuthService | null = null; let linearOAuthServiceAdeDir: string | null = null; + let linearOAuthServiceTransition: Promise | null = null; const appControlRateBuckets = new Map(); const builtInBrowserRateBuckets = new Map(); let fallbackAnalyticsEnabled = true; @@ -1794,12 +1795,22 @@ export function registerIpc({ throw error; }; - const getLinearOAuthBridge = (ctx: AppContext): LinearOAuthService => { + const getLinearOAuthBridge = async (ctx: AppContext): Promise => { + if (linearOAuthServiceTransition) await linearOAuthServiceTransition; if (!ctx.linearCredentialService) { throw new Error("Linear credential service is not available."); } - if (!linearOAuthService || linearOAuthServiceAdeDir !== ctx.adeDir) { - linearOAuthService?.dispose(); + if (linearOAuthService && linearOAuthServiceAdeDir !== ctx.adeDir) { + const previousService = linearOAuthService; + linearOAuthService = null; + linearOAuthServiceAdeDir = null; + const transition = previousService.dispose().finally(() => { + if (linearOAuthServiceTransition === transition) linearOAuthServiceTransition = null; + }); + linearOAuthServiceTransition = transition; + await transition; + } + if (!linearOAuthService) { linearOAuthService = createLinearOAuthService({ credentials: ctx.linearCredentialService, logger: ctx.logger, @@ -10422,14 +10433,14 @@ export function registerIpc({ ipcMain.handle(IPC.ctoStartLinearOAuth, async (): Promise => { const ctx = getCtx(); - return getLinearOAuthBridge(ctx).startSession(); + return (await getLinearOAuthBridge(ctx)).startSession(); }); ipcMain.handle( IPC.ctoGetLinearOAuthSession, async (_event, arg: CtoGetLinearOAuthSessionArgs): Promise => { const ctx = getCtx(); - const session = getLinearOAuthBridge(ctx).getSession(arg.sessionId); + const session = (await getLinearOAuthBridge(ctx)).getSession(arg.sessionId); if (session.status !== "completed") { return session; } diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index f92baf0da..3fd9dd800 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -940,9 +940,10 @@ describe("prService repository-scoped GraphQL mutations", () => { }), }); const { service } = buildService({ db, githubService }); + const syntheticPrId = `gh:${REPO.owner}/${REPO.name}#${row.github_pr_number}`; - await service.replyToReviewThread({ prId: row.id, threadId: "thread-1", body: "reply" }); - await service.resolveReviewThread({ prId: row.id, threadId: "thread-1" }); + await service.replyToReviewThread({ prId: syntheticPrId, threadId: "thread-1", body: "reply" }); + await service.resolveReviewThread({ prId: syntheticPrId, threadId: "thread-1" }); await service.postReviewComment({ prId: row.id, threadId: "thread-1", body: "reply" }); await service.setReviewThreadResolved({ prId: row.id, threadId: "thread-1", resolved: true }); await service.reactToComment({ prId: row.id, commentId: "comment-1", content: "+1" }); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 3763eb6da..ef5199890 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -2098,7 +2098,7 @@ export function createPrService({ if (uniquePrIds.length === 0) return; for (const prId of uniquePrIds) { // A poll result can itself report the PR as changed. Treating that as a - // brand-new hot window re-armed the 5-second loop indefinitely while CI + // brand-new hot window re-armed the 15-second loop indefinitely while CI // was active and could exhaust the user's shared GitHub REST quota. // Keep the original start time so every hot period is strictly bounded. if (!hotRefreshStartedAtByPrId.has(prId)) { @@ -10883,12 +10883,7 @@ export function createPrService({ }, async replyToReviewThread(args: ReplyToPrReviewThreadArgs): Promise { - const row = requireRow(args.prId); - const repo = repoFromRow(row); - const threads = await fetchReviewThreads(repo, Number(row.github_pr_number)); - if (!threads.some((t) => t.id === args.threadId)) { - throw new Error(`Thread ${args.threadId} does not belong to PR ${args.prId}`); - } + const { repo } = await assertThreadBelongsToPr(args.prId, args.threadId); const data = await graphqlRequest<{ addPullRequestReviewThreadReply?: { comment?: { @@ -10944,12 +10939,7 @@ export function createPrService({ }, async resolveReviewThread(args: ResolvePrReviewThreadArgs): Promise { - const row = requireRow(args.prId); - const repo = repoFromRow(row); - const threads = await fetchReviewThreads(repo, Number(row.github_pr_number)); - if (!threads.some((t) => t.id === args.threadId)) { - throw new Error(`Thread ${args.threadId} does not belong to PR ${args.prId}`); - } + const { repo } = await assertThreadBelongsToPr(args.prId, args.threadId); await graphqlRequest( ` mutation AdeResolveReviewThread($threadId: ID!) { From 5362ee1053a4c3c72a46cc352947b1f5855d1389 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:22:06 -0400 Subject: [PATCH 09/12] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20addre?= =?UTF-8?q?ss=20#3695760038=20#3695760040?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../github/githubCredentialHealth.test.ts | 44 +++++++++++++++++-- .../services/github/githubCredentialHealth.ts | 10 ++++- .../src/main/services/prs/prAsync.test.ts | 21 ++++----- .../src/main/services/prs/prPollingService.ts | 2 +- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts index bdb16e9e8..9bd3a6141 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts @@ -4,6 +4,7 @@ import { clearGithubCredentialHealth, githubBackgroundRequestPauseUntilMs, githubCredentialCooldown, + githubCredentialNonRateLimitCooldown, githubCredentialRateLimitCooldown, recordGithubCredentialFailure, recordGithubOperationFailure, @@ -147,13 +148,50 @@ describe("githubCredentialHealth", () => { ?.failure.kind).toBe("rate_limited"); }); - it("does not globally cool a credential after an operation-level permission denial", () => { - recordGithubOperationFailure(appCandidate, { + it("keeps permission-denied cooldowns resource-scoped", () => { + const permissionDenied = { kind: "permission_denied", message: "Resource protected by organization policy", retryAt: null, - }, null); + } as const; + recordGithubCredentialFailure(appCandidate, permissionDenied, { + limit: 5000, + remaining: 4999, + used: 1, + resetAt: null, + resource: "graphql", + }); + expect(githubCredentialCooldown(appCandidate, Date.now(), { resource: "graphql" })) + .not.toBeNull(); + expect(githubCredentialCooldown(appCandidate, Date.now(), { resource: "core" })).toBeNull(); + + clearGithubCredentialHealth(); + recordGithubOperationFailure(appCandidate, permissionDenied, null); expect(githubCredentialCooldown(appCandidate)).toBeNull(); }); + + it.each(["graphql", "search"])( + "applies an invalid-token cooldown recorded for %s to every API resource", + (resource) => { + recordGithubOperationFailure(ghCandidate, { + kind: "invalid_token", + message: "Bad credentials", + retryAt: null, + }, { + limit: resource === "search" ? 30 : 5000, + remaining: 1, + used: resource === "search" ? 29 : 4999, + resetAt: null, + resource, + }); + + expect(githubCredentialCooldown(ghCandidate, Date.now(), { resource: "core" }) + ?.failure.kind).toBe("invalid_token"); + expect(githubCredentialNonRateLimitCooldown(ghCandidate, Date.now(), { resource: "core" }) + ?.failure.kind).toBe("invalid_token"); + expect(githubCredentialRateLimitCooldown(ghCandidate, Date.now(), { resource: "core" })) + .toBeNull(); + }, + ); }); diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.ts index 00e0305b9..9da82cbb2 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.ts @@ -247,7 +247,15 @@ function githubCredentialCooldownMatching( if (!health) return null; const requestedResource = options.resource?.trim().toLowerCase() || null; const entries = requestedResource - ? [health.resources.get(requestedResource), health.resources.get("unknown")] + ? [...health.resources.entries()] + .filter(([resource, entry]) => ( + resource === requestedResource + || resource === "unknown" + // A rejected token is unusable for every GitHub resource, while + // rate-limit buckets remain scoped to the resource GitHub reported. + || entry.failure?.kind === "invalid_token" + )) + .map(([, entry]) => entry) : [...health.resources.values()]; const cooling = entries .filter((entry): entry is NonNullable => Boolean( diff --git a/apps/desktop/src/main/services/prs/prAsync.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts index 13b09b370..e504664e8 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -254,16 +254,15 @@ describe("prPollingService", () => { expect(refresh).toHaveBeenCalledTimes(1); }); - it("backs off a failed relay safety sweep without retrying it every second", async () => { + it("retries a failed relay safety sweep after backoff, then restores the safety cadence", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-24T12:00:00.000Z")); vi.spyOn(Math, "random").mockReturnValue(0.5); const summary = createSummary(); - const listAll = vi.fn(() => [summary]); - const refresh = vi.fn(async () => { - throw new Error("safety sweep failed"); - }); + const listAll = () => [summary]; + const refresh = vi.fn(async () => [summary]) + .mockRejectedValueOnce(new Error("safety sweep failed")); const service = createPrPollingService({ logger: createLogger() as any, prService: { @@ -282,18 +281,16 @@ describe("prPollingService", () => { service.start(); await vi.advanceTimersByTimeAsync(12_000); expect(refresh).toHaveBeenCalledTimes(1); - expect(listAll).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(9_999); - expect(listAll).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1); - // The backoff tick reads once to choose work and once to publish the - // unchanged snapshot; the failed safety sweep itself is not retried. - expect(listAll).toHaveBeenCalledTimes(3); expect(refresh).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(refresh).toHaveBeenCalledTimes(2); - await vi.advanceTimersByTimeAsync(15 * 60_000 - 10_000); + await vi.advanceTimersByTimeAsync(15 * 60_000 - 1); expect(refresh).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + expect(refresh).toHaveBeenCalledTimes(3); }); it("discovers lane PRs when the local PR cache starts empty", async () => { diff --git a/apps/desktop/src/main/services/prs/prPollingService.ts b/apps/desktop/src/main/services/prs/prPollingService.ts index d80d3a77a..18b62c40e 100644 --- a/apps/desktop/src/main/services/prs/prPollingService.ts +++ b/apps/desktop/src/main/services/prs/prPollingService.ts @@ -307,8 +307,8 @@ export function createPrPollingService({ await prService.refresh({ prIds: targetedPrIds }); } else if (relayHealthy) { if (Date.now() - lastRelaySafetySweepAtMs >= RELAY_SAFETY_SWEEP_INTERVAL_MS) { - lastRelaySafetySweepAtMs = Date.now(); await prService.refresh(); + lastRelaySafetySweepAtMs = Date.now(); } } else if (hotPrIds.length > 0) { await prService.refresh({ prIds: hotPrIds }); From 789b069014ad481502e276dc9c2ee5e2151d3342 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:29:55 -0400 Subject: [PATCH 10/12] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20addre?= =?UTF-8?q?ss=20#3695800347=20#3695802053?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/services/cto/linearAuth.test.ts | 130 +++++++++-- .../main/services/cto/linearOAuthService.ts | 54 +++-- .../src/main/services/ipc/registerIpc.ts | 84 ++++--- .../main/services/ipc/runtimeBridge.test.ts | 207 ++++++++++++++++++ 4 files changed, 419 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 66e6561c6..81b5722af 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -517,15 +517,15 @@ describe("linearOAuthService", () => { }); }); - it("ends concurrent callbacks after one completes the OAuth session", async () => { + it("lets only one concurrent callback exchange the single-use authorization code", async () => { const credentials = createCredentialsMock(); - const exchangeResolvers: Array<(response: { + let resolveExchange!: (response: { ok: boolean; status: number; json: () => Promise<{ access_token: string }>; - }) => void> = []; + }) => void; const mockFetch = vi.fn(() => new Promise((resolve) => { - exchangeResolvers.push(resolve); + resolveExchange = resolve; })) as any; const service = createLinearOAuthService({ credentials: credentials as any, @@ -539,15 +539,13 @@ describe("linearOAuthService", () => { const callbackUrl = `${redirectUri}?code=test-code&state=${stateParam}`; const firstCallback = httpGet(callbackUrl); const secondCallback = httpGet(callbackUrl); - await vi.waitFor(() => expect(exchangeResolvers).toHaveLength(2)); + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); - for (const resolveExchange of exchangeResolvers) { - resolveExchange({ - ok: true, - status: 200, - json: async () => ({ access_token: "linear-access-token-123" }), - }); - } + resolveExchange({ + ok: true, + status: 200, + json: async () => ({ access_token: "linear-access-token-123" }), + }); const responses = await Promise.race([ Promise.all([firstCallback, secondCallback]), @@ -556,9 +554,13 @@ describe("linearOAuthService", () => { }), ]); expect(responses.map((response) => response.statusCode).sort()).toEqual([200, 409]); + expect(responses.find((response) => response.statusCode === 409)?.body).toContain( + "already being completed", + ); + expect(credentials.setOAuthToken).toHaveBeenCalledTimes(1); }); - it("ends an exchanging callback when a concurrent error callback finishes the session", async () => { + it("does not let a duplicate error callback abort the callback exchanging the code", async () => { const credentials = createCredentialsMock(); let markExchangeStarted!: () => void; const exchangeStarted = new Promise((resolve) => { @@ -588,18 +590,23 @@ describe("linearOAuthService", () => { const errorCallback = httpGet( `${redirectUri}?error=access_denied&error_description=User+declined&state=${stateParam}`, ); - await expect(errorCallback).resolves.toMatchObject({ statusCode: 400 }); + await expect(errorCallback).resolves.toMatchObject({ + statusCode: 409, + body: expect.stringContaining("already being completed"), + }); releaseExchange({ ok: true, status: 200, json: async () => ({ access_token: "linear-access-token-123" }), }); - await expect(exchangingCallback).resolves.toMatchObject({ statusCode: 409 }); + await expect(exchangingCallback).resolves.toMatchObject({ statusCode: 200 }); expect(service.getSession(sessionId)).toMatchObject({ - status: "failed", - error: "User declined", + status: "completed", + error: null, }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(credentials.setOAuthToken).toHaveBeenCalledTimes(1); }); it("handles OAuth callback with error parameter from Linear", async () => { @@ -960,6 +967,95 @@ describe("linearOAuthService", () => { message: expect.stringContaining("not found or has expired"), }); }); + + it("coalesces concurrent external OAuth completions onto one code exchange", async () => { + const credentials = createCredentialsMock({ clientSecret: null, clientSource: "ade-app" }); + let resolveExchange!: (response: { + ok: boolean; + status: number; + json: () => Promise<{ access_token: string }>; + }) => void; + const mockFetch = vi.fn(() => new Promise((resolve) => { + resolveExchange = resolve; + })) as any; + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: mockFetch, + }); + activeServices.push(service); + + const started = await service.startExternalSession({ + redirectUri: LINEAR_MOBILE_OAUTH_REDIRECT_URI, + }); + const state = new URL(started.authorizeUrl).searchParams.get("state")!; + const firstCompletion = service.completeExternalSession({ + sessionId: started.sessionId, + code: "mobile-authorization-code", + state, + }); + const duplicateCompletion = service.completeExternalSession({ + sessionId: started.sessionId, + code: "mobile-authorization-code", + state, + }); + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); + + resolveExchange({ + ok: true, + status: 200, + json: async () => ({ access_token: "linear-mobile-access-token" }), + }); + const [firstResult, duplicateResult] = await Promise.all([ + firstCompletion, + duplicateCompletion, + ]); + + expect(firstResult).toEqual({ ok: true }); + expect(duplicateResult).toEqual({ ok: true }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(credentials.setOAuthToken).toHaveBeenCalledTimes(1); + }); + + it("allows an external OAuth completion retry after a failed coalesced exchange", async () => { + const credentials = createCredentialsMock({ clientSecret: null, clientSource: "ade-app" }); + const mockFetch = vi.fn() + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ error: "invalid_grant" }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ access_token: "linear-mobile-access-token" }), + }) as any; + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: mockFetch, + }); + activeServices.push(service); + + const started = await service.startExternalSession({ + redirectUri: LINEAR_MOBILE_OAUTH_REDIRECT_URI, + }); + const state = new URL(started.authorizeUrl).searchParams.get("state")!; + + await expect(service.completeExternalSession({ + sessionId: started.sessionId, + code: "first-code", + state, + })).resolves.toEqual({ ok: false, message: "invalid_grant" }); + await expect(service.completeExternalSession({ + sessionId: started.sessionId, + code: "replacement-code", + state, + })).resolves.toEqual({ ok: true }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(credentials.setOAuthToken).toHaveBeenCalledTimes(1); + }); }); // ===================================================================== diff --git a/apps/desktop/src/main/services/cto/linearOAuthService.ts b/apps/desktop/src/main/services/cto/linearOAuthService.ts index 6843b4b26..0c9596b2d 100644 --- a/apps/desktop/src/main/services/cto/linearOAuthService.ts +++ b/apps/desktop/src/main/services/cto/linearOAuthService.ts @@ -29,6 +29,7 @@ type LinearOAuthSessionState = { createdAt: number; status: CtoGetLinearOAuthSessionResult["status"]; error: string | null; + callbackClaimed: boolean; server: http.Server; abortController: AbortController; closePromise: Promise | null; @@ -42,6 +43,7 @@ type LinearExternalOAuthSessionState = { codeVerifier: string; createdAt: number; expiresAt: string; + completionInFlight: Promise | null; }; export type LinearExternalOAuthStartResult = { @@ -184,6 +186,15 @@ export function createLinearOAuthService(args: { ) ); + const respondCallbackInProgress = (response: http.ServerResponse): Promise => ( + writeResponse( + response, + 409, + "text/plain; charset=utf-8", + "This Linear sign-in is already being completed. Return to ADE to continue.", + ) + ); + const pruneExpiredSessions = () => { const now = Date.now(); for (const session of sessions.values()) { @@ -357,6 +368,14 @@ export function createLinearOAuthService(args: { await respondAlreadyFinished(res); return; } + if (session.callbackClaimed) { + await respondCallbackInProgress(res); + return; + } + // Authorization codes are single-use. Claim the callback synchronously + // before the token exchange yields so a duplicate request cannot race + // the owning callback or change its terminal outcome. + session.callbackClaimed = true; if (error) { await respondAndFinalizeSession( @@ -499,6 +518,7 @@ export function createLinearOAuthService(args: { createdAt: Date.now(), status: "pending", error: null, + callbackClaimed: false, server, abortController: new AbortController(), closePromise: null, @@ -568,6 +588,7 @@ export function createLinearOAuthService(args: { codeVerifier: pkce.verifier, createdAt, expiresAt, + completionInFlight: null, }); return { sessionId, authorizeUrl, expiresAt }; @@ -596,21 +617,28 @@ export function createLinearOAuthService(args: { message: "Linear OAuth state did not match the active sign-in. Start a new sign-in and try again.", }; } + if (session.completionInFlight) return session.completionInFlight; - try { - await exchangeCode(session, input.code); - externalSessions.delete(session.id); - return { ok: true }; - } catch (error) { - const message = error instanceof Error && error.message - ? error.message - : "Linear OAuth token exchange failed."; - args.logger?.warn("linear_sync.external_oauth_exchange_failed", { - sessionId: session.id, - error: message, + const completion = exchangeCode(session, input.code) + .then(() => { + externalSessions.delete(session.id); + return { ok: true }; + }) + .catch((error: unknown) => { + const message = error instanceof Error && error.message + ? error.message + : "Linear OAuth token exchange failed."; + args.logger?.warn("linear_sync.external_oauth_exchange_failed", { + sessionId: session.id, + error: message, + }); + return { ok: false, message }; + }) + .finally(() => { + if (session.completionInFlight === completion) session.completionInFlight = null; }); - return { ok: false, message }; - } + session.completionInFlight = completion; + return completion; }; return { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 096774243..c78b2a00d 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1642,7 +1642,14 @@ export function registerIpc({ const watcherCleanupBoundSenders = new Set(); let linearOAuthService: LinearOAuthService | null = null; let linearOAuthServiceAdeDir: string | null = null; - let linearOAuthServiceTransition: Promise | null = null; + let linearOAuthOperationTail: Promise = Promise.resolve(); + let linearOAuthQueuedAdeDir: string | null = null; + let linearOAuthQueueEpoch = 0; + let linearOAuthStartInFlight: { + adeDir: string; + queueEpoch: number; + promise: Promise; + } | null = null; const appControlRateBuckets = new Map(); const builtInBrowserRateBuckets = new Map(); let fallbackAnalyticsEnabled = true; @@ -1795,29 +1802,40 @@ export function registerIpc({ throw error; }; - const getLinearOAuthBridge = async (ctx: AppContext): Promise => { - if (linearOAuthServiceTransition) await linearOAuthServiceTransition; - if (!ctx.linearCredentialService) { - throw new Error("Linear credential service is not available."); - } - if (linearOAuthService && linearOAuthServiceAdeDir !== ctx.adeDir) { - const previousService = linearOAuthService; - linearOAuthService = null; - linearOAuthServiceAdeDir = null; - const transition = previousService.dispose().finally(() => { - if (linearOAuthServiceTransition === transition) linearOAuthServiceTransition = null; - }); - linearOAuthServiceTransition = transition; - await transition; - } - if (!linearOAuthService) { - linearOAuthService = createLinearOAuthService({ - credentials: ctx.linearCredentialService, - logger: ctx.logger, - }); - linearOAuthServiceAdeDir = ctx.adeDir; + const enterLinearOAuthQueueContext = (adeDir: string): number => { + if (linearOAuthQueuedAdeDir !== adeDir) { + linearOAuthQueuedAdeDir = adeDir; + linearOAuthQueueEpoch += 1; } - return linearOAuthService; + return linearOAuthQueueEpoch; + }; + + const withLinearOAuthBridge = ( + ctx: AppContext, + useService: (service: LinearOAuthService) => T | Promise, + ): Promise => { + enterLinearOAuthQueueContext(ctx.adeDir); + const work = linearOAuthOperationTail.then(async () => { + if (!ctx.linearCredentialService) { + throw new Error("Linear credential service is not available."); + } + if (linearOAuthService && linearOAuthServiceAdeDir !== ctx.adeDir) { + const previousService = linearOAuthService; + linearOAuthService = null; + linearOAuthServiceAdeDir = null; + await previousService.dispose(); + } + if (!linearOAuthService) { + linearOAuthService = createLinearOAuthService({ + credentials: ctx.linearCredentialService, + logger: ctx.logger, + }); + linearOAuthServiceAdeDir = ctx.adeDir; + } + return useService(linearOAuthService); + }); + linearOAuthOperationTail = work.then(() => undefined, () => undefined); + return work; }; const withIpcTiming = async ( @@ -10431,16 +10449,30 @@ export function registerIpc({ return buildLinearConnectionStatus(ctx, tokenStored); }); - ipcMain.handle(IPC.ctoStartLinearOAuth, async (): Promise => { + ipcMain.handle(IPC.ctoStartLinearOAuth, (): Promise => { const ctx = getCtx(); - return (await getLinearOAuthBridge(ctx)).startSession(); + const queueEpoch = enterLinearOAuthQueueContext(ctx.adeDir); + if ( + linearOAuthStartInFlight?.adeDir === ctx.adeDir + && linearOAuthStartInFlight.queueEpoch === queueEpoch + ) { + return linearOAuthStartInFlight.promise; + } + const start = withLinearOAuthBridge(ctx, (service) => service.startSession()); + const trackedStart = start.finally(() => { + if (linearOAuthStartInFlight?.promise === trackedStart) { + linearOAuthStartInFlight = null; + } + }); + linearOAuthStartInFlight = { adeDir: ctx.adeDir, queueEpoch, promise: trackedStart }; + return trackedStart; }); ipcMain.handle( IPC.ctoGetLinearOAuthSession, async (_event, arg: CtoGetLinearOAuthSessionArgs): Promise => { const ctx = getCtx(); - const session = (await getLinearOAuthBridge(ctx)).getSession(arg.sessionId); + const session = await withLinearOAuthBridge(ctx, (service) => service.getSession(arg.sessionId)); if (session.status !== "completed") { return session; } diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index e032ecc47..31b4d9efc 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -36,6 +36,7 @@ const remoteDisconnectMock = vi.hoisted(() => vi.fn()); const hasKnownSshHostKeyForTargetMock = vi.hoisted(() => vi.fn(() => false)); const getSshHostKeyTrustForTargetMock = vi.hoisted(() => vi.fn()); const trustSshHostKeyForTargetMock = vi.hoisted(() => vi.fn()); +const createLinearOAuthServiceMock = vi.hoisted(() => vi.fn()); vi.mock("electron", () => ({ app: { @@ -119,6 +120,10 @@ vi.mock("../git/git", () => ({ runGit: vi.fn(), })); +vi.mock("../cto/linearOAuthService", () => ({ + createLinearOAuthService: createLinearOAuthServiceMock, +})); + import { getOrCreateLocalAccountMachineIdentity, registerRuntimeBridge, @@ -1821,12 +1826,214 @@ describe("registerIpc sync bridge", () => { ipcHandlers.clear(); browserWindowFromWebContents.mockReset().mockReturnValue({ id: 7 }); showOpenDialogMock.mockReset(); + createLinearOAuthServiceMock.mockReset(); }); afterEach(() => { vi.useRealTimers(); }); + it("coalesces concurrent Linear OAuth starts within one project credential context", async () => { + let releaseStart!: () => void; + const startReleased = new Promise((resolve) => { + releaseStart = resolve; + }); + const startSession = vi.fn(async () => { + await startReleased; + return { sessionId: "session-a", authUrl: "https://linear.test/a", redirectUri: "http://a" }; + }); + const service = { + startSession, + getSession: vi.fn(), + dispose: vi.fn(async () => undefined), + }; + createLinearOAuthServiceMock.mockReturnValue(service); + + const credentials = { id: "credentials-a" }; + const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }; + registerIpc({ + getCtx: () => ({ + adeDir: "/repo-a/.ade", + linearCredentialService: credentials, + logger, + }) as any, + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + const handler = ipcHandlers.get(IPC.ctoStartLinearOAuth); + const firstRequest = handler?.(eventForSender()) as Promise; + const duplicateRequest = handler?.(eventForSender()) as Promise; + await vi.waitFor(() => expect(startSession).toHaveBeenCalledTimes(1)); + + releaseStart(); + const [firstResult, duplicateResult] = await Promise.all([firstRequest, duplicateRequest]); + + expect(firstResult).toEqual({ + sessionId: "session-a", + authUrl: "https://linear.test/a", + redirectUri: "http://a", + }); + expect(duplicateResult).toEqual(firstResult); + expect(createLinearOAuthServiceMock).toHaveBeenCalledTimes(1); + expect(service.dispose).not.toHaveBeenCalled(); + }); + + it("serializes Linear OAuth operations across project credential contexts", async () => { + let releaseFirstStart!: () => void; + const firstStartReleased = new Promise((resolve) => { + releaseFirstStart = resolve; + }); + const firstStartSession = vi.fn(async () => { + await firstStartReleased; + return { sessionId: "session-a", authUrl: "https://linear.test/a", redirectUri: "http://a" }; + }); + const firstDispose = vi.fn(async () => undefined); + const firstCredentials = { id: "credentials-a" }; + const secondCredentials = { id: "credentials-b" }; + const firstService = { + startSession: firstStartSession, + getSession: vi.fn(), + dispose: firstDispose, + }; + const secondService = { + startSession: vi.fn(async () => ({ + sessionId: "session-b", + authUrl: "https://linear.test/b", + redirectUri: "http://b", + })), + getSession: vi.fn(), + dispose: vi.fn(async () => undefined), + }; + createLinearOAuthServiceMock.mockImplementation(({ credentials }) => ( + credentials === firstCredentials ? firstService : secondService + )); + + const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }; + let activeContext = { + adeDir: "/repo-a/.ade", + linearCredentialService: firstCredentials, + logger, + }; + registerIpc({ + getCtx: () => activeContext as any, + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + const handler = ipcHandlers.get(IPC.ctoStartLinearOAuth); + const firstRequest = handler?.(eventForSender()) as Promise; + await vi.waitFor(() => expect(firstStartSession).toHaveBeenCalledTimes(1)); + + activeContext = { + adeDir: "/repo-b/.ade", + linearCredentialService: secondCredentials, + logger, + }; + const secondRequest = handler?.(eventForSender()) as Promise; + await Promise.resolve(); + + expect(firstDispose).not.toHaveBeenCalled(); + expect(createLinearOAuthServiceMock).toHaveBeenCalledTimes(1); + + releaseFirstStart(); + await expect(firstRequest).resolves.toMatchObject({ sessionId: "session-a" }); + await expect(secondRequest).resolves.toMatchObject({ sessionId: "session-b" }); + + expect(firstDispose).toHaveBeenCalledTimes(1); + expect(createLinearOAuthServiceMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + credentials: firstCredentials, + })); + expect(createLinearOAuthServiceMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + credentials: secondCredentials, + })); + expect(secondService.startSession).toHaveBeenCalledTimes(1); + }); + + it("does not coalesce Linear OAuth starts across an intervening project session poll", async () => { + let releaseFirstStart!: () => void; + const firstStartReleased = new Promise((resolve) => { + releaseFirstStart = resolve; + }); + const firstService = { + startSession: vi.fn(async () => { + await firstStartReleased; + return { sessionId: "session-a1", authUrl: "https://linear.test/a1", redirectUri: "http://a1" }; + }), + getSession: vi.fn(), + dispose: vi.fn(async () => undefined), + }; + const secondService = { + startSession: vi.fn(), + getSession: vi.fn(() => ({ status: "pending", error: null })), + dispose: vi.fn(async () => undefined), + }; + const thirdService = { + startSession: vi.fn(async () => ({ + sessionId: "session-a2", + authUrl: "https://linear.test/a2", + redirectUri: "http://a2", + })), + getSession: vi.fn(), + dispose: vi.fn(async () => undefined), + }; + createLinearOAuthServiceMock + .mockReturnValueOnce(firstService) + .mockReturnValueOnce(secondService) + .mockReturnValueOnce(thirdService); + + const logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const firstCredentials = { id: "credentials-a" }; + const secondCredentials = { id: "credentials-b" }; + let activeContext = { + adeDir: "/repo-a/.ade", + linearCredentialService: firstCredentials, + logger, + }; + registerIpc({ + getCtx: () => activeContext as any, + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + const startHandler = ipcHandlers.get(IPC.ctoStartLinearOAuth); + const getHandler = ipcHandlers.get(IPC.ctoGetLinearOAuthSession); + const firstStart = startHandler?.(eventForSender()) as Promise; + await vi.waitFor(() => expect(firstService.startSession).toHaveBeenCalledTimes(1)); + + activeContext = { + adeDir: "/repo-b/.ade", + linearCredentialService: secondCredentials, + logger, + }; + const interveningPoll = getHandler?.(eventForSender(), { sessionId: "session-b" }) as Promise; + + activeContext = { + adeDir: "/repo-a/.ade", + linearCredentialService: firstCredentials, + logger, + }; + const secondStart = startHandler?.(eventForSender()) as Promise; + await Promise.resolve(); + expect(createLinearOAuthServiceMock).toHaveBeenCalledTimes(1); + + releaseFirstStart(); + await expect(firstStart).resolves.toMatchObject({ sessionId: "session-a1" }); + await expect(interveningPoll).resolves.toEqual({ status: "pending", error: null }); + await expect(secondStart).resolves.toMatchObject({ sessionId: "session-a2" }); + + expect(createLinearOAuthServiceMock).toHaveBeenCalledTimes(3); + expect(firstService.dispose).toHaveBeenCalledTimes(1); + expect(secondService.dispose).toHaveBeenCalledTimes(1); + expect(thirdService.startSession).toHaveBeenCalledTimes(1); + }); + it("dispatches lane archived automation once after IPC reclaim archives, even when removal later fails", async () => { const onLaneArchived = vi.fn(); const archiveAndReclaim = vi.fn() From cb74e95fc0e651fce255f70d4e947f7b41da6dea Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:08:26 -0400 Subject: [PATCH 11/12] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20addre?= =?UTF-8?q?ss=20#3695958093=20review=20#4834973395?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/services/cto/linearAuth.test.ts | 119 ++++++++++++ .../main/services/cto/linearOAuthService.ts | 46 +++-- .../services/github/githubCredentialHealth.ts | 10 +- .../services/github/githubService.test.ts | 171 ++++++++++++++++++ .../src/main/services/github/githubService.ts | 78 ++++++-- .../main/services/ipc/runtimeBridge.test.ts | 2 - 6 files changed, 396 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 81b5722af..09cec8d7c 100644 --- a/apps/desktop/src/main/services/cto/linearAuth.test.ts +++ b/apps/desktop/src/main/services/cto/linearAuth.test.ts @@ -517,6 +517,125 @@ describe("linearOAuthService", () => { }); }); + it("settles the callback when writing the first response throws synchronously", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: "linear-access-token-123" }), + })) as any, + }); + activeServices.push(service); + const { sessionId, authUrl, redirectUri } = await service.startSession(); + const state = new URL(authUrl).searchParams.get("state")!; + const originalWriteHead = http.ServerResponse.prototype.writeHead; + let writeAttempts = 0; + const writeHeadSpy = vi.spyOn(http.ServerResponse.prototype, "writeHead").mockImplementation(function ( + this: http.ServerResponse, + ...args: Parameters + ) { + writeAttempts += 1; + if (writeAttempts === 1) throw new Error("response write failed"); + return Reflect.apply(originalWriteHead, this, args); + } as http.ServerResponse["writeHead"]); + + try { + const response = await Promise.race([ + httpGet(`${redirectUri}?code=test-code&state=${state}`), + waitMs(1_000).then(() => { + throw new Error("OAuth callback response did not settle"); + }), + ]); + + expect(response.statusCode).toBe(409); + expect(writeAttempts).toBe(2); + expect(service.getSession(sessionId).status).toBe("completed"); + expect(credentials.setOAuthToken).toHaveBeenCalledTimes(1); + } finally { + writeHeadSpy.mockRestore(); + } + }); + + it("closes the callback listener when both the primary and fallback responses throw", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: "linear-access-token-123" }), + })) as any, + }); + activeServices.push(service); + const { authUrl, redirectUri } = await service.startSession(); + const state = new URL(authUrl).searchParams.get("state")!; + const writeHeadSpy = vi.spyOn(http.ServerResponse.prototype, "writeHead") + .mockImplementation(() => { + throw new Error("response write failed"); + }); + + try { + await expect(httpGet(`${redirectUri}?code=test-code&state=${state}`)).resolves.toMatchObject({ + statusCode: 0, + }); + await expect(service.startSession()).resolves.toMatchObject({ + redirectUri: expect.stringContaining(":19836/oauth/callback"), + }); + } finally { + writeHeadSpy.mockRestore(); + } + }); + + it("waits for the callback listener to close when disposal races a failed response write", async () => { + const credentials = createCredentialsMock(); + const service = createLinearOAuthService({ + credentials: credentials as any, + logger: createLogger(), + fetchImpl: vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: "linear-access-token-123" }), + })) as any, + }); + activeServices.push(service); + const { authUrl, redirectUri } = await service.startSession(); + const state = new URL(authUrl).searchParams.get("state")!; + const originalWriteHead = http.ServerResponse.prototype.writeHead; + let disposePromise: Promise | null = null; + let writeAttempts = 0; + const writeHeadSpy = vi.spyOn(http.ServerResponse.prototype, "writeHead").mockImplementation(function ( + this: http.ServerResponse, + ...args: Parameters + ) { + writeAttempts += 1; + if (writeAttempts === 1) { + disposePromise = service.dispose(); + throw new Error("response write failed"); + } + return Reflect.apply(originalWriteHead, this, args); + } as http.ServerResponse["writeHead"]); + + try { + await httpGet(`${redirectUri}?code=test-code&state=${state}`); + await expect(disposePromise).resolves.toBeUndefined(); + + const replacement = createLinearOAuthService({ + credentials: createCredentialsMock() as any, + logger: createLogger(), + }); + activeServices.push(replacement); + await expect(replacement.startSession()).resolves.toMatchObject({ + redirectUri: expect.stringContaining(":19836/oauth/callback"), + }); + } finally { + writeHeadSpy.mockRestore(); + } + }); + it("lets only one concurrent callback exchange the single-use authorization code", async () => { const credentials = createCredentialsMock(); let resolveExchange!: (response: { diff --git a/apps/desktop/src/main/services/cto/linearOAuthService.ts b/apps/desktop/src/main/services/cto/linearOAuthService.ts index 0c9596b2d..9bc3283e9 100644 --- a/apps/desktop/src/main/services/cto/linearOAuthService.ts +++ b/apps/desktop/src/main/services/cto/linearOAuthService.ts @@ -89,19 +89,29 @@ function writeResponse( contentType: string, body: string, ): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { let settled = false; + const cleanup = () => { + response.off("finish", finish); + response.off("close", finish); + }; const finish = () => { if (settled) return; settled = true; - response.off("finish", finish); - response.off("close", finish); + cleanup(); resolve(); }; response.once("finish", finish); response.once("close", finish); - response.writeHead(status, { "content-type": contentType }); - response.end(body); + try { + response.writeHead(status, { "content-type": contentType }); + response.end(body); + } catch (error) { + if (settled) return; + settled = true; + cleanup(); + reject(error); + } }); } @@ -171,10 +181,8 @@ export function createLinearOAuthService(args: { }, ): Promise => { markSessionTerminal(session, patch); - const work = writeResponse(response, reply.status, reply.contentType, reply.body) - .then(() => beginServerClose(session)); - session.closePromise = work; - return work; + return writeResponse(response, reply.status, reply.contentType, reply.body) + .then(() => closeSessionServer(session)); }; const respondAlreadyFinished = (response: http.ServerResponse): Promise => ( @@ -186,6 +194,22 @@ export function createLinearOAuthService(args: { ) ); + const respondAlreadyFinishedAndClose = async ( + session: LinearOAuthSessionState, + response: http.ServerResponse, + ): Promise => { + try { + await respondAlreadyFinished(response); + } catch (error) { + args.logger?.warn("linear_sync.oauth_callback_fallback_response_failed", { + sessionId: session.id, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + await forceCloseSessionServer(session); + } + }; + const respondCallbackInProgress = (response: http.ServerResponse): Promise => ( writeResponse( response, @@ -410,7 +434,7 @@ export function createLinearOAuthService(args: { await exchangeCode(session, code, session.abortController.signal); if (session.status !== "pending") { - await respondAlreadyFinished(res); + await respondAlreadyFinishedAndClose(session, res); return; } await respondAndFinalizeSession( @@ -425,7 +449,7 @@ export function createLinearOAuthService(args: { ); } catch (error) { if (session.status !== "pending") { - await respondAlreadyFinished(res); + await respondAlreadyFinishedAndClose(session, res); return; } const message = error instanceof Error ? error.message : "OAuth callback failed."; diff --git a/apps/desktop/src/main/services/github/githubCredentialHealth.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.ts index 9da82cbb2..a45840416 100644 --- a/apps/desktop/src/main/services/github/githubCredentialHealth.ts +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.ts @@ -318,6 +318,10 @@ export function clearGithubCredentialHealth(token?: string): void { export function githubCredentialStates(args: { candidates: readonly GithubCredentialCandidate[]; availableSources: ReadonlySet; + sourceFailures: ReadonlyMap; activeReadSource: GitHubCredentialSource | null; activeWriteSource: Exclude | null; }): GitHubCredentialState[] { @@ -327,13 +331,14 @@ export function githubCredentialStates(args: { const candidate = bySource.get(source) ?? null; const health = candidate ? healthFor(candidate) : null; const cooling = candidate ? githubCredentialCooldown(candidate) : null; + const sourceFailure = args.sourceFailures.get(source) ?? null; const capabilities = [...githubOperationCredentialCapabilities(source)]; const activeFor: GitHubCredentialCapability[] = []; if (args.activeReadSource === source) activeFor.push("read"); if (args.activeWriteSource === source) activeFor.push("write"); let state: GitHubCredentialState["state"] = "unavailable"; if (args.availableSources.has(source)) state = "ready"; - if (cooling) state = "cooldown"; + if (cooling || sourceFailure) state = "cooldown"; if (activeFor.length > 0) state = "active"; return { source, @@ -341,9 +346,10 @@ export function githubCredentialStates(args: { capabilities, activeFor, state, - failure: cooling?.failure ?? null, + failure: cooling?.failure ?? sourceFailure?.authFailure ?? null, rateLimit: cooling?.rateLimit ?? [...(health?.resources.values() ?? [])].find((entry) => entry.rateLimit)?.rateLimit + ?? sourceFailure?.rateLimit ?? null, }; }); diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 51474231e..b19a9e06d 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -1413,6 +1413,177 @@ describe("githubService.getStatus", () => { await expect(service.getReadTokenOrThrowAsync()).resolves.toBe("ghu_app_user_token"); }); + 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({ + 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", + error_description: "Bad credentials", + })); + + const status = await makeService({ credentialStore }).getStatus(); + + expect(status).toMatchObject({ + tokenStored: true, + authSource: "app", + connected: false, + authFailure: { + kind: "invalid_token", + message: "Bad credentials", + retryAt: null, + }, + credentialStates: expect.arrayContaining([ + expect.objectContaining({ + source: "app", + available: false, + state: "cooldown", + failure: expect.objectContaining({ kind: "invalid_token" }), + }), + ]), + }); + }); + + it("reports GitHub App refresh fallback when gh remains usable", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + 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", + error_description: "Bad credentials", + })) + .mockResolvedValueOnce( + jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), + ); + + const status = await makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }).getStatus(); + + expect(status).toMatchObject({ + authSource: "gh", + connected: true, + credentialFallback: { + capability: "read", + fromSource: "app", + toSource: "gh", + reason: "invalid_token", + retryAt: null, + }, + credentialStates: expect.arrayContaining([ + expect.objectContaining({ + source: "app", + available: false, + failure: expect.objectContaining({ kind: "invalid_token" }), + }), + ]), + }); + }); + + it("does not report fallback from a lower-precedence App failure", async () => { + stubOriginRemote(); + process.env.GITHUB_TOKEN = "ghp_environment_token"; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + 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", + error_description: "Bad credentials", + })) + .mockResolvedValueOnce( + jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), + ); + + const status = await makeService({ credentialStore }).getStatus(); + + expect(status).toMatchObject({ + authSource: "environment", + connected: true, + credentialFallback: null, + }); + }); + + it("reports the highest-precedence failed read credential when falling back", async () => { + stubOriginRemote(); + process.env.GITHUB_TOKEN = "ghp_invalid_environment_token"; + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + 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", + error_description: "Bad credentials", + })) + .mockResolvedValueOnce(jsonResponse(401, { message: "Bad credentials" })) + .mockResolvedValueOnce( + jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), + ); + + const status = await makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }).getStatus(); + + expect(status).toMatchObject({ + authSource: "gh", + connected: true, + credentialFallback: { + capability: "read", + fromSource: "environment", + toSource: "gh", + reason: "invalid_token", + retryAt: null, + }, + }); + }); + it("does not advertise an unvalidated lower-precedence write credential", async () => { stubOriginRemote(); delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 1051f3069..5388e8008 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -24,6 +24,7 @@ import type { SyncCredentialStore } from "../../../../../ade-cli/src/services/cr import { evaluateGithubCredentialCapabilities, githubOperationCredentialCandidates, + githubOperationCredentialPrecedence, resolveGithubOperationCredentialCandidate, resolveGithubStatusCredentials, selectGithubOperationCredential, @@ -182,6 +183,12 @@ type GitHubTokenCandidate = GitHubTokenLookup & GithubCredentialCandidate & { type GitHubCredentialInventory = { candidates: GitHubTokenCandidate[]; availableSources: Set; + failures: Array<{ + source: GitHubTokenCandidate["source"]; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + }>; + appTokenStored: boolean; patTokenStored: boolean; ghCliPath: string | null; ghAuthError: string | null; @@ -804,12 +811,25 @@ export function createGithubService({ const patTokenStored = Boolean(patLookup); const environment = readEnvironmentAuthToken(); const appStatus = appUserAuth.getAuthStatus(); - const [appToken, gh] = await Promise.all([ + const [appResult, gh] = await Promise.all([ appStatus.tokenStored - ? appUserAuth.getValidTokenForRelay().catch(() => null) - : Promise.resolve(null), + ? 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 }), readGhAuthToken(), ]); + const appToken = appResult.token; + const appTokenStored = appToken != null || appUserAuth.getAuthStatus().tokenStored; const candidates: GitHubTokenCandidate[] = []; if (environment?.token) { candidates.push({ @@ -851,6 +871,10 @@ export function createGithubService({ return { candidates, availableSources: new Set(candidates.map((candidate) => candidate.source)), + failures: appResult.failure + ? [{ source: "app", ...appResult.failure }] + : [], + appTokenStored, patTokenStored, ghCliPath: gh.ghCliPath, ghAuthError: gh.ghAuthError, @@ -1585,16 +1609,20 @@ export function createGithubService({ const primaryCandidate = readCandidates[0] ?? null; const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); const credentialInventoryKey = githubCredentialInventoryKey(inventory.candidates); + const inventoryFailuresBySource = new Map( + inventory.failures.map((failure) => [failure.source, failure] as const), + ); const statusCooldown = (candidate: GitHubTokenCandidate) => opts.forceRefresh === true ? githubCredentialRateLimitCooldown(candidate, Date.now(), { resource: "core" }) : githubCredentialCooldown(candidate, Date.now(), { resource: "core" }); if (!primaryCandidate) { + const failure = inventory.failures[0] ?? null; cachedStatus = { - tokenStored: false, + tokenStored: inventory.appTokenStored, patTokenStored: inventory.patTokenStored, tokenDecryptionFailed, storageScope: "app", - authSource: "none", + authSource: failure?.source ?? "none", writeAuthSource: "none", tokenType: "unknown", repo, @@ -1604,11 +1632,12 @@ export function createGithubService({ ghCliPath: inventory.ghCliPath, ghAuthError: inventory.ghAuthError, checkedAt: null, - authFailure: null, - rateLimit: null, + authFailure: failure?.authFailure ?? null, + rateLimit: failure?.rateLimit ?? null, credentialStates: githubCredentialStates({ candidates: inventory.candidates, availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, activeReadSource: null, activeWriteSource: null, }), @@ -1665,6 +1694,7 @@ export function createGithubService({ credentialStates: githubCredentialStates({ candidates: inventory.candidates, availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, activeReadSource, activeWriteSource, }), @@ -1712,10 +1742,27 @@ export function createGithubService({ } }, }); + const readCredentialFailures = [ + ...inventory.failures, + ...failures.map((failure) => ({ + source: failure.candidate.source, + authFailure: failure.authFailure, + rateLimit: failure.rateLimit, + })), + ]; const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); if (active) { const { candidate, value } = active; const { validated, repoAccessOk, repoAccessError } = value; + const readFailuresBySource = new Map( + readCredentialFailures.map((failure) => [failure.source, failure] as const), + ); + const activePrecedenceIndex = githubOperationCredentialPrecedence("read") + .indexOf(candidate.source); + const fallbackFailure = githubOperationCredentialPrecedence("read") + .slice(0, activePrecedenceIndex) + .map((source) => readFailuresBySource.get(source) ?? null) + .find((failure) => failure != null) ?? null; // Classic PATs and gh OAuth tokens expose scopes in the /user response. // Fine-grained tokens do not expose selected repos and need a repo probe. if (repo && validated.tokenType === "fine-grained" && repoAccessOk === false) { @@ -1746,16 +1793,17 @@ export function createGithubService({ credentialStates: githubCredentialStates({ candidates: inventory.candidates, availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, activeReadSource: candidate.source, activeWriteSource, }), - credentialFallback: failures[0] && failures[0].candidate.source !== candidate.source + credentialFallback: fallbackFailure ? { capability: "read", - fromSource: failures[0].candidate.source, + fromSource: fallbackFailure.source, toSource: candidate.source, - reason: failures[0].authFailure.kind, - retryAt: failures[0].authFailure.retryAt, + reason: fallbackFailure.authFailure.kind, + retryAt: fallbackFailure.authFailure.retryAt, } : null, backgroundRefreshPausedUntil: pauseUntilMs == null @@ -1771,11 +1819,10 @@ export function createGithubService({ return status; } - const failure = failures.find((entry) => entry.authFailure.kind === "rate_limited") - ?? failures[0] + const failure = readCredentialFailures.find((entry) => entry.authFailure.kind === "rate_limited") + ?? readCredentialFailures[0] ?? { - candidate: primaryCandidate, - error: "GitHub authentication could not be verified.", + source: primaryCandidate.source, authFailure: classifyGitHubAuthFailure({ message: "GitHub authentication could not be verified." }).authFailure, rateLimit: null, }; @@ -1799,6 +1846,7 @@ export function createGithubService({ credentialStates: githubCredentialStates({ candidates: inventory.candidates, availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, activeReadSource: null, activeWriteSource: null, }), diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 31b4d9efc..90849a54d 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1935,7 +1935,6 @@ describe("registerIpc sync bridge", () => { logger, }; const secondRequest = handler?.(eventForSender()) as Promise; - await Promise.resolve(); expect(firstDispose).not.toHaveBeenCalled(); expect(createLinearOAuthServiceMock).toHaveBeenCalledTimes(1); @@ -2020,7 +2019,6 @@ describe("registerIpc sync bridge", () => { logger, }; const secondStart = startHandler?.(eventForSender()) as Promise; - await Promise.resolve(); expect(createLinearOAuthServiceMock).toHaveBeenCalledTimes(1); releaseFirstStart(); From af19b33564af250db6490d09e528e2f57196f1a1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:07:52 -0400 Subject: [PATCH 12/12] =?UTF-8?q?ship:=20iteration=205=20=E2=80=94=20fix?= =?UTF-8?q?=20GitHub=20CI=20and=20credential=20attribution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments 3696023035 and 3696023038. --- .../src/headlessLinearServices.test.ts | 163 ++++++++++++++++++ apps/ade-cli/src/headlessLinearServices.ts | 128 ++++++++++++-- .../main/services/adeActions/registry.test.ts | 15 ++ .../src/main/services/adeActions/registry.ts | 4 +- .../services/github/githubService.test.ts | 56 ++++++ .../src/main/services/github/githubService.ts | 50 +++++- .../src/main/services/ipc/registerIpc.ts | 9 +- .../src/main/services/prs/prService.test.ts | 64 +++++++ .../src/main/services/prs/prService.ts | 10 +- apps/desktop/src/preload/global.d.ts | 3 +- apps/desktop/src/preload/preload.ts | 3 +- .../components/onboarding/GitHubCard.tsx | 5 +- .../components/projects/CloneProjectForm.tsx | 8 +- .../projects/PublishToGitHubDialog.tsx | 8 +- .../components/settings/GitHubSection.tsx | 19 +- .../lib/githubIntegrationStatus.test.ts | 56 ++++++ .../renderer/lib/githubIntegrationStatus.ts | 49 ++++++ .../shared/githubOperationCredential.test.ts | 4 +- .../src/shared/githubOperationCredential.ts | 126 +++++++++++++- apps/desktop/src/shared/types/git.ts | 13 ++ 20 files changed, 745 insertions(+), 48 deletions(-) diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 391495797..0ab4a831f 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -1395,6 +1395,169 @@ describe("headlessLinearServices", () => { } }); + it("reports the validated identity of a different headless write credential", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-write-identity-", { + emptyGhConfig: true, + }); + storeHeadlessAppUserToken(); + new EncryptedFileCredentialStore().setSync("github.token.v1", "ghp_backup_token"); + globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get("authorization"); + const login = authorization === "Bearer gho_cli_token" + ? "bob" + : authorization === "Bearer ghp_backup_token" + ? "carol" + : "alice"; + return new Response(JSON.stringify({ login }), { + status: 200, + headers: { + "content-type": "application/json", + "x-oauth-scopes": authorization === "Bearer ghu_app_user_token" + ? "" + : "repo, workflow", + }, + }); + }) as unknown as typeof fetch; + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + const status = await githubService.getStatus({ forceRefresh: true }); + expect(status).toMatchObject({ + authSource: "app", + userLogin: "alice", + writeAuthSource: "gh", + writeUserLogin: "bob", + connected: true, + }); + await expect(githubService.verifyStoredPat(status)).resolves.toMatchObject({ + source: "pat", + capabilities: ["read", "write"], + userLogin: "carol", + failure: null, + }); + } finally { + environment.restore(); + } + }); + + it("preserves a stored GitHub App refresh failure in headless status", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-app-refresh-", { + emptyGhConfig: true, + }); + new EncryptedFileCredentialStore().setSync("github.appUserToken.v1", JSON.stringify({ + 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(async () => new Response(JSON.stringify({ + error: "bad_verification_code", + error_description: "Bad credentials", + }), { + status: 400, + headers: { "content-type": "application/json" }, + })) 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 expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ + tokenStored: true, + authSource: "app", + connected: false, + authFailure: expect.objectContaining({ kind: "invalid_token" }), + credentialStates: expect.arrayContaining([ + expect.objectContaining({ + source: "app", + available: false, + state: "cooldown", + failure: expect.objectContaining({ kind: "invalid_token" }), + }), + ]), + }); + } finally { + environment.restore(); + } + }); + + it("reports headless fallback when App refresh fails and GitHub CLI remains usable", async () => { + const environment = isolateHeadlessGithubAuth("ade-headless-github-app-refresh-fallback-", { + emptyGhConfig: true, + }); + new EncryptedFileCredentialStore().setSync("github.appUserToken.v1", JSON.stringify({ + 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_description: "Bad credentials", + }), { + status: 400, + headers: { "content-type": "application/json" }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ login: "bob" }), { + status: 200, + headers: { + "content-type": "application/json", + "x-oauth-scopes": "repo, workflow", + }, + })) as unknown as typeof fetch; + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + { + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ + authSource: "gh", + writeAuthSource: "gh", + writeUserLogin: "bob", + connected: true, + credentialFallback: { + capability: "read", + fromSource: "app", + toSource: "gh", + reason: "invalid_token", + }, + }); + } finally { + environment.restore(); + } + }); + it("uses a stored PAT instead of the App token for headless Git transport", async () => { const environment = isolateHeadlessGithubAuth("ade-headless-github-transport-", { emptyGhConfig: true, diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 762e77f3a..e316af14e 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -29,6 +29,7 @@ import type { GitHubAppDeviceAuthPollResult, GitHubAppDeviceAuthStartResult, GitHubAppUserAuthStatus, + GitHubCredentialVerification, GitHubRepoRef, GitHubRateLimitState, GitHubStatus, @@ -69,9 +70,11 @@ import { EncryptedFileCredentialStore } from "./services/credentials/credentialS import { evaluateGithubCredentialCapabilities, githubOperationCredentialCandidates, + githubOperationCredentialPrecedence, resolveGithubOperationCredentialCandidate, resolveGithubStatusCredentials, selectGithubOperationCredential, + verifyGithubCredentialSource, type GithubOperationCredentialCapability, } from "../../desktop/src/shared/githubOperationCredential"; import { @@ -311,6 +314,12 @@ type HeadlessGitHubTokenCandidate = HeadlessGitHubTokenLookup & GithubCredential type HeadlessGitHubCredentialInventory = { candidates: HeadlessGitHubTokenCandidate[]; availableSources: Set; + failures: Array<{ + source: Exclude; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + }>; + appTokenStored: boolean; patTokenStored: boolean; ghCliPath: string | null; ghAuthError: string | null; @@ -726,12 +735,24 @@ export function createHeadlessGitHubService( const patTokenStored = Boolean(patToken); const environmentToken = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); const appStatus = appUserAuth.getAuthStatus(); - const [appToken, gh] = await Promise.all([ + const [appResult, gh] = await Promise.all([ appStatus.tokenStored - ? appUserAuth.getValidTokenForRelay().catch(() => null) - : Promise.resolve(null), + ? 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 }), Promise.resolve(options.ghAuthTokenProvider?.() ?? ghAuthTokenAsync()), ]); + const appToken = appResult.token; const candidates: HeadlessGitHubTokenCandidate[] = []; if (environmentToken) { candidates.push({ @@ -777,6 +798,10 @@ export function createHeadlessGitHubService( return { candidates, availableSources: new Set(candidates.map((candidate) => candidate.source)), + failures: appResult.failure + ? [{ source: "app", ...appResult.failure }] + : [], + appTokenStored: appToken != null || appStatus.tokenStored, patTokenStored, ghCliPath: gh.ghCliPath, ghAuthError: gh.ghAuthError, @@ -1039,6 +1064,50 @@ export function createHeadlessGitHubService( } }; + const verifyStoredPat = async ( + status?: HeadlessGitHubStatus, + ): Promise => { + const [origin, inventory] = await Promise.all([ + readGitOriginAsync(projectRoot), + readCredentialInventoryAsync(), + ]); + const repo = parseGitHubRepoFromRemoteUrl(origin ?? ""); + const candidate = inventory.candidates.find((entry) => entry.source === "pat") ?? null; + return await verifyGithubCredentialSource({ + source: "pat", + status, + candidate, + cooldown: (entry) => githubCredentialRateLimitCooldown( + entry, + Date.now(), + { resource: "core" }, + ), + probe: (entry) => probeCandidate(entry, repo, true), + capabilities: (entry, value) => validatedCredentialCapabilities(entry, value, repo), + userLogin: (value) => value.validated.userLogin, + rateLimit: (value) => value.validated.rateLimit, + isRepositoryAccessFailure: (result) => result.authFailure.kind === "permission_denied" + && result.value?.repoAccessOk === false, + onAuthenticatedProbe: (entry, value) => { + registerGithubCredentialIdentity(entry, value.validated.userLogin); + }, + onUsableProbe: (entry, value) => { + recordGithubCredentialProbeSuccess( + entry, + value.validated.rateLimit, + value.validated.userLogin, + ); + }, + onRejectedProbe: (entry, result, repositoryAccessFailure) => { + if (!repositoryAccessFailure) { + recordGithubCredentialFailure(entry, result.authFailure, result.rateLimit); + } + }, + missingMessage: "No personal access token is stored.", + missingPermissionMessage: "This personal access token does not grant the required GitHub write access.", + }); + }; + const conditionalRequestCache = createGithubConditionalRequestCache(); const requestRawWithCredentialFallback = async ( @@ -1510,6 +1579,7 @@ export function createHeadlessGitHubService( }; service = { + verifyStoredPat, async getStatus(opts: { forceRefresh?: boolean } = {}) { if (opts.forceRefresh) { invalidateStatusCache(); @@ -1520,6 +1590,9 @@ export function createHeadlessGitHubService( ]); const repo = parseGitHubRepoFromRemoteUrl(origin ?? ""); const hasOrigin = Boolean(origin); + const inventoryFailuresBySource = new Map( + inventory.failures.map((failure) => [failure.source, failure] as const), + ); const binding = `${githubCredentialInventoryKey(inventory.candidates)}:${repo?.owner ?? ""}/${repo?.name ?? ""}`; const now = Date.now(); if ( @@ -1565,6 +1638,7 @@ export function createHeadlessGitHubService( credentialStates: githubCredentialStates({ candidates: inventory.candidates, availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, activeReadSource: cachedStatus.authSource === "none" ? null : cachedStatus.authSource, @@ -1595,12 +1669,13 @@ export function createHeadlessGitHubService( : githubCredentialCooldown(candidate, Date.now(), { resource: "core" }); const primaryCandidate = readCandidates[0] ?? null; if (!primaryCandidate) { + const failure = inventory.failures[0] ?? null; return { - tokenStored: false, + tokenStored: inventory.appTokenStored, patTokenStored: inventory.patTokenStored, tokenDecryptionFailed, storageScope: "app", - authSource: "none", + authSource: failure?.source ?? "none", writeAuthSource: "none", tokenType: "unknown", repo, @@ -1610,11 +1685,12 @@ export function createHeadlessGitHubService( ghCliPath: inventory.ghCliPath, ghAuthError: inventory.ghAuthError, checkedAt: null, - authFailure: null, - rateLimit: null, + authFailure: failure?.authFailure ?? null, + rateLimit: failure?.rateLimit ?? null, credentialStates: githubCredentialStates({ candidates: inventory.candidates, availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, activeReadSource: null, activeWriteSource: null, }), @@ -1626,7 +1702,7 @@ export function createHeadlessGitHubService( }; } - const { active, activeWriteSource, failures } = await resolveGithubStatusCredentials({ + const { active, activeWrite, failures } = await resolveGithubStatusCredentials({ readCandidates, writeCandidates, cooldown: statusCooldown, @@ -1662,10 +1738,28 @@ export function createHeadlessGitHubService( } }, }); + const activeWriteSource = activeWrite?.source ?? null; + const credentialFailures = [ + ...inventory.failures, + ...failures.map((failure) => ({ + source: failure.candidate.source, + authFailure: failure.authFailure, + rateLimit: failure.rateLimit, + })), + ]; const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); if (active) { const { candidate, value } = active; const { validated, repoAccessOk, repoAccessError } = value; + const failuresBySource = new Map( + credentialFailures.map((failure) => [failure.source, failure] as const), + ); + const activePrecedenceIndex = githubOperationCredentialPrecedence("read") + .indexOf(candidate.source); + const fallbackFailure = githubOperationCredentialPrecedence("read") + .slice(0, activePrecedenceIndex) + .map((source) => failuresBySource.get(source) ?? null) + .find((failure) => failure != null) ?? null; return { tokenStored: true, patTokenStored: inventory.patTokenStored, @@ -1673,6 +1767,7 @@ export function createHeadlessGitHubService( storageScope: "app", authSource: candidate.source, writeAuthSource: activeWriteSource ?? "none", + writeUserLogin: activeWrite?.value.validated.userLogin ?? null, tokenType: validated.tokenType, repo, hasOrigin, @@ -1686,16 +1781,17 @@ export function createHeadlessGitHubService( credentialStates: githubCredentialStates({ candidates: inventory.candidates, availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, activeReadSource: candidate.source, activeWriteSource, }), - credentialFallback: failures[0] && failures[0].candidate.source !== candidate.source + credentialFallback: fallbackFailure ? { capability: "read", - fromSource: failures[0].candidate.source, + fromSource: fallbackFailure.source, toSource: candidate.source, - reason: failures[0].authFailure.kind, - retryAt: failures[0].authFailure.retryAt, + reason: fallbackFailure.authFailure.kind, + retryAt: fallbackFailure.authFailure.retryAt, } : null, backgroundRefreshPausedUntil: pauseUntilMs == null @@ -1707,11 +1803,10 @@ export function createHeadlessGitHubService( }; } - const failure = failures.find((entry) => entry.authFailure.kind === "rate_limited") - ?? failures[0] + const failure = credentialFailures.find((entry) => entry.authFailure.kind === "rate_limited") + ?? credentialFailures[0] ?? { - candidate: primaryCandidate, - error: "GitHub authentication could not be verified.", + source: primaryCandidate.source, authFailure: classifyGitHubAuthFailure({ message: "GitHub authentication could not be verified." }).authFailure, rateLimit: null, }; @@ -1735,6 +1830,7 @@ export function createHeadlessGitHubService( credentialStates: githubCredentialStates({ candidates: inventory.candidates, availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, activeReadSource: null, activeWriteSource: null, }), diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 3760cb5d5..788292902 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -2742,6 +2742,13 @@ describe("runtime GitHub actions", () => { repoAccessError: tokenStored ? null : "GitHub auth missing.", connected: tokenStored, })), + verifyStoredPat: vi.fn(async () => ({ + source: "pat", + capabilities: ["read", "write"], + userLogin: "octocat", + failure: null, + rateLimit: null, + })), setToken, clearToken, }, @@ -2750,6 +2757,10 @@ describe("runtime GitHub actions", () => { const githubService = getAdeActionDomainServices(runtime).github as { setToken(token: string): Promise<{ connected: boolean; + credentialVerification: { + source: "pat"; + failure: null; + }; hasOrigin: boolean; repoAccessError: string | null; repoAccessOk: boolean | null; @@ -2766,6 +2777,10 @@ describe("runtime GitHub actions", () => { await expect(githubService.setToken("ghp_test")).resolves.toMatchObject({ connected: true, + credentialVerification: { + source: "pat", + failure: null, + }, hasOrigin: true, repoAccessError: null, repoAccessOk: true, diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 81b487ea8..b615646da 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -3592,7 +3592,9 @@ function buildGithubDomainService(runtime: AdeRuntime): OpaqueService | null { }, async setToken(args?: unknown) { githubService.setToken(readStringActionArg(args, "token")); - return githubService.getStatus(); + const status = await githubService.getStatus(); + const credentialVerification = await githubService.verifyStoredPat(status); + return { ...status, credentialVerification }; }, async clearToken() { githubService.clearToken(); diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index b19a9e06d..5be6269c1 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -1413,6 +1413,62 @@ describe("githubService.getStatus", () => { await expect(service.getReadTokenOrThrowAsync()).resolves.toBe("ghu_app_user_token"); }); + it("preserves the validated identity of a different active write credential", async () => { + stubOriginRemote(); + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; + const credentialStore = new MemoryCredentialStore(); + credentialStore.setSync("github.token.v1", "ghp_backup_token"); + credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ + accessToken: "ghu_app_user_token", + tokenType: "bearer", + scope: null, + expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), + refreshToken: null, + refreshTokenExpiresAt: null, + userLogin: "alice", + updatedAt: new Date().toISOString(), + })); + mockFetch + .mockResolvedValueOnce(jsonResponse(200, { login: "alice" })) + .mockResolvedValueOnce(jsonResponse(200, { full_name: "acme/ade" })) + .mockResolvedValueOnce( + jsonResponse(200, { login: "bob" }, { "x-oauth-scopes": "repo, workflow" }), + ) + .mockResolvedValueOnce( + jsonResponse(200, { login: "carol" }, { "x-oauth-scopes": "repo, workflow" }), + ); + + const service = makeService({ + credentialStore, + ghAuthTokenProvider: () => ({ + token: "gho_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }); + const status = await service.getStatus(); + + expect(status).toMatchObject({ + authSource: "app", + userLogin: "alice", + writeAuthSource: "gh", + writeUserLogin: "bob", + connected: true, + }); + await expect(service.verifyStoredPat(status)).resolves.toMatchObject({ + source: "pat", + capabilities: ["read", "write"], + userLogin: "carol", + failure: null, + }); + expect(mockFetch).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: "Bearer ghp_backup_token" }), + }), + ); + }); + it("reports a GitHub App refresh failure instead of treating authorization as missing", async () => { stubOriginRemote(); const credentialStore = new MemoryCredentialStore(); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 5388e8008..2517fb271 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -13,6 +13,7 @@ import type { GitHubAppInstallationStatus, GitHubAppUserAuthStatus, GitHubAutolink, + GitHubCredentialVerification, GitHubRateLimitState, GitHubRepoRef, GitHubStatus, @@ -28,6 +29,7 @@ import { resolveGithubOperationCredentialCandidate, resolveGithubStatusCredentials, selectGithubOperationCredential, + verifyGithubCredentialSource, type GithubOperationCredentialCapability, } from "../../../shared/githubOperationCredential"; import { @@ -1710,7 +1712,7 @@ export function createGithubService({ ? await readSharedGithubStatusProbe(candidate, repo, opts.forceRefresh === true) : await computeGithubStatusProbe(candidate, repo, opts.forceRefresh === true) ); - const { active, activeWriteSource, failures } = await resolveGithubStatusCredentials({ + const { active, activeWrite, failures } = await resolveGithubStatusCredentials({ readCandidates, writeCandidates, cooldown: statusCooldown, @@ -1742,6 +1744,7 @@ export function createGithubService({ } }, }); + const activeWriteSource = activeWrite?.source ?? null; const readCredentialFailures = [ ...inventory.failures, ...failures.map((failure) => ({ @@ -1780,6 +1783,7 @@ export function createGithubService({ storageScope: "app", authSource: candidate.source, writeAuthSource: activeWriteSource ?? "none", + writeUserLogin: activeWrite?.value.validated.userLogin ?? null, tokenType: validated.tokenType, repo, hasOrigin, @@ -1877,6 +1881,49 @@ export function createGithubService({ } }; + const verifyStoredPat = async ( + status?: GitHubStatus, + ): Promise => { + const [inventory, origin] = await Promise.all([ + readCredentialInventory(), + detectOrigin().catch(() => ({ repo: null, hasOrigin: false })), + ]); + const candidate = inventory.candidates.find((entry) => entry.source === "pat") ?? null; + return await verifyGithubCredentialSource({ + source: "pat", + status, + candidate, + cooldown: (entry) => githubCredentialRateLimitCooldown( + entry, + Date.now(), + { resource: "core" }, + ), + probe: (entry) => computeGithubStatusProbe(entry, origin.repo, true), + capabilities: (entry, value) => validatedCredentialCapabilities(entry, value, origin.repo), + userLogin: (value) => value.validated.userLogin, + rateLimit: (value) => value.validated.rateLimit, + isRepositoryAccessFailure: (result) => result.authFailure.kind === "permission_denied" + && result.value?.repoAccessOk === false, + onAuthenticatedProbe: (entry, value) => { + registerGithubCredentialIdentity(entry, value.validated.userLogin); + }, + onUsableProbe: (entry, value) => { + recordGithubCredentialProbeSuccess( + entry, + value.validated.rateLimit, + value.validated.userLogin, + ); + }, + onRejectedProbe: (entry, result, repositoryAccessFailure) => { + if (!repositoryAccessFailure) { + recordGithubCredentialFailure(entry, result.authFailure, result.rateLimit); + } + }, + missingMessage: "No personal access token is stored.", + missingPermissionMessage: "This personal access token does not grant the required GitHub write access.", + }); + }; + const listRepoLabels = async (owner: string, name: string): Promise => { return await apiRequestAllPages({ path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/labels`, @@ -2305,6 +2352,7 @@ export function createGithubService({ getRemoteStatus: detectOrigin, getStatus, + verifyStoredPat, async getBackgroundRequestPauseUntilMs(): Promise { const inventory = await readCredentialInventory(); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index c78b2a00d..45e7a4f1f 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -245,6 +245,7 @@ import type { GitHubAppUserAuthStatus, GitHubAutolink, GitHubRepoRef, + GitHubSetTokenResult, GitHubStatus, AdeAccountStatus, AdeAccountLoginStart, @@ -9124,12 +9125,14 @@ export function registerIpc({ return await ctx.githubService.getRemoteStatus(); }); - ipcMain.handle(IPC.githubSetToken, async (_event, arg: { token: string }): Promise => { + ipcMain.handle(IPC.githubSetToken, async (_event, arg: { token: string }): Promise => { const ctx = getCtx(); ctx.githubService.setToken(arg.token); const status = await ctx.githubService.getStatus(); - broadcastGithubStatus(status); - return status; + const credentialVerification = await ctx.githubService.verifyStoredPat(status); + const verifiedStatus = { ...status, credentialVerification }; + broadcastGithubStatus(verifiedStatus); + return verifiedStatus; }); ipcMain.handle(IPC.githubClearToken, async (): Promise => { diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 3fd9dd800..85411a4c0 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -4872,6 +4872,70 @@ describe("prService.land", () => { expect(githubService.apiRequest).not.toHaveBeenCalledWith(expect.objectContaining({ method: "PUT" })); }); + it("attributes a successful merge to the active write credential", async () => { + const row = makePrRow({ id: "pr-write-identity", github_pr_number: 95 }); + const db = makeMockDb(); + installPullRequestRowStore(db, [row]); + const githubService = makeGithubService({ + getStatus: vi.fn(async () => makeGithubStatus({ + authSource: "app", + userLogin: "alice", + writeAuthSource: "gh", + writeUserLogin: "bob", + })), + apiRequest: vi.fn(async (args: { method: string; path: string }) => { + if (args.method === "GET" && args.path.endsWith("/pulls/95")) { + return { data: makeGitHubPull({ number: 95, mergeable: true, mergeable_state: "clean" }) }; + } + if (args.method === "PUT" && args.path.endsWith("/pulls/95/merge")) { + return { data: { sha: "merge-sha" } }; + } + return { data: {} }; + }), + }); + const { service } = buildService({ db, githubService }); + + await expect(service.land({ prId: row.id, method: "squash" })).resolves.toMatchObject({ + success: true, + mergeCommitSha: "merge-sha", + }); + + const outcomeWrite = db.run.mock.calls.find(([sql]: unknown[]) => + String(sql).includes("merged_by_login = coalesce")); + expect(outcomeWrite?.[1]).toEqual(expect.arrayContaining(["bob"])); + }); + + it("preserves merge attribution from a legacy writable GitHub status", async () => { + const row = makePrRow({ id: "pr-legacy-write-identity", github_pr_number: 96 }); + const db = makeMockDb(); + installPullRequestRowStore(db, [row]); + const githubService = makeGithubService({ + getStatus: vi.fn(async () => { + const status = makeGithubStatus({ authSource: "gh", userLogin: "octocat" }); + return { ...status, writeAuthSource: undefined }; + }), + apiRequest: vi.fn(async (args: { method: string; path: string }) => { + if (args.method === "GET" && args.path.endsWith("/pulls/96")) { + return { data: makeGitHubPull({ number: 96, mergeable: true, mergeable_state: "clean" }) }; + } + if (args.method === "PUT" && args.path.endsWith("/pulls/96/merge")) { + return { data: { sha: "legacy-merge-sha" } }; + } + return { data: {} }; + }), + }); + const { service } = buildService({ db, githubService }); + + await expect(service.land({ prId: row.id, method: "squash" })).resolves.toMatchObject({ + success: true, + mergeCommitSha: "legacy-merge-sha", + }); + + const outcomeWrite = db.run.mock.calls.find(([sql]: unknown[]) => + String(sql).includes("merged_by_login = coalesce")); + expect(outcomeWrite?.[1]).toEqual(expect.arrayContaining(["octocat"])); + }); + // Helper: a minimal stand-in for a child_process.ChildProcess that the runGh // promise consumes (stdout/stderr `.on`, top-level `.on`, `.kill`). function makeFakeGhChild() { diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index ef5199890..2a03d6200 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -8641,7 +8641,15 @@ export function createPrService({ /** The account that performed the merge is the authenticated viewer. */ const resolveViewerLoginForMerge = async (): Promise => { try { - return (await githubService.getStatus()).userLogin ?? null; + const status = await githubService.getStatus(); + if (status.writeUserLogin) return status.writeUserLogin; + const effectiveWriteSource = status.writeAuthSource + ?? (status.authSource === "app" || status.authSource === "none" + ? "none" + : status.authSource); + return effectiveWriteSource === status.authSource + ? status.userLogin ?? null + : null; } catch { return null; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 9e390d61f..a81a6d4fb 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -330,6 +330,7 @@ import type { GitHubAppUserAuthStatus, GitHubAutolink, GitHubRepoRef, + GitHubSetTokenResult, GitHubStatus, AdeAccountStatus, AdeAccountLoginStart, @@ -2296,7 +2297,7 @@ declare global { getRemoteStatus: (opts?: { forceRefresh?: boolean; }) => Promise<{ repo: GitHubRepoRef | null; hasOrigin: boolean }>; - setToken: (token: string) => Promise; + setToken: (token: string) => Promise; clearToken: () => Promise; getAppUserAuthStatus: () => Promise; startAppUserDeviceAuth: () => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d30ecd6d2..6c5cad57f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -260,6 +260,7 @@ import type { GitHubAppUserAuthStatus, GitHubAutolink, GitHubRepoRef, + GitHubSetTokenResult, GitHubStatus, AdeAccountStatus, AdeAccountLoginStart, @@ -8483,7 +8484,7 @@ contextBridge.exposeInMainWorld("ade", { : githubRemoteStatusCache.get(), ); }, - setToken: async (token: string): Promise => + setToken: async (token: string): Promise => clearAround( () => { githubStatusCache.clear(); diff --git a/apps/desktop/src/renderer/components/onboarding/GitHubCard.tsx b/apps/desktop/src/renderer/components/onboarding/GitHubCard.tsx index a44b467d9..2dba1ceff 100644 --- a/apps/desktop/src/renderer/components/onboarding/GitHubCard.tsx +++ b/apps/desktop/src/renderer/components/onboarding/GitHubCard.tsx @@ -7,6 +7,7 @@ import { GitHubAppInstallPanel } from "../github/GitHubAppInstallPanel"; import { InputPopover } from "./InputPopover"; import { RescanButton } from "./RescanButton"; import { BRAND, CARD_BASE, SECTION_LABEL, logoTile, statusDot } from "./onboardingTheme"; +import { describeGithubPatVerification } from "../../lib/githubIntegrationStatus"; export function GitHubCard() { const [status, setStatus] = useState(null); @@ -28,8 +29,8 @@ export function GitHubCard() { try { const next = await window.ade.github.setToken(token); setStatus(next as GitHubStatus); - const ok = (next as GitHubStatus).connected; - return { ok, message: ok ? "Connected" : (next as GitHubStatus).ghAuthError ?? "Token rejected" }; + const verification = describeGithubPatVerification(next); + return { ok: verification.verified, message: verification.message }; } catch (e) { return { ok: false, message: e instanceof Error ? e.message : String(e) }; } diff --git a/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx b/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx index b26c7105b..130cc4b8a 100644 --- a/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx +++ b/apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx @@ -18,6 +18,7 @@ import { } from "@phosphor-icons/react"; import { motion, AnimatePresence } from "motion/react"; import { extractError } from "../../lib/format"; +import { describeGithubPatVerification } from "../../lib/githubIntegrationStatus"; import type { CloneProjectInput, CloneProjectResult, @@ -685,7 +686,12 @@ function ConnectGithubPrompt({ setPending(true); setError(null); try { - await window.ade.github.setToken(trimmed); + const result = await window.ade.github.setToken(trimmed); + const verification = describeGithubPatVerification(result); + if (!verification.verified) { + setError(verification.message); + return; + } onConnected(); } catch (err) { setError(extractError(err)); diff --git a/apps/desktop/src/renderer/components/projects/PublishToGitHubDialog.tsx b/apps/desktop/src/renderer/components/projects/PublishToGitHubDialog.tsx index d2b76b493..d05afe0e2 100644 --- a/apps/desktop/src/renderer/components/projects/PublishToGitHubDialog.tsx +++ b/apps/desktop/src/renderer/components/projects/PublishToGitHubDialog.tsx @@ -20,6 +20,7 @@ import { import type { PublishProjectResult } from "../../../shared/types"; import { extractCodeFromMessage } from "../../lib/codedError"; import { extractError } from "../../lib/format"; +import { describeGithubPatVerification } from "../../lib/githubIntegrationStatus"; import { fadeScale } from "../../lib/motion"; import { COLORS, @@ -187,7 +188,12 @@ export function PublishToGitHubDialog({ setTokenSaving(true); setTokenError(null); try { - await window.ade.github.setToken(token); + const result = await window.ade.github.setToken(token); + const verification = describeGithubPatVerification(result); + if (!verification.verified) { + setTokenError(verification.message); + return; + } setTokenDraft(""); setConnectMode(false); void window.ade.github.getStatus({ forceRefresh: true }).then((status) => { diff --git a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx index fe25e5aeb..15d60a52f 100644 --- a/apps/desktop/src/renderer/components/settings/GitHubSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GitHubSection.tsx @@ -19,6 +19,7 @@ import { getGitHubTokenAccessState, REQUIRED_GITHUB_CLASSIC_SCOPES } from "../.. import { COLORS, MONO_FONT, SANS_FONT, cardStyle, LABEL_STYLE, inlineBadge, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; import { GitHubAppInstallPanel } from "../github/GitHubAppInstallPanel"; import { + describeGithubPatVerification, describeGithubAuthFailure, githubCredentialPresentation, } from "../../lib/githubIntegrationStatus"; @@ -154,23 +155,13 @@ export function GitHubSection({ embedded = false }: { embedded?: boolean }) { .then((status) => { setGithubStatus(status); setGithubTokenDraft(""); - if (status.connected) { + const verification = describeGithubPatVerification(status); + if (verification.verified) { setShowPatSetup(false); - setSaveNotice("Personal access token saved and verified."); + setSaveNotice(verification.message); return; } - if (!status.userLogin) { - setActionError("Token saved, but authentication failed. Re-check the token value."); - } else if (status.tokenType === "fine-grained" && status.repoAccessOk === false) { - const repoLabel = status.repo ? `${status.repo.owner}/${status.repo.name}` : "this repo"; - setActionError( - `Token saved, but it cannot access ${repoLabel}` + - (status.repoAccessError ? ` (${status.repoAccessError})` : "") + - ". Grant this repo Contents, Pull requests, Metadata, Actions, and Workflows permissions.", - ); - } else { - setActionError("Token saved, but it is missing required permissions. See the diagnostic below."); - } + setActionError(verification.message); }) .catch((err) => setActionError(err instanceof Error ? err.message : String(err))) .finally(() => setGithubBusy(false)); diff --git a/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts b/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts index fbfaf1719..67cf08566 100644 --- a/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts +++ b/apps/desktop/src/renderer/lib/githubIntegrationStatus.test.ts @@ -4,7 +4,9 @@ import { deriveGithubRepoConnectionState, describeGithubAuthFailure, describeGithubCliBanner, + describeGithubPatVerification, githubCredentialPresentation, + githubStatusHasUsablePat, isGithubRateLimitMessage, isGithubRepoAccessPending, } from "./githubIntegrationStatus"; @@ -173,6 +175,60 @@ describe("describeGithubCliBanner", () => { }); }); +describe("githubStatusHasUsablePat", () => { + it("requires the saved PAT itself to be ready for writes", () => { + expect(githubStatusHasUsablePat({ + ...makeCliStatus({ authSource: "app", writeAuthSource: "gh", connected: true }), + credentialVerification: { + source: "pat", + capabilities: ["read", "write"], + userLogin: "octocat", + failure: null, + rateLimit: null, + }, + })).toBe(true); + + expect(githubStatusHasUsablePat({ + ...makeCliStatus({ authSource: "app", writeAuthSource: "gh", connected: true }), + credentialVerification: { + source: "pat", + capabilities: [], + userLogin: null, + failure: { + kind: "invalid_token", + message: "Bad credentials", + retryAt: null, + }, + rateLimit: null, + }, + })).toBe(false); + }); + + it.each([ + ["invalid_token", "authentication failed"], + ["rate_limited", "temporarily paused verification"], + ["permission_denied", "cannot use it for write actions"], + ["network", "could not reach GitHub"], + ["unknown", "could not verify it for GitHub write actions"], + ] as const)("uses clear shared copy for %s failures", (kind, message) => { + const result = { + ...makeCliStatus({ repo: { owner: "acme", name: "ade" } }), + credentialVerification: { + source: "pat" as const, + capabilities: [], + userLogin: null, + failure: { kind, message: "backend detail", retryAt: null }, + rateLimit: null, + }, + }; + + expect(describeGithubPatVerification(result)).toMatchObject({ + verified: false, + message: expect.stringContaining(message), + }); + }); +}); + describe("githubCredentialPresentation", () => { it("treats GitHub App authorization as installation permissions, not OAuth scopes", () => { const presentation = githubCredentialPresentation(makeCliStatus({ diff --git a/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts b/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts index da13ae9e1..68a683996 100644 --- a/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts +++ b/apps/desktop/src/renderer/lib/githubIntegrationStatus.ts @@ -1,6 +1,7 @@ import type { GitHubAppInstallationStatus, GitHubAppUserAuthStatus, + GitHubSetTokenResult, GitHubStatus, } from "../../shared/types"; @@ -246,6 +247,54 @@ export function githubStatusHasWriteCredential(status: GitHubStatus | null): boo && status.authSource !== "none"; } +export function githubStatusHasUsablePat(result: GitHubSetTokenResult | null): boolean { + const pat = result?.credentialVerification; + return Boolean( + pat?.source === "pat" + && pat.failure == null + && pat.capabilities.includes("write") + ); +} + +export function describeGithubPatVerification(result: GitHubSetTokenResult): { + verified: boolean; + message: string; +} { + if (githubStatusHasUsablePat(result)) { + return { verified: true, message: "Personal access token saved and verified." }; + } + const failure = result.credentialVerification.failure; + if (failure?.kind === "invalid_token") { + return { + verified: false, + message: "Token saved, but authentication failed. Re-check the token value.", + }; + } + if (failure?.kind === "rate_limited") { + return { + verified: false, + message: "Token saved, but GitHub temporarily paused verification. ADE will try it again when needed.", + }; + } + if (failure?.kind === "permission_denied") { + const repoLabel = result.repo ? `${result.repo.owner}/${result.repo.name}` : "this repository"; + return { + verified: false, + message: `Token saved, but ADE cannot use it for write actions on ${repoLabel}. Check the token's repository access and write permissions.`, + }; + } + if (failure?.kind === "network") { + return { + verified: false, + message: "Token saved, but ADE could not reach GitHub to verify it. Try again.", + }; + } + return { + verified: false, + message: "Token saved, but ADE could not verify it for GitHub write actions. Check the token and its repository permissions.", + }; +} + export function describeGithubCliBanner(status: GitHubStatus): { subState: string; title: string; diff --git a/apps/desktop/src/shared/githubOperationCredential.test.ts b/apps/desktop/src/shared/githubOperationCredential.test.ts index 1aaaf85d8..18c188ad0 100644 --- a/apps/desktop/src/shared/githubOperationCredential.test.ts +++ b/apps/desktop/src/shared/githubOperationCredential.test.ts @@ -48,7 +48,7 @@ describe("githubOperationCredential", () => { }); expect(result.active?.candidate.source).toBe("gh"); - expect(result.activeWriteSource).toBe("gh"); + expect(result.activeWrite?.source).toBe("gh"); expect(result.failures.map((failure) => failure.candidate.source)).toEqual(["app"]); expect(rejected).toHaveBeenCalledWith( app, @@ -92,7 +92,7 @@ describe("githubOperationCredential", () => { }); expect(result.active?.candidate).toBe(gh); - expect(result.activeWriteSource).toBe("gh"); + expect(result.activeWrite?.source).toBe("gh"); expect(probe).toHaveBeenCalledTimes(2); }); diff --git a/apps/desktop/src/shared/githubOperationCredential.ts b/apps/desktop/src/shared/githubOperationCredential.ts index 3f2b7899a..e73bf4453 100644 --- a/apps/desktop/src/shared/githubOperationCredential.ts +++ b/apps/desktop/src/shared/githubOperationCredential.ts @@ -2,7 +2,9 @@ import type { GitHubAuthFailure, GitHubCredentialCapability, GitHubCredentialSource, + GitHubCredentialVerification, GitHubRateLimitState, + GitHubStatus, GitHubTokenType, } from "./types/git"; import { getGitHubTokenAccessState } from "./githubScopes"; @@ -129,6 +131,114 @@ export type GithubStatusCredentialProbeResult = value?: Probe; }; +export async function verifyGithubCredentialSource< + Candidate extends { + source: GithubOperationCredentialSource; + token: string; + }, + Probe, +>(args: { + source: GithubOperationCredentialSource; + status?: GitHubStatus; + candidate: Candidate | null; + cooldown: (candidate: Candidate) => { + failure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + } | null; + probe: (candidate: Candidate) => Promise>; + capabilities: (candidate: Candidate, probe: Probe) => Readonly<{ read: boolean; write: boolean }>; + userLogin: (probe: Probe) => string | null; + rateLimit: (probe: Probe) => GitHubRateLimitState | null; + isRepositoryAccessFailure: ( + result: Extract, { ok: false }>, + ) => boolean; + onAuthenticatedProbe: (candidate: Candidate, probe: Probe) => void; + onUsableProbe: (candidate: Candidate, probe: Probe) => void; + onRejectedProbe: ( + candidate: Candidate, + result: Extract, { ok: false }>, + repositoryAccessFailure: boolean, + ) => void; + missingMessage: string; + missingPermissionMessage: string; +}): Promise { + const { source, status, candidate } = args; + if (status?.writeAuthSource === source) { + const state = status.credentialStates?.find((entry) => entry.source === source); + return { + source, + capabilities: state?.capabilities ?? ["read", "write"], + userLogin: status.writeUserLogin + ?? (status.authSource === source ? status.userLogin : null), + failure: null, + rateLimit: state?.rateLimit ?? status.rateLimit ?? null, + }; + } + if (!candidate) { + return { + source, + capabilities: [], + userLogin: null, + failure: { kind: "invalid_token", message: args.missingMessage, retryAt: null }, + rateLimit: null, + }; + } + const cooldown = args.cooldown(candidate); + if (cooldown) { + return { + source, + capabilities: [], + userLogin: null, + failure: cooldown.failure, + rateLimit: cooldown.rateLimit, + }; + } + const result = await args.probe(candidate); + if (!result.ok) { + if (result.value) args.onAuthenticatedProbe(candidate, result.value); + args.onRejectedProbe(candidate, result, args.isRepositoryAccessFailure(result)); + return { + source, + capabilities: [], + userLogin: result.value ? args.userLogin(result.value) : null, + failure: result.authFailure, + rateLimit: result.rateLimit, + }; + } + const capabilities = args.capabilities(candidate, result.value); + args.onAuthenticatedProbe(candidate, result.value); + if (!capabilities.write) { + const authFailure: GitHubAuthFailure = { + kind: "permission_denied", + message: args.missingPermissionMessage, + retryAt: null, + }; + const rejected = { + ok: false as const, + error: authFailure.message, + authFailure, + rateLimit: args.rateLimit(result.value), + value: result.value, + }; + args.onRejectedProbe(candidate, rejected, false); + return { + source, + capabilities: capabilities.read ? ["read"] : [], + userLogin: args.userLogin(result.value), + failure: authFailure, + rateLimit: rejected.rateLimit, + }; + } + args.onUsableProbe(candidate, result.value); + return { + source, + capabilities: capabilities.read ? ["read", "write"] : ["write"], + userLogin: args.userLogin(result.value), + failure: null, + rateLimit: args.rateLimit(result.value), + }; +} + export async function resolveGithubStatusCredentials< Candidate extends { source: GithubOperationCredentialSource; @@ -159,7 +269,11 @@ export async function resolveGithubStatusCredentials< ) => void; }): Promise<{ active: { candidate: Candidate; value: Probe } | null; - activeWriteSource: Exclude | null; + activeWrite: { + source: Exclude; + candidate: Candidate; + value: Probe; + } | null; failures: Array<{ candidate: Candidate; error: string; @@ -240,7 +354,11 @@ export async function resolveGithubStatusCredentials< break; } - let activeWriteSource: Exclude | null = null; + let activeWrite: { + source: Exclude; + candidate: Candidate; + value: Probe; + } | null = null; if (active) { for (const candidate of args.writeCandidates) { if (candidate.source === "app" || args.cooldown(candidate)) continue; @@ -259,13 +377,13 @@ export async function resolveGithubStatusCredentials< args.onUsableProbe(candidate, result.value); } if (args.capabilities(candidate, result.value).write) { - activeWriteSource = candidate.source; + activeWrite = { source: candidate.source, candidate, value: result.value }; break; } } } - return { active, activeWriteSource, failures }; + return { active, activeWrite, failures }; } type CredentialResolvers = Record< diff --git a/apps/desktop/src/shared/types/git.ts b/apps/desktop/src/shared/types/git.ts index de186a1e5..0b5067d41 100644 --- a/apps/desktop/src/shared/types/git.ts +++ b/apps/desktop/src/shared/types/git.ts @@ -327,6 +327,14 @@ export type GitHubCredentialState = { rateLimit: GitHubRateLimitState | null; }; +export type GitHubCredentialVerification = { + source: GitHubCredentialSource; + capabilities: GitHubCredentialCapability[]; + userLogin: string | null; + failure: GitHubAuthFailure | null; + rateLimit: GitHubRateLimitState | null; +}; + export type GitHubCredentialFallback = { capability: GitHubCredentialCapability; fromSource: GitHubCredentialSource; @@ -362,6 +370,7 @@ export type GitHubStatus = { // Optional for compatibility with older runtimes. These fields describe the // operation credential chain without exposing credential material. writeAuthSource?: Exclude; + writeUserLogin?: string | null; credentialStates?: GitHubCredentialState[]; credentialFallback?: GitHubCredentialFallback | null; backgroundRefreshPausedUntil?: string | null; @@ -375,6 +384,10 @@ export type GitHubStatus = { connected: boolean; }; +export type GitHubSetTokenResult = GitHubStatus & { + credentialVerification: GitHubCredentialVerification; +}; + export type GitHubAppInstallationStatus = { repo: GitHubRepoRef | null; appName: string;