diff --git a/action.yml b/action.yml index 9c55d7e..ab8cad6 100644 --- a/action.yml +++ b/action.yml @@ -27,7 +27,11 @@ inputs: default: "https://bitfreedom.net/code/" forgejo_token: - description: "Forgejo PAT with repo scope (contents, pull-requests, issues)" + description: "Forgejo PAT used by the outer action for read operations (clone, fetch, read APIs). Recommended scopes: read:repository, read:issue, read:user. Never exposed to the opencode subprocess." + required: false + + forgejo_push_token: + description: "Optional separate Forgejo PAT used only for write operations (git push, create/update comments, create MR). Recommended scopes: write:repository, write:issue. Falls back to forgejo_token if unset." required: false mentions: @@ -85,6 +89,7 @@ runs: PROMPT: ${{ inputs.prompt }} FORGEJO_API_URL: ${{ inputs.forgejo_api_url }} FORGEJO_TOKEN: ${{ inputs.forgejo_token }} + FORGEJO_PUSH_TOKEN: ${{ inputs.forgejo_push_token }} MENTIONS: ${{ inputs.mentions }} VARIANT: ${{ inputs.variant }} run: | diff --git a/index.ts b/index.ts index 1eea5ac..9fddfa0 100644 --- a/index.ts +++ b/index.ts @@ -121,7 +121,6 @@ const SERVER_URL = `http://${HOST}:${PORT}` let proc: ReturnType | undefined let accessToken: string let commentId: number -let gitConfig: string let session: { id: string; title: string; version: string } let shareId: string | undefined let exitCode = 0 @@ -190,8 +189,16 @@ async function createAuthConfig(): Promise { try { await createAuthConfig() + // Strip Forgejo write credentials from opencode's env so its bash tool cannot reach them. + const STRIP_FROM_AGENT_ENV = new Set(["FORGEJO_TOKEN", "FORGEJO_PUSH_TOKEN", "GITHUB_TOKEN"]) + const agentEnv: NodeJS.ProcessEnv = {} + for (const [k, v] of Object.entries(process.env)) { + if (!STRIP_FROM_AGENT_ENV.has(k)) agentEnv[k] = v + } + proc = spawn(`opencode`, [`serve`, `--hostname=${HOST}`, `--port=${PORT}`], { stdio: ["ignore", "inherit", "inherit"], + env: agentEnv, }) assertContextEvent("issue_comment", "pull_request_review_comment", "pull_request_review") assertPayloadKeyword() @@ -203,7 +210,7 @@ try { accessToken = forgejoToken const { userPrompt, promptFiles } = await getUserPrompt() - await configureGit(accessToken) + await configureGitIdentity() await assertPermissions() const comment = await createComment() @@ -295,7 +302,6 @@ try { if (proc) { proc.kill() } - await restoreGitConfig() } process.exit(exitCode) @@ -311,7 +317,14 @@ function getForgejoConfig() { if (!token) { throw new Error(`Environment variable "FORGEJO_TOKEN" is not set`) } - return { forgejoApiUrl: apiUrl, forgejoToken: token } + const pushToken = process.env["FORGEJO_PUSH_TOKEN"] || token + return { forgejoApiUrl: apiUrl, forgejoToken: token, forgejoPushToken: pushToken } +} + +async function authedGit(token: string, args: string[]) { + const credential = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64") + const headerCfg = `http.https://${forgejoHost}/.extraheader=AUTHORIZATION: basic ${credential}` + return await $`git -c ${headerCfg} ${args}` } function forgejoApiUrl(...pathParts: string[]): string { @@ -322,11 +335,14 @@ function forgejoApiUrl(...pathParts: string[]): string { } async function forgejoFetch(url: string, options?: RequestInit): Promise { - const { forgejoToken } = getForgejoConfig() + const { forgejoToken, forgejoPushToken } = getForgejoConfig() + const method = (options?.method || "GET").toUpperCase() + const isWrite = method !== "GET" && method !== "HEAD" + const token = isWrite ? forgejoPushToken : forgejoToken const res = await fetch(url, { ...options, headers: { - Authorization: `token ${forgejoToken}`, + Authorization: `token ${token}`, "Content-Type": "application/json", ...(options?.headers || {}), }, @@ -653,29 +669,12 @@ async function assertPermissions() { // ─── Git operations ───────────────────────────────────────────────────────── -async function configureGit(appToken: string) { - console.log("Configuring git...") - const config = "http.https://github.com/.extraheader" - const ret = await $`git config --local --get ${config}`.catch(() => ({ stdout: "" })) - gitConfig = (ret.stdout as string)?.toString().trim() || "" - - // Use Forgejo host for git credentials - const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64") - const gitUrl = `http.https://${forgejoHost}/.extraheader` - - await $`git config --local --unset-all ${config}`.catch(() => {}) - await $`git config --local ${gitUrl} "AUTHORIZATION: basic ${newCredentials}"` +async function configureGitIdentity() { + console.log("Configuring git identity...") await $`git config --global user.name "opencode-agent[bot]"` await $`git config --global user.email "opencode-agent[bot]@users.noreply.${forgejoHost}"` } -async function restoreGitConfig() { - if (gitConfig === undefined) return - console.log("Restoring git config...") - const config = "http.https://github.com/.extraheader" - await $`git config --local ${config} "${gitConfig}"`.catch(() => {}) -} - async function checkoutNewBranch() { console.log("Checking out new branch...") const branch = generateBranchName("issue") @@ -686,8 +685,9 @@ async function checkoutNewBranch() { async function checkoutLocalBranch(pr: ForgejoPullRequest) { console.log("Checking out local branch...") const branch = pr.head.ref + const { forgejoToken } = getForgejoConfig() - await $`git fetch origin --depth=100 ${branch}` + await authedGit(forgejoToken, ["fetch", "origin", "--depth=100", branch]) await $`git checkout ${branch}` } @@ -695,10 +695,11 @@ async function checkoutForkBranch(pr: ForgejoPullRequest) { console.log("Checking out fork branch...") const remoteBranch = pr.head.ref const localBranch = generateBranchName("pr") + const { forgejoToken } = getForgejoConfig() const forkRemote = `https://${forgejoHost}/${pr.head.repo?.full_name}.git` await $`git remote add fork ${forkRemote}` - await $`git fetch fork --depth=100 ${remoteBranch}` + await authedGit(forgejoToken, ["fetch", "fork", "--depth=100", remoteBranch]) await $`git checkout -b ${localBranch} fork/${remoteBranch}` } @@ -715,33 +716,36 @@ function generateBranchName(type: "issue" | "pr") { async function pushToNewBranch(summary: string, branch: string) { console.log("Pushing to new branch...") const actor = useContext().actor + const { forgejoPushToken } = getForgejoConfig() await $`git add .` await $`git commit -m "${summary} Co-authored-by: ${actor} <${actor}@users.noreply.${forgejoHost}>"` - await $`git push -u origin ${branch}` + await authedGit(forgejoPushToken, ["push", "-u", "origin", branch]) } async function pushToLocalBranch(summary: string) { console.log("Pushing to local branch...") const actor = useContext().actor + const { forgejoPushToken } = getForgejoConfig() await $`git add .` await $`git commit -m "${summary} Co-authored-by: ${actor} <${actor}@users.noreply.${forgejoHost}>"` - await $`git push` + await authedGit(forgejoPushToken, ["push"]) } async function pushToForkBranch(summary: string, pr: ForgejoPullRequest) { console.log("Pushing to fork branch...") + const { forgejoPushToken } = getForgejoConfig() await $`git add .` await $`git commit -m "${summary} Co-authored-by: ${useContext().actor} <${useContext().actor}@users.noreply.${forgejoHost}>"` - await $`git push fork HEAD:${pr.head.ref}` + await authedGit(forgejoPushToken, ["push", "fork", `HEAD:${pr.head.ref}`]) } async function branchIsDirty() {