Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 30 additions & 22 deletions packages/function/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SyncServer>
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions packages/function/src/github-oidc.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
15 changes: 15 additions & 0 deletions packages/function/src/github-oidc.ts
Original file line number Diff line number Diff line change
@@ -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]!,
}
}
25 changes: 20 additions & 5 deletions packages/opencode/src/cli/cmd/github.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Loading