diff --git a/apps/api/src/services/git-token-service.ts b/apps/api/src/services/git-token-service.ts index bf284c78..8a7ecb60 100644 --- a/apps/api/src/services/git-token-service.ts +++ b/apps/api/src/services/git-token-service.ts @@ -21,10 +21,10 @@ export async function getGitToken( context: GitTokenContext, ): Promise { if (platform === "github") { - if (context.server) return getGitHubToken({ server: true }); + if (context.server) return getGitHubToken({ server: true, workspaceId: context.workspaceId }); if (context.userId) return getGitHubToken({ userId: context.userId, workspaceId: context.workspaceId }); - return getGitHubToken({ server: true }); + return getGitHubToken({ server: true, workspaceId: context.workspaceId }); } // GitLab: try user-scoped token, then workspace/global GITLAB_TOKEN diff --git a/apps/api/src/services/github-token-service.test.ts b/apps/api/src/services/github-token-service.test.ts index 7dc2b32f..f50e0efc 100644 --- a/apps/api/src/services/github-token-service.test.ts +++ b/apps/api/src/services/github-token-service.test.ts @@ -216,7 +216,16 @@ describe("github-token-service", () => { db: { select: () => ({ from: () => ({ - where: (...args: unknown[]) => mockDbWhere(...args), + where: () => { + const res = { + limit: (...args: unknown[]) => mockDbWhere(...args), + // Handle direct execution after where() without limit() + then: (cb: any) => mockDbWhere().then(cb), + }; + // @ts-expect-error Mocking async iterator for Drizzle query result + res[Symbol.iterator] = [][Symbol.iterator]; + return res; + }, }), }), }, @@ -228,6 +237,11 @@ describe("github-token-service", () => { createdBy: "created_by", workspaceId: "workspace_id", }, + secrets: { + id: "id", + name: "name", + workspaceId: "workspace_id", + }, })); vi.doMock("./secret-service.js", () => ({ @@ -335,7 +349,7 @@ describe("github-token-service", () => { }); it("resolves task creator's token", async () => { - mockDbWhere.mockResolvedValue([{ createdBy: "user-5", workspaceId: "ws-2" }]); + mockDbWhere.mockResolvedValueOnce([{ createdBy: "user-5", workspaceId: "ws-2" }]); const futureDate = new Date(Date.now() + 60 * 60 * 1000).toISOString(); mockRetrieveSecret .mockResolvedValueOnce("ghu_task_user_token") @@ -360,6 +374,8 @@ describe("github-token-service", () => { it("falls back to PAT when GitHub App not configured (server context)", async () => { mockIsConfigured.mockReturnValue(false); + // Return no token found by the new fallback logic + mockDbWhere.mockResolvedValueOnce([]); mockRetrieveSecretWithFallback.mockResolvedValue("ghp_server_pat"); const token = await getGitHubToken({ server: true }); @@ -372,6 +388,22 @@ describe("github-token-service", () => { ); }); + it("falls back to any available PAT when GitHub App not configured (server context, no workspaceId)", async () => { + mockIsConfigured.mockReturnValue(false); + // Simulate finding an existing token in a different workspace + mockDbWhere.mockResolvedValueOnce([{ workspaceId: "ws-other" }]); + mockRetrieveSecretWithFallback.mockResolvedValue("ghp_other_pat"); + + const token = await getGitHubToken({ server: true }); + + expect(token).toBe("ghp_other_pat"); + expect(mockRetrieveSecretWithFallback).toHaveBeenCalledWith( + "GITHUB_TOKEN", + "global", + "ws-other", + ); + }); + it("storeUserGitHubTokens stores 3 secrets", async () => { await storeUserGitHubTokens("user-6", { accessToken: "ghu_access", diff --git a/apps/api/src/services/github-token-service.ts b/apps/api/src/services/github-token-service.ts index 7c329ac6..7216458d 100644 --- a/apps/api/src/services/github-token-service.ts +++ b/apps/api/src/services/github-token-service.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import { db } from "../db/client.js"; -import { tasks } from "../db/schema.js"; +import { tasks, secrets } from "../db/schema.js"; import { retrieveSecret, retrieveSecretWithFallback, @@ -16,24 +16,40 @@ const TOKEN_REFRESH_BUFFER_MS = 10 * 60 * 1000; export type GitHubTokenContext = | { taskId: string } | { userId: string; workspaceId?: string | null } - | { server: true }; + | { server: true; workspaceId?: string | null }; export async function getGitHubToken(context: GitHubTokenContext): Promise { - if ("server" in context) return getServerToken(); + if ("server" in context) return getServerToken(context.workspaceId); if ("taskId" in context) return getTokenForTask(context.taskId); return getTokenForUser(context.userId, context.workspaceId); } -async function getServerToken(): Promise { +async function getServerToken(workspaceId?: string | null): Promise { if (isGitHubAppConfigured()) { try { return await getInstallationToken(); } catch (err) { logger.warn({ err }, "Installation token failed, falling back to PAT"); - return getPatFallback(); + return getPatFallback(workspaceId); } } - return getPatFallback(); + // If no GitHub App, and no workspace context provided (e.g. repo-init), + // try to find ANY global GITHUB_TOKEN to use as a server-level fallback. + // This handles the case where a token exists but is scoped to a workspace, + // preventing AAD decryption failures during system-level clones. + if (!workspaceId) { + const [anyGlobalToken] = await db + .select({ workspaceId: secrets.workspaceId }) + .from(secrets) + .where(eq(secrets.name, "GITHUB_TOKEN")) + .limit(1); + + if (anyGlobalToken) { + return getPatFallback(anyGlobalToken.workspaceId); + } + } + + return getPatFallback(workspaceId); } async function getTokenForTask(taskId: string): Promise {