From 5edb99f3b141444ba363b0da527114ce9dc60276 Mon Sep 17 00:00:00 2001 From: Adolfo Date: Mon, 21 Sep 2026 15:16:08 -0300 Subject: [PATCH 1/3] fix(github): authorize configured apps requesting reviews --- .../opencode/src/cli/cmd/github.handler.ts | 25 +---- .../src/cli/cmd/github.permissions.ts | 41 +++++++++ .../test/cli/github-permissions.test.ts | 92 +++++++++++++++++++ 3 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/github.permissions.ts create mode 100644 packages/opencode/test/cli/github-permissions.test.ts diff --git a/packages/opencode/src/cli/cmd/github.handler.ts b/packages/opencode/src/cli/cmd/github.handler.ts index fcf44279ce7f..22c7ca5bc687 100644 --- a/packages/opencode/src/cli/cmd/github.handler.ts +++ b/packages/opencode/src/cli/cmd/github.handler.ts @@ -34,6 +34,7 @@ import { Process } from "@/util/process" import { parseGitHubRemote } from "@/util/repository" import { Effect } from "effect" import { extractResponseText, formatPromptTooLargeError } from "./github.shared" +import { assertPermissions } from "./github.permissions" type GitHubAuthor = { login: string @@ -494,7 +495,7 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: } // Skip permission check and reactions for repo events (no actor to check, no issue to react to) if (isUserEvent) { - await assertPermissions() + await assertPermissions(context, octoRest, process.env["ALLOWED_APP_IDS"]) await addReaction(commentType) } @@ -1162,28 +1163,6 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: return parseInt(result.stdout.toString().trim()) > 0 } - async function assertPermissions() { - // Only called for non-schedule events, so actor is defined - console.log(`Asserting permissions for user ${actor}...`) - - let permission - try { - const response = await octoRest.repos.getCollaboratorPermissionLevel({ - owner, - repo, - username: actor!, - }) - - permission = response.data.permission - console.log(` permission: ${permission}`) - } catch (error) { - console.error(`Failed to check permissions: ${error}`) - throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error }) - } - - if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`) - } - async function addReaction(commentType?: "issue" | "pr_review") { // Only called for non-schedule events, so triggerCommentId is defined console.log("Adding reaction...") diff --git a/packages/opencode/src/cli/cmd/github.permissions.ts b/packages/opencode/src/cli/cmd/github.permissions.ts new file mode 100644 index 000000000000..0ba7cda085ae --- /dev/null +++ b/packages/opencode/src/cli/cmd/github.permissions.ts @@ -0,0 +1,41 @@ +import type { Context } from "@actions/github/lib/context" +import type { Octokit } from "@octokit/rest" + +export async function assertPermissions( + context: Pick, + octokit: Octokit, + allowedAppIds = "", +) { + const { actor } = context + console.log(`Asserting permissions for user ${actor}...`) + + const comment = context.payload.comment + const app = comment?.performed_via_github_app + if ( + context.eventName === "issue_comment" && + comment?.user?.type === "Bot" && + comment.user.login === actor && + Number.isSafeInteger(app?.id) && + app.id > 0 && + allowedAppIds.split(",").some((id) => id.trim() === String(app.id)) + ) { + console.log(` allowed GitHub App: ${app.id}`) + return + } + + let permission + try { + const response = await octokit.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: actor, + }) + + permission = response.data.permission + console.log(` permission: ${permission}`) + } catch (error) { + console.error(`Failed to check permissions: ${String(error)}`) + throw new Error(`Failed to check permissions for user ${actor}: ${String(error)}`, { cause: error }) + } + + if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`) +} diff --git a/packages/opencode/test/cli/github-permissions.test.ts b/packages/opencode/test/cli/github-permissions.test.ts new file mode 100644 index 000000000000..75049c3e9c0a --- /dev/null +++ b/packages/opencode/test/cli/github-permissions.test.ts @@ -0,0 +1,92 @@ +import { afterAll, describe, expect, test } from "bun:test" +import { rejects } from "node:assert/strict" +import { Octokit } from "@octokit/rest" +import type { Context } from "@actions/github/lib/context" +import { assertPermissions } from "../../src/cli/cmd/github.permissions" + +const requests: string[] = [] +const server = Bun.serve({ + port: 0, + fetch(request) { + const actor = decodeURIComponent(new URL(request.url).pathname.split("/").at(-2)!) + requests.push(actor) + if (actor === "unavailable") return Response.json({ message: "Unavailable" }, { status: 503 }) + return Response.json({ permission: actor === "developer" ? "write" : actor === "owner" ? "admin" : "none" }) + }, +}) +const octokit = new Octokit({ baseUrl: server.url.toString(), log: { debug() {}, info() {}, warn() {}, error() {} } }) + +afterAll(() => server.stop(true)) + +function context( + input: { + actor?: string + eventName?: string + app?: number | null + login?: string + type?: string + } = {}, +): Pick { + return { + actor: input.actor ?? "trusted-agent[bot]", + eventName: input.eventName ?? "issue_comment", + repo: { owner: "example", repo: "project" }, + payload: { + comment: { + id: 1, + user: { login: input.login ?? "trusted-agent[bot]", type: input.type ?? "Bot" }, + performed_via_github_app: input.app === null ? null : { id: input.app ?? 123 }, + }, + }, + } +} + +describe("GitHub caller permissions", () => { + test("accepts a configured app without consulting collaborator permissions", async () => { + const count = requests.length + await assertPermissions(context(), octokit, "456, 123") + expect(requests.length).toBe(count) + }) + + test.each([undefined, "", "456"])("rejects an app absent from configuration %p", async (allowed) => { + await rejects(assertPermissions(context(), octokit, allowed), { + message: "User trusted-agent[bot] does not have write permissions", + }) + }) + + test.each(["developer", "owner"])("preserves collaborator access for %s", async (actor) => { + await assertPermissions(context({ actor, login: actor, type: "User", app: null }), octokit, "123") + expect(requests.at(-1)).toBe(actor) + }) + + test("rejects an unauthorized human", async () => { + await rejects( + assertPermissions(context({ actor: "outsider", login: "outsider", type: "User", app: null }), octokit, "123"), + { message: "User outsider does not have write permissions" }, + ) + }) + + test.each([ + { app: null }, + { app: 0 }, + { actor: "outsider" }, + { type: "User" }, + { eventName: "pull_request" }, + { eventName: "issues" }, + ])("does not bypass collaborator checks with mismatched event metadata %p", async (input) => { + await rejects(assertPermissions(context(input), octokit, "123"), /does not have write permissions/) + }) + + test("does not treat an app name mentioned in the comment as authorization", async () => { + const event = context({ app: null }) + event.payload.comment!.body = "fin review from app 123" + await rejects(assertPermissions(event, octokit, "123"), /does not have write permissions/) + }) + + test("retains permission lookup failures", async () => { + await rejects( + assertPermissions(context({ actor: "unavailable" }), octokit), + /Failed to check permissions for user unavailable/, + ) + }) +}) From 64ea6fc9a26f251452ac44c87f725fe7130b2356 Mon Sep 17 00:00:00 2001 From: Adolfo Date: Mon, 21 Sep 2026 15:16:09 -0300 Subject: [PATCH 2/3] chore(github): distribute and update the patched review CLI --- .github/workflows/fintoc-cli.yml | 65 ++++++++++++++++ .github/workflows/fintoc-update.yml | 77 +++++++++++++++++++ github/FINTOC.md | 55 +++++++++++++ github/action.yml | 27 ++++++- github/fintoc.json | 4 + github/install-fintoc.sh | 31 ++++++++ .../opencode/test/cli/fintoc-install.test.ts | 61 +++++++++++++++ 7 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/fintoc-cli.yml create mode 100644 .github/workflows/fintoc-update.yml create mode 100644 github/FINTOC.md create mode 100644 github/fintoc.json create mode 100644 github/install-fintoc.sh create mode 100644 packages/opencode/test/cli/fintoc-install.test.ts diff --git a/.github/workflows/fintoc-cli.yml b/.github/workflows/fintoc-cli.yml new file mode 100644 index 000000000000..aa1e7bf9a3a8 --- /dev/null +++ b/.github/workflows/fintoc-cli.yml @@ -0,0 +1,65 @@ +name: Fintoc CLI + +on: + pull_request: + workflow_dispatch: + inputs: + publish: + description: Publish the tested binary from the default branch + type: boolean + default: false + +permissions: + contents: read + +jobs: + build: + if: github.repository != 'anomalyco/opencode' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: ./.github/actions/setup-bun + with: + install-flags: --frozen-lockfile + - name: Check permissions and existing GitHub behavior + working-directory: packages/opencode + run: bun test test/cli/fintoc-install.test.ts test/cli/github-permissions.test.ts test/cli/github-action.test.ts test/cli/github-remote.test.ts + - name: Check types + working-directory: packages/opencode + run: bun typecheck + - name: Build and smoke-test the patched CLI + run: | + export OPENCODE_VERSION=$(jq -er .version github/fintoc.json) + bun run --cwd packages/opencode build --single --skip-install --skip-embed-web-ui + mkdir -p release + tar -czf release/opencode-linux-x64.tar.gz -C packages/opencode/dist/opencode-linux-x64/bin opencode + cd release + sha256sum opencode-linux-x64.tar.gz > SHA256SUMS + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: fintoc-cli + path: release/ + + publish: + if: github.event_name == 'workflow_dispatch' && inputs.publish && github.ref_name == github.event.repository.default_branch + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: fintoc-cli + path: release + - name: Publish immutable version + env: + GH_TOKEN: ${{ github.token }} + run: | + version=$(jq -er .version github/fintoc.json) + upstream=$(jq -er .upstream github/fintoc.json) + printf 'OpenCode %s with explicit GitHub App authorization.\nSource commit: %s\n' "$upstream" "$GITHUB_SHA" > "$RUNNER_TEMP/release.md" + gh release create "v$version" release/opencode-linux-x64.tar.gz release/SHA256SUMS \ + --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \ + --title "OpenCode $version" --notes-file "$RUNNER_TEMP/release.md" --prerelease diff --git a/.github/workflows/fintoc-update.yml b/.github/workflows/fintoc-update.yml new file mode 100644 index 000000000000..61eb5e4d11e2 --- /dev/null +++ b/.github/workflows/fintoc-update.yml @@ -0,0 +1,77 @@ +name: Update Fintoc OpenCode + +on: + schedule: + - cron: "0 12 * * 1" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: fintoc-upstream-update + cancel-in-progress: false + +jobs: + update: + if: github.repository != 'anomalyco/opencode' + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + - name: Incorporate the latest upstream release + id: update + run: | + upstream=$(jq -er .upstream github/fintoc.json) + latest=$(gh api repos/anomalyco/opencode/releases/latest --jq .tag_name) + [[ "$latest" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + if [[ "$latest" == "$upstream" ]]; then exit 0; fi + branch="update-opencode-${latest#v}" + if [[ $(gh pr list --repo "$GITHUB_REPOSITORY" --head "$branch" --state open --json number --jq length) != 0 ]]; then exit 0; fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch https://github.com/anomalyco/opencode.git "refs/tags/$latest:refs/tags/$latest" + git switch --create "$branch" + git merge --no-commit --no-ff "$latest" + jq --arg upstream "$latest" --arg version "${latest#v}-fintoc.1" \ + '.upstream = $upstream | .version = $version' github/fintoc.json > "$RUNNER_TEMP/fintoc.json" + mv "$RUNNER_TEMP/fintoc.json" github/fintoc.json + git add github/fintoc.json + git commit -m "chore(fork): retain trusted app reviews on $latest" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + echo "version=$latest" >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/setup-bun + if: steps.update.outputs.branch != '' + with: + install-flags: --frozen-lockfile + - name: Verify the candidate before opening a PR + if: steps.update.outputs.branch != '' + run: | + bun run --cwd packages/opencode typecheck + cd packages/opencode + bun test test/cli/fintoc-install.test.ts test/cli/github-permissions.test.ts test/cli/github-action.test.ts test/cli/github-remote.test.ts + export OPENCODE_VERSION=$(jq -er .version ../../github/fintoc.json) + bun run build --single --skip-install --skip-embed-web-ui + - name: Open an update PR + if: steps.update.outputs.branch != '' + env: + UPDATE_BRANCH: ${{ steps.update.outputs.branch }} + UPDATE_VERSION: ${{ steps.update.outputs.version }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + git push origin "$UPDATE_BRANCH" + cat > "$RUNNER_TEMP/update.md" <<'BODY' + Updates the upstream CLI while retaining explicit GitHub App authorization and the fork's binary installer. + + Validation: targeted GitHub tests, package typecheck, Linux build, and binary version smoke test passed before opening this PR. These run in the updater because PRs created with GITHUB_TOKEN do not trigger another workflow run. + + After merging, publish the new CLI with the Fintoc CLI workflow, then update consumers to the merged action commit. Existing consumers stay pinned to their current version until updated. + BODY + gh pr create --repo "$GITHUB_REPOSITORY" --base "$DEFAULT_BRANCH" --head "$UPDATE_BRANCH" \ + --title "chore(fork): update OpenCode to $UPDATE_VERSION" --body-file "$RUNNER_TEMP/update.md" diff --git a/github/FINTOC.md b/github/FINTOC.md new file mode 100644 index 000000000000..15b539d220f9 --- /dev/null +++ b/github/FINTOC.md @@ -0,0 +1,55 @@ +# Fintoc OpenCode fork + +This fork lets configured GitHub Apps request reviews through `issue_comment` while preserving upstream's collaborator checks for other callers. The model, prompt, PR context, reactions, and review execution remain in upstream OpenCode. + +## Authorization + +Set `allowed_app_ids` to a comma-separated list of GitHub App IDs in the trusted workflow. The default is empty. The CLI accepts the exception only when GitHub's event says the comment was created by a Bot, the comment author matches the triggering actor, and `comment.performed_via_github_app.id` is in that list. Comment text and repository configuration cannot grant this exception. Human users continue to need `write` or `admin` collaborator permission. Other event types retain upstream behavior. + +The CLI reads `ALLOWED_APP_IDS`; the action passes its `allowed_app_ids` input. Both token modes use the same authorization rule. App installation IDs and bot user IDs are different identifiers and must not be used here. + +## Build and release + +`github/fintoc.json` records the upstream base and the patched CLI version. Increment the `-fintoc.N` suffix for a new patch on the same upstream release. Do not replace existing release assets. + +Run the **Fintoc CLI** workflow on the fork's default branch with `publish` enabled. It runs the GitHub tests and package typecheck, builds and smoke-tests the Linux X64 binary, and publishes a versioned prerelease with its checksum. The build omits the embedded web UI because this distribution is for the GitHub runner. Publishing from other branches is disabled. + +The fork action requires `release_repository` and installs exactly the version in its own `github/fintoc.json`. It never installs upstream latest. It checks the archive checksum and the executable's version, and separates its cache from upstream's. This installer supports Linux X64 runners. + +Example consumer configuration (replace the action commit): + +```yaml +- uses: fintoc-com/opencode/github@FULL_COMMIT_SHA + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + with: + release_repository: fintoc-com/opencode + allowed_app_ids: "4551783,1658531" + use_github_token: true + model: openai/gpt-5.4 + share: false + prompt: ${{ steps.review-pr-prompt.outputs.prompt }} +``` + +Publish the matching release before updating consumers. Public forks work with the consumer's built-in GitHub token; access to a private release repository would require a separate installation credential and is not implemented here. + +For a local build on the current platform: + +```bash +bun install --frozen-lockfile +cd packages/opencode +bun test test/cli/fintoc-install.test.ts test/cli/github-permissions.test.ts test/cli/github-action.test.ts test/cli/github-remote.test.ts +bun typecheck +OPENCODE_VERSION=1.18.31-fintoc.1 bun run build --single --skip-install --skip-embed-web-ui +``` + +## Upstream updates + +Use a fork default branch based on the release in `github/fintoc.json`, with these changes committed on top. Enable **Update Fintoc OpenCode** and **Fintoc CLI** in the fork; upstream infrastructure workflows are not part of this fork's release process. The repository must permit Actions to create pull requests for the scheduled updater. + +Each Monday, or on manual dispatch, the updater checks the latest stable upstream release. It creates an update branch from the fork's default branch and merges the upstream release tag into it, preserving the fork's commits. A conflict stops the update and leaves the default branch and existing consumers unchanged. + +Before opening an update PR, the updater runs the authorization and GitHub tests, package typecheck, Linux build, and version smoke test. These checks run inside the updater because its `GITHUB_TOKEN`-created PR does not trigger another workflow. The updater never merges or publishes automatically. + +After reviewing and merging an update, publish the new binary through **Fintoc CLI**, then update consumers to the new action commit. Features in the new upstream release are included; any conflicting fork changes need manual resolution. If upstream adds compatible App authorization, remove this patch and return consumers to the official action. diff --git a/github/action.yml b/github/action.yml index 3d983a160995..8eb82e7d640d 100644 --- a/github/action.yml +++ b/github/action.yml @@ -5,6 +5,10 @@ branding: color: "orange" inputs: + release_repository: + description: "Repository containing the patched CLI releases (owner/repo)." + required: true + model: description: "Model to use" required: true @@ -26,6 +30,11 @@ inputs: required: false default: "false" + allowed_app_ids: + description: "Comma-separated GitHub App IDs allowed to request runs through issue comments. Other actors must have write or admin access." + required: false + default: "" + mentions: description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'" required: false @@ -45,20 +54,29 @@ runs: id: version shell: bash run: | - VERSION=$(curl -sf https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4) - echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT + echo "version=$(jq -er .version "$GITHUB_ACTION_PATH/fintoc.json")" >> "$GITHUB_OUTPUT" - name: Cache opencode id: cache uses: actions/cache@v4 with: path: ~/.opencode/bin - key: opencode-${{ runner.os }}-${{ runner.arch }}-${{ steps.version.outputs.version }} + key: opencode-fintoc-${{ inputs.release_repository }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.version.outputs.version }} - name: Install opencode if: steps.cache.outputs.cache-hit != 'true' shell: bash - run: curl -fsSL https://opencode.ai/install | bash + run: bash "$GITHUB_ACTION_PATH/install-fintoc.sh" + env: + GH_TOKEN: ${{ github.token }} + OPENCODE_RELEASE_REPOSITORY: ${{ inputs.release_repository }} + OPENCODE_RELEASE_VERSION: ${{ steps.version.outputs.version }} + + - name: Verify opencode version + shell: bash + run: test "$("$HOME/.opencode/bin/opencode" --version)" = "$EXPECTED_VERSION" + env: + EXPECTED_VERSION: ${{ steps.version.outputs.version }} - name: Add opencode to PATH shell: bash @@ -74,6 +92,7 @@ runs: SHARE: ${{ inputs.share }} PROMPT: ${{ inputs.prompt }} USE_GITHUB_TOKEN: ${{ inputs.use_github_token }} + ALLOWED_APP_IDS: ${{ inputs.allowed_app_ids }} MENTIONS: ${{ inputs.mentions }} VARIANT: ${{ inputs.variant }} OIDC_BASE_URL: ${{ inputs.oidc_base_url }} diff --git a/github/fintoc.json b/github/fintoc.json new file mode 100644 index 000000000000..af35ba915e5b --- /dev/null +++ b/github/fintoc.json @@ -0,0 +1,4 @@ +{ + "upstream": "v1.18.31", + "version": "1.18.31-fintoc.1" +} diff --git a/github/install-fintoc.sh b/github/install-fintoc.sh new file mode 100644 index 000000000000..a858bef9d5c5 --- /dev/null +++ b/github/install-fintoc.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${RUNNER_OS:-}" != Linux || "${RUNNER_ARCH:-}" != X64 ]]; then + echo "The Fintoc CLI release supports Linux X64 GitHub runners." >&2 + exit 1 +fi +if [[ ! "${OPENCODE_RELEASE_REPOSITORY:-}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "Set release_repository to the fork's owner/repo." >&2 + exit 1 +fi +if [[ ! "${OPENCODE_RELEASE_VERSION:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-fintoc\.[0-9]+$ ]]; then + echo "Expected an explicit Fintoc CLI version." >&2 + exit 1 +fi + +download_dir=$(mktemp -d) +trap 'rm -rf "$download_dir"' EXIT +gh release download "v$OPENCODE_RELEASE_VERSION" \ + --repo "$OPENCODE_RELEASE_REPOSITORY" \ + --pattern opencode-linux-x64.tar.gz \ + --pattern SHA256SUMS \ + --dir "$download_dir" +( + cd "$download_dir" + sha256sum --check SHA256SUMS + tar -xzf opencode-linux-x64.tar.gz opencode +) +install_dir=${OPENCODE_INSTALL_DIR:-"$HOME/.opencode/bin"} +install -d "$install_dir" +install -m 755 "$download_dir/opencode" "$install_dir/opencode" diff --git a/packages/opencode/test/cli/fintoc-install.test.ts b/packages/opencode/test/cli/fintoc-install.test.ts new file mode 100644 index 000000000000..4b0272be8672 --- /dev/null +++ b/packages/opencode/test/cli/fintoc-install.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test" +import { $ } from "bun" +import { chmod, mkdir } from "node:fs/promises" +import { tmpdir } from "../fixture/fixture" + +const script = new URL("../../../../github/install-fintoc.sh", import.meta.url).pathname + +test.each([false, true])("installs only checksum-verified release archives (corrupted=%p)", async (corrupted) => { + await using dir = await tmpdir() + await mkdir(`${dir.path}/commands`) + await Bun.write(`${dir.path}/opencode`, "#!/bin/sh\nprintf '1.18.31-fintoc.1\\n'\n") + await $`tar -czf ${dir.path}/opencode-linux-x64.tar.gz -C ${dir.path} opencode`.quiet() + await Bun.write(`${dir.path}/SHA256SUMS`, await $`sha256sum opencode-linux-x64.tar.gz`.cwd(dir.path).text()) + if (corrupted) await Bun.write(`${dir.path}/opencode-linux-x64.tar.gz`, "corrupted archive") + await Bun.write( + `${dir.path}/commands/gh`, + `#!/usr/bin/env bash +set -euo pipefail +[[ "$1" == release && "$2" == download && "$3" == v1.18.31-fintoc.1 && "$4" == --repo && "$5" == example/opencode ]] +for arg in "$@"; do destination="$arg"; done +cp "$FIXTURE_RELEASE_DIR/opencode-linux-x64.tar.gz" "$FIXTURE_RELEASE_DIR/SHA256SUMS" "$destination" +`, + ) + await chmod(`${dir.path}/commands/gh`, 0o755) + const result = await $`bash ${script}` + .env({ + ...process.env, + PATH: `${dir.path}/commands:${process.env.PATH}`, + RUNNER_OS: "Linux", + RUNNER_ARCH: "X64", + OPENCODE_RELEASE_REPOSITORY: "example/opencode", + OPENCODE_RELEASE_VERSION: "1.18.31-fintoc.1", + OPENCODE_INSTALL_DIR: `${dir.path}/installed`, + FIXTURE_RELEASE_DIR: dir.path, + }) + .quiet() + .nothrow() + + if (corrupted) { + expect(result.exitCode).not.toBe(0) + expect(await Bun.file(`${dir.path}/installed/opencode`).exists()).toBe(false) + return + } + expect(result.exitCode).toBe(0) + expect((await $`${dir.path}/installed/opencode --version`.text()).trim()).toBe("1.18.31-fintoc.1") +}) + +test("rejects a floating release version before downloading", async () => { + const result = await $`bash ${script}` + .env({ + ...process.env, + RUNNER_OS: "Linux", + RUNNER_ARCH: "X64", + OPENCODE_RELEASE_REPOSITORY: "example/opencode", + OPENCODE_RELEASE_VERSION: "latest", + }) + .quiet() + .nothrow() + expect(result.exitCode).not.toBe(0) + expect(result.stderr.toString()).toContain("Expected an explicit Fintoc CLI version") +}) From ddaa9c8cd358a67963e22d564258681c1a376361 Mon Sep 17 00:00:00 2001 From: Adolfo Date: Tue, 22 Sep 2026 16:55:12 -0300 Subject: [PATCH 3/3] fix(github): authorize configured bots across PR and comment events --- github/FINTOC.md | 8 +- github/action.yml | 6 +- .../opencode/src/cli/cmd/github.handler.ts | 2 +- .../src/cli/cmd/github.permissions.ts | 17 ++--- .../test/cli/github-permissions.test.ts | 75 ++++++++++++------- 5 files changed, 62 insertions(+), 46 deletions(-) diff --git a/github/FINTOC.md b/github/FINTOC.md index 15b539d220f9..e6449cf9274e 100644 --- a/github/FINTOC.md +++ b/github/FINTOC.md @@ -1,12 +1,12 @@ # Fintoc OpenCode fork -This fork lets configured GitHub Apps request reviews through `issue_comment` while preserving upstream's collaborator checks for other callers. The model, prompt, PR context, reactions, and review execution remain in upstream OpenCode. +This fork lets configured bots trigger reviews through `pull_request`, `issue_comment`, and `pull_request_review_comment` while preserving upstream's collaborator checks for other callers. The model, prompt, PR context, reactions, and review execution remain in upstream OpenCode. ## Authorization -Set `allowed_app_ids` to a comma-separated list of GitHub App IDs in the trusted workflow. The default is empty. The CLI accepts the exception only when GitHub's event says the comment was created by a Bot, the comment author matches the triggering actor, and `comment.performed_via_github_app.id` is in that list. Comment text and repository configuration cannot grant this exception. Human users continue to need `write` or `admin` collaborator permission. Other event types retain upstream behavior. +Set `allowed_bots` to a comma-separated list of exact bot logins in the trusted workflow. The default is empty. The CLI accepts the exception only when GitHub's event identifies the sender as a Bot, the sender matches the triggering actor, and that login is in the list. The PR author and comment text cannot grant this exception. Human users continue to need `write` or `admin` collaborator permission, even if their login is listed. Other event types retain upstream behavior. -The CLI reads `ALLOWED_APP_IDS`; the action passes its `allowed_app_ids` input. Both token modes use the same authorization rule. App installation IDs and bot user IDs are different identifiers and must not be used here. +The CLI reads `ALLOWED_BOTS`; the action passes its `allowed_bots` input. Both token modes use the same authorization rule. Use full GitHub logins, including `[bot]`; wildcards and partial matches are not supported. Configure the list centrally in Hermes; caller repos and agents keep their existing PR and `fin review` flows. ## Build and release @@ -25,7 +25,7 @@ Example consumer configuration (replace the action commit): OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} with: release_repository: fintoc-com/opencode - allowed_app_ids: "4551783,1658531" + allowed_bots: "fin-tank-agent[bot],linear-code[bot]" use_github_token: true model: openai/gpt-5.4 share: false diff --git a/github/action.yml b/github/action.yml index 8eb82e7d640d..2a1bca472c7d 100644 --- a/github/action.yml +++ b/github/action.yml @@ -30,8 +30,8 @@ inputs: required: false default: "false" - allowed_app_ids: - description: "Comma-separated GitHub App IDs allowed to request runs through issue comments. Other actors must have write or admin access." + allowed_bots: + description: "Comma-separated bot logins allowed to trigger pull request and comment runs. Other actors must have write or admin access." required: false default: "" @@ -92,7 +92,7 @@ runs: SHARE: ${{ inputs.share }} PROMPT: ${{ inputs.prompt }} USE_GITHUB_TOKEN: ${{ inputs.use_github_token }} - ALLOWED_APP_IDS: ${{ inputs.allowed_app_ids }} + ALLOWED_BOTS: ${{ inputs.allowed_bots }} MENTIONS: ${{ inputs.mentions }} VARIANT: ${{ inputs.variant }} OIDC_BASE_URL: ${{ inputs.oidc_base_url }} diff --git a/packages/opencode/src/cli/cmd/github.handler.ts b/packages/opencode/src/cli/cmd/github.handler.ts index 22c7ca5bc687..ffcfa7d64421 100644 --- a/packages/opencode/src/cli/cmd/github.handler.ts +++ b/packages/opencode/src/cli/cmd/github.handler.ts @@ -495,7 +495,7 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: } // Skip permission check and reactions for repo events (no actor to check, no issue to react to) if (isUserEvent) { - await assertPermissions(context, octoRest, process.env["ALLOWED_APP_IDS"]) + await assertPermissions(context, octoRest, process.env["ALLOWED_BOTS"]) await addReaction(commentType) } diff --git a/packages/opencode/src/cli/cmd/github.permissions.ts b/packages/opencode/src/cli/cmd/github.permissions.ts index 0ba7cda085ae..97b66af8bbf4 100644 --- a/packages/opencode/src/cli/cmd/github.permissions.ts +++ b/packages/opencode/src/cli/cmd/github.permissions.ts @@ -4,22 +4,19 @@ import type { Octokit } from "@octokit/rest" export async function assertPermissions( context: Pick, octokit: Octokit, - allowedAppIds = "", + allowedBots = "", ) { const { actor } = context console.log(`Asserting permissions for user ${actor}...`) - const comment = context.payload.comment - const app = comment?.performed_via_github_app + const sender = context.payload.sender if ( - context.eventName === "issue_comment" && - comment?.user?.type === "Bot" && - comment.user.login === actor && - Number.isSafeInteger(app?.id) && - app.id > 0 && - allowedAppIds.split(",").some((id) => id.trim() === String(app.id)) + ["issue_comment", "pull_request", "pull_request_review_comment"].includes(context.eventName) && + sender?.type === "Bot" && + sender.login === actor && + allowedBots.split(",").some((login) => login.trim() === actor) ) { - console.log(` allowed GitHub App: ${app.id}`) + console.log(` allowed bot: ${actor}`) return } diff --git a/packages/opencode/test/cli/github-permissions.test.ts b/packages/opencode/test/cli/github-permissions.test.ts index 75049c3e9c0a..5118ddb8f684 100644 --- a/packages/opencode/test/cli/github-permissions.test.ts +++ b/packages/opencode/test/cli/github-permissions.test.ts @@ -22,7 +22,6 @@ function context( input: { actor?: string eventName?: string - app?: number | null login?: string type?: string } = {}, @@ -32,55 +31,75 @@ function context( eventName: input.eventName ?? "issue_comment", repo: { owner: "example", repo: "project" }, payload: { + sender: { login: input.login ?? "trusted-agent[bot]", type: input.type ?? "Bot" }, comment: { id: 1, - user: { login: input.login ?? "trusted-agent[bot]", type: input.type ?? "Bot" }, - performed_via_github_app: input.app === null ? null : { id: input.app ?? 123 }, + user: { login: "trusted-agent[bot]", type: "Bot" }, }, }, } } describe("GitHub caller permissions", () => { - test("accepts a configured app without consulting collaborator permissions", async () => { - const count = requests.length - await assertPermissions(context(), octokit, "456, 123") - expect(requests.length).toBe(count) - }) + test.each(["issue_comment", "pull_request", "pull_request_review_comment"])( + "accepts a configured bot triggering %s without consulting collaborator permissions", + async (eventName) => { + const event = context({ eventName }) + if (eventName === "pull_request") { + delete event.payload.comment + event.payload.pull_request = { number: 1, user: { login: "developer", type: "User" } } + } + const count = requests.length + await assertPermissions(event, octokit, "another-agent[bot], trusted-agent[bot]") + expect(requests.length).toBe(count) + }, + ) - test.each([undefined, "", "456"])("rejects an app absent from configuration %p", async (allowed) => { - await rejects(assertPermissions(context(), octokit, allowed), { - message: "User trusted-agent[bot] does not have write permissions", - }) - }) + test.each([undefined, "", "another-agent[bot]", "*", "trusted-agent", "other-trusted-agent[bot]"])( + "rejects a bot absent from configuration %p", + async (allowed) => { + await rejects(assertPermissions(context(), octokit, allowed), { + message: "User trusted-agent[bot] does not have write permissions", + }) + }, + ) test.each(["developer", "owner"])("preserves collaborator access for %s", async (actor) => { - await assertPermissions(context({ actor, login: actor, type: "User", app: null }), octokit, "123") + await assertPermissions(context({ actor, login: actor, type: "User" }), octokit, "trusted-agent[bot]") expect(requests.at(-1)).toBe(actor) }) test("rejects an unauthorized human", async () => { await rejects( - assertPermissions(context({ actor: "outsider", login: "outsider", type: "User", app: null }), octokit, "123"), + assertPermissions(context({ actor: "outsider", login: "outsider", type: "User" }), octokit, "outsider"), { message: "User outsider does not have write permissions" }, ) }) - test.each([ - { app: null }, - { app: 0 }, - { actor: "outsider" }, - { type: "User" }, - { eventName: "pull_request" }, - { eventName: "issues" }, - ])("does not bypass collaborator checks with mismatched event metadata %p", async (input) => { - await rejects(assertPermissions(context(input), octokit, "123"), /does not have write permissions/) + test.each([{ actor: "outsider" }, { login: "another-agent[bot]" }, { type: "User" }, { eventName: "issues" }])( + "does not bypass collaborator checks with mismatched event metadata %p", + async (input) => { + await rejects(assertPermissions(context(input), octokit, "trusted-agent[bot]"), /does not have write permissions/) + }, + ) + + test("requires sender metadata even when the comment author is trusted", async () => { + const event = context() + delete event.payload.sender + await rejects(assertPermissions(event, octokit, "trusted-agent[bot]"), /does not have write permissions/) + }) + + test("does not authorize an outsider updating a trusted bot's PR", async () => { + const event = context({ eventName: "pull_request", actor: "outsider", login: "outsider", type: "User" }) + delete event.payload.comment + event.payload.pull_request = { number: 1, user: { login: "trusted-agent[bot]", type: "Bot" } } + await rejects(assertPermissions(event, octokit, "trusted-agent[bot]"), /does not have write permissions/) }) - test("does not treat an app name mentioned in the comment as authorization", async () => { - const event = context({ app: null }) - event.payload.comment!.body = "fin review from app 123" - await rejects(assertPermissions(event, octokit, "123"), /does not have write permissions/) + test("does not treat a bot name mentioned in the comment as authorization", async () => { + const event = context({ actor: "outsider", login: "outsider", type: "User" }) + event.payload.comment!.body = "fin review from trusted-agent[bot]" + await rejects(assertPermissions(event, octokit, "trusted-agent[bot]"), /does not have write permissions/) }) test("retains permission lookup failures", async () => {