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..e014b319d 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); @@ -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), @@ -1978,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/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..0ab4a831f 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,6 +36,62 @@ vi.mock("../../desktop/src/main/services/automations/automationSecretService", ( import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import { createHeadlessGitHubService, createHeadlessLinearServices } from "./headlessLinearServices"; +import { + clearGithubCredentialHealth, + githubCredentialCooldown, + githubCredentialRepositoryAccess, + recordGithubCredentialFailure, +} 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; + 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) { + const ghConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}gh-`)); + temporaryDirectories.push(ghConfigDir); + process.env.GH_CONFIG_DIR = ghConfigDir; + } + return { + restore(): void { + globalThis.fetch = previousFetch; + for (const [key, value] of previousEnvironment) { + if (value == null) delete process.env[key]; + else process.env[key] = value; + } + for (const temporaryDirectory of temporaryDirectories) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }, + }; +} + +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"; @@ -71,6 +128,7 @@ function createDeps(overrides: Record = {}) { describe("headlessLinearServices", () => { beforeEach(() => { + clearGithubCredentialHealth(); process.env.ADE_DISABLE_GH_AUTH_FALLBACK = "1"; vi.clearAllMocks(); }); @@ -173,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; @@ -673,7 +810,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; @@ -687,6 +824,7 @@ describe("headlessLinearServices", () => { await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ authSource: "environment", connected: true, + writeAuthSource: "none", patTokenStored: true, userLogin: "octocat", }); @@ -711,7 +849,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 +895,7 @@ describe("headlessLinearServices", () => { try { await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ authSource: "pat", + writeAuthSource: "pat", connected: true, patTokenStored: true, userLogin: "octocat", @@ -769,6 +908,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; @@ -784,7 +931,722 @@ describe("headlessLinearServices", () => { } }); - it("keeps GitHub CLI auth ahead of GitHub App authorization for async REST calls", async () => { + it("falls back when headless GraphQL returns rate-limit errors with HTTP 200", async () => { + 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) => { + 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 { + environment.restore(); + } + }); + + 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("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, + }); + 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(); + 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 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") ?? ""); + 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 { + environment.restore(); + } + }); + + it("keeps App reads connected without advertising an invalid GitHub CLI writer", async () => { + 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") ?? ""; + 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: "gho_invalid_cli_token", + ghCliPath: "/opt/homebrew/bin/gh", + ghAuthError: null, + }), + }, + ); + + try { + await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ + authSource: "app", + writeAuthSource: "none", + connected: true, + userLogin: "octocat", + }); + expect(authorizations).toEqual([ + "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"); + await expect(githubService.getGitTransportTokenOrThrowAsync()).rejects.toThrow("GitHub auth missing"); + } finally { + environment.restore(); + } + }); + + 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, + }); + 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 environment = isolateHeadlessGithubAuth("ade-headless-github-cache-"); + 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 { + environment.restore(); + } + }); + + 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 +1698,7 @@ describe("headlessLinearServices", () => { try { await expect(githubService.getStatus({ forceRefresh: true })).resolves.toMatchObject({ authSource: "gh", + writeAuthSource: "gh", connected: true, patTokenStored: true, userLogin: "octocat", @@ -848,6 +1711,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..e316af14e 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -23,14 +23,15 @@ 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, GitHubAppDeviceAuthStartResult, GitHubAppUserAuthStatus, + GitHubCredentialVerification, + GitHubRepoRef, + GitHubRateLimitState, GitHubStatus, } from "../../desktop/src/shared/types"; import type { @@ -46,7 +47,18 @@ 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 { + requestGithubRawWithCredentialFallback, + type GithubRawRequestArgs, +} from "../../desktop/src/main/services/github/githubRawRequest"; +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 +68,39 @@ 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, + githubOperationCredentialPrecedence, + resolveGithubOperationCredentialCandidate, + resolveGithubStatusCredentials, selectGithubOperationCredential, - selectGithubOperationCredentialAsync, + verifyGithubCredentialSource, + type GithubOperationCredentialCapability, } from "../../desktop/src/shared/githubOperationCredential"; +import { + classifyGitHubRepositoryApiPath, + createGithubRepositoryRequestFallback, + isGithubRepositorySpecificAccessDenial, +} from "../../desktop/src/shared/githubApiPath"; +import { createGithubConditionalRequestCache } from "../../desktop/src/shared/githubConditionalRequestCache"; +import { + clearGithubCredentialHealth, + githubBackgroundRequestPauseUntilMs, + githubCredentialCooldown, + githubCredentialNonRateLimitCooldown, + githubCredentialRateLimitCooldown, + githubCredentialInventoryKey, + githubCredentialRepositoryAccess, + githubCredentialStates, + githubCredentialTokenDigest, + recordGithubCredentialFailure, + recordGithubOperationFailure, + recordGithubCredentialProbeSuccess, + recordGithubCredentialRepositoryAccess, + recordGithubCredentialSuccess, + registerGithubCredentialIdentity, + type GithubCredentialCandidate, +} from "../../desktop/src/main/services/github/githubCredentialHealth"; import { linearInvalidGrantLikelyStaleRotation, linearTokenNeedsRefresh, @@ -265,6 +307,46 @@ type HeadlessGitHubTokenLookup = { ghAuthError: string | null; }; +type HeadlessGitHubTokenCandidate = HeadlessGitHubTokenLookup & GithubCredentialCandidate & { + token: string; +}; + +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; +}; + +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 @@ -533,6 +615,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 }); @@ -545,6 +631,7 @@ async function fetchGitHub(input: string | URL, init: RequestInit): Promise > | 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; }; @@ -616,6 +706,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 +715,7 @@ export function createHeadlessGitHubService( pat: () => patToken ? { ...ghFallback, token: patToken, source: "pat", patTokenStored } : null, - }) ?? ghFallback; + }, "write") ?? ghFallback; }; const readStoredPatTokenAsync = async (): Promise => { @@ -639,37 +730,128 @@ export function createHeadlessGitHubService( return null; }; - const readTokenAsync = async (): Promise => { + const readCredentialInventoryAsync = async (): Promise => { const patToken = await readStoredPatTokenAsync(); const patTokenStored = Boolean(patToken); - let ghFallback: HeadlessGitHubTokenLookup = { + const environmentToken = envToken("ADE_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"); + const appStatus = appUserAuth.getAuthStatus(); + const [appResult, gh] = await Promise.all([ + appStatus.tokenStored + ? appUserAuth.getValidTokenForRelay() + .then((token) => ({ token, failure: null })) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + const failure = classifyGitHubAuthFailure({ message }); + logger.warn("github.app_user_token_refresh_failed", { + error: message, + kind: failure.authFailure.kind, + retryAt: failure.authFailure.retryAt, + }); + return { token: null, failure }; + }) + : Promise.resolve({ token: null, failure: null }), + Promise.resolve(options.ghAuthTokenProvider?.() ?? ghAuthTokenAsync()), + ]); + const appToken = appResult.token; + 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)), + failures: appResult.failure + ? [{ source: "app", ...appResult.failure }] + : [], + appTokenStored: appToken != null || appStatus.tokenStored, + patTokenStored, + ghCliPath: gh.ghCliPath, + ghAuthError: gh.ghAuthError, + }; + }; + + const readTokenAsync = async ( + capability: GithubOperationCredentialCapability = "write", + ): Promise => { + const inventory = await readCredentialInventoryAsync(); + return resolveGithubOperationCredentialCandidate({ + candidates: inventory.candidates, + capability, + isAvailable: (candidate) => !githubCredentialCooldown( + candidate, + Date.now(), + { resource: "core" }, + ), + }) + ?? { + token: null, + source: "none", + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + }; + }; + + 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, - ghCliPath: null, - ghAuthError: null, + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.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 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"; @@ -686,33 +868,13 @@ 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", { method: "GET", @@ -723,13 +885,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 +909,64 @@ 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 }; }; + + 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 }, - ): Promise<{ ok: boolean; error: string | null }> => { + 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)}`, @@ -751,34 +974,185 @@ 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) return { ok: true, error: null }; + if (response.ok) { + recordGithubCredentialRepositoryAccess(candidate, repo, true); + 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 repositoryAccessDenied = isGithubRepositorySpecificAccessDenial( + response.status, + message, + ); + 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; + if (authFailure?.kind === "permission_denied" && repositoryAccessDenied) { + recordGithubCredentialRepositoryAccess(candidate, repo, false); + } 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 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 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 ( + args: GithubRawRequestArgs, + ): 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; query?: Record; body?: unknown; token?: string; + accept?: string; + capability?: GithubOperationCredentialCapability; + repo?: GitHubRepoRef; }): 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 explicitToken = args.token?.trim() ?? ""; + const candidates: HeadlessGitHubTokenCandidate[] = explicitToken + ? [{ + token: explicitToken, + source: "environment", + patTokenStored: false, + ghCliPath: null, + ghAuthError: null, + capabilities: [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.", ); @@ -788,34 +1162,217 @@ 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); + const repositoryPath = classifyGitHubRepositoryApiPath(args.path) + ?? (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: HeadlessGithubCredentialAttemptError | null = null; + let firstRateLimitError: 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 }); + 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 + }; + let releaseConditionalRequest: (() => void) | null = null; + if (args.method === "GET") { + 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?.(); + } + if (response.status === 304) { + 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; + 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, + }); + const { repositoryNotFound, ambiguousRepositoryNotFound } = + repositoryFallback.classifyFailure(candidate, response.status); + if (!repositoryNotFound) { + recordGithubOperationFailure(candidate, failure.authFailure, failure.rateLimit); + } + const attemptError = new HeadlessGithubCredentialAttemptError( + message, + failure.authFailure, + failure.rateLimit, + ); + lastAttemptError = attemptError; + if (attemptError.authFailure.kind === "rate_limited") { + firstRateLimitError ??= attemptError; + } + const canTryNext = !args.token + && ( + 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 + ? "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) { + const { repositoryNotFound } = repositoryFallback.classifyFailure( + candidate, + graphqlFailure.status, + ); + if (!repositoryNotFound) { + recordGithubOperationFailure( + candidate, + graphqlFailure.authFailure, + graphqlFailure.rateLimit, + ); + } + const attemptError = new HeadlessGithubCredentialAttemptError( + graphqlFailure.message, + graphqlFailure.authFailure, + graphqlFailure.rateLimit, + ); + lastAttemptError = attemptError; + if (attemptError.authFailure.kind === "rate_limited") { + firstRateLimitError ??= attemptError; + } + 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); + } + throw attemptError; + } + + recordGithubCredentialSuccess(candidate, response.headers); + repositoryFallback.recordSuccess(candidate); + 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) { + conditionalRequestCache.store(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 unavailableError = firstUnavailable + ? new HeadlessGithubCredentialAttemptError( + 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); + 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 +1383,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 +1393,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; }; @@ -1022,127 +1579,277 @@ export function createHeadlessGitHubService( }; service = { + verifyStoredPat, async getStatus(opts: { forceRefresh?: boolean } = {}) { - if ( - opts.forceRefresh - && statusLookupInFlight?.generation !== statusLookupGeneration - ) { + if (opts.forceRefresh) { invalidateStatusCache(); } + const [origin, inventory] = await Promise.all([ + readGitOriginAsync(projectRoot), + readCredentialInventoryAsync(), + ]); + 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 (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, + sourceFailures: inventoryFailuresBySource, + 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, tokenLookup] = await Promise.all([ - readGitOriginAsync(projectRoot), - readTokenAsync(), - ]); - 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) => opts.forceRefresh === true + ? githubCredentialRateLimitCooldown(candidate, Date.now(), { resource: "core" }) + : githubCredentialCooldown(candidate, Date.now(), { resource: "core" }); + const primaryCandidate = readCandidates[0] ?? null; + if (!primaryCandidate) { + const failure = inventory.failures[0] ?? null; return { - tokenStored: false, - patTokenStored: tokenLookup.patTokenStored, + tokenStored: inventory.appTokenStored, + patTokenStored: inventory.patTokenStored, tokenDecryptionFailed, storageScope: "app", - authSource: "none", + authSource: failure?.source ?? "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: failure?.authFailure ?? null, + rateLimit: failure?.rateLimit ?? null, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, + 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 { active, activeWrite, 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, + onAuthenticatedProbe: (candidate, value) => { + registerGithubCredentialIdentity(candidate, value.validated.userLogin); + }, + onUsableProbe: (candidate, value) => { + recordGithubCredentialProbeSuccess( + candidate, + value.validated.rateLimit, + value.validated.userLogin, + ); + }, + onRejectedProbe: (candidate, result, context) => { + if (!context.repositoryAccessFailure) { + recordGithubCredentialFailure(candidate, result.authFailure, result.rateLimit); + } + 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 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: tokenLookup.patTokenStored, + patTokenStored: inventory.patTokenStored, tokenDecryptionFailed: false, storageScope: "app", - authSource: tokenLookup.source, + authSource: candidate.source, + writeAuthSource: activeWriteSource ?? "none", + writeUserLogin: activeWrite?.value.validated.userLogin ?? null, 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, + sourceFailures: inventoryFailuresBySource, + activeReadSource: candidate.source, + activeWriteSource, + }), + credentialFallback: fallbackFailure + ? { + capability: "read", + fromSource: fallbackFailure.source, + toSource: candidate.source, + reason: fallbackFailure.authFailure.kind, + retryAt: fallbackFailure.authFailure.retryAt, + } + : null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), repoAccessOk, repoAccessError, - connected: computeConnected({ - tokenStored: true, - userLogin: validated.userLogin, - authSource: tokenLookup.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, + connected: validatedCredentialCapabilities(candidate, value, repo).read, }; } + + const failure = credentialFailures.find((entry) => entry.authFailure.kind === "rate_limited") + ?? credentialFailures[0] + ?? { + source: primaryCandidate.source, + 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: "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, + sourceFailures: inventoryFailuresBySource, + activeReadSource: null, + activeWriteSource: null, + }), + credentialFallback: null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + repoAccessOk: null, + repoAccessError: null, + 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 { @@ -1151,6 +1858,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 +1899,22 @@ export function createHeadlessGitHubService( return await appUserAuth.startDeviceAuth(); }, async pollAppUserDeviceAuth(args: { sessionId: string }): Promise { - return await appUserAuth.pollDeviceAuth(args); + const previousToken = appUserAuth.getStoredTokenForHealth(); + const result = await appUserAuth.pollDeviceAuth(args); + if (result.status === "authorized") { + const currentToken = appUserAuth.getStoredTokenForHealth(); + if (previousToken) clearGithubCredentialHealth(previousToken); + if (currentToken && currentToken !== previousToken) clearGithubCredentialHealth(currentToken); + invalidateStatusCache(); + } + return result; }, clearAppUserAuth(): GitHubAppUserAuthStatus { - return appUserAuth.clearAuth(); + const previousToken = appUserAuth.getStoredTokenForHealth(); + const status = appUserAuth.clearAuth(); + if (previousToken) clearGithubCredentialHealth(previousToken); + invalidateStatusCache(); + return status; }, async getRepoOrThrow() { const repo = await detectGitHubRepoAsync(projectRoot); @@ -1207,7 +1933,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 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.", @@ -1222,6 +1966,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); @@ -1229,16 +1974,21 @@ export function createHeadlessGitHubService( credentialStore.deleteSync(tokenKey); } tokenDecryptionFailed = false; + if (previousToken) clearGithubCredentialHealth(previousToken); + if (clean && clean !== previousToken) clearGithubCredentialHealth(clean); invalidateStatusCache(); emitStatusChanged(); }, clearToken() { + const previousToken = readStoredPatToken(); tokenOverride = null; credentialStore.deleteSync(tokenKey); tokenDecryptionFailed = false; + if (previousToken) clearGithubCredentialHealth(previousToken); invalidateStatusCache(); emitStatusChanged(); }, + requestRawWithCredentialFallback, apiRequest, createRepository, getRepository, 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 8b43d4027..2f6b5f060 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), @@ -3250,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); @@ -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/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/automations/automationIngressService.test.ts b/apps/desktop/src/main/services/automations/automationIngressService.test.ts index 4684d07d3..90a1b6f7d 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" }), ); }); @@ -993,6 +993,252 @@ 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")); + 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("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 () => @@ -1195,4 +1441,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..2479f77ae 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -75,6 +75,56 @@ 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; +const GITHUB_RELAY_ERROR_MESSAGE_MAX_LENGTH = 500; + +class GithubRelayPollError extends Error { + constructor( + message: string, + readonly retryAtMs: number | null, + ) { + super(message); + this.name = "GithubRelayPollError"; + } +} + +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; + 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, @@ -316,9 +366,11 @@ 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; + let relayPollGeneration = 0; let started = false; let stopped = false; let subscriptionSocket: WebSocket | null = null; @@ -328,6 +380,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 +394,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg ); const updateGithubRelayStatus = (patch: Partial) => { + if (typeof patch.healthy === "boolean") githubRelayHealthy = patch.healthy; args.automationService?.updateIngressStatus({ githubRelay: patch, }); @@ -562,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); @@ -715,11 +788,21 @@ 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) { + scheduleRelayPollRetry(); + 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 @@ -736,6 +819,7 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg }); if (!config.configured) { disableRelaySubscription(); + updateGithubRelayStatus({ healthy: false }); return; } const committedIngestedPrIds = new Set(); @@ -763,7 +847,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 @@ -789,8 +875,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; @@ -845,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", }); @@ -864,17 +956,32 @@ 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) { - throw new Error(`GitHub relay poll failed (${response.status})`); + const responseText = await run.wait(response.text().catch(() => "")); + let responseMessage = responseText.trim(); + try { + const parsed = JSON.parse(responseText) as { error?: unknown; message?: unknown }; + if (typeof parsed.error === "string") responseMessage = parsed.error; + else if (typeof parsed.message === "string") responseMessage = parsed.message; + } 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}` + : `GitHub relay poll failed (${response.status})`, + relayRetryAtMs(response.headers), + ); } - const payload = await response.json() as { + const payload = await run.wait(response.json()) as { events?: Array>; nextCursor?: unknown; cursorExpired?: unknown; @@ -898,7 +1005,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, @@ -909,7 +1016,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 @@ -918,7 +1025,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", @@ -927,8 +1034,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, @@ -942,7 +1050,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 @@ -950,6 +1058,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; @@ -958,7 +1067,11 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg } pageCursor = pageLastCursor; } + run.assertCurrent(); flushCommittedPrReconciliation(); + relayPollFailureCount = 0; + relayPollCooldownUntilMs = 0; + clearRelayPollRetryTimer(); updateGithubRelayStatus({ healthy: true, status: "ready", @@ -968,10 +1081,26 @@ 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; + const backoffMs = Math.min( + 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( + nowMs + backoffMs, + Math.min( + nowMs + GITHUB_RELAY_POLL_BACKOFF_CAP_MS, + 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(), }); updateGithubRelayStatus({ healthy: false, @@ -990,8 +1119,12 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg pollInFlight = (async () => { do { pollRerunRequested = false; - await pollGithubRelay(); - } while (pollRerunRequested && !stopped); + try { + await pollGithubRelay(relayPollGeneration); + } catch (error) { + if (!(error instanceof GithubRelayPollSupersededError)) throw error; + } + } while (pollRerunRequested && !stopped && Date.now() >= relayPollCooldownUntilMs); })().finally(() => { pollInFlight = null; }); @@ -1003,6 +1136,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) { @@ -1034,6 +1168,10 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg return args.automationService?.getIngressStatus() ?? null; }, + isGithubRelayHealthy() { + return githubRelayHealthy; + }, + listRecentEvents(limit = 20) { return args.automationService?.listIngressEvents(limit) ?? []; }, @@ -1042,16 +1180,24 @@ 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(); }, stop() { stopped = true; started = false; + relayPollGeneration += 1; + githubRelayHealthy = false; + relayPollCooldownUntilMs = 0; + relayPollFailureCount = 0; if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + clearRelayPollRetryTimer(); clearSubscriptionReconnectTimer(); clearSubscriptionConnectTimer(); pollAbortController?.abort(); diff --git a/apps/desktop/src/main/services/cto/linearAuth.test.ts b/apps/desktop/src/main/services/cto/linearAuth.test.ts index 0f0c7e1ed..09cec8d7c 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,221 @@ 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("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: { + 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 { 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(mockFetch).toHaveBeenCalledTimes(1)); + + 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]); + expect(responses.find((response) => response.statusCode === 409)?.body).toContain( + "already being completed", + ); + expect(credentials.setOAuthToken).toHaveBeenCalledTimes(1); + }); + + 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) => { + 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: 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: 200 }); + expect(service.getSession(sessionId)).toMatchObject({ + status: "completed", + error: null, + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(credentials.setOAuthToken).toHaveBeenCalledTimes(1); }); it("handles OAuth callback with error parameter from Linear", async () => { @@ -642,6 +847,116 @@ 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 () => { + 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); + + try { + const interruptedStart = service.startSession(); + await portBound; + const disposal = service.dispose(); + releaseListeningCallback(); + + await expect(interruptedStart).rejects.toThrow("no longer active"); + await disposal; + } finally { + releaseListeningCallback(); + listenSpy.mockRestore(); + } + + 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({ @@ -650,7 +965,7 @@ describe("linearOAuthService", () => { }); const { sessionId } = await service.startSession(); - service.dispose(); + await service.dispose(); const session = service.getSession(sessionId); expect(session.status).toBe("expired"); @@ -771,6 +1086,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 9c80d5182..9bc3283e9 100644 --- a/apps/desktop/src/main/services/cto/linearOAuthService.ts +++ b/apps/desktop/src/main/services/cto/linearOAuthService.ts @@ -29,7 +29,10 @@ type LinearOAuthSessionState = { createdAt: number; status: CtoGetLinearOAuthSessionResult["status"]; error: string | null; + callbackClaimed: boolean; server: http.Server; + abortController: AbortController; + closePromise: Promise | null; }; type LinearExternalOAuthSessionState = { @@ -40,6 +43,7 @@ type LinearExternalOAuthSessionState = { codeVerifier: string; createdAt: number; expiresAt: string; + completionInFlight: Promise | null; }; export type LinearExternalOAuthStartResult = { @@ -69,6 +73,47 @@ function createOAuthPortInUseError(): Error { return error; } +function closeServerAndWait(server: http.Server): Promise { + return new Promise((resolve) => { + try { + server.close(() => resolve()); + } catch { + resolve(); + } + }); +} + +function writeResponse( + response: http.ServerResponse, + status: number, + contentType: string, + body: string, +): Promise { + 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; + cleanup(); + resolve(); + }; + response.once("finish", finish); + response.once("close", finish); + try { + response.writeHead(status, { "content-type": contentType }); + response.end(body); + } catch (error) { + if (settled) return; + settled = true; + cleanup(); + reject(error); + } + }); +} export function createLinearOAuthService(args: { credentials: LinearCredentialService; @@ -78,25 +123,107 @@ 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; + let disposeInFlight: Promise | null = null; - const finalizeSession = (session: LinearOAuthSessionState, patch: { + const assertActive = (): void => { + if (disposed) throw new Error("Linear OAuth service is no longer active."); + }; + + const markSessionTerminal = (session: LinearOAuthSessionState, patch: { status: LinearOAuthSessionState["status"]; error?: string | null; - }) => { + }): void => { session.status = patch.status; session.error = patch.error ?? null; + session.abortController.abort(); + }; + + const beginServerClose = (session: LinearOAuthSessionState): Promise => { + const closed = closeServerAndWait(session.server); + 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); + return writeResponse(response, reply.status, reply.contentType, reply.body) + .then(() => closeSessionServer(session)); + }; + + 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 respondAlreadyFinishedAndClose = async ( + session: LinearOAuthSessionState, + response: http.ServerResponse, + ): Promise => { try { - session.server.close(); - } catch { - // best effort + 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, + 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()) { 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 +277,7 @@ export function createLinearOAuthService(args: { const exchangeCode = async ( session: Pick, code: string, + signal?: AbortSignal, ): Promise => { const oauthClient = args.credentials.getOAuthClientCredentials(); if (!oauthClient) { @@ -175,6 +303,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 +313,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 +335,25 @@ 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.", + })); + } else if (prev.closePromise) { + supersededSessions.push(prev.closePromise); } } + 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."); @@ -245,56 +388,119 @@ export function createLinearOAuthService(args: { return; } + if (session.status !== "pending") { + 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) { - 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) { - 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); - 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."); + await exchangeCode(session, code, session.abortController.signal); + if (session.status !== "pending") { + await respondAlreadyFinishedAndClose(session, 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") { + await respondAlreadyFinishedAndClose(session, res); + return; + } const message = error instanceof Error ? error.message : "OAuth callback failed."; - 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; 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 +511,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") { @@ -329,9 +542,13 @@ export function createLinearOAuthService(args: { createdAt: Date.now(), status: "pending", error: null, + callbackClaimed: false, server, + abortController: new AbortController(), + closePromise: null, }; sessions.set(sessionId, session); + if (startingServer === server) startingServer = null; return { sessionId, @@ -340,6 +557,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); @@ -384,6 +612,7 @@ export function createLinearOAuthService(args: { codeVerifier: pkce.verifier, createdAt, expiresAt, + completionInFlight: null, }); return { sessionId, authorizeUrl, expiresAt }; @@ -412,21 +641,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 { @@ -434,16 +670,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; + closePromises.push(closeServerAndWait(server)); + server.closeAllConnections(); + } for (const session of sessions.values()) { - try { - session.server.close(); - } catch { - // best effort - } + 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/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/githubCredentialHealth.test.ts b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts new file mode 100644 index 000000000..9bd3a6141 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitHubAuthFailure } from "../../../shared/types"; +import { + clearGithubCredentialHealth, + githubBackgroundRequestPauseUntilMs, + githubCredentialCooldown, + githubCredentialNonRateLimitCooldown, + githubCredentialRateLimitCooldown, + recordGithubCredentialFailure, + recordGithubOperationFailure, + 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)); + + 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", + 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(githubCredentialRateLimitCooldown(appCandidate, Date.now())) + .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(githubCredentialRateLimitCooldown(appCandidate, Date.now()) + ?.failure.kind).toBe("rate_limited"); + }); + + it("keeps permission-denied cooldowns resource-scoped", () => { + const permissionDenied = { + kind: "permission_denied", + message: "Resource protected by organization policy", + retryAt: 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 new file mode 100644 index 000000000..a45840416 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubCredentialHealth.ts @@ -0,0 +1,382 @@ +import { createHash } from "node:crypto"; +import type { + GitHubAuthFailure, + GitHubCredentialCapability, + GitHubCredentialSource, + GitHubCredentialState, + GitHubRateLimitState, + GitHubRepoRef, +} 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; +const REPOSITORY_ACCESS_TTL_MS = 2 * 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(); +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; +} + +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), + }); +} + +function recordGithubFailure( + candidate: GithubCredentialCandidate, + failure: GitHubAuthFailure, + rateLimit: GitHubRateLimitState | null, + cooldownUntilMs: number, +): void { + const digest = githubCredentialTokenDigest(candidate.token); + const existing = healthByTokenDigest.get(digest); + const userLogin = normalizedLogin(candidate.userLogin ?? existing?.userLogin); + 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 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 } = {}, + matches: (failure: GitHubAuthFailure) => 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.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( + entry?.failure + && entry.cooldownUntilMs > nowMs + && 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); + 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: { + candidates: readonly GithubCredentialCandidate[]; + availableSources: ReadonlySet; + sourceFailures: ReadonlyMap; + 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 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 || sourceFailure) state = "cooldown"; + if (activeFor.length > 0) state = "active"; + return { + source, + available: args.availableSources.has(source), + capabilities, + activeFor, + state, + failure: cooling?.failure ?? sourceFailure?.authFailure ?? null, + rateLimit: cooling?.rateLimit + ?? [...(health?.resources.values() ?? [])].find((entry) => entry.rateLimit)?.rateLimit + ?? sourceFailure?.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..232d72fbc 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,94 @@ export function classifyGitHubAuthFailure(args: { }; } +export function classifyGitHubGraphqlCredentialFailure( + payload: unknown, + headers: Pick, +): { + status: 403 | 404 | 429; + message: string; + hasData: boolean; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; +} | null { + if ( + !payload + || typeof payload !== "object" + || !("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 []; + 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 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()); + }); + 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, + hasData, + ...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, + 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; +} + export function githubRateLimitResetAtMs(rateLimit: GitHubRateLimitState | null): number | null { if (!rateLimit?.resetAt) return null; const parsed = Date.parse(rateLimit.resetAt); @@ -120,3 +219,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/githubRawRequest.ts b/apps/desktop/src/main/services/github/githubRawRequest.ts new file mode 100644 index 000000000..26e8a075b --- /dev/null +++ b/apps/desktop/src/main/services/github/githubRawRequest.ts @@ -0,0 +1,188 @@ +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, + recordGithubOperationFailure, + 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) { + recordGithubOperationFailure(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; + // 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 + ?? 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 60ccb34ea..5be6269c1 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -55,6 +55,12 @@ import { createGithubService, fetchAdeLatestRelease, } from "./githubService"; +import { + clearGithubCredentialHealth, + githubCredentialCooldown, + githubCredentialRepositoryAccess, + recordGithubCredentialFailure, +} from "./githubCredentialHealth"; // --------------------------------------------------------------------------- // Helpers @@ -70,6 +76,7 @@ function makeLogger() { } function resetMocks() { + clearGithubCredentialHealth(); vi.clearAllMocks(); mockFetch.mockReset(); runGitMock.mockReset(); @@ -291,6 +298,236 @@ 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("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 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(); + 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", @@ -370,15 +607,379 @@ 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("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 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(); + 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(); + 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(); + 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(); }); }); @@ -716,6 +1317,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(); @@ -738,20 +1353,71 @@ describe("githubService.getStatus", () => { delete process.env.ADE_GITHUB_TOKEN; }); - // Mocks `git remote get-url origin` so detectRepo returns acme/ade. - function stubOriginRemote() { - runGitMock.mockResolvedValue({ - exitCode: 0, - stdout: "git@github.com:acme/ade.git\n", - stderr: "", + // Mocks `git remote get-url origin` so detectRepo returns acme/ade. + function stubOriginRemote() { + runGitMock.mockResolvedValue({ + exitCode: 0, + stdout: "git@github.com:acme/ade.git\n", + stderr: "", + }); + } + + it("keeps repo-capable classic tokens connected while withholding write access", async () => { + stubOriginRemote(); + process.env.GITHUB_TOKEN = "ghp_classic"; + mockFetch.mockResolvedValueOnce( + jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo" }), + ); + 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"]); + expect(status.repoAccessOk).toBeNull(); + expect(status.connected).toBe(true); + expect(status.writeAuthSource).toBe("none"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + 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.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" })); + const service = makeService({ credentialStore }); + const status = await service.getStatus(); + + expect(status).toMatchObject({ + authSource: "app", + writeAuthSource: "none", + connected: true, + patTokenStored: false, + repoAccessOk: true, + userLogin: "alice", }); - } + expect(mockFetch).toHaveBeenCalledTimes(2); + await expect(service.getTokenOrThrowAsync()).rejects.toThrow("GitHub write access is unavailable"); + await expect(service.getReadTokenOrThrowAsync()).resolves.toBe("ghu_app_user_token"); + }); - it("classic token with required scopes is connected (no repo probe needed)", async () => { + it("preserves the validated identity of a different active write credential", async () => { stubOriginRemote(); - process.env.GITHUB_TOKEN = "ghp_classic"; + delete process.env.ADE_DISABLE_GH_AUTH_FALLBACK; const credentialStore = new MemoryCredentialStore(); - credentialStore.setSync("github.token.v1", "ghp_stored_token"); + credentialStore.setSync("github.token.v1", "ghp_backup_token"); credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ accessToken: "ghu_app_user_token", tokenType: "bearer", @@ -762,9 +1428,110 @@ describe("githubService.getStatus", () => { userLogin: "alice", updatedAt: new Date().toISOString(), })); - mockFetch.mockResolvedValueOnce( - jsonResponse(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), + 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(); + 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: () => ({ @@ -774,94 +1541,108 @@ describe("githubService.getStatus", () => { }), }).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.repoAccessOk).toBeNull(); - expect(status.connected).toBe(true); - expect(mockFetch).toHaveBeenCalledTimes(1); + 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("keeps the read-only GitHub App out of operational REST credential selection", async () => { + 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.token.v1", "ghp_stored_token"); credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ - accessToken: "ghu_app_user_token", + accessToken: "ghu_expiring_app_token", tokenType: "bearer", scope: null, - expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), - refreshToken: null, - refreshTokenExpiresAt: 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(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), - ); + 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: "pat", + authSource: "environment", connected: true, - patTokenStored: true, - repoAccessOk: null, - userLogin: "alice", + credentialFallback: null, }); - 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 () => { + 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.token.v1", "ghp_stored_token"); credentialStore.setSync("github.appUserToken.v1", JSON.stringify({ - accessToken: "ghu_app_user_token", + accessToken: "ghu_expiring_app_token", tokenType: "bearer", scope: null, - expiresAt: new Date(Date.now() + 60 * 60_000).toISOString(), - refreshToken: null, - refreshTokenExpiresAt: 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(200, { login: "alice" }, { "x-oauth-scopes": "repo, workflow" }), - ); + 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 service = makeService({ + const status = await makeService({ credentialStore, ghAuthTokenProvider: () => ({ token: "gho_cli_token", ghCliPath: "/opt/homebrew/bin/gh", ghAuthError: null, }), - }); - const status = await service.getStatus(); + }).getStatus(); expect(status).toMatchObject({ authSource: "gh", connected: true, - patTokenStored: true, - repoAccessOk: null, - userLogin: "alice", + credentialFallback: { + capability: "read", + fromSource: "environment", + toSource: "gh", + reason: "invalid_token", + retryAt: null, + }, }); - 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 () => { + 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", @@ -873,16 +1654,26 @@ describe("githubService.getStatus", () => { userLogin: "alice", updatedAt: new Date().toISOString(), })); - const status = await makeService({ credentialStore }).getStatus(); + 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: "none", - connected: false, - patTokenStored: false, - repoAccessOk: null, - userLogin: null, + authSource: "app", + writeAuthSource: "none", + connected: true, }); - expect(mockFetch).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(3); }); it("reports an exhausted GitHub API quota as rate limited instead of missing permissions", async () => { @@ -1100,32 +1891,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 +1949,89 @@ 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("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; @@ -1262,6 +2110,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, + }); + service.clearToken(); + 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 () => { @@ -2032,6 +2922,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", @@ -2070,6 +2973,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 () => { @@ -2164,6 +3069,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, { @@ -2179,6 +3097,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 caeb7abc9..2517fb271 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"; @@ -14,29 +13,66 @@ import type { GitHubAppInstallationStatus, GitHubAppUserAuthStatus, GitHubAutolink, + GitHubCredentialVerification, GitHubRateLimitState, GitHubRepoRef, GitHubStatus, } 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, + githubOperationCredentialPrecedence, + resolveGithubOperationCredentialCandidate, + resolveGithubStatusCredentials, selectGithubOperationCredential, - selectGithubOperationCredentialAsync, + verifyGithubCredentialSource, + type GithubOperationCredentialCapability, } from "../../../shared/githubOperationCredential"; +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, GitHubRateLimitError, + githubRateLimitResourceForPath, githubRateLimitRetryAtMs, isTransientGithubProbeFailure, readGitHubRateLimitState, } from "./githubRateLimit"; +import { + clearGithubCredentialHealth, + githubBackgroundRequestPauseUntilMs, + githubCredentialCooldown, + githubCredentialNonRateLimitCooldown, + githubCredentialRateLimitCooldown, + githubCredentialInventoryKey, + githubCredentialRepositoryAccess, + githubCredentialStates, + githubCredentialTokenDigest, + recordGithubCredentialFailure, + recordGithubOperationFailure, + recordGithubCredentialProbeSuccess, + recordGithubCredentialRepositoryAccess, + recordGithubCredentialSuccess, + registerGithubCredentialIdentity, + type GithubCredentialCandidate, +} from "./githubCredentialHealth"; import { nowIso, asString } from "../shared/utils"; @@ -47,6 +83,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); @@ -79,7 +116,7 @@ type SharedGithubStatusProbe = { validated: { userLogin: string | null; scopes: string[]; - tokenType: GitHubStatus["tokenType"]; + tokenType: NonNullable; rateLimit: GitHubRateLimitState | null; }; repoAccessOk: boolean | null; @@ -93,6 +130,7 @@ type SharedGithubStatusProbeResult = error: string; authFailure: GitHubAuthFailure; rateLimit: GitHubRateLimitState | null; + value?: SharedGithubStatusProbe; }; type ProcessGithubAuthState = { @@ -100,6 +138,7 @@ type ProcessGithubAuthState = { authInFlight: Promise | null; statusCache: Map; statusInFlight: Map>; + credentialInventoryRevision: number; }; const processGithubAuthStates = new WeakMap(); @@ -123,25 +162,51 @@ function processGithubAuthState(provider: GitHubCliAuthProvider): ProcessGithubA authInFlight: null, statusCache: new Map(), statusInFlight: new Map(), + credentialInventoryRevision: 0, }; processGithubAuthStates.set(provider, created); return created; } 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; + failures: Array<{ + source: GitHubTokenCandidate["source"]; + authFailure: GitHubAuthFailure; + rateLimit: GitHubRateLimitState | null; + }>; + appTokenStored: boolean; + 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 @@ -362,7 +427,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"; @@ -507,7 +572,38 @@ 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; + 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; @@ -712,28 +808,144 @@ export function createGithubService({ : null; }; - const readAuthToken = async (): Promise => { + const buildCredentialInventory = async (): Promise => { const patLookup = readPatAuthToken(); const patTokenStored = Boolean(patLookup); - let ghFallback: GitHubTokenLookup = { + const environment = readEnvironmentAuthToken(); + const appStatus = appUserAuth.getAuthStatus(); + const [appResult, gh] = await Promise.all([ + appStatus.tokenStored + ? appUserAuth.getValidTokenForRelay() + .then((token) => ({ token, failure: null })) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + const failure = classifyGitHubAuthFailure({ message }); + logger.warn("github.app_user_token_refresh_failed", { + error: message, + kind: failure.authFailure.kind, + retryAt: failure.authFailure.retryAt, + }); + return { token: null, failure }; + }) + : Promise.resolve({ token: null, failure: null }), + readGhAuthToken(), + ]); + const appToken = appResult.token; + const appTokenStored = appToken != null || appUserAuth.getAuthStatus().tokenStored; + 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)), + failures: appResult.failure + ? [{ source: "app", ...appResult.failure }] + : [], + appTokenStored, + patTokenStored, + ghCliPath: gh.ghCliPath, + ghAuthError: gh.ghAuthError, + }; + }; + + 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", + ): Promise => { + const inventory = await readCredentialInventory(); + const resolved = resolveGithubOperationCredentialCandidate({ + candidates: inventory.candidates, + capability, + isAvailable: (candidate) => !githubCredentialCooldown( + candidate, + Date.now(), + { resource: "core" }, + ), + }); + return resolved ?? { token: null, source: "none", - patTokenStored, - ghCliPath: null, - ghAuthError: null, + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + }; + }; + + 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, }; - 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 +963,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 +982,7 @@ export function createGithubService({ return gh.token ? { ...gh, source: "gh", patTokenStored } : null; }, pat: () => patLookup, - }) ?? ghFallback; + }, "write") ?? ghFallback; }; const persistToken = (token: string | null): void => { @@ -818,7 +1031,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", { @@ -859,14 +1072,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)}`, @@ -874,13 +1105,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, @@ -896,10 +1128,27 @@ 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) + ? { + kind: "permission_denied" as const, + message: `This credential cannot access ${repo.owner}/${repo.name}.`, + retryAt: null, + } + : failure.authFailure.kind === "unknown" + ? null + : failure.authFailure; + if (authFailure?.kind === "permission_denied" && repositoryAccessDenied) { + recordGithubCredentialRepositoryAccess(candidate, repo, false); + } return { ok: false, error: `${response.status}: ${message}`, - authFailure: failure.authFailure.kind === "unknown" ? null : failure.authFailure, + authFailure, rateLimit: failure.rateLimit, }; } catch (error) { @@ -915,16 +1164,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 { @@ -932,6 +1181,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; @@ -959,11 +1209,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(() => {}); @@ -977,7 +1227,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; @@ -993,7 +1243,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, @@ -1014,17 +1264,25 @@ 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 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"; @@ -1032,6 +1290,8 @@ export function createGithubService({ query?: Record; 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 @@ -1039,8 +1299,22 @@ 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 explicitToken = args.token?.trim() ?? ""; + const candidates: GitHubTokenCandidate[] = explicitToken + ? [{ + token: explicitToken, + source: "environment", + patTokenStored: false, + ghCliPath: null, + ghAuthError: null, + capabilities: [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."); } @@ -1051,104 +1325,234 @@ 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); + const repositoryPath = classifyGitHubRepositoryApiPath(args.path) + ?? (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: GithubCredentialAttemptError | null = null; + let firstRateLimitError: 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 }); + if (cooldown) { + firstUnavailable ??= cooldown; + continue; + } - // 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); + 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 releaseConditionalRequest: (() => void) | null = null; + if (args.method === "GET") { + 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.toString(), { - method: args.method, - headers, - body: args.body != null ? JSON.stringify(args.body) : undefined - }); - } finally { - if (sentConditionalGet) { - inFlightConditionalGetKeys.delete(urlKey); + let response: Response; + try { + response = await fetchGitHub(url.toString(), { + method: args.method, + headers, + body: args.body != null ? JSON.stringify(args.body) : undefined, + }); + } finally { + releaseConditionalRequest?.(); } - } - // 304 Not Modified — return cached data (free, no rate limit cost) - if (response.status === 304) { - const cached = etagCache.get(urlKey); - if (cached) { + if (response.status === 304) { + 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); - return { data: cached.data as T, response, linkHeader: cached.linkHeader }; + 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(); - let data: unknown = text; - try { - data = text.trim().length ? JSON.parse(text) : {}; - } catch { - // keep text - } + 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})`; - 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("; "); + 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, + }); + const { repositoryNotFound, ambiguousRepositoryNotFound } = + repositoryFallback.classifyFailure(candidate, response.status); + if (!repositoryNotFound) { + recordGithubOperationFailure(candidate, failure.authFailure, failure.rateLimit); + } + const attemptError = new GithubCredentialAttemptError( + message + detail, + failure.authFailure, + failure.rateLimit, + ); + lastAttemptError = attemptError; + if (attemptError.authFailure.kind === "rate_limited") { + firstRateLimitError ??= attemptError; + } + const canTryNext = !args.token + && ( + 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 + ? "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) { + const { repositoryNotFound } = repositoryFallback.classifyFailure( + candidate, + graphqlFailure.status, + ); + if (!repositoryNotFound) { + recordGithubOperationFailure( + candidate, + graphqlFailure.authFailure, + graphqlFailure.rateLimit, + ); + } + const attemptError = new GithubCredentialAttemptError( + graphqlFailure.message, + graphqlFailure.authFailure, + graphqlFailure.rateLimit, ); + lastAttemptError = attemptError; + if (attemptError.authFailure.kind === "rate_limited") { + firstRateLimitError ??= attemptError; + } + 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); + } + 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); + repositoryFallback.recordSuccess(candidate); + 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) { + conditionalRequestCache.store(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 unavailableError = firstUnavailable + ? new GithubCredentialAttemptError( + 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); + 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: { @@ -1175,124 +1579,193 @@ 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(); + invalidateCredentialInventory(); } - 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 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, - patTokenStored: tokenLookup.patTokenStored, + tokenStored: inventory.appTokenStored, + patTokenStored: inventory.patTokenStored, tokenDecryptionFailed, storageScope: "app", - authSource: "none", + authSource: failure?.source ?? "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, + authFailure: failure?.authFailure ?? null, + rateLimit: failure?.rateLimit ?? null, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, + activeReadSource: null, + activeWriteSource: null, + }), + credentialFallback: null, + backgroundRefreshPausedUntil: null, repoAccessOk: null, repoAccessError: null, connected: false, }; cachedAt = Date.now(); - cachedStatusTokenDigest = null; + cachedStatusCredentialInventoryKey = credentialInventoryKey; return cachedStatus; } const now = Date.now(); - const tokenDigest = githubTokenDigest(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) { + 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 { - // 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 = cachedStatus.writeAuthSource + && cachedStatus.writeAuthSource !== "none" + ? cachedStatus.writeAuthSource + : 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, + sourceFailures: inventoryFailuresBySource, + 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); - if (!statusProbe.ok) { - probeFailure = statusProbe; - throw new Error(statusProbe.error); - } - const { validated, repoAccessOk, repoAccessError } = statusProbe.value; + 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, activeWrite, 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, + onAuthenticatedProbe: (candidate, value) => { + registerGithubCredentialIdentity(candidate, value.validated.userLogin); + }, + onUsableProbe: (candidate, value) => { + recordGithubCredentialProbeSuccess( + candidate, + value.validated.rateLimit, + value.validated.userLogin, + ); + }, + onRejectedProbe: (candidate, result, context) => { + if (!context.repositoryAccessFailure) { + recordGithubCredentialFailure(candidate, result.authFailure, result.rateLimit); + } + 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 activeWriteSource = activeWrite?.source ?? null; + 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) { @@ -1302,72 +1775,96 @@ export function createGithubService({ error: repoAccessError, }); } - const connected = computeConnected({ + const connected = validatedCredentialCapabilities(candidate, value, repo).read; + const status: GitHubStatus = { tokenStored: true, - userLogin: validated.userLogin, - authSource: tokenLookup.source, - tokenType: validated.tokenType, - scopes: validated.scopes, - repo, - repoAccessOk, - }); - cachedStatus = { - tokenStored: true, - patTokenStored: tokenLookup.patTokenStored, + patTokenStored: inventory.patTokenStored, tokenDecryptionFailed: false, storageScope: "app", - authSource: tokenLookup.source, + authSource: candidate.source, + writeAuthSource: activeWriteSource ?? "none", + writeUserLogin: activeWrite?.value.validated.userLogin ?? null, 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, + sourceFailures: inventoryFailuresBySource, + activeReadSource: candidate.source, + activeWriteSource, + }), + credentialFallback: fallbackFailure + ? { + capability: "read", + fromSource: fallbackFailure.source, + toSource: candidate.source, + reason: fallbackFailure.authFailure.kind, + retryAt: fallbackFailure.authFailure.retryAt, + } + : null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), repoAccessOk, repoAccessError, connected, }; + cachedStatus = status; 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; - return cachedStatus; + cachedStatusCredentialInventoryKey = credentialInventoryKey; + return status; } + + const failure = readCredentialFailures.find((entry) => entry.authFailure.kind === "rate_limited") + ?? readCredentialFailures[0] + ?? { + source: primaryCandidate.source, + 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: "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, + sourceFailures: inventoryFailuresBySource, + activeReadSource: null, + activeWriteSource: null, + }), + credentialFallback: null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + repoAccessOk: null, + repoAccessError: null, + connected: false, + }; + cachedAt = now; + cachedStatusCredentialInventoryKey = credentialInventoryKey; + return cachedStatus; }; const getStatus = async (opts: { forceRefresh?: boolean } = {}): Promise => { @@ -1384,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`, @@ -1421,9 +1961,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); }; @@ -1646,7 +2184,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 +2243,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"; @@ -1799,7 +2337,7 @@ export function createGithubService({ cachedStatus = null; cachedAt = 0; - cachedStatusTokenDigest = null; + cachedStatusCredentialInventoryKey = null; return { state: resultState, @@ -1814,6 +2352,15 @@ export function createGithubService({ getRemoteStatus: detectOrigin, getStatus, + verifyStoredPat, + + async getBackgroundRequestPauseUntilMs(): Promise { + const inventory = await readCredentialInventory(); + return githubBackgroundRequestPauseUntilMs( + Date.now(), + githubOperationCredentialCandidates(inventory.candidates, "read"), + ); + }, getAppUserAuthStatus(): GitHubAppUserAuthStatus { return appUserAuth.getAuthStatus(); @@ -1824,31 +2371,41 @@ export function createGithubService({ }, async pollAppUserDeviceAuth(args: { sessionId: string }): Promise { - return await appUserAuth.pollDeviceAuth(args); + const previousToken = appUserAuth.getStoredTokenForHealth(); + const result = await appUserAuth.pollDeviceAuth(args); + if (result.status === "authorized") { + const currentToken = appUserAuth.getStoredTokenForHealth(); + credentialsChanged({ tokensToClear: [previousToken, currentToken] }); + } + return result; }, clearAppUserAuth(): GitHubAppUserAuthStatus { - return appUserAuth.clearAuth(); + const previousToken = appUserAuth.getStoredTokenForHealth(); + const status = appUserAuth.clearAuth(); + credentialsChanged({ tokensToClear: [previousToken] }); + return status; }, setToken(token: string): void { + const previousToken = readStoredPatToken(); persistToken(token); tokenDecryptionFailed = false; - cachedStatus = null; - cachedAt = 0; - cachedStatusTokenDigest = null; - sharedGhAuth.authCache = null; - sharedGhAuth.statusCache.clear(); + const currentToken = token.trim(); + credentialsChanged({ + tokensToClear: [previousToken, currentToken], + clearGhCaches: true, + }); }, clearToken(): void { + const previousToken = readStoredPatToken(); persistToken(null); tokenDecryptionFailed = false; - cachedStatus = null; - cachedAt = 0; - cachedStatusTokenDigest = null; - sharedGhAuth.authCache = null; - sharedGhAuth.statusCache.clear(); + credentialsChanged({ + tokensToClear: [previousToken], + clearGhCaches: true, + }); }, async getRepoOrThrow(): Promise { @@ -1864,11 +2421,25 @@ export function createGithubService({ }, async getTokenOrThrowAsync(): Promise { - const token = (await readAuthToken()).token; + 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 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; + }, + + requestRawWithCredentialFallback, + async getAppUserTokenForRelay(): Promise { return await appUserAuth.getValidTokenForRelay(); }, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 9af99cbb5..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, @@ -1642,6 +1643,14 @@ export function registerIpc({ const watcherCleanupBoundSenders = new Set(); let linearOAuthService: LinearOAuthService | null = null; let linearOAuthServiceAdeDir: string | 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; @@ -1794,19 +1803,40 @@ export function registerIpc({ throw error; }; - const getLinearOAuthBridge = (ctx: AppContext): LinearOAuthService => { - if (!ctx.linearCredentialService) { - throw new Error("Linear credential service is not available."); - } - if (!linearOAuthService || linearOAuthServiceAdeDir !== ctx.adeDir) { - linearOAuthService?.dispose(); - 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 ( @@ -4304,7 +4334,7 @@ export function registerIpc({ bindRemoteProject, getGitHubTokenForRemoteClone: async () => { try { - return await getCtx().githubService.getTokenOrThrowAsync(); + return await getCtx().githubService.getGitTransportTokenOrThrowAsync(); } catch { return null; } @@ -9095,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 => { @@ -10420,16 +10452,30 @@ export function registerIpc({ return buildLinearConnectionStatus(ctx, tokenStored); }); - ipcMain.handle(IPC.ctoStartLinearOAuth, async (): Promise => { + ipcMain.handle(IPC.ctoStartLinearOAuth, (): Promise => { const ctx = getCtx(); - return 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 = 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..90849a54d 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,212 @@ 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; + + 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; + 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() 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 2afe79e52..e504664e8 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -12,6 +12,14 @@ import { import { createPrMergeAutoSettlementService } from "./prMergeAutoSettlementService"; import { createPrPollingService } from "./prPollingService"; import { buildPrSummaryPrompt, createPrSummaryService, parsePrSummaryJson } from "./prSummaryService"; +import { + clearGithubCredentialHealth, + recordGithubCredentialSuccess, +} from "../github/githubCredentialHealth"; + +afterEach(() => { + clearGithubCredentialHealth(); +}); // --------------------------------------------------------------------------- // Shared helpers @@ -166,6 +174,125 @@ 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("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("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 = () => [summary]; + const refresh = vi.fn(async () => [summary]) + .mockRejectedValueOnce(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); + + await vi.advanceTimersByTimeAsync(9_999); + expect(refresh).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(refresh).toHaveBeenCalledTimes(2); + + 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 () => { 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..18b62c40e 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,30 @@ export function createPrPollingService({ const polledAt = nowIso(); try { + const backgroundPauseUntilMs = await Promise.resolve( + getGithubBackgroundPauseUntilMs + ? 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 +305,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 +486,14 @@ 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 = 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 b68fc4c00..85411a4c0 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -244,6 +244,9 @@ function makeGithubService(overrides?: Record) { clearToken: vi.fn(), getTokenOrThrow, getTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), + getReadTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), + getGitTransportTokenOrThrowAsync: vi.fn(async () => getTokenOrThrow()), + requestRawWithCredentialFallback: vi.fn(), ...remainingOverrides, } as any; } @@ -255,6 +258,7 @@ function makeGithubStatus(overrides?: Record) { tokenDecryptionFailed: false, storageScope: "app", authSource: "pat", + writeAuthSource: "pat", tokenType: "classic", connected: true, repo: REPO, @@ -875,6 +879,90 @@ 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 }); + const syntheticPrId = `gh:${REPO.owner}/${REPO.name}#${row.github_pr_number}`; + + 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" }); + + 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(); @@ -932,6 +1020,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()), @@ -3605,6 +3717,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", () => { @@ -4675,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() { @@ -6362,13 +6623,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..2a03d6200 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 => { @@ -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)) { @@ -4204,14 +4204,19 @@ 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 }>; }>({ method: "POST", path: "/graphql", + capability: /^\s*mutation\b/i.test(query) ? "write" : "read", + ...(repo ? { repo } : {}), body: { query, variables }, ...(options.accept ? { accept: options.accept } : {}), }); @@ -5743,23 +5748,20 @@ export function createPrService({ repo: GitHubRepoRef; jobId: number; }): Promise<{ text: string; truncated: boolean } | null> => { - const token = await githubService.getTokenOrThrowAsync(); 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 { @@ -5777,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; @@ -8638,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; } @@ -10880,12 +10891,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?: { @@ -10922,6 +10928,7 @@ export function createPrService({ threadId: args.threadId, body: args.body, }, + { repo }, ); const comment = data.addPullRequestReviewThreadReply?.comment; @@ -10940,12 +10947,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!) { @@ -10958,11 +10960,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?: { @@ -10990,6 +10993,7 @@ export function createPrService({ } `, { threadId: args.threadId, body: args.body }, + { repo }, ); const comment = data.addPullRequestReviewThreadReply?.comment; if (!comment) { @@ -11007,7 +11011,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; @@ -11020,6 +11024,7 @@ export function createPrService({ } `, { threadId: args.threadId }, + { repo }, ); const thread = data.resolveReviewThread?.thread ?? null; return { @@ -11038,6 +11043,7 @@ export function createPrService({ } `, { threadId: args.threadId }, + { repo }, ); const thread = data.unresolveReviewThread?.thread ?? null; return { @@ -11047,14 +11053,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( ` @@ -11065,6 +11066,7 @@ export function createPrService({ } `, { subjectId: args.commentId, content: contentEnum }, + { repo }, ); }, 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/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/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 b697fc460..15d60a52f 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, @@ -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"; @@ -62,8 +63,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 +78,37 @@ 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"; +} + +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); @@ -123,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)); @@ -177,6 +199,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; @@ -184,9 +212,15 @@ 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) { + 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,70 @@ 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", readsWithLabel)} + {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 = credentialStateColor(credential); + 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 +478,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..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"; @@ -119,8 +121,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"); }); @@ -148,6 +150,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: { @@ -162,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 f891595c7..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"; @@ -238,6 +239,62 @@ 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 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; @@ -252,6 +309,14 @@ export function describeGithubCliBanner(status: GitHubStatus): { action: "Connect GitHub", }; } + if (status.connected && !githubStatusHasWriteCredential(status)) { + 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 +350,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 +381,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/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..b801fbb17 --- /dev/null +++ b/apps/desktop/src/shared/githubApiPath.ts @@ -0,0 +1,66 @@ +import type { GitHubRepoRef } from "./types/git"; + +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\/([^/]+)\/([^/]+)(\/.*)?$/); + 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/githubConditionalRequestCache.test.ts b/apps/desktop/src/shared/githubConditionalRequestCache.test.ts new file mode 100644 index 000000000..4cc147749 --- /dev/null +++ b/apps/desktop/src/shared/githubConditionalRequestCache.test.ts @@ -0,0 +1,37 @@ +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" }); + }); + + 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 new file mode 100644 index 000000000..e48a971bd --- /dev/null +++ b/apps/desktop/src/shared/githubConditionalRequestCache.ts @@ -0,0 +1,66 @@ +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); + }; + + 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 = touch(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 touch(key); + }, + 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.delete(key); + entries.set(key, entry); + }, + }; +} diff --git a/apps/desktop/src/shared/githubOperationCredential.test.ts b/apps/desktop/src/shared/githubOperationCredential.test.ts new file mode 100644 index 000000000..18c188ad0 --- /dev/null +++ b/apps/desktop/src/shared/githubOperationCredential.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; +import { + evaluateGithubCredentialCapabilities, + resolveGithubStatusCredentials, +} from "./githubOperationCredential"; +import type { GithubStatusCredentialProbeResult } 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 authenticated = vi.fn(); + const usable = 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, + onAuthenticatedProbe: authenticated, + onUsableProbe: usable, + onRejectedProbe: rejected, + }); + + expect(result.active?.candidate.source).toBe("gh"); + expect(result.activeWrite?.source).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(authenticated).toHaveBeenCalledTimes(1); + expect(usable).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, + onAuthenticatedProbe: vi.fn(), + onUsableProbe: vi.fn(), + onRejectedProbe: vi.fn(), + }); + + expect(result.active?.candidate).toBe(gh); + expect(result.activeWrite?.source).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 be862cd6d..e73bf4453 100644 --- a/apps/desktop/src/shared/githubOperationCredential.ts +++ b/apps/desktop/src/shared/githubOperationCredential.ts @@ -1,39 +1,403 @@ -export const GITHUB_OPERATION_CREDENTIAL_PRECEDENCE = [ - "environment", - "gh", - "pat", -] as const; +import type { + GitHubAuthFailure, + GitHubCredentialCapability, + GitHubCredentialSource, + GitHubCredentialVerification, + GitHubRateLimitState, + GitHubStatus, + GitHubTokenType, +} from "./types/git"; +import { getGitHubTokenAccessState } from "./githubScopes"; -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: + readonly GithubOperationCredentialSource[] = GITHUB_OPERATION_CREDENTIALS.map( + ({ source }) => source, + ); + +const GITHUB_WRITE_CREDENTIAL_PRECEDENCE: + readonly Exclude[] = + 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; + }); +} + +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; + 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 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; + 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; + onAuthenticatedProbe: (candidate: Candidate, probe: Probe) => void; + onUsableProbe: (candidate: Candidate, probe: Probe) => void; + onRejectedProbe: ( + candidate: Candidate, + result: Extract, { ok: false }>, + context: { repositoryAccessFailure: boolean; phase: "read" | "write" }, + ) => void; +}): Promise<{ + active: { candidate: Candidate; value: Probe } | null; + activeWrite: { + source: Exclude; + candidate: Candidate; + value: Probe; + } | 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 }; + args.onAuthenticatedProbe(candidate, result.value); + break; + } + failures.push({ candidate, ...result }); + args.onRejectedProbe(candidate, result, { repositoryAccessFailure, phase: "read" }); + 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.onAuthenticatedProbe(candidate, result.value); + args.onUsableProbe(candidate, result.value); + break; + } + + 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; + 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.onAuthenticatedProbe(candidate, result.value); + args.onUsableProbe(candidate, result.value); + } + if (args.capabilities(candidate, result.value).write) { + activeWrite = { source: candidate.source, candidate, value: result.value }; + break; + } + } + } + + return { active, activeWrite, failures }; +} 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..0b5067d41 100644 --- a/apps/desktop/src/shared/types/git.ts +++ b/apps/desktop/src/shared/types/git.ts @@ -308,18 +308,50 @@ 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 GitHubCredentialVerification = { + source: GitHubCredentialSource; + capabilities: GitHubCredentialCapability[]; + userLogin: string | null; + failure: GitHubAuthFailure | null; + rateLimit: GitHubRateLimitState | null; +}; + +export type GitHubCredentialFallback = { + capability: GitHubCredentialCapability; + fromSource: GitHubCredentialSource; + toSource: GitHubCredentialSource; + reason: GitHubAuthFailure["kind"]; + 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 @@ -335,17 +367,27 @@ 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; + writeUserLogin?: string | null; + 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; }; +export type GitHubSetTokenResult = GitHubStatus & { + credentialVerification: GitHubCredentialVerification; +}; + export type GitHubAppInstallationStatus = { repo: GitHubRepoRef | null; appName: string; diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index be3ce2c0f..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 { + 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 env.DB - .prepare("select account_id from github_app_repositories where repository_key = ? limit 1") - .bind(`${repo.owner}/${repo.name}`.toLowerCase()) - .first(); + const row = await readInstalledGitHubRepositoryAccount(env, repo); return row?.account_id === accountId; } @@ -1517,14 +1524,43 @@ 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) { + 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 }), + }; } - 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..9a825114a 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")) { @@ -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); @@ -527,7 +546,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}`, @@ -617,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", @@ -659,4 +685,22 @@ 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", { + authorization: "Bearer ghp_repo_token", + 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