diff --git a/packages/function/src/api.ts b/packages/function/src/api.ts index 58c74fe32254..9cb2897f99dc 100644 --- a/packages/function/src/api.ts +++ b/packages/function/src/api.ts @@ -5,6 +5,7 @@ import { jwtVerify, createRemoteJWKSet } from "jose" import { createAppAuth } from "@octokit/auth-app" import { Octokit } from "@octokit/rest" import { Resource } from "sst" +import { parseGithubOidcSub } from "./github-oidc" type Env = { SYNC_SERVER: DurableObjectNamespace @@ -275,36 +276,43 @@ export default new Hono<{ Bindings: Env }>() issuer: GITHUB_ISSUER, audience: EXPECTED_AUDIENCE, }) - const sub = payload.sub // e.g. 'repo:my-org/my-repo:ref:refs/heads/main' - const parts = sub.split(":")[1].split("/") - owner = parts[0] - repo = parts[1] + // classic: repo:owner/name:ref:... + // immutable: repo:owner@id/name@id:ref:... + const parsed = parseGithubOidcSub(payload.sub ?? "") + owner = parsed.owner + repo = parsed.repo } catch (err) { console.error("Token verification failed:", err) return c.json({ error: "Invalid or expired token" }, { status: 403 }) } - // Create app JWT token - const auth = createAppAuth({ - appId: Resource.GITHUB_APP_ID.value, - privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value, - }) - const appAuth = await auth({ type: "app" }) + try { + // Create app JWT token + const auth = createAppAuth({ + appId: Resource.GITHUB_APP_ID.value, + privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value, + }) + const appAuth = await auth({ type: "app" }) - // Lookup installation - const octokit = new Octokit({ auth: appAuth.token }) - const { data: installation } = await octokit.apps.getRepoInstallation({ - owner, - repo, - }) + // Lookup installation + const octokit = new Octokit({ auth: appAuth.token }) + const { data: installation } = await octokit.apps.getRepoInstallation({ + owner, + repo, + }) - // Get installation token - const installationAuth = await auth({ - type: "installation", - installationId: installation.id, - }) + // Get installation token + const installationAuth = await auth({ + type: "installation", + installationId: installation.id, + }) - return c.json({ token: installationAuth.token }) + return c.json({ token: installationAuth.token }) + } catch (err) { + console.error("Failed to get installation token:", err) + const message = err instanceof Error ? err.message : "Failed to get installation token" + return c.json({ error: message }, { status: 500 }) + } }) /** * Used by the GitHub action to get GitHub installation access token given user PAT token (used when testing `opencode github run` locally) diff --git a/packages/function/src/github-oidc.test.ts b/packages/function/src/github-oidc.test.ts new file mode 100644 index 000000000000..ba39e5e56acd --- /dev/null +++ b/packages/function/src/github-oidc.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { parseGithubOidcSub } from "./github-oidc" + +describe("parseGithubOidcSub", () => { + test("parses classic OIDC subject", () => { + expect(parseGithubOidcSub("repo:octocat/my-repo:ref:refs/heads/main")).toEqual({ + owner: "octocat", + repo: "my-repo", + }) + }) + + test("strips immutable owner and repo id suffixes", () => { + expect(parseGithubOidcSub("repo:octocat@123456/my-repo@456789:ref:refs/heads/main")).toEqual({ + owner: "octocat", + repo: "my-repo", + }) + }) + + test("parses job_workflow_ref style subjects", () => { + expect( + parseGithubOidcSub("repo:my-org/my-repo:job_workflow_ref:my-org/my-repo/.github/workflows/ci.yml@refs/heads/main"), + ).toEqual({ + owner: "my-org", + repo: "my-repo", + }) + }) + + test("throws on invalid subject", () => { + expect(() => parseGithubOidcSub("invalid")).toThrow("Invalid OIDC subject") + expect(() => parseGithubOidcSub("repo:only-owner")).toThrow("Invalid OIDC subject") + }) +}) diff --git a/packages/function/src/github-oidc.ts b/packages/function/src/github-oidc.ts new file mode 100644 index 000000000000..78ee21c6236c --- /dev/null +++ b/packages/function/src/github-oidc.ts @@ -0,0 +1,15 @@ +/** + * Parse repo owner/name from a GitHub Actions OIDC `sub` claim. + * Supports classic `repo:owner/name:...` and immutable + * `repo:owner@account_id/name@repo_id:...` subject claims. + */ +export function parseGithubOidcSub(sub: string) { + const repoPart = sub.split(":")[1] + if (!repoPart) throw new Error(`Invalid OIDC subject: ${sub}`) + const [rawOwner, rawRepo] = repoPart.split("/") + if (!rawOwner || !rawRepo) throw new Error(`Invalid OIDC subject: ${sub}`) + return { + owner: rawOwner.split("@")[0]!, + repo: rawRepo.split("@")[0]!, + } +} diff --git a/packages/opencode/src/cli/cmd/github.handler.ts b/packages/opencode/src/cli/cmd/github.handler.ts index d55c0bf3fbfa..bde62ab0b2df 100644 --- a/packages/opencode/src/cli/cmd/github.handler.ts +++ b/packages/opencode/src/cli/cmd/github.handler.ts @@ -638,8 +638,16 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: msg = e.message } if (isUserEvent) { - await createComment(`${msg}${footer()}`) - await removeReaction(commentType) + try { + await createComment(`${msg}${footer()}`) + await removeReaction(commentType) + } catch (commentError) { + // Token exchange may fail before octoRest is initialized + console.error( + "Failed to update GitHub comment/reaction:", + commentError instanceof Error ? commentError.message : commentError, + ) + } } core.setFailed(msg) // Also output the clean error message for the action to capture @@ -1001,12 +1009,19 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: }, }) + const body = await response.text() if (!response.ok) { - const responseJson = (await response.json()) as { error?: string } - throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`) + let detail = body + try { + const parsed = JSON.parse(body) as { error?: string } + if (parsed.error) detail = parsed.error + } catch { + // non-JSON error body from upstream + } + throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${detail}`) } - const responseJson = (await response.json()) as { token: string } + const responseJson = JSON.parse(body) as { token: string } return responseJson.token }