From 09866239c175bd56a10689eae9a401c2a5cc18f9 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Sun, 14 Jun 2026 20:29:41 +0300 Subject: [PATCH 01/26] feat(mint): cross-org authorization and e2e WIF auth (#2155) Authorize cross-org mint via target-org FULLSEND_FOREIGN__REPOS, replace Playwright e2e auth with OIDC mint in CI, and add admin foreign CLI. Signed-off-by: Barak Korren Co-authored-by: Cursor --- .github/workflows/e2e.yml | 20 +- CLAUDE.md | 16 +- Makefile | 35 +- .../0040-org-pool-for-parallel-e2e-tests.md | 7 +- ...rg-mint-authorization-via-org-variables.md | 70 ++++ docs/guides/dev/e2e-testing.md | 78 ++++- e2e/admin/admin_test.go | 58 +--- e2e/admin/auth.go | 158 +++++++++ e2e/admin/lock_test.go | 8 +- e2e/admin/login.go | 165 --------- e2e/admin/pat.go | 170 ---------- e2e/admin/testutil.go | 134 ++++++-- e2e/cmd/export-session/main.go | 129 ------- hack/setup-new-e2e-org.sh | 35 +- internal/cli/admin.go | 1 + internal/cli/foreign.go | 314 ++++++++++++++++++ internal/cli/foreign_test.go | 36 ++ internal/cli/github.go | 13 + .../gcf/mintsrc/mintcore/foreign.go.embed | 58 ++++ .../gcf/mintsrc/mintcore/github.go.embed | 143 ++++++++ .../gcf/mintsrc/mintcore/handler.go.embed | 118 ++++++- internal/dispatch/gcf/provisioner.go | 3 +- internal/dispatch/gcf/provisioner_test.go | 3 +- internal/forge/fake.go | 52 ++- internal/forge/forge.go | 8 + internal/forge/github/github.go | 55 ++- internal/mintcore/foreign.go | 58 ++++ internal/mintcore/foreign_test.go | 35 ++ internal/mintcore/github.go | 143 ++++++++ internal/mintcore/handler.go | 118 ++++++- internal/mintcore/handler_test.go | 180 +++++++++- 31 files changed, 1760 insertions(+), 661 deletions(-) create mode 100644 docs/ADRs/0046-cross-org-mint-authorization-via-org-variables.md create mode 100644 e2e/admin/auth.go delete mode 100644 e2e/admin/login.go delete mode 100644 e2e/admin/pat.go delete mode 100644 e2e/cmd/export-session/main.go create mode 100644 internal/cli/foreign.go create mode 100644 internal/cli/foreign_test.go create mode 100644 internal/dispatch/gcf/mintsrc/mintcore/foreign.go.embed create mode 100644 internal/mintcore/foreign.go create mode 100644 internal/mintcore/foreign_test.go diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3c0d553a28..9e05aa80e2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -101,29 +101,17 @@ jobs: with: go-version-file: go.mod - - name: Install Playwright system dependencies - run: npx playwright install-deps chromium - - name: Check for secrets id: secrets-check run: | - if [ -z "$E2E_GITHUB_SESSION_B64" ]; then - echo "::warning::E2E secrets are not configured. Skipping e2e tests." + if [ -z "$E2E_MINT_URL" ]; then + echo "::warning::E2E_MINT_URL is not configured. Skipping e2e tests." echo "available=false" >> "$GITHUB_OUTPUT" else echo "available=true" >> "$GITHUB_OUTPUT" fi env: - E2E_GITHUB_SESSION_B64: ${{ secrets.E2E_GITHUB_SESSION }} - - - name: Decode session - if: steps.secrets-check.outputs.available == 'true' - run: | - SESSION_FILE="${RUNNER_TEMP}/github-session.json" - printf '%s' "$E2E_GITHUB_SESSION_B64" | base64 -d > "$SESSION_FILE" - echo "E2E_GITHUB_SESSION_FILE=${SESSION_FILE}" >> "$GITHUB_ENV" - env: - E2E_GITHUB_SESSION_B64: ${{ secrets.E2E_GITHUB_SESSION }} + E2E_MINT_URL: ${{ secrets.E2E_MINT_URL }} - name: Authenticate to GCP if: steps.secrets-check.outputs.available == 'true' @@ -137,8 +125,6 @@ jobs: run: make e2e-test env: E2E_SCREENSHOT_DIR: ${{ runner.temp }}/e2e-screenshots - E2E_GITHUB_PASSWORD: ${{ secrets.E2E_GITHUB_PASSWORD }} - E2E_GITHUB_TOTP_SECRET: ${{ secrets.E2E_GITHUB_TOTP_SECRET }} E2E_MINT_URL: ${{ secrets.E2E_MINT_URL }} E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }} diff --git a/CLAUDE.md b/CLAUDE.md index 6b521bfc3a..795862b8df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,18 +32,20 @@ When making changes to Go code under `cmd/` or `internal/`: 1. **Unit tests:** Run `make go-test` (or `go test ./...`) and fix any failures before committing. 2. **Vet:** Run `make go-vet` to catch common issues. -3. **E2E tests:** Run `make e2e-test` if your changes touch `internal/appsetup/`, `internal/forge/`, `internal/cli/`, or `internal/layers/`. These tests exercise the full admin install/uninstall flow against a live GitHub org using Playwright browser automation. +3. **E2E tests:** Run `make e2e-test` if your changes touch `internal/appsetup/`, `internal/forge/`, `internal/cli/`, `internal/layers/`, or `internal/mintcore/`. These tests exercise the full admin install/uninstall flow against a live GitHub org from the halfsend pool. ### Running e2e tests -The e2e tests require GitHub credentials. There are three ways to provide them: +**CI:** GitHub Actions mints cross-org installation tokens via `E2E_MINT_URL` (role `e2e`, `target_org` = pool org). Pool orgs must install the e2e app and set `FULLSEND_FOREIGN_E2E_REPOS=fullsend-ai/fullsend` — see [ADR 0046](docs/ADRs/0046-cross-org-mint-authorization-via-org-variables.md) and [e2e-testing.md](docs/guides/dev/e2e-testing.md). -- **`E2E_GITHUB_PASSWORD` env var:** Set directly with the password. -- **`E2E_GITHUB_PASSWORD_FILE` env var:** Set to a file path containing the password (used in devaipod environments where secrets are mounted as files). -- **`E2E_GITHUB_SESSION_FILE` env var:** Set to a pre-exported Playwright session file (skips login). -- **`E2E_GITHUB_TOTP_SECRET` env var:** Optional. The TOTP secret (base32) for the test account's 2FA. Required only when the test account has 2FA enabled — used during session export and sudo confirmation. +**Local:** Authenticate with pool-org admin access: -If only `E2E_GITHUB_USERNAME` and a password source are available, `make e2e-test` will automatically generate a session file before running tests. See `make help` for all available targets. +```bash +gh auth login --web +make e2e-test +``` + +Alternatively set `GH_TOKEN` or `GITHUB_TOKEN`. See `make help` and [e2e-testing.md](docs/guides/dev/e2e-testing.md) for pool provisioning (`hack/setup-new-e2e-org.sh`) and CI secrets. ## Key design decisions made diff --git a/Makefile b/Makefile index 43d4f927db..982eb2a387 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ .PHONY: help bootstrap lint lint-all check fmt \ mindmap go-build go-test go-lint go-fmt go-vet go-tidy \ lint-md-links script-test test \ - e2e-test e2e-playwright e2e-export-session e2e-upload-session + e2e-test # Let Go automatically download the toolchain version required by go.mod. # This ensures local builds use the right version without manual intervention. @@ -27,9 +27,7 @@ help: @echo " lint-md-links - Check markdown files for broken in-repo links and anchors" @echo " script-test - Run shell script tests (post-triage, post-code, post-review, pre-fetch-prior-review, reconcile-repos, validate-output-schema)" @echo " test - Run all checks: lint-all, go-test, script-test" - @echo " e2e-test - Run admin e2e tests (requires E2E_GITHUB_SESSION_FILE or E2E_GITHUB_USERNAME + E2E_GITHUB_PASSWORD)" - @echo " e2e-export-session - Login to GitHub and export a Playwright session file" - @echo " e2e-upload-session - Export session and upload it as a GitHub repo secret" + @echo " e2e-test - Run admin e2e tests (CI: E2E_MINT_URL; local: gh auth login or GH_TOKEN)" # Install all development tools needed for linting, formatting, and pre-commit hooks. # Prerequisites: uv (https://docs.astral.sh/uv/) and go (https://go.dev/) @@ -120,32 +118,5 @@ script-test: test: lint-all go-test script-test -E2E_SESSION_FILE ?= $(CURDIR)/.playwright/session.json - -e2e-test: e2e-playwright - @if [ -n "$$E2E_GITHUB_PASSWORD_FILE" ] && [ -z "$$E2E_GITHUB_PASSWORD" ]; then \ - export E2E_GITHUB_PASSWORD="$$(cat "$$E2E_GITHUB_PASSWORD_FILE")"; \ - fi; \ - if [ -z "$$E2E_GITHUB_SESSION_FILE" ] && [ -n "$$E2E_GITHUB_USERNAME" ] && [ -n "$$E2E_GITHUB_PASSWORD" ]; then \ - echo "==> No session file set, generating one from credentials..."; \ - $(MAKE) e2e-export-session; \ - export E2E_GITHUB_SESSION_FILE="$(E2E_SESSION_FILE)"; \ - fi; \ +e2e-test: go test -tags e2e -v -count=1 -timeout 30m ./e2e/admin/ - -e2e-export-session: e2e-playwright - @if [ -n "$$E2E_GITHUB_PASSWORD_FILE" ] && [ -z "$$E2E_GITHUB_PASSWORD" ]; then \ - export E2E_GITHUB_PASSWORD="$$(cat "$$E2E_GITHUB_PASSWORD_FILE")"; \ - fi; \ - E2E_GITHUB_SESSION_FILE="$(E2E_SESSION_FILE)" go run ./e2e/cmd/export-session/ - -e2e-upload-session: e2e-export-session - @echo "==> Uploading session to GitHub repo secret..." - base64 -w0 "$(E2E_SESSION_FILE)" | gh secret set E2E_GITHUB_SESSION - @echo "==> Done. Session uploaded as E2E_GITHUB_SESSION." - -e2e-playwright: - @if [ -z "$$(ls -d $(HOME)/.cache/ms-playwright/chromium-* 2>/dev/null)" ]; then \ - echo "==> Installing Playwright Chromium..."; \ - go run github.com/playwright-community/playwright-go/cmd/playwright install chromium; \ - fi diff --git a/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md b/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md index 62870d61b1..426b5a3fad 100644 --- a/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md +++ b/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md @@ -68,7 +68,10 @@ slice in the test code. No architectural changes are needed. `test-repo` for enrollment testing. - A crashed run leaves a stale lock that self-heals via the age-based staleness check. -- The single `botsend` test account and its stored browser session are shared - across all orgs; session export and PAT creation remain per-run. +- Each pool org must install the `fullsend-ai-e2e` app and authorize CI via + `FULLSEND_FOREIGN_E2E_REPOS` on the target org — see + [ADR 0046](0046-cross-org-mint-authorization-via-org-variables.md). +- CI acquires per-org tokens via cross-org mint ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)); + local runs use a user token with pool-org admin access (`gh auth login`). - Pool expansion is an operational task (provision org, update one slice literal), not an architectural change. diff --git a/docs/ADRs/0046-cross-org-mint-authorization-via-org-variables.md b/docs/ADRs/0046-cross-org-mint-authorization-via-org-variables.md new file mode 100644 index 0000000000..90274a14ac --- /dev/null +++ b/docs/ADRs/0046-cross-org-mint-authorization-via-org-variables.md @@ -0,0 +1,70 @@ +--- +title: "46. Cross-org mint authorization via org variables" +status: Accepted +relates_to: + - agent-infrastructure + - security-threat-model +topics: + - identity + - oidc + - github-apps + - cross-org +--- + +# 46. Cross-org mint authorization via org variables + +Date: 2026-06-07 + +## Status + +Accepted + +## Context + +The central token mint ([ADR 0029](0029-central-token-mint-secretless-fullsend.md)) issues +short-lived GitHub App installation tokens to OIDC-authenticated workflows. Today the mint +scopes tokens to the caller's `repository_owner`: the App installation lookup and PEM +lookup both use the org from the OIDC `repository` claim. + +Some workloads need to act on a **different** org than the workflow's owner. The e2e +test pool ([ADR 0040](0040-org-pool-for-parallel-e2e-tests.md)) runs CI from +`fullsend-ai/fullsend` but mutates dedicated pool orgs (`halfsend-01`, …). Future +cross-org agent flows ([#672](https://github.com/fullsend-ai/fullsend/issues/672), +[#1916](https://github.com/fullsend-ai/fullsend/issues/1916)) have the same shape. + +The target org must explicitly authorize which foreign repos or orgs may request tokens +for a given role. A mint-operator central allowlist does not scale and does not give target +orgs control over their own policy. + +## Decision + +1. **Optional `target_org` on mint requests.** When omitted, or when equal to the caller's + `repository_owner` (case-insensitive), behavior is unchanged from pre-0046 mint: same + `mintToken` path, repo-based installation lookup, no FOREIGN check. + +2. **Cross-org path** applies only when `target_org` is set and differs from the caller org: + - Resolve the requested role's App installation on `target_org` via org-level installation lookup. + - Read `FULLSEND_FOREIGN__REPOS` on the target org using that role's App installation + token (`actions_variables: read`). + - Deny if installation lookup fails, the variable is missing/empty, or the OIDC caller + (`repository` or bare `repository_owner`) is not on the allowlist. + - Mint a scoped installation token for the requested repos on the target org. + +3. **Variable format.** Org-level GitHub Actions variable on the **target** org: + - Name: `FULLSEND_FOREIGN__REPOS` (uppercase role suffix, per [ADR 0014](0014-admin-install-github-apps-secrets-v1.md)) + - Value: comma-separated list of `org/repo` (exact `repository` match) and/or bare `org` + (`repository_owner` match) + +4. **Role-agnostic mechanism.** Any allowed role may use the cross-org path when the target + org has installed that role's App and configured the FOREIGN variable. The `e2e` role + ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)) is the first consumer. + +5. **CLI.** `fullsend admin foreign allow|list|revoke` manages FOREIGN variables on a target org. + +## Consequences + +- Cross-org mint requests add GitHub API calls (FOREIGN variable cached with short TTL). +- Roles used on the cross-org path need `actions_variables: read` on their App permissions. +- Target orgs opt in by installing the role App and setting the FOREIGN allowlist. +- Same-org mint for enrolled orgs is unchanged: zero new API calls or permission changes. +- Pool org provisioning must install the e2e App and set `FULLSEND_FOREIGN_E2E_REPOS` for CI callers. diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index 529cdb7d68..0b47ef0c8d 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -2,29 +2,84 @@ Guide for running and debugging fullsend admin e2e tests locally and in CI. -Related ADRs: [0010](../../ADRs/0010-stored-session-for-e2e-browser-auth.md) (browser -session), [0039](../../ADRs/0039-totp-automation-for-e2e-2fa.md) (2FA), -[0040](../../ADRs/0040-org-pool-for-parallel-e2e-tests.md) (org pool), +Related ADRs: [0040](../../ADRs/0040-org-pool-for-parallel-e2e-tests.md) (org pool), +[0046](../../ADRs/0046-cross-org-mint-authorization-via-org-variables.md) (cross-org mint), [0009](../../ADRs/0009-pull-request-target-in-shim-workflows.md) (pull_request_target security model for shims; e2e uses a separate gate pattern documented below). +Historical ADRs [0010](../../ADRs/0010-stored-session-for-e2e-browser-auth.md) (browser session) and +[0039](../../ADRs/0039-totp-automation-for-e2e-2fa.md) (2FA) are superseded for CI by cross-org mint +auth ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)); local runs no longer use +Playwright or stored sessions. + ## Local runs ```bash -# Export a Playwright session (once per session expiry) -make e2e-export-session +# Authenticate as an admin on the pool orgs +gh auth login --web -# Run tests (uses E2E_GITHUB_SESSION_FILE or credentials from env) +# Run tests (uses gh auth token, GH_TOKEN, or GITHUB_TOKEN) make e2e-test - -# Upload session to GitHub repo secret (maintainers) -make e2e-upload-session ``` -Required environment variables are documented in the `Makefile` help (`make help`). +Optional environment variables: + +| Variable | Purpose | +|----------|---------| +| `GH_TOKEN` / `GITHUB_TOKEN` | Override token source for local runs | +| `E2E_LOCK_TIMEOUT` | Max wait for a free pool org (default 10m) | +| `E2E_GCP_PROJECT_ID` | GCP project for inference-related setup (if needed) | Tests acquire an exclusive lock on one org from the pool (`halfsend-01` … `halfsend-06`) — see [ADR 0040](../../ADRs/0040-org-pool-for-parallel-e2e-tests.md). +## CI runs + +In GitHub Actions, tests mint a cross-org installation token via the mint service: + +1. Workflow requests a GHA OIDC token (`id-token: write`) +2. `resolveE2EToken` POSTs to `E2E_MINT_URL/v1/token` with `{role: "e2e", target_org: "", repos: [...]}` +3. Mint verifies the caller against `FULLSEND_FOREIGN_E2E_REPOS` on the target org ([ADR 0046](../../ADRs/0046-cross-org-mint-authorization-via-org-variables.md)) + +Required repository secrets: + +| Secret | Purpose | +|--------|---------| +| `E2E_MINT_URL` | Mint service base URL | +| `E2E_GCP_WIF_PROVIDER` | GCP WIF provider (inference / auxiliary GCP access) | +| `E2E_GCP_SERVICE_ACCOUNT` | GCP service account for WIF | +| `E2E_GCP_PROJECT_ID` | GCP project ID | + +If `E2E_MINT_URL` is unset, the e2e job skips with a warning. + +## Pool org provisioning + +Each pool org must be provisioned before e2e can use it: + +1. Org exists with `botsend` as owner +2. `test-repo` and `e2e-lock` repos (lock created at runtime) +3. All role apps installed, including `fullsend-ai-e2e` +4. `FULLSEND_FOREIGN_E2E_REPOS` includes `fullsend-ai/fullsend` (authorizes CI workflows) +5. Mint enrolled: org in `ALLOWED_ORGS`, `${ORG}/e2e` in `ROLE_APP_IDS`, e2e app PEM enrolled + +Use the idempotent setup script: + +```bash +MINT_PROJECT=... MINT_FUNCTION=... hack/setup-new-e2e-org.sh 07 +``` + +Verify foreign authorization: + +```bash +fullsend admin foreign list --org halfsend-01 +# expect e2e → fullsend-ai/fullsend +``` + +Existing pool orgs (`halfsend-01` … `halfsend-06`) need a one-time operator pass: install the e2e app (if missing) and run: + +```bash +fullsend admin foreign allow --org halfsend-NN --role e2e --caller fullsend-ai/fullsend +``` + ## CI authorization Pull requests trigger e2e via `pull_request_target` in @@ -63,7 +118,8 @@ appropriate. 1. **Gate** — authorize the PR author or a fresh `ok-to-test` label (base checkout only; never checks out PR head) -2. **E2E** — checkout PR head SHA, authenticate to GCP via WIF, `make e2e-test` +2. **E2E** — checkout PR head SHA, authenticate to GCP via WIF, mint cross-org + tokens per pool org, `make e2e-test` Pushes to `main`, merge queue, and `workflow_dispatch` skip the gate and run e2e directly. diff --git a/e2e/admin/admin_test.go b/e2e/admin/admin_test.go index 948832d44d..e046852166 100644 --- a/e2e/admin/admin_test.go +++ b/e2e/admin/admin_test.go @@ -17,7 +17,6 @@ import ( "time" "github.com/google/uuid" - "github.com/playwright-community/playwright-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -31,7 +30,6 @@ import ( type e2eEnv struct { cfg envConfig org string // the org acquired from the pool - page playwright.Page client *gh.LiveClient token string runID string @@ -39,8 +37,7 @@ type e2eEnv struct { binary string } -// setupE2ETest performs the common Playwright, login, PAT, lock, and cleanup -// steps. Returns the shared env. +// setupE2ETest performs lock acquisition, cleanup, and shared test setup. func setupE2ETest(t *testing.T) *e2eEnv { t.Helper() if testing.Short() { @@ -54,72 +51,25 @@ func setupE2ETest(t *testing.T) *e2eEnv { } _ = os.MkdirAll(screenshotDir, 0o755) - // Build CLI binary early so we fail fast on compilation errors. binary := buildCLIBinary(t) - // --- Playwright setup --- - pw, err := playwright.Run() - require.NoError(t, err, "starting Playwright") - t.Cleanup(func() { - if stopErr := pw.Stop(); stopErr != nil { - t.Logf("warning: could not stop Playwright: %v", stopErr) - } - }) - - browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ - Headless: playwright.Bool(os.Getenv("E2E_HEADED") != "true"), - }) - require.NoError(t, err, "launching Playwright browser") - t.Cleanup(func() { _ = browser.Close() }) - - // Load pre-authenticated session via storageState (ADR 0010). - t.Logf("Loading browser session from %s", cfg.sessionFile) - browserCtx, err := browser.NewContext(playwright.BrowserNewContextOptions{ - StorageStatePath: playwright.String(cfg.sessionFile), - }) - require.NoError(t, err, "creating browser context with storageState") - t.Cleanup(func() { _ = browserCtx.Close() }) - - page, err := browserCtx.NewPage() - require.NoError(t, err, "creating Playwright page") - - // Verify the session is valid by navigating to a page that requires auth. - err = verifyGitHubSession(page, screenshotDir, t.Logf) - require.NoError(t, err, "verifying GitHub session — session may be expired, re-export it locally") - - // Generate a PAT for API access. - patNote := fmt.Sprintf("fullsend-e2e-%d", time.Now().Unix()) - t.Logf("Creating PAT: %s", patNote) - token, err := createPAT(page, patNote, cfg.password, cfg.totpSecret, screenshotDir, t.Logf) - require.NoError(t, err, "creating PAT") - t.Cleanup(func() { - t.Log("Deleting PAT...") - if delErr := deletePAT(page, patNote, t.Logf); delErr != nil { - t.Logf("warning: could not delete PAT: %v", delErr) - } - }) - - // --- GitHub client --- - client := newLiveClient(token) - - // Acquire an org from the pool. runID := uuid.New().String() t.Logf("E2E run ID: %s", runID) - org, err := acquireOrg(context.Background(), client, token, runID, orgPool, cfg.lockTimeout, t.Logf) + org, token, err := acquireOrg(context.Background(), cfg, runID, orgPool, cfg.lockTimeout, t.Logf) require.NoError(t, err, "acquiring org from pool") t.Logf("Acquired org: %s", org) + + client := newLiveClient(token) t.Cleanup(func() { releaseLock(context.Background(), client, org, runID, t) }) - // Teardown-first cleanup. cleanupStaleResources(context.Background(), client, token, org, t) return &e2eEnv{ cfg: cfg, org: org, - page: page, client: client, token: token, runID: runID, diff --git a/e2e/admin/auth.go b/e2e/admin/auth.go new file mode 100644 index 0000000000..4754c3e5c5 --- /dev/null +++ b/e2e/admin/auth.go @@ -0,0 +1,158 @@ +//go:build e2e + +package admin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "time" +) + +const defaultMintAudience = "fullsend-mint" + +type mintTokenRequest struct { + Role string `json:"role"` + TargetOrg string `json:"target_org"` + Repos []string `json:"repos"` +} + +type mintTokenResponse struct { + Token string `json:"token"` + Error string `json:"error"` +} + +// resolveLocalToken returns a user token from env or gh auth. +func resolveLocalToken() (string, error) { + if token := os.Getenv("GH_TOKEN"); token != "" { + return token, nil + } + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + return token, nil + } + out, err := exec.Command("gh", "auth", "token").Output() + if err == nil { + token := strings.TrimSpace(string(out)) + if token != "" { + return token, nil + } + } + return "", fmt.Errorf("no GitHub token found: set GH_TOKEN, GITHUB_TOKEN, or run 'gh auth login'") +} + +// runningInGitHubActions reports whether the test process runs inside GHA. +func runningInGitHubActions() bool { + return os.Getenv("GITHUB_ACTIONS") == "true" +} + +// requestGitHubOIDCToken obtains a GHA OIDC token for the mint audience. +func requestGitHubOIDCToken(ctx context.Context) (string, error) { + reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + if reqURL == "" || reqToken == "" { + return "", fmt.Errorf("ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN not set") + } + + fullURL := reqURL + if !strings.Contains(fullURL, "audience=") { + sep := "?" + if strings.Contains(fullURL, "?") { + sep = "&" + } + fullURL = fullURL + sep + "audience=" + defaultMintAudience + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return "", fmt.Errorf("creating OIDC request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+reqToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("requesting OIDC token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", fmt.Errorf("OIDC request returned %d: %s", resp.StatusCode, body) + } + + var payload struct { + Value string `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("decoding OIDC response: %w", err) + } + if payload.Value == "" { + return "", fmt.Errorf("empty OIDC token") + } + return payload.Value, nil +} + +// resolveE2EToken mints a cross-org e2e installation token for targetOrg. +func resolveE2EToken(ctx context.Context, mintURL, targetOrg string, repos []string) (string, error) { + if mintURL == "" { + return "", fmt.Errorf("E2E_MINT_URL not set") + } + oidcToken, err := requestGitHubOIDCToken(ctx) + if err != nil { + return "", err + } + + body, err := json.Marshal(mintTokenRequest{ + Role: "e2e", + TargetOrg: targetOrg, + Repos: repos, + }) + if err != nil { + return "", fmt.Errorf("marshaling mint request: %w", err) + } + + mintEndpoint := strings.TrimRight(mintURL, "/") + "/v1/token" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, mintEndpoint, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("creating mint request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+oidcToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("calling mint: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if err != nil { + return "", fmt.Errorf("reading mint response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("mint returned %d: %s", resp.StatusCode, respBody) + } + + var mintResp mintTokenResponse + if err := json.Unmarshal(respBody, &mintResp); err != nil { + return "", fmt.Errorf("decoding mint response: %w", err) + } + if mintResp.Token == "" { + return "", fmt.Errorf("mint returned empty token") + } + return mintResp.Token, nil +} + +// tokenForOrg returns an API token for operating on a pool org. +func tokenForOrg(ctx context.Context, cfg envConfig, org string) (string, error) { + if cfg.useMint { + return resolveE2EToken(ctx, cfg.mintURL, org, []string{lockRepo, testRepo}) + } + return resolveLocalToken() +} diff --git a/e2e/admin/lock_test.go b/e2e/admin/lock_test.go index b28520d5eb..f15e790561 100644 --- a/e2e/admin/lock_test.go +++ b/e2e/admin/lock_test.go @@ -70,7 +70,7 @@ func TestAcquireOrg_FirstOrgAvailable(t *testing.T) { pool := []string{"test-org-1", "test-org-2", "test-org-3"} - org, err := acquireOrg(ctx, fake, "", "run-1", pool, 5*time.Second, t.Logf) + org, err := acquireOrgWithClient(ctx, fake, "", "run-1", pool, 5*time.Second, t.Logf) require.NoError(t, err) assert.Contains(t, pool, org, "should acquire one of the pool orgs") @@ -93,7 +93,7 @@ func TestAcquireOrg_SkipsLockedOrg(t *testing.T) { }) fake.FileContents["test-org-1/"+lockRepo+"/README.md"] = []byte("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") - org, err := acquireOrg(ctx, fake, "", "run-2", pool, 5*time.Second, t.Logf) + org, err := acquireOrgWithClient(ctx, fake, "", "run-2", pool, 5*time.Second, t.Logf) require.NoError(t, err) assert.NotEqual(t, "test-org-1", org, "should skip locked test-org-1") assert.Contains(t, []string{"test-org-2", "test-org-3"}, org, "should acquire an unlocked org") @@ -116,7 +116,7 @@ func TestAcquireOrg_AllLockedTimesOut(t *testing.T) { } // Use a very short timeout so the test doesn't block. - _, err := acquireOrg(ctx, fake, "", "run-3", pool, 1*time.Second, t.Logf) + _, err := acquireOrgWithClient(ctx, fake, "", "run-3", pool, 1*time.Second, t.Logf) require.Error(t, err) assert.Contains(t, err.Error(), "could not acquire any org") } @@ -132,6 +132,6 @@ func TestAcquireOrg_PropagatesErrors(t *testing.T) { // The error from tryCreateLock should be logged and the function // should fall through to the timeout path. - _, err := acquireOrg(ctx, fake, "", "run-4", pool, 1*time.Second, t.Logf) + _, err := acquireOrgWithClient(ctx, fake, "", "run-4", pool, 1*time.Second, t.Logf) require.Error(t, err) } diff --git a/e2e/admin/login.go b/e2e/admin/login.go deleted file mode 100644 index d645b01895..0000000000 --- a/e2e/admin/login.go +++ /dev/null @@ -1,165 +0,0 @@ -//go:build e2e - -package admin - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/fullsend-ai/fullsend/e2e/internal/otp" - "github.com/playwright-community/playwright-go" -) - -// verifyGitHubSession checks that the browser context has a valid GitHub -// session by navigating to a page that requires authentication. If the -// session is expired or invalid, it returns an error. -func verifyGitHubSession(page playwright.Page, screenshotDir string, logf func(string, ...any)) error { - if _, err := page.Goto("https://github.com/settings/profile", playwright.PageGotoOptions{ - WaitUntil: playwright.WaitUntilStateDomcontentloaded, - Timeout: playwright.Float(15000), - }); err != nil { - return fmt.Errorf("navigating to settings/profile: %w", err) - } - - url := page.URL() - logf("[session] Verification URL: %s", url) - - if strings.Contains(url, "/login") || strings.Contains(url, "/session") { - saveDebugScreenshot(page, screenshotDir, "session-expired", logf) - return fmt.Errorf("session is not authenticated: navigating to /settings/profile redirected to %s\n\nThe stored browser session has expired. To fix:\n 1. make e2e-export-session # re-login and export a fresh session\n 2. make e2e-upload-session # export + upload to GitHub secret", url) - } - - logf("[session] Session is valid") - return nil -} - -// handleSudoIfPresent detects GitHub's "Confirm access" sudo page and -// enters the password (or TOTP code if 2FA is enabled) to proceed. -// GitHub requires sudo confirmation when accessing sensitive settings pages -// (token management, app settings) even with a valid session. -// Returns true if sudo was handled. -func handleSudoIfPresent(page playwright.Page, password, totpSecret, screenshotDir string, logf func(string, ...any)) (bool, error) { - pageTitle, _ := page.Title() - if !strings.Contains(pageTitle, "Confirm access") && !strings.Contains(pageTitle, "Sudo") { - return false, nil - } - - logf("[sudo] Detected sudo confirmation page (title: %s)", pageTitle) - - // GitHub may show a password field or a TOTP field (or both with a toggle). - // Try password first, fall back to TOTP. - passwordInput := page.Locator("#sudo_password") - passwordVisible := passwordInput.WaitFor(playwright.LocatorWaitForOptions{ - State: playwright.WaitForSelectorStateVisible, - Timeout: playwright.Float(2000), - }) == nil - - if passwordVisible && password != "" { - if err := passwordInput.Fill(password); err != nil { - return false, fmt.Errorf("filling sudo password: %w", err) - } - - confirmBtn := page.Locator("button[type='submit']:has-text('Confirm'), button[type='submit']:has-text('Confirm password'), button[type='submit']") - if err := confirmBtn.First().Click(playwright.LocatorClickOptions{ - Timeout: playwright.Float(5000), - }); err != nil { - saveDebugScreenshot(page, screenshotDir, "sudo-confirm-click-failed", logf) - return false, fmt.Errorf("clicking sudo confirm button: %w", err) - } - - if err := waitForPageToLeave(page, "Confirm access", "Sudo"); err != nil { - if totpSecret != "" { - logf("[sudo] Password did not clear sudo page, falling back to TOTP") - if handled, totpErr := handleTOTPIfPresent(page, totpSecret, screenshotDir, logf); totpErr != nil { - return false, fmt.Errorf("TOTP fallback after password failed: %w", totpErr) - } else if handled { - if err := waitForPageToLeave(page, "Confirm access", "Sudo"); err != nil { - saveDebugScreenshot(page, screenshotDir, "sudo-totp-fallback-still-on-page", logf) - return false, err - } - logf("[sudo] Sudo confirmation succeeded via TOTP fallback") - return true, nil - } - } - saveDebugScreenshot(page, screenshotDir, "sudo-still-on-page", logf) - return false, err - } - - logf("[sudo] Sudo confirmation succeeded via password") - return true, nil - } else if passwordVisible && totpSecret != "" { - if handled, err := handleTOTPIfPresent(page, totpSecret, screenshotDir, logf); err != nil { - return false, fmt.Errorf("TOTP on sudo page (password field present but empty): %w", err) - } else if handled { - if err := waitForPageToLeave(page, "Confirm access", "Sudo"); err != nil { - saveDebugScreenshot(page, screenshotDir, "sudo-totp-still-on-page", logf) - return false, err - } - return true, nil - } - saveDebugScreenshot(page, screenshotDir, "sudo-password-not-set", logf) - return false, fmt.Errorf("sudo page shows password field but neither password nor TOTP succeeded") - } else if passwordVisible { - saveDebugScreenshot(page, screenshotDir, "sudo-password-not-set", logf) - return false, fmt.Errorf("sudo page shows password field but E2E_GITHUB_PASSWORD is not set") - } else if totpSecret != "" { - if handled, err := handleTOTPIfPresent(page, totpSecret, screenshotDir, logf); err != nil { - return false, fmt.Errorf("TOTP on sudo page: %w", err) - } else if !handled { - saveDebugScreenshot(page, screenshotDir, "sudo-no-auth-method", logf) - return false, fmt.Errorf("sudo page has no visible password or TOTP field") - } - if err := waitForPageToLeave(page, "Confirm access", "Sudo"); err != nil { - saveDebugScreenshot(page, screenshotDir, "sudo-totp-still-on-page", logf) - return false, err - } - return true, nil - } else { - saveDebugScreenshot(page, screenshotDir, "sudo-no-credentials", logf) - return false, fmt.Errorf("sudo confirmation required but no password or TOTP secret available — set E2E_GITHUB_PASSWORD or E2E_GITHUB_TOTP_SECRET") - } -} - -// handleTOTPIfPresent detects a GitHub 2FA/TOTP input on the current page -// and fills in a generated code. Works on both the post-login 2FA page -// (/sessions/two-factor) and the sudo TOTP prompt. Returns true if a TOTP -// form was found and submitted. -func handleTOTPIfPresent(page playwright.Page, totpSecret, screenshotDir string, logf func(string, ...any)) (bool, error) { - handled, err := otp.EnterTOTPCode(page, totpSecret, logf) - if err != nil { - saveDebugScreenshot(page, screenshotDir, "totp-failed", logf) - } - return handled, err -} - -// waitForPageToLeave waits until the page title no longer contains any of -// the given substrings, or until the timeout (10s) is reached. -func waitForPageToLeave(page playwright.Page, titleSubstrings ...string) error { - checks := make([]string, len(titleSubstrings)) - for i, sub := range titleSubstrings { - checks[i] = fmt.Sprintf("!document.title.includes(%q)", sub) - } - jsExpr := "() => " + strings.Join(checks, " && ") - _, err := page.WaitForFunction(jsExpr, nil, playwright.PageWaitForFunctionOptions{ - Timeout: playwright.Float(10000), - }) - if err != nil { - title, _ := page.Title() - return fmt.Errorf("still on page after 10s (title: %s)", title) - } - return nil -} - -// saveDebugScreenshot saves a screenshot to dir for debugging. -func saveDebugScreenshot(page playwright.Page, dir, name string, logf func(string, ...any)) { - path := filepath.Join(dir, fmt.Sprintf("e2e-debug-%s.png", name)) - if _, err := page.Screenshot(playwright.PageScreenshotOptions{ - Path: playwright.String(path), - FullPage: playwright.Bool(true), - }); err != nil { - logf("[debug] Could not save screenshot %s: %v", path, err) - return - } - logf("[debug] Screenshot saved: %s", path) -} diff --git a/e2e/admin/pat.go b/e2e/admin/pat.go deleted file mode 100644 index 7e72558ab3..0000000000 --- a/e2e/admin/pat.go +++ /dev/null @@ -1,170 +0,0 @@ -//go:build e2e - -package admin - -import ( - "fmt" - "strings" - - "github.com/playwright-community/playwright-go" -) - -// patScopes are the classic PAT scopes needed for e2e tests. -var patScopes = []string{ - "repo", - "admin:org", - "delete_repo", - "workflow", -} - -// createPAT creates a classic GitHub Personal Access Token via the browser. -// The token is created with a 7-day expiry and the scopes needed for e2e tests. -// Returns the token string. -func createPAT(page playwright.Page, note, password, totpSecret, screenshotDir string, logf func(string, ...any)) (string, error) { - url := "https://github.com/settings/tokens/new" - if _, err := page.Goto(url, playwright.PageGotoOptions{ - WaitUntil: playwright.WaitUntilStateDomcontentloaded, - Timeout: playwright.Float(7500), - }); err != nil { - logf("[pat] Current URL after navigation failure: %s", page.URL()) - return "", fmt.Errorf("navigating to token creation page: %w", err) - } - logf("[pat] Navigated to: %s", page.URL()) - - // If we got redirected to login, the session isn't valid. - if strings.Contains(page.URL(), "/login") { - pageTitle, _ := page.Title() - logf("[pat] ERROR: redirected to login page. Title: %s", pageTitle) - return "", fmt.Errorf("redirected to login when accessing token page (URL: %s) — session is not authenticated", page.URL()) - } - - // Handle sudo confirmation if GitHub requires re-authentication. - if handled, err := handleSudoIfPresent(page, password, totpSecret, screenshotDir, logf); err != nil { - return "", fmt.Errorf("sudo confirmation for PAT creation: %w", err) - } else if handled { - // After sudo, we may need to re-navigate to the token page. - if _, err := page.Goto(url, playwright.PageGotoOptions{ - WaitUntil: playwright.WaitUntilStateDomcontentloaded, - Timeout: playwright.Float(7500), - }); err != nil { - return "", fmt.Errorf("re-navigating to token page after sudo: %w", err) - } - } - - // Verify we're on the right page. - if err := page.Locator("#oauth_access_description").WaitFor(playwright.LocatorWaitForOptions{ - Timeout: playwright.Float(5000), - }); err != nil { - pageTitle, _ := page.Title() - pageURL := page.URL() - logf("[pat] ERROR: form not found. URL=%s Title=%s", pageURL, pageTitle) - return "", fmt.Errorf("token creation form not found at %s (title: %s): %w", pageURL, pageTitle, err) - } - - // Fill in the token note/description. - if err := page.Locator("#oauth_access_description").Fill(note); err != nil { - return "", fmt.Errorf("filling token note: %w", err) - } - - // Set expiration to 7 days. - expirationSelect := page.Locator("#token_expiration") - if _, err := expirationSelect.SelectOption(playwright.SelectOptionValues{ - Values: playwright.StringSlice("seven_days"), - }, playwright.LocatorSelectOptionOptions{ - Timeout: playwright.Float(5000), - }); err != nil { - logf("[pat] Warning: could not set expiration, using default: %v", err) - } - - // Check the required scope checkboxes. - for _, scope := range patScopes { - checkbox := page.Locator(fmt.Sprintf("input[type='checkbox'][value='%s']", scope)) - if err := checkbox.Check(); err != nil { - return "", fmt.Errorf("checking scope %s: %w", scope, err) - } - } - - // Click "Generate token". - generateBtn := page.Locator("button:has-text('Generate token')") - if err := generateBtn.Click(); err != nil { - return "", fmt.Errorf("clicking Generate token: %w", err) - } - - // Wait for the page to load with the new token displayed. - if err := page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{ - State: playwright.LoadStateDomcontentloaded, - }); err != nil { - return "", fmt.Errorf("waiting for token page to load: %w", err) - } - - // Extract the token value. - tokenElement := page.Locator("#new-oauth-token") - if err := tokenElement.WaitFor(playwright.LocatorWaitForOptions{ - Timeout: playwright.Float(5000), - }); err != nil { - return "", fmt.Errorf("token element not found on page: %w", err) - } - - token, err := tokenElement.TextContent() - if err != nil { - return "", fmt.Errorf("extracting token text: %w", err) - } - - if token == "" { - return "", fmt.Errorf("extracted token is empty") - } - - logf("[pat] Created PAT: %s**** (note: %s)", token[:4], note) - return token, nil -} - -// deletePAT deletes a classic GitHub PAT by navigating to the tokens page -// and clicking delete for the token matching the given note. -func deletePAT(page playwright.Page, note string, logf func(string, ...any)) error { - if _, err := page.Goto("https://github.com/settings/tokens", playwright.PageGotoOptions{ - WaitUntil: playwright.WaitUntilStateDomcontentloaded, - Timeout: playwright.Float(7500), - }); err != nil { - return fmt.Errorf("navigating to tokens page: %w", err) - } - - // Find the row containing our token note and click its delete button. - tokenRow := page.Locator(fmt.Sprintf("a:has-text('%s')", note)).Locator("xpath=ancestor::div[contains(@class, 'list-group-item')]") - - // Wait for the token row to appear. - if err := tokenRow.WaitFor(playwright.LocatorWaitForOptions{ - Timeout: playwright.Float(5000), - State: playwright.WaitForSelectorStateVisible, - }); err != nil { - logf("[pat] Token %q not found on page, may already be deleted", note) - return nil - } - - deleteBtn := tokenRow.Locator("button:has-text('Delete')") - if err := deleteBtn.Click(); err != nil { - return fmt.Errorf("clicking delete for token %q: %w", note, err) - } - - // Wait for confirmation button in the modal. - confirmBtn := page.Locator("button:has-text('I understand, delete this token')") - if err := confirmBtn.WaitFor(playwright.LocatorWaitForOptions{ - State: playwright.WaitForSelectorStateVisible, - Timeout: playwright.Float(5000), - }); err != nil { - return fmt.Errorf("waiting for deletion confirmation for %q: %w", note, err) - } - if err := confirmBtn.Click(playwright.LocatorClickOptions{ - Timeout: playwright.Float(5000), - }); err != nil { - return fmt.Errorf("confirming token deletion for %q: %w", note, err) - } - - if err := page.WaitForLoadState(playwright.PageWaitForLoadStateOptions{ - State: playwright.LoadStateDomcontentloaded, - }); err != nil { - return fmt.Errorf("waiting for deletion to complete: %w", err) - } - - logf("[pat] Deleted PAT: %s", note) - return nil -} diff --git a/e2e/admin/testutil.go b/e2e/admin/testutil.go index b19d46330b..14aeeeeec6 100644 --- a/e2e/admin/testutil.go +++ b/e2e/admin/testutil.go @@ -58,9 +58,8 @@ var orgPool = []string{ } // acquireOrg scans the pool for an unlocked org and acquires its lock. -// If all orgs are locked, it round-robin polls until one frees up or the -// timeout expires. Returns the org name. -func acquireOrg(ctx context.Context, client forge.Client, token, runID string, pool []string, timeout time.Duration, logf func(string, ...any)) (string, error) { +// Returns the org name and token used to hold the lock. +func acquireOrg(ctx context.Context, cfg envConfig, runID string, pool []string, timeout time.Duration, logf func(string, ...any)) (string, string, error) { // Shuffle the pool so concurrent runners don't all compete for the // same first org (thundering herd). shuffled := make([]string, len(pool)) @@ -72,6 +71,13 @@ func acquireOrg(ctx context.Context, client forge.Client, token, runID string, p // waste pool capacity on crashed runs. for _, org := range shuffled { logf("[org-pool] Trying to acquire %s...", org) + token, tokErr := tokenForOrg(ctx, cfg, org) + if tokErr != nil { + logf("[org-pool] Could not get token for %s: %v", org, tokErr) + continue + } + client := newLiveClient(token) + acquired, err := tryCreateLock(ctx, client, org, runID, logf) if err != nil { logf("[org-pool] Error trying %s: %v", org, err) @@ -82,19 +88,19 @@ func acquireOrg(ctx context.Context, client forge.Client, token, runID string, p if token != "" && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnprocessableEntity { logf("[org-pool] 422 on %s — will check for stale lock", org) if reclaimed := tryReclaimStaleLock(ctx, client, token, org, runID, logf); reclaimed { - return org, nil + return org, token, nil } } continue } if acquired { logf("[org-pool] Acquired %s", org) - return org, nil + return org, token, nil } // Lock exists — check if it's stale and force-acquire if so. if token != "" { if reclaimed := tryReclaimStaleLock(ctx, client, token, org, runID, logf); reclaimed { - return org, nil + return org, token, nil } } logf("[org-pool] %s is locked, trying next", org) @@ -113,9 +119,16 @@ func acquireOrg(ctx context.Context, client forge.Client, token, runID string, p select { case <-time.After(wait): case <-ctx.Done(): - return "", ctx.Err() + return "", "", ctx.Err() } for _, org := range shuffled { + token, tokErr := tokenForOrg(ctx, cfg, org) + if tokErr != nil { + logf("[org-pool] Could not get token for %s: %v", org, tokErr) + continue + } + client := newLiveClient(token) + acquired, err := tryCreateLock(ctx, client, org, runID, logf) if err != nil { logf("[org-pool] Error trying %s: %v", org, err) @@ -123,26 +136,96 @@ func acquireOrg(ctx context.Context, client forge.Client, token, runID string, p if token != "" && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnprocessableEntity { logf("[org-pool] 422 on %s — will check for stale lock", org) if reclaimed := tryReclaimStaleLock(ctx, client, token, org, runID, logf); reclaimed { - return org, nil + return org, token, nil } } continue } if acquired { logf("[org-pool] Acquired %s", org) - return org, nil + return org, token, nil } // Also try stale reclaim during polling — a lock may // have aged past staleLockTimeout since the first pass. if token != "" { if reclaimed := tryReclaimStaleLock(ctx, client, token, org, runID, logf); reclaimed { - return org, nil + return org, token, nil } } } } - return "", fmt.Errorf("could not acquire any org from pool after %s (tried %d orgs)", timeout, len(pool)) + return "", "", fmt.Errorf("could not acquire any org from pool after %s (tried %d orgs)", timeout, len(pool)) +} + +// acquireOrgWithClient runs pool acquisition using a fixed client (unit tests). +func acquireOrgWithClient(ctx context.Context, client forge.Client, token, runID string, pool []string, timeout time.Duration, logf func(string, ...any)) (string, error) { + org, _, err := acquireOrgFromClient(ctx, client, token, runID, pool, timeout, logf) + return org, err +} + +func acquireOrgFromClient(ctx context.Context, client forge.Client, token, runID string, pool []string, timeout time.Duration, logf func(string, ...any)) (string, string, error) { + shuffled := make([]string, len(pool)) + copy(shuffled, pool) + rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) + + for _, org := range shuffled { + logf("[org-pool] Trying to acquire %s...", org) + acquired, err := tryCreateLock(ctx, client, org, runID, logf) + if err != nil { + logf("[org-pool] Error trying %s: %v", org, err) + var apiErr *gh.APIError + if token != "" && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnprocessableEntity { + if reclaimed := tryReclaimStaleLock(ctx, client, token, org, runID, logf); reclaimed { + return org, token, nil + } + } + continue + } + if acquired { + return org, token, nil + } + if token != "" { + if reclaimed := tryReclaimStaleLock(ctx, client, token, org, runID, logf); reclaimed { + return org, token, nil + } + } + } + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + wait := min(lockPollInterval, remaining) + select { + case <-time.After(wait): + case <-ctx.Done(): + return "", "", ctx.Err() + } + for _, org := range shuffled { + acquired, err := tryCreateLock(ctx, client, org, runID, logf) + if err != nil { + var apiErr *gh.APIError + if token != "" && errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnprocessableEntity { + if reclaimed := tryReclaimStaleLock(ctx, client, token, org, runID, logf); reclaimed { + return org, token, nil + } + } + continue + } + if acquired { + return org, token, nil + } + if token != "" { + if reclaimed := tryReclaimStaleLock(ctx, client, token, org, runID, logf); reclaimed { + return org, token, nil + } + } + } + } + return "", "", fmt.Errorf("could not acquire any org from pool after %s (tried %d orgs)", timeout, len(pool)) } // defaultRoles is the standard set of agent roles. @@ -153,10 +236,8 @@ const e2eAppSet = "fullsend-ai" // envConfig holds required environment configuration. type envConfig struct { - sessionFile string - password string - totpSecret string mintURL string + useMint bool gcpProjectID string lockTimeout time.Duration } @@ -167,20 +248,15 @@ type envConfig struct { func loadEnvConfig(t *testing.T) envConfig { t.Helper() - sessionFile := os.Getenv("E2E_GITHUB_SESSION_FILE") - if sessionFile == "" { - t.Skip("E2E_GITHUB_SESSION_FILE not set, skipping e2e test") - } - if _, err := os.Stat(sessionFile); err != nil { - t.Fatalf("E2E_GITHUB_SESSION_FILE %q does not exist: %v", sessionFile, err) - } - - password := os.Getenv("E2E_GITHUB_PASSWORD") - totpSecret := os.Getenv("E2E_GITHUB_TOTP_SECRET") - mintURL := os.Getenv("E2E_MINT_URL") - if mintURL == "" { - t.Skip("E2E_MINT_URL not set, skipping e2e test") + useMint := runningInGitHubActions() + if useMint && mintURL == "" { + t.Skip("E2E_MINT_URL not set, skipping e2e test in CI") + } + if !useMint { + if _, err := resolveLocalToken(); err != nil { + t.Skip("no local GitHub token (gh auth login), skipping e2e test") + } } gcpProjectID := os.Getenv("E2E_GCP_PROJECT_ID") @@ -195,10 +271,8 @@ func loadEnvConfig(t *testing.T) envConfig { } return envConfig{ - sessionFile: sessionFile, - password: password, - totpSecret: totpSecret, mintURL: mintURL, + useMint: useMint, gcpProjectID: gcpProjectID, lockTimeout: lockTimeout, } diff --git a/e2e/cmd/export-session/main.go b/e2e/cmd/export-session/main.go deleted file mode 100644 index 8f9300903e..0000000000 --- a/e2e/cmd/export-session/main.go +++ /dev/null @@ -1,129 +0,0 @@ -// Command export-session logs into GitHub via Playwright and exports the -// browser session (cookies + localStorage) as a Playwright storageState -// JSON file. This is used to generate pre-authenticated sessions for e2e -// tests that run in CI where password login is blocked. -// -// Required environment variables: -// - E2E_GITHUB_USERNAME: GitHub username -// - E2E_GITHUB_PASSWORD: GitHub password (use `pass` or similar) -// -// Optional environment variables: -// - E2E_GITHUB_TOTP_SECRET: Base32-encoded TOTP secret for 2FA accounts -// -// Output is written to E2E_GITHUB_SESSION_FILE (default: .playwright/session.json). -package main - -import ( - "fmt" - "log" - "os" - "path/filepath" - "strings" - - "github.com/fullsend-ai/fullsend/e2e/internal/otp" - "github.com/playwright-community/playwright-go" -) - -func main() { - username := os.Getenv("E2E_GITHUB_USERNAME") - password := os.Getenv("E2E_GITHUB_PASSWORD") - if username == "" || password == "" { - log.Fatal("Set E2E_GITHUB_USERNAME and E2E_GITHUB_PASSWORD") - } - totpSecret := os.Getenv("E2E_GITHUB_TOTP_SECRET") - - outFile := os.Getenv("E2E_GITHUB_SESSION_FILE") - if outFile == "" { - outFile = filepath.Join(".playwright", "session.json") - } - - if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil { - log.Fatalf("creating output directory: %v", err) - } - - pw, err := playwright.Run() - if err != nil { - log.Fatalf("starting playwright: %v", err) - } - defer pw.Stop() - - browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{ - Headless: playwright.Bool(true), - }) - if err != nil { - log.Fatalf("launching browser: %v", err) - } - defer browser.Close() - - ctx, err := browser.NewContext() - if err != nil { - log.Fatalf("creating context: %v", err) - } - - page, err := ctx.NewPage() - if err != nil { - log.Fatalf("creating page: %v", err) - } - - if _, err := page.Goto("https://github.com/login", playwright.PageGotoOptions{ - WaitUntil: playwright.WaitUntilStateDomcontentloaded, - }); err != nil { - log.Fatalf("navigating to login: %v", err) - } - - // Already logged in? - if !strings.Contains(page.URL(), "/login") && !strings.Contains(page.URL(), "/session") { - fmt.Println("Already logged in") - export(ctx, outFile) - return - } - - if err := page.Locator("#login_field").Fill(username); err != nil { - log.Fatalf("filling username: %v", err) - } - if err := page.Locator("#password").Fill(password); err != nil { - log.Fatalf("filling password: %v", err) - } - if err := page.Locator("input[type='submit'], button[type='submit']").First().Click(); err != nil { - log.Fatalf("clicking submit: %v", err) - } - - if err := page.WaitForURL("https://github.com/**", playwright.PageWaitForURLOptions{ - Timeout: playwright.Float(15000), - }); err != nil { - log.Fatalf("post-login navigation: %v (url: %s)", err, page.URL()) - } - - // Handle 2FA if the account has TOTP enabled. - url := page.URL() - if strings.Contains(url, "/two-factor") || strings.Contains(url, "/2fa") { - if totpSecret == "" { - log.Fatalf("2FA page detected at %s but E2E_GITHUB_TOTP_SECRET is not set", url) - } - fmt.Println("2FA page detected, entering TOTP code...") - - handled, err := otp.EnterTOTPCode(page, totpSecret, log.Printf) - if err != nil { - log.Fatalf("TOTP submission failed: %v", err) - } - if !handled { - log.Fatalf("2FA page detected but TOTP input not found at %s", url) - } - - url = page.URL() - } - - if strings.Contains(url, "/login") || strings.Contains(url, "/session") { - log.Fatalf("login failed, still at: %s", url) - } - - fmt.Printf("Logged in (URL: %s)\n", url) - export(ctx, outFile) -} - -func export(ctx playwright.BrowserContext, outFile string) { - if _, err := ctx.StorageState(outFile); err != nil { - log.Fatalf("exporting storageState: %v", err) - } - fmt.Printf("Session exported to %s\n", outFile) -} diff --git a/hack/setup-new-e2e-org.sh b/hack/setup-new-e2e-org.sh index a5a420aa8c..69245c1d35 100755 --- a/hack/setup-new-e2e-org.sh +++ b/hack/setup-new-e2e-org.sh @@ -10,7 +10,7 @@ set -euo pipefail APP_SET="fullsend-ai" -ROLES=(fullsend triage coder review retro prioritize) +ROLES=(fullsend triage coder review retro prioritize e2e) BOT_USER="botsend" # open_browser tries to open a URL in the default browser. @@ -175,6 +175,39 @@ for role in "${ROLES[@]}"; do fi done +# --- 4b. cross-org mint authorization for CI --- +echo +echo "==> Checking cross-org mint authorization (FULLSEND_FOREIGN_E2E_REPOS)..." +FOREIGN_VAR="FULLSEND_FOREIGN_E2E_REPOS" +FOREIGN_CALLER="fullsend-ai/fullsend" +foreign_value=$(gh api "/orgs/${ORG}/actions/variables/${FOREIGN_VAR}" --jq '.value' 2>/dev/null || echo "") + +if echo "${foreign_value}" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -qx "${FOREIGN_CALLER}"; then + echo " OK: ${FOREIGN_VAR} lists ${FOREIGN_CALLER}" +else + echo " MISSING: ${FOREIGN_VAR} does not authorize ${FOREIGN_CALLER}" + if command -v fullsend &>/dev/null; then + echo " Running: fullsend admin foreign allow --org ${ORG} --role e2e --caller ${FOREIGN_CALLER}" + fullsend admin foreign allow --org "${ORG}" --role e2e --caller "${FOREIGN_CALLER}" + echo " OK: updated ${FOREIGN_VAR}" + else + new_value="${FOREIGN_CALLER}" + if [[ -n "${foreign_value}" ]]; then + new_value="${foreign_value}, ${FOREIGN_CALLER}" + fi + echo " Setting ${FOREIGN_VAR} via gh api..." + gh api "/orgs/${ORG}/actions/variables/${FOREIGN_VAR}" \ + -X PATCH \ + -f value="${new_value}" 2>/dev/null \ + || gh api "/orgs/${ORG}/actions/variables" \ + -f name="${FOREIGN_VAR}" \ + -f value="${new_value}" \ + -f visibility="all" + echo " OK: updated ${FOREIGN_VAR}" + fi +fi +echo " Verify with: fullsend admin foreign list --org ${ORG}" + # --- 5. check mint enrollment --- MINT_PROJECT="${MINT_PROJECT:?MINT_PROJECT must be set}" MINT_REGION="${MINT_REGION:-us-central1}" diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 0e23ad809d..fddd7954eb 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -48,6 +48,7 @@ func newAdminCmd() *cobra.Command { cmd.AddCommand(newAnalyzeCmd()) cmd.AddCommand(newEnableCmd()) cmd.AddCommand(newDisableCmd()) + cmd.AddCommand(newForeignCmd()) return cmd } diff --git a/internal/cli/foreign.go b/internal/cli/foreign.go new file mode 100644 index 0000000000..2357b1d892 --- /dev/null +++ b/internal/cli/foreign.go @@ -0,0 +1,314 @@ +package cli + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "github.com/spf13/cobra" + + gh "github.com/fullsend-ai/fullsend/internal/forge/github" + "github.com/fullsend-ai/fullsend/internal/mintcore" + "github.com/fullsend-ai/fullsend/internal/ui" +) + +func newForeignCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "foreign", + Short: "Manage cross-org mint authorization on a target org", + Long: "Manage FULLSEND_FOREIGN__REPOS org variables that authorize foreign workflows to mint tokens for this org.", + } + cmd.AddCommand(newForeignAllowCmd()) + cmd.AddCommand(newForeignListCmd()) + cmd.AddCommand(newForeignRevokeCmd()) + return cmd +} + +func newForeignAllowCmd() *cobra.Command { + var org string + cmd := &cobra.Command{ + Use: "allow", + Short: "Authorize a foreign org/repo to mint for a role on this org", + RunE: func(cmd *cobra.Command, args []string) error { + role, err := cmd.Flags().GetString("role") + if err != nil { + return err + } + caller, err := cmd.Flags().GetString("caller") + if err != nil { + return err + } + if org == "" { + return fmt.Errorf("--org is required") + } + if err := validateOrgName(org); err != nil { + return err + } + if err := mintcore.ValidateRoleName(role); err != nil { + return fmt.Errorf("invalid --role: %w", err) + } + if err := validateForeignCaller(caller); err != nil { + return err + } + + token, err := resolveToken() + if err != nil { + return err + } + client := gh.New(token) + printer := ui.New(os.Stdout) + ctx := cmd.Context() + + varName := mintcore.ForeignVariableName(role) + allowlist, err := loadForeignAllowlist(ctx, client, org, varName) + if err != nil { + return err + } + + if containsForeignCaller(allowlist, caller) { + printer.StepDone(fmt.Sprintf("%s already lists %q", varName, caller)) + return nil + } + allowlist = append(allowlist, caller) + value := strings.Join(allowlist, ", ") + + printer.StepStart(fmt.Sprintf("Updating %s on %s", varName, org)) + if err := client.CreateOrUpdateOrgVariable(ctx, org, varName, value, nil); err != nil { + printer.StepFail(fmt.Sprintf("Failed to update %s", varName)) + return err + } + printer.StepDone(fmt.Sprintf("Added %q to %s", caller, varName)) + return nil + }, + } + cmd.Flags().StringVar(&org, "org", "", "Target GitHub organization") + cmd.Flags().String("role", "", "Agent role (e.g. e2e)") + cmd.Flags().String("caller", "", "Foreign caller: org/repo or bare org") + _ = cmd.MarkFlagRequired("role") + _ = cmd.MarkFlagRequired("caller") + return cmd +} + +func newForeignListCmd() *cobra.Command { + var org string + var role string + cmd := &cobra.Command{ + Use: "list", + Short: "List foreign caller allowlists on an org", + RunE: func(cmd *cobra.Command, args []string) error { + if org == "" { + return fmt.Errorf("--org is required") + } + if err := validateOrgName(org); err != nil { + return err + } + if role != "" { + if err := mintcore.ValidateRoleName(role); err != nil { + return fmt.Errorf("invalid --role: %w", err) + } + } + + token, err := resolveToken() + if err != nil { + return err + } + client := gh.New(token) + printer := ui.New(os.Stdout) + ctx := cmd.Context() + + if role != "" { + varName := mintcore.ForeignVariableName(role) + allowlist, err := loadForeignAllowlist(ctx, client, org, varName) + if err != nil { + return err + } + if len(allowlist) == 0 { + printer.StepInfo(fmt.Sprintf("%s: (not set)", varName)) + return nil + } + printer.StepInfo(fmt.Sprintf("%s:", varName)) + for _, entry := range allowlist { + printer.StepInfo(fmt.Sprintf(" - %s", entry)) + } + return nil + } + + vars, err := client.ListOrgVariables(ctx, org) + if err != nil { + return err + } + var foreign []struct { + role string + allowlist []string + } + for _, v := range vars { + roleName, ok := parseForeignVariableName(v.Name) + if !ok { + continue + } + foreign = append(foreign, struct { + role string + allowlist []string + }{role: roleName, allowlist: mintcore.ParseForeignAllowlist(v.Value)}) + } + if len(foreign) == 0 { + printer.StepInfo("No FULLSEND_FOREIGN_* variables found") + return nil + } + sort.Slice(foreign, func(i, j int) bool { return foreign[i].role < foreign[j].role }) + for _, entry := range foreign { + printer.StepInfo(fmt.Sprintf("%s:", mintcore.ForeignVariableName(entry.role))) + if len(entry.allowlist) == 0 { + printer.StepInfo(" (empty)") + continue + } + for _, caller := range entry.allowlist { + printer.StepInfo(fmt.Sprintf(" - %s", caller)) + } + } + return nil + }, + } + cmd.Flags().StringVar(&org, "org", "", "Target GitHub organization") + cmd.Flags().StringVar(&role, "role", "", "Filter to a single role (optional)") + return cmd +} + +func newForeignRevokeCmd() *cobra.Command { + var org string + cmd := &cobra.Command{ + Use: "revoke", + Short: "Remove a foreign caller from a role allowlist", + RunE: func(cmd *cobra.Command, args []string) error { + role, err := cmd.Flags().GetString("role") + if err != nil { + return err + } + caller, err := cmd.Flags().GetString("caller") + if err != nil { + return err + } + if org == "" { + return fmt.Errorf("--org is required") + } + if err := validateOrgName(org); err != nil { + return err + } + if err := mintcore.ValidateRoleName(role); err != nil { + return fmt.Errorf("invalid --role: %w", err) + } + if err := validateForeignCaller(caller); err != nil { + return err + } + + token, err := resolveToken() + if err != nil { + return err + } + client := gh.New(token) + printer := ui.New(os.Stdout) + ctx := cmd.Context() + + varName := mintcore.ForeignVariableName(role) + allowlist, err := loadForeignAllowlist(ctx, client, org, varName) + if err != nil { + return err + } + + updated, changed := removeForeignCaller(allowlist, caller) + if !changed { + printer.StepInfo(fmt.Sprintf("%q not in %s", caller, varName)) + return nil + } + + printer.StepStart(fmt.Sprintf("Updating %s on %s", varName, org)) + if len(updated) == 0 { + if err := client.DeleteOrgVariable(ctx, org, varName); err != nil { + printer.StepFail(fmt.Sprintf("Failed to delete %s", varName)) + return err + } + printer.StepDone(fmt.Sprintf("Removed %q; deleted empty %s", caller, varName)) + return nil + } + value := strings.Join(updated, ", ") + if err := client.CreateOrUpdateOrgVariable(ctx, org, varName, value, nil); err != nil { + printer.StepFail(fmt.Sprintf("Failed to update %s", varName)) + return err + } + printer.StepDone(fmt.Sprintf("Removed %q from %s", caller, varName)) + return nil + }, + } + cmd.Flags().StringVar(&org, "org", "", "Target GitHub organization") + cmd.Flags().String("role", "", "Agent role (e.g. e2e)") + cmd.Flags().String("caller", "", "Foreign caller to remove") + _ = cmd.MarkFlagRequired("role") + _ = cmd.MarkFlagRequired("caller") + return cmd +} + +func loadForeignAllowlist(ctx context.Context, client *gh.LiveClient, org, varName string) ([]string, error) { + value, exists, err := client.GetOrgVariable(ctx, org, varName) + if err != nil { + return nil, err + } + if !exists { + return nil, nil + } + return mintcore.ParseForeignAllowlist(value), nil +} + +func parseForeignVariableName(name string) (role string, ok bool) { + const prefix = "FULLSEND_FOREIGN_" + const suffix = "_REPOS" + if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, suffix) { + return "", false + } + role = strings.ToLower(strings.TrimSuffix(strings.TrimPrefix(name, prefix), suffix)) + if err := mintcore.ValidateRoleName(role); err != nil { + return "", false + } + return role, true +} + +func validateForeignCaller(caller string) error { + caller = strings.TrimSpace(caller) + if caller == "" { + return fmt.Errorf("--caller must not be empty") + } + if strings.Contains(caller, "/") { + parts := strings.SplitN(caller, "/", 2) + if err := validateOrgName(parts[0]); err != nil { + return fmt.Errorf("invalid org in --caller: %w", err) + } + if parts[1] == "" || !githubRepoPattern.MatchString(parts[1]) { + return fmt.Errorf("invalid repo in --caller %q", caller) + } + return nil + } + return validateOrgName(caller) +} + +func containsForeignCaller(allowlist []string, caller string) bool { + for _, entry := range allowlist { + if strings.EqualFold(entry, caller) { + return true + } + } + return false +} + +func removeForeignCaller(allowlist []string, caller string) ([]string, bool) { + var out []string + changed := false + for _, entry := range allowlist { + if strings.EqualFold(entry, caller) { + changed = true + continue + } + out = append(out, entry) + } + return out, changed +} diff --git a/internal/cli/foreign_test.go b/internal/cli/foreign_test.go new file mode 100644 index 0000000000..06b1328bac --- /dev/null +++ b/internal/cli/foreign_test.go @@ -0,0 +1,36 @@ +package cli + +import "testing" + +func TestParseForeignVariableName(t *testing.T) { + role, ok := parseForeignVariableName("FULLSEND_FOREIGN_E2E_REPOS") + if !ok || role != "e2e" { + t.Fatalf("got role=%q ok=%v", role, ok) + } + if _, ok := parseForeignVariableName("FULLSEND_MINT_URL"); ok { + t.Fatal("expected non-foreign name to fail") + } +} + +func TestValidateForeignCaller(t *testing.T) { + if err := validateForeignCaller("fullsend-ai/fullsend"); err != nil { + t.Fatalf("org/repo: %v", err) + } + if err := validateForeignCaller("fullsend-ai"); err != nil { + t.Fatalf("bare org: %v", err) + } + if err := validateForeignCaller("bad org/repo"); err == nil { + t.Fatal("expected invalid caller") + } +} + +func TestForeignAllowRevoke(t *testing.T) { + list := []string{"a/b", "c"} + if !containsForeignCaller(list, "a/b") { + t.Fatal("expected contains") + } + updated, changed := removeForeignCaller(list, "a/b") + if !changed || len(updated) != 1 || updated[0] != "c" { + t.Fatalf("got %v changed=%v", updated, changed) + } +} diff --git a/internal/cli/github.go b/internal/cli/github.go index ed695b7213..c628763423 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -16,6 +16,7 @@ import ( "github.com/fullsend-ai/fullsend/internal/inference" "github.com/fullsend-ai/fullsend/internal/inference/vertex" "github.com/fullsend-ai/fullsend/internal/layers" + "github.com/fullsend-ai/fullsend/internal/mintcore" "github.com/fullsend-ai/fullsend/internal/scaffold" "github.com/fullsend-ai/fullsend/internal/ui" ) @@ -762,6 +763,18 @@ func runGitHubStatus(ctx context.Context, client forge.Client, printer *ui.Print printer.StepFail("FULLSEND_MINT_URL org variable not found") } + vars, err := client.ListOrgVariables(ctx, org) + if err != nil { + printer.StepWarn("Could not list org variables: " + err.Error()) + } else { + for _, v := range vars { + if role, ok := parseForeignVariableName(v.Name); ok { + entries := mintcore.ParseForeignAllowlist(v.Value) + printer.StepDone(fmt.Sprintf("%s (%s): %s", v.Name, role, strings.Join(entries, ", "))) + } + } + } + // Check inference secrets on .fullsend repo. inferenceSecrets := []string{"FULLSEND_GCP_PROJECT_ID", "FULLSEND_GCP_WIF_PROVIDER"} for _, name := range inferenceSecrets { diff --git a/internal/dispatch/gcf/mintsrc/mintcore/foreign.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/foreign.go.embed new file mode 100644 index 0000000000..4e2b6e999f --- /dev/null +++ b/internal/dispatch/gcf/mintsrc/mintcore/foreign.go.embed @@ -0,0 +1,58 @@ +package mintcore + +import ( + "fmt" + "strings" +) + +const ( + foreignVarPrefix = "FULLSEND_FOREIGN_" + foreignVarSuffix = "_REPOS" +) + +// ForeignVariableName returns the org variable name for cross-org allowlist policy. +func ForeignVariableName(role string) string { + return foreignVarPrefix + strings.ToUpper(role) + foreignVarSuffix +} + +// ParseForeignAllowlist splits a comma-separated FOREIGN variable value into entries. +func ParseForeignAllowlist(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + var out []string + for _, entry := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(entry); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// CallerAllowed reports whether repository/repositoryOwner matches an allowlist entry. +// Entries with a slash match the repository claim exactly; bare org names match repository_owner. +func CallerAllowed(allowlist []string, repository, repositoryOwner string) bool { + for _, entry := range allowlist { + if strings.Contains(entry, "/") { + if strings.EqualFold(entry, repository) { + return true + } + } else if strings.EqualFold(entry, repositoryOwner) { + return true + } + } + return false +} + +// foreignCacheKey builds a cache key for target org + role policy lookups. +func foreignCacheKey(targetOrg, role string) string { + return strings.ToLower(targetOrg) + "/" + strings.ToLower(role) +} + +// validateTargetOrg checks target_org when cross-org mint is requested. +func validateTargetOrg(targetOrg string) error { + if err := ValidateOrgName(targetOrg); err != nil { + return fmt.Errorf("invalid target_org: %w", err) + } + return nil +} diff --git a/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed index d2077a6d19..8a141d3930 100644 --- a/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed +++ b/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed @@ -64,6 +64,12 @@ var canonicalRolePermissions = map[string]map[string]string{ "retro": {"actions": "read", "contents": "read", "pull_requests": "write", "issues": "write", "metadata": "read"}, "prioritize": {"contents": "read", "issues": "write", "organization_projects": "write", "metadata": "read"}, "fullsend": {"actions": "write", "actions_variables": "read", "contents": "write", "pull_requests": "write", "workflows": "write", "metadata": "read"}, + "e2e": { + "actions": "write", "actions_variables": "read", "administration": "write", + "contents": "write", "issues": "write", "members": "write", "metadata": "read", + "organization_administration": "write", "pull_requests": "write", + "secrets": "write", "workflows": "write", + }, } // RolePermissions returns a deep copy of the role-to-permissions map, @@ -196,6 +202,143 @@ func FindInstallation(ctx context.Context, httpClient HTTPDoer, githubBaseURL, j return inst.ID, nil } +// FindOrgInstallation looks up a GitHub App's installation ID for an organization. +func FindOrgInstallation(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt, org string) (int64, error) { + reqURL := fmt.Sprintf("%s/orgs/%s/installation", githubBaseURL, org) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return 0, fmt.Errorf("creating org installation request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("getting org installation: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return 0, fmt.Errorf("getting org installation for %s returned status %d", org, resp.StatusCode) + } + + var inst installationResponse + if err := json.NewDecoder(resp.Body).Decode(&inst); err != nil { + return 0, fmt.Errorf("decoding org installation: %w", err) + } + + if inst.ID == 0 { + return 0, fmt.Errorf("no installation found for org %s", org) + } + + if !strings.EqualFold(inst.Account.Login, org) { + return 0, fmt.Errorf("installation for org %s belongs to %s, not %s", + org, inst.Account.Login, org) + } + + return inst.ID, nil +} + +// orgVariableResponse is the response from GET /orgs/{org}/actions/variables/{name}. +type orgVariableResponse struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// GetOrgVariable reads an org-level Actions variable using an installation token. +func GetOrgVariable(ctx context.Context, httpClient HTTPDoer, githubBaseURL, installationToken, org, name string) (value string, exists bool, err error) { + reqURL := fmt.Sprintf("%s/orgs/%s/actions/variables/%s", githubBaseURL, org, name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return "", false, fmt.Errorf("creating org variable request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+installationToken) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := httpClient.Do(req) + if err != nil { + return "", false, fmt.Errorf("getting org variable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return "", false, nil + } + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return "", false, fmt.Errorf("getting org variable %s returned status %d", name, resp.StatusCode) + } + + var varResp orgVariableResponse + if err := json.NewDecoder(resp.Body).Decode(&varResp); err != nil { + return "", false, fmt.Errorf("decoding org variable: %w", err) + } + return varResp.Value, true, nil +} + +// createInstallationTokenWithPermissions creates an installation access token with explicit permissions. +func createInstallationTokenWithPermissions(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, perms map[string]string, repos []string) (string, error) { + tokenReqBody := map[string]interface{}{ + "permissions": perms, + } + if len(repos) > 0 { + tokenReqBody["repositories"] = repos + } + + tokenReqBytes, err := json.Marshal(tokenReqBody) + if err != nil { + return "", fmt.Errorf("marshaling token request: %w", err) + } + + reqURL := fmt.Sprintf("%s/app/installations/%d/access_tokens", githubBaseURL, installationID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(tokenReqBytes)) + if err != nil { + return "", fmt.Errorf("creating token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("creating installation token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return "", fmt.Errorf("creating installation token returned status %d", resp.StatusCode) + } + + var tokenResp installationTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", fmt.Errorf("decoding token response: %w", err) + } + if tokenResp.Token == "" { + return "", fmt.Errorf("empty installation token returned") + } + return tokenResp.Token, nil +} + +// ReadForeignAllowlist reads FULLSEND_FOREIGN__REPOS from the target org. +func ReadForeignAllowlist(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, targetOrg, role string) ([]string, error) { + policyToken, err := createInstallationTokenWithPermissions(ctx, httpClient, githubBaseURL, jwt, installationID, + map[string]string{"actions_variables": "read"}, nil) + if err != nil { + return nil, fmt.Errorf("creating policy check token: %w", err) + } + + value, exists, err := GetOrgVariable(ctx, httpClient, githubBaseURL, policyToken, targetOrg, ForeignVariableName(role)) + if err != nil { + return nil, err + } + if !exists || strings.TrimSpace(value) == "" { + return nil, nil + } + return ParseForeignAllowlist(value), nil +} + // CreateInstallationToken exchanges a JWT for an installation access token, // scoped to the given repos and role-specific permissions. func CreateInstallationToken(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, role string, repos []string) (string, string, *GrantedScope, error) { diff --git a/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed index 04b167aabe..940469d17d 100644 --- a/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed +++ b/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed @@ -11,15 +11,24 @@ import ( "os" "sort" "strings" + "sync" "time" ) const maxRepos = 500 +const defaultForeignCacheTTL = 60 * time.Second + +type foreignCacheEntry struct { + allowlist []string + fetchedAt time.Time +} + // mintRequest is the JSON body sent by .fullsend agent workflows. type mintRequest struct { - Role string `json:"role"` - Repos []string `json:"repos,omitempty"` + Role string `json:"role"` + TargetOrg string `json:"target_org,omitempty"` + Repos []string `json:"repos,omitempty"` } // mintResponse is returned on success. @@ -47,6 +56,10 @@ type Handler struct { roleAppIDs map[string]string allowedRoles []string + + foreignCache map[string]foreignCacheEntry + foreignCacheTTL time.Duration + foreignCacheMu sync.Mutex } // NewHandler creates a Handler with the given dependencies. @@ -59,10 +72,12 @@ func NewHandler(pemAccessor PEMAccessor, oidcVerifier OIDCVerifier) (*Handler, e httpClient := &http.Client{Timeout: 30 * time.Second} h := &Handler{ - httpClient: httpClient, - pemAccessor: pemAccessor, - oidcVerifier: oidcVerifier, - githubBaseURL: "https://api.github.com", + httpClient: httpClient, + pemAccessor: pemAccessor, + oidcVerifier: oidcVerifier, + githubBaseURL: "https://api.github.com", + foreignCache: make(map[string]foreignCacheEntry), + foreignCacheTTL: defaultForeignCacheTTL, } if raw := os.Getenv("ROLE_APP_IDS"); raw != "" { @@ -193,6 +208,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + if req.TargetOrg != "" { + if err := validateTargetOrg(req.TargetOrg); err != nil { + writeError(w, http.StatusBadRequest, "invalid target_org") + return + } + } + ctx := r.Context() claims, err := h.oidcVerifier.Verify(ctx, oidcToken) @@ -202,11 +224,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - org := strings.ToLower(claims.RepositoryOwner) + callerOrg := strings.ToLower(claims.RepositoryOwner) + targetOrg := strings.ToLower(strings.TrimSpace(req.TargetOrg)) + if targetOrg == "" { + targetOrg = callerOrg + } - token, expiresAt, granted, err := h.mintToken(ctx, org, req.Role, req.Repos) + var token, expiresAt string + var granted *GrantedScope + + if strings.EqualFold(targetOrg, callerOrg) { + token, expiresAt, granted, err = h.mintToken(ctx, callerOrg, req.Role, req.Repos) + } else { + token, expiresAt, granted, err = h.mintTokenCrossOrg(ctx, claims, targetOrg, req.Role, req.Repos) + } if err != nil { - log.Printf("failed to mint token: org=%s role=%s err=%v", org, req.Role, err) + log.Printf("failed to mint token: org=%s target_org=%s role=%s err=%v", callerOrg, targetOrg, req.Role, err) var me *mintError if errors.As(err, &me) { writeError(w, me.status, "mint failed") @@ -217,8 +250,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if granted != nil { - log.Printf("minted: org=%s role=%s app_id=%s installation_id=%d requested_repos=%v source_repo=%s workflow_ref=%s", - org, req.Role, granted.AppID, granted.InstallationID, req.Repos, claims.Repository, claims.JobWorkflowRef) + log.Printf("minted: org=%s target_org=%s role=%s app_id=%s installation_id=%d requested_repos=%v source_repo=%s workflow_ref=%s", + callerOrg, targetOrg, req.Role, granted.AppID, granted.InstallationID, req.Repos, claims.Repository, claims.JobWorkflowRef) log.Printf("granted scope: repos=%v permissions=%v repo_selection=%s", granted.Repos, granted.Permissions, granted.RepoSelection) if granted.RepoSelection == "all" { @@ -318,6 +351,69 @@ func (h *Handler) mintToken(ctx context.Context, org, role string, repos []strin return token, expiresAt, granted, nil } +func (h *Handler) mintTokenCrossOrg(ctx context.Context, claims *Claims, targetOrg, role string, repos []string) (string, string, *GrantedScope, error) { + allowlist, err := h.loadForeignAllowlist(ctx, targetOrg, role) + if err != nil { + return "", "", nil, &mintError{status: http.StatusBadGateway, msg: err.Error()} + } + if len(allowlist) == 0 { + return "", "", nil, &mintError{status: http.StatusForbidden, msg: "foreign caller not authorized for target org"} + } + if !CallerAllowed(allowlist, claims.Repository, claims.RepositoryOwner) { + return "", "", nil, &mintError{status: http.StatusForbidden, msg: "foreign caller not authorized for target org"} + } + + return h.mintToken(ctx, targetOrg, role, repos) +} + +func (h *Handler) loadForeignAllowlist(ctx context.Context, targetOrg, role string) ([]string, error) { + key := foreignCacheKey(targetOrg, role) + + h.foreignCacheMu.Lock() + if entry, ok := h.foreignCache[key]; ok && time.Since(entry.fetchedAt) < h.foreignCacheTTL { + allowlist := append([]string(nil), entry.allowlist...) + h.foreignCacheMu.Unlock() + return allowlist, nil + } + h.foreignCacheMu.Unlock() + + appID, err := h.lookupRoleAppID(targetOrg, role) + if err != nil { + return nil, fmt.Errorf("looking up app ID for role %s on %s: %v", role, targetOrg, err) + } + + pemData, err := h.pemAccessor.AccessPEM(ctx, role) + if err != nil { + return nil, fmt.Errorf("reading PEM secret for role %s: %v", role, err) + } + defer func() { + for i := range pemData { + pemData[i] = 0 + } + }() + + jwt, err := GenerateAppJWT(appID, pemData) + if err != nil { + return nil, fmt.Errorf("generating app JWT: %v", err) + } + + installationID, err := FindOrgInstallation(ctx, h.httpClient, h.githubBaseURL, jwt, targetOrg) + if err != nil { + return nil, fmt.Errorf("finding org installation on %s: %v", targetOrg, err) + } + + allowlist, err := ReadForeignAllowlist(ctx, h.httpClient, h.githubBaseURL, jwt, installationID, targetOrg, role) + if err != nil { + return nil, err + } + + h.foreignCacheMu.Lock() + h.foreignCache[key] = foreignCacheEntry{allowlist: append([]string(nil), allowlist...), fetchedAt: time.Now()} + h.foreignCacheMu.Unlock() + + return allowlist, nil +} + func (h *Handler) checkAllowedRole(role string) bool { for _, entry := range h.allowedRoles { if entry == role { diff --git a/internal/dispatch/gcf/provisioner.go b/internal/dispatch/gcf/provisioner.go index 99cc2cbbe6..9299366416 100644 --- a/internal/dispatch/gcf/provisioner.go +++ b/internal/dispatch/gcf/provisioner.go @@ -42,7 +42,7 @@ const ( // ErrFunctionNotFound is returned when the mint function does not exist. var ErrFunctionNotFound = errors.New("mint function not found") -//go:embed mintsrc/go.mod.embed mintsrc/go.sum.embed mintsrc/main.go.embed mintsrc/mintcore/go.mod.embed mintsrc/mintcore/go.sum.embed mintsrc/mintcore/gcp_pem.go.embed mintsrc/mintcore/github.go.embed mintsrc/mintcore/handler.go.embed mintsrc/mintcore/interfaces.go.embed mintsrc/mintcore/jwks_verifier.go.embed mintsrc/mintcore/claims.go.embed mintsrc/mintcore/patterns.go.embed mintsrc/mintcore/sts_verifier.go.embed mintsrc/mintcore/wif.go.embed +//go:embed mintsrc/go.mod.embed mintsrc/go.sum.embed mintsrc/main.go.embed mintsrc/mintcore/go.mod.embed mintsrc/mintcore/go.sum.embed mintsrc/mintcore/gcp_pem.go.embed mintsrc/mintcore/github.go.embed mintsrc/mintcore/handler.go.embed mintsrc/mintcore/foreign.go.embed mintsrc/mintcore/interfaces.go.embed mintsrc/mintcore/jwks_verifier.go.embed mintsrc/mintcore/claims.go.embed mintsrc/mintcore/patterns.go.embed mintsrc/mintcore/sts_verifier.go.embed mintsrc/mintcore/wif.go.embed var embeddedMintSource embed.FS // embeddedMintFiles maps embedded filenames (.embed suffix avoids @@ -57,6 +57,7 @@ var embeddedMintFiles = map[string]string{ "mintcore/gcp_pem.go.embed": "mintcore/gcp_pem.go", "mintcore/github.go.embed": "mintcore/github.go", "mintcore/handler.go.embed": "mintcore/handler.go", + "mintcore/foreign.go.embed": "mintcore/foreign.go", "mintcore/interfaces.go.embed": "mintcore/interfaces.go", "mintcore/jwks_verifier.go.embed": "mintcore/jwks_verifier.go", "mintcore/claims.go.embed": "mintcore/claims.go", diff --git a/internal/dispatch/gcf/provisioner_test.go b/internal/dispatch/gcf/provisioner_test.go index 8660d38bbe..b36d40291c 100644 --- a/internal/dispatch/gcf/provisioner_test.go +++ b/internal/dispatch/gcf/provisioner_test.go @@ -1568,9 +1568,10 @@ func TestBundleEmbeddedMintSource(t *testing.T) { assert.Contains(t, names, "mintcore/sts_verifier.go") assert.Contains(t, names, "mintcore/wif.go") assert.Contains(t, names, "mintcore/handler.go") + assert.Contains(t, names, "mintcore/foreign.go") assert.Contains(t, names, "mintcore/interfaces.go") assert.Contains(t, names, "mintcore/go.sum") - assert.Len(t, names, 14) + assert.Len(t, names, 15) } func TestEmbeddedMintSource_MatchesOriginal(t *testing.T) { diff --git a/internal/forge/fake.go b/internal/forge/fake.go index aec624109b..043c793e2e 100644 --- a/internal/forge/fake.go +++ b/internal/forge/fake.go @@ -3,6 +3,7 @@ package forge import ( "context" "fmt" + "strings" "sync" ) @@ -1144,18 +1145,57 @@ func (f *FakeClient) CreateOrUpdateOrgVariable(_ context.Context, org, name, val return nil } -func (f *FakeClient) OrgVariableExists(_ context.Context, org, name string) (bool, error) { +func (f *FakeClient) OrgVariableExists(ctx context.Context, org, name string) (bool, error) { + f.mu.Lock() + e := f.err("OrgVariableExists") + f.mu.Unlock() + if e != nil { + return false, e + } + _, exists, err := f.GetOrgVariable(ctx, org, name) + return exists, err +} + +func (f *FakeClient) GetOrgVariable(_ context.Context, org, name string) (string, bool, error) { f.mu.Lock() defer f.mu.Unlock() - if e := f.err("OrgVariableExists"); e != nil { - return false, e + if e := f.err("GetOrgVariable"); e != nil { + return "", false, e } - if f.OrgVariables == nil { - return false, nil + key := org + "/" + name + if f.OrgVariables == nil || !f.OrgVariables[key] { + return "", false, nil + } + if f.OrgVariableValues == nil { + return "", true, nil + } + return f.OrgVariableValues[key], true, nil +} + +func (f *FakeClient) ListOrgVariables(_ context.Context, org string) ([]OrgVariable, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if e := f.err("ListOrgVariables"); e != nil { + return nil, e + } + + prefix := org + "/" + var out []OrgVariable + for key, ok := range f.OrgVariables { + if !ok || !strings.HasPrefix(key, prefix) { + continue + } + name := strings.TrimPrefix(key, prefix) + val := "" + if f.OrgVariableValues != nil { + val = f.OrgVariableValues[key] + } + out = append(out, OrgVariable{Name: name, Value: val}) } - return f.OrgVariables[org+"/"+name], nil + return out, nil } func (f *FakeClient) SetOrgVariableRepos(_ context.Context, org, name string, repoIDs []int64) error { diff --git a/internal/forge/forge.go b/internal/forge/forge.go index 2bb135aba4..cde5519e3e 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -114,6 +114,12 @@ type Installation struct { Permissions map[string]string } +// OrgVariable is an org-level GitHub Actions variable. +type OrgVariable struct { + Name string + Value string +} + // TreeFile represents a file to be committed via the Git Trees API. // Mode controls file permissions: "100644" for regular files, // "100755" for executable files (e.g., shell scripts). @@ -228,6 +234,8 @@ type Client interface { // Org-level variables (for dispatch function URL) CreateOrUpdateOrgVariable(ctx context.Context, org, name, value string, selectedRepoIDs []int64) error OrgVariableExists(ctx context.Context, org, name string) (bool, error) + GetOrgVariable(ctx context.Context, org, name string) (value string, exists bool, err error) + ListOrgVariables(ctx context.Context, org string) ([]OrgVariable, error) DeleteOrgVariable(ctx context.Context, org, name string) error SetOrgVariableRepos(ctx context.Context, org, name string, repoIDs []int64) error // GetOrgVariableRepos returns the list of repository IDs that have access diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index b048221d02..af64bae85b 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -2165,22 +2165,65 @@ func (c *LiveClient) CreateOrUpdateOrgVariable(ctx context.Context, org, name, v // OrgVariableExists checks if an org-level variable exists. func (c *LiveClient) OrgVariableExists(ctx context.Context, org, name string) (bool, error) { + _, exists, err := c.GetOrgVariable(ctx, org, name) + return exists, err +} + +// GetOrgVariable reads an org-level Actions variable value. +func (c *LiveClient) GetOrgVariable(ctx context.Context, org, name string) (string, bool, error) { resp, err := c.do(ctx, http.MethodGet, fmt.Sprintf("/orgs/%s/actions/variables/%s", org, name), nil) if err != nil { - return false, fmt.Errorf("check org variable %s: %w", name, err) + return "", false, fmt.Errorf("get org variable %s: %w", name, err) } - resp.Body.Close() + defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: - return true, nil + var varResp struct { + Name string `json:"name"` + Value string `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&varResp); err != nil { + return "", false, fmt.Errorf("decoding org variable %s: %w", name, err) + } + return varResp.Value, true, nil case http.StatusNotFound: - return false, nil + return "", false, nil case http.StatusForbidden: - return false, &APIError{StatusCode: http.StatusForbidden, Message: "insufficient permissions to check org variable (missing admin:org scope?)"} + return "", false, &APIError{StatusCode: http.StatusForbidden, Message: "insufficient permissions to read org variable (missing admin:org scope?)"} default: - return false, &APIError{StatusCode: resp.StatusCode, Message: "unexpected status checking org variable"} + return "", false, &APIError{StatusCode: resp.StatusCode, Message: "unexpected status reading org variable"} + } +} + +// ListOrgVariables lists org-level Actions variables (paginated). +func (c *LiveClient) ListOrgVariables(ctx context.Context, org string) ([]forge.OrgVariable, error) { + var all []forge.OrgVariable + page := 1 + for { + path := fmt.Sprintf("/orgs/%s/actions/variables?per_page=100&page=%d", org, page) + resp, err := c.get(ctx, path) + if err != nil { + return nil, fmt.Errorf("list org variables: %w", err) + } + + var result struct { + Variables []forge.OrgVariable `json:"variables"` + TotalCount int `json:"total_count"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("decoding org variables: %w", err) + } + resp.Body.Close() + + all = append(all, result.Variables...) + if len(all) >= result.TotalCount || len(result.Variables) == 0 { + break + } + page++ } + return all, nil } // DeleteOrgVariable deletes an org-level variable. It is idempotent: a 404 diff --git a/internal/mintcore/foreign.go b/internal/mintcore/foreign.go new file mode 100644 index 0000000000..4e2b6e999f --- /dev/null +++ b/internal/mintcore/foreign.go @@ -0,0 +1,58 @@ +package mintcore + +import ( + "fmt" + "strings" +) + +const ( + foreignVarPrefix = "FULLSEND_FOREIGN_" + foreignVarSuffix = "_REPOS" +) + +// ForeignVariableName returns the org variable name for cross-org allowlist policy. +func ForeignVariableName(role string) string { + return foreignVarPrefix + strings.ToUpper(role) + foreignVarSuffix +} + +// ParseForeignAllowlist splits a comma-separated FOREIGN variable value into entries. +func ParseForeignAllowlist(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + var out []string + for _, entry := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(entry); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// CallerAllowed reports whether repository/repositoryOwner matches an allowlist entry. +// Entries with a slash match the repository claim exactly; bare org names match repository_owner. +func CallerAllowed(allowlist []string, repository, repositoryOwner string) bool { + for _, entry := range allowlist { + if strings.Contains(entry, "/") { + if strings.EqualFold(entry, repository) { + return true + } + } else if strings.EqualFold(entry, repositoryOwner) { + return true + } + } + return false +} + +// foreignCacheKey builds a cache key for target org + role policy lookups. +func foreignCacheKey(targetOrg, role string) string { + return strings.ToLower(targetOrg) + "/" + strings.ToLower(role) +} + +// validateTargetOrg checks target_org when cross-org mint is requested. +func validateTargetOrg(targetOrg string) error { + if err := ValidateOrgName(targetOrg); err != nil { + return fmt.Errorf("invalid target_org: %w", err) + } + return nil +} diff --git a/internal/mintcore/foreign_test.go b/internal/mintcore/foreign_test.go new file mode 100644 index 0000000000..dc62c03048 --- /dev/null +++ b/internal/mintcore/foreign_test.go @@ -0,0 +1,35 @@ +package mintcore + +import "testing" + +func TestForeignVariableName(t *testing.T) { + if got := ForeignVariableName("e2e"); got != "FULLSEND_FOREIGN_E2E_REPOS" { + t.Fatalf("got %q", got) + } +} + +func TestParseForeignAllowlist(t *testing.T) { + got := ParseForeignAllowlist(" fullsend-ai/fullsend , fullsend-ai ") + if len(got) != 2 { + t.Fatalf("got %v", got) + } + if got[0] != "fullsend-ai/fullsend" || got[1] != "fullsend-ai" { + t.Fatalf("got %v", got) + } + if ParseForeignAllowlist(" ") != nil { + t.Fatal("expected nil for empty") + } +} + +func TestCallerAllowed(t *testing.T) { + list := []string{"fullsend-ai/fullsend", "konflux-ci"} + if !CallerAllowed(list, "fullsend-ai/fullsend", "fullsend-ai") { + t.Fatal("expected repo match") + } + if !CallerAllowed(list, "konflux-ci/foo", "konflux-ci") { + t.Fatal("expected org match") + } + if CallerAllowed(list, "other-org/repo", "other-org") { + t.Fatal("expected deny") + } +} diff --git a/internal/mintcore/github.go b/internal/mintcore/github.go index d2077a6d19..8a141d3930 100644 --- a/internal/mintcore/github.go +++ b/internal/mintcore/github.go @@ -64,6 +64,12 @@ var canonicalRolePermissions = map[string]map[string]string{ "retro": {"actions": "read", "contents": "read", "pull_requests": "write", "issues": "write", "metadata": "read"}, "prioritize": {"contents": "read", "issues": "write", "organization_projects": "write", "metadata": "read"}, "fullsend": {"actions": "write", "actions_variables": "read", "contents": "write", "pull_requests": "write", "workflows": "write", "metadata": "read"}, + "e2e": { + "actions": "write", "actions_variables": "read", "administration": "write", + "contents": "write", "issues": "write", "members": "write", "metadata": "read", + "organization_administration": "write", "pull_requests": "write", + "secrets": "write", "workflows": "write", + }, } // RolePermissions returns a deep copy of the role-to-permissions map, @@ -196,6 +202,143 @@ func FindInstallation(ctx context.Context, httpClient HTTPDoer, githubBaseURL, j return inst.ID, nil } +// FindOrgInstallation looks up a GitHub App's installation ID for an organization. +func FindOrgInstallation(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt, org string) (int64, error) { + reqURL := fmt.Sprintf("%s/orgs/%s/installation", githubBaseURL, org) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return 0, fmt.Errorf("creating org installation request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("getting org installation: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return 0, fmt.Errorf("getting org installation for %s returned status %d", org, resp.StatusCode) + } + + var inst installationResponse + if err := json.NewDecoder(resp.Body).Decode(&inst); err != nil { + return 0, fmt.Errorf("decoding org installation: %w", err) + } + + if inst.ID == 0 { + return 0, fmt.Errorf("no installation found for org %s", org) + } + + if !strings.EqualFold(inst.Account.Login, org) { + return 0, fmt.Errorf("installation for org %s belongs to %s, not %s", + org, inst.Account.Login, org) + } + + return inst.ID, nil +} + +// orgVariableResponse is the response from GET /orgs/{org}/actions/variables/{name}. +type orgVariableResponse struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// GetOrgVariable reads an org-level Actions variable using an installation token. +func GetOrgVariable(ctx context.Context, httpClient HTTPDoer, githubBaseURL, installationToken, org, name string) (value string, exists bool, err error) { + reqURL := fmt.Sprintf("%s/orgs/%s/actions/variables/%s", githubBaseURL, org, name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return "", false, fmt.Errorf("creating org variable request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+installationToken) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := httpClient.Do(req) + if err != nil { + return "", false, fmt.Errorf("getting org variable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return "", false, nil + } + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return "", false, fmt.Errorf("getting org variable %s returned status %d", name, resp.StatusCode) + } + + var varResp orgVariableResponse + if err := json.NewDecoder(resp.Body).Decode(&varResp); err != nil { + return "", false, fmt.Errorf("decoding org variable: %w", err) + } + return varResp.Value, true, nil +} + +// createInstallationTokenWithPermissions creates an installation access token with explicit permissions. +func createInstallationTokenWithPermissions(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, perms map[string]string, repos []string) (string, error) { + tokenReqBody := map[string]interface{}{ + "permissions": perms, + } + if len(repos) > 0 { + tokenReqBody["repositories"] = repos + } + + tokenReqBytes, err := json.Marshal(tokenReqBody) + if err != nil { + return "", fmt.Errorf("marshaling token request: %w", err) + } + + reqURL := fmt.Sprintf("%s/app/installations/%d/access_tokens", githubBaseURL, installationID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(tokenReqBytes)) + if err != nil { + return "", fmt.Errorf("creating token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("creating installation token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return "", fmt.Errorf("creating installation token returned status %d", resp.StatusCode) + } + + var tokenResp installationTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", fmt.Errorf("decoding token response: %w", err) + } + if tokenResp.Token == "" { + return "", fmt.Errorf("empty installation token returned") + } + return tokenResp.Token, nil +} + +// ReadForeignAllowlist reads FULLSEND_FOREIGN__REPOS from the target org. +func ReadForeignAllowlist(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, targetOrg, role string) ([]string, error) { + policyToken, err := createInstallationTokenWithPermissions(ctx, httpClient, githubBaseURL, jwt, installationID, + map[string]string{"actions_variables": "read"}, nil) + if err != nil { + return nil, fmt.Errorf("creating policy check token: %w", err) + } + + value, exists, err := GetOrgVariable(ctx, httpClient, githubBaseURL, policyToken, targetOrg, ForeignVariableName(role)) + if err != nil { + return nil, err + } + if !exists || strings.TrimSpace(value) == "" { + return nil, nil + } + return ParseForeignAllowlist(value), nil +} + // CreateInstallationToken exchanges a JWT for an installation access token, // scoped to the given repos and role-specific permissions. func CreateInstallationToken(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, role string, repos []string) (string, string, *GrantedScope, error) { diff --git a/internal/mintcore/handler.go b/internal/mintcore/handler.go index 04b167aabe..940469d17d 100644 --- a/internal/mintcore/handler.go +++ b/internal/mintcore/handler.go @@ -11,15 +11,24 @@ import ( "os" "sort" "strings" + "sync" "time" ) const maxRepos = 500 +const defaultForeignCacheTTL = 60 * time.Second + +type foreignCacheEntry struct { + allowlist []string + fetchedAt time.Time +} + // mintRequest is the JSON body sent by .fullsend agent workflows. type mintRequest struct { - Role string `json:"role"` - Repos []string `json:"repos,omitempty"` + Role string `json:"role"` + TargetOrg string `json:"target_org,omitempty"` + Repos []string `json:"repos,omitempty"` } // mintResponse is returned on success. @@ -47,6 +56,10 @@ type Handler struct { roleAppIDs map[string]string allowedRoles []string + + foreignCache map[string]foreignCacheEntry + foreignCacheTTL time.Duration + foreignCacheMu sync.Mutex } // NewHandler creates a Handler with the given dependencies. @@ -59,10 +72,12 @@ func NewHandler(pemAccessor PEMAccessor, oidcVerifier OIDCVerifier) (*Handler, e httpClient := &http.Client{Timeout: 30 * time.Second} h := &Handler{ - httpClient: httpClient, - pemAccessor: pemAccessor, - oidcVerifier: oidcVerifier, - githubBaseURL: "https://api.github.com", + httpClient: httpClient, + pemAccessor: pemAccessor, + oidcVerifier: oidcVerifier, + githubBaseURL: "https://api.github.com", + foreignCache: make(map[string]foreignCacheEntry), + foreignCacheTTL: defaultForeignCacheTTL, } if raw := os.Getenv("ROLE_APP_IDS"); raw != "" { @@ -193,6 +208,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + if req.TargetOrg != "" { + if err := validateTargetOrg(req.TargetOrg); err != nil { + writeError(w, http.StatusBadRequest, "invalid target_org") + return + } + } + ctx := r.Context() claims, err := h.oidcVerifier.Verify(ctx, oidcToken) @@ -202,11 +224,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - org := strings.ToLower(claims.RepositoryOwner) + callerOrg := strings.ToLower(claims.RepositoryOwner) + targetOrg := strings.ToLower(strings.TrimSpace(req.TargetOrg)) + if targetOrg == "" { + targetOrg = callerOrg + } - token, expiresAt, granted, err := h.mintToken(ctx, org, req.Role, req.Repos) + var token, expiresAt string + var granted *GrantedScope + + if strings.EqualFold(targetOrg, callerOrg) { + token, expiresAt, granted, err = h.mintToken(ctx, callerOrg, req.Role, req.Repos) + } else { + token, expiresAt, granted, err = h.mintTokenCrossOrg(ctx, claims, targetOrg, req.Role, req.Repos) + } if err != nil { - log.Printf("failed to mint token: org=%s role=%s err=%v", org, req.Role, err) + log.Printf("failed to mint token: org=%s target_org=%s role=%s err=%v", callerOrg, targetOrg, req.Role, err) var me *mintError if errors.As(err, &me) { writeError(w, me.status, "mint failed") @@ -217,8 +250,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } if granted != nil { - log.Printf("minted: org=%s role=%s app_id=%s installation_id=%d requested_repos=%v source_repo=%s workflow_ref=%s", - org, req.Role, granted.AppID, granted.InstallationID, req.Repos, claims.Repository, claims.JobWorkflowRef) + log.Printf("minted: org=%s target_org=%s role=%s app_id=%s installation_id=%d requested_repos=%v source_repo=%s workflow_ref=%s", + callerOrg, targetOrg, req.Role, granted.AppID, granted.InstallationID, req.Repos, claims.Repository, claims.JobWorkflowRef) log.Printf("granted scope: repos=%v permissions=%v repo_selection=%s", granted.Repos, granted.Permissions, granted.RepoSelection) if granted.RepoSelection == "all" { @@ -318,6 +351,69 @@ func (h *Handler) mintToken(ctx context.Context, org, role string, repos []strin return token, expiresAt, granted, nil } +func (h *Handler) mintTokenCrossOrg(ctx context.Context, claims *Claims, targetOrg, role string, repos []string) (string, string, *GrantedScope, error) { + allowlist, err := h.loadForeignAllowlist(ctx, targetOrg, role) + if err != nil { + return "", "", nil, &mintError{status: http.StatusBadGateway, msg: err.Error()} + } + if len(allowlist) == 0 { + return "", "", nil, &mintError{status: http.StatusForbidden, msg: "foreign caller not authorized for target org"} + } + if !CallerAllowed(allowlist, claims.Repository, claims.RepositoryOwner) { + return "", "", nil, &mintError{status: http.StatusForbidden, msg: "foreign caller not authorized for target org"} + } + + return h.mintToken(ctx, targetOrg, role, repos) +} + +func (h *Handler) loadForeignAllowlist(ctx context.Context, targetOrg, role string) ([]string, error) { + key := foreignCacheKey(targetOrg, role) + + h.foreignCacheMu.Lock() + if entry, ok := h.foreignCache[key]; ok && time.Since(entry.fetchedAt) < h.foreignCacheTTL { + allowlist := append([]string(nil), entry.allowlist...) + h.foreignCacheMu.Unlock() + return allowlist, nil + } + h.foreignCacheMu.Unlock() + + appID, err := h.lookupRoleAppID(targetOrg, role) + if err != nil { + return nil, fmt.Errorf("looking up app ID for role %s on %s: %v", role, targetOrg, err) + } + + pemData, err := h.pemAccessor.AccessPEM(ctx, role) + if err != nil { + return nil, fmt.Errorf("reading PEM secret for role %s: %v", role, err) + } + defer func() { + for i := range pemData { + pemData[i] = 0 + } + }() + + jwt, err := GenerateAppJWT(appID, pemData) + if err != nil { + return nil, fmt.Errorf("generating app JWT: %v", err) + } + + installationID, err := FindOrgInstallation(ctx, h.httpClient, h.githubBaseURL, jwt, targetOrg) + if err != nil { + return nil, fmt.Errorf("finding org installation on %s: %v", targetOrg, err) + } + + allowlist, err := ReadForeignAllowlist(ctx, h.httpClient, h.githubBaseURL, jwt, installationID, targetOrg, role) + if err != nil { + return nil, err + } + + h.foreignCacheMu.Lock() + h.foreignCache[key] = foreignCacheEntry{allowlist: append([]string(nil), allowlist...), fetchedAt: time.Now()} + h.foreignCacheMu.Unlock() + + return allowlist, nil +} + func (h *Handler) checkAllowedRole(role string) bool { for _, entry := range h.allowedRoles { if entry == role { diff --git a/internal/mintcore/handler_test.go b/internal/mintcore/handler_test.go index db9751575e..5f8e956bc7 100644 --- a/internal/mintcore/handler_test.go +++ b/internal/mintcore/handler_test.go @@ -66,7 +66,8 @@ func (f *fakePEMAccessor) AccessPEM(_ context.Context, role string) ([]byte, err if !ok { return nil, fmt.Errorf("PEM not found for %s", key) } - return data, nil + cp := append([]byte(nil), data...) + return cp, nil } func mustNewHandler(t *testing.T, pemAccessor PEMAccessor, verifier OIDCVerifier) *Handler { @@ -716,7 +717,7 @@ func TestHandler_FullFlowGrantedScopeAll(t *testing.T) { } env := newTestOIDCEnv(t, &fakePEMAccessor{ - pems: map[string][]byte{"test-org/coder": pemData}, + pems: map[string][]byte{"coder": pemData}, }) token := env.signToken(t, nil) @@ -1856,7 +1857,7 @@ func TestHandler_LogsRequestedPermissionNotGranted(t *testing.T) { } env := newTestOIDCEnv(t, &fakePEMAccessor{ - pems: map[string][]byte{"test-org/coder": pemData}, + pems: map[string][]byte{"coder": pemData}, }) token := env.signToken(t, nil) @@ -1909,3 +1910,176 @@ func TestHandler_LogsRequestedPermissionNotGranted(t *testing.T) { } } } + +func TestHandler_SameOrgExplicitTargetOrg(t *testing.T) { + t.Setenv("ROLE_APP_IDS", `{"test-org/coder":"200"}`) + + pemData, err := generateTestRSAKey() + if err != nil { + t.Fatalf("generating test key: %v", err) + } + + env := newTestOIDCEnv(t, &fakePEMAccessor{pems: map[string][]byte{"coder": pemData}}) + token := env.signToken(t, nil) + + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/test-org/test-repo/installation" && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(installationResponse{ + ID: 12345, Account: struct { + Login string `json:"login"` + }{Login: "test-org"}, + }) + case strings.HasPrefix(r.URL.Path, "/app/installations/12345/access_tokens") && r.Method == http.MethodPost: + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(installationTokenResponse{ + Token: "ghs_test_token", + ExpiresAt: "2026-05-06T12:00:00Z", + Permissions: map[string]string{ + "contents": "write", "pull_requests": "write", "issues": "write", + "checks": "read", "metadata": "read", + }, + Repositories: []installationTokenRepository{{FullName: "test-org/test-repo"}}, + RepositorySelection: "selected", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer github.Close() + env.handler.githubBaseURL = github.URL + + body := `{"role":"coder","target_org":"test-org","repos":["test-repo"]}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/token", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandler_CrossOrgFullFlow(t *testing.T) { + t.Setenv("ALLOWED_ORGS", "test-org,fullsend-ai") + t.Setenv("ROLE_APP_IDS", `{"pool-org/e2e":"300"}`) + + pemData, err := generateTestRSAKey() + if err != nil { + t.Fatalf("generating test key: %v", err) + } + + env := newTestOIDCEnv(t, &fakePEMAccessor{pems: map[string][]byte{"e2e": pemData}}) + token := env.signToken(t, map[string]interface{}{ + "repository": "fullsend-ai/fullsend", + "repository_owner": "fullsend-ai", + "job_workflow_ref": "fullsend-ai/fullsend/.github/workflows/e2e.yml@refs/heads/main", + }) + + var tokenCalls int + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/orgs/pool-org/installation" && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(installationResponse{ + ID: 999, Account: struct { + Login string `json:"login"` + }{Login: "pool-org"}, + }) + case r.URL.Path == "/app/installations/999/access_tokens" && r.Method == http.MethodPost: + tokenCalls++ + w.WriteHeader(http.StatusCreated) + if tokenCalls == 1 { + json.NewEncoder(w).Encode(installationTokenResponse{Token: "ghs_policy_token"}) + return + } + json.NewEncoder(w).Encode(installationTokenResponse{ + Token: "ghs_e2e_token", + ExpiresAt: "2026-05-06T12:00:00Z", + Permissions: map[string]string{"contents": "write", "metadata": "read"}, + Repositories: []installationTokenRepository{{FullName: "pool-org/e2e-lock"}}, + RepositorySelection: "selected", + }) + case r.URL.Path == "/orgs/pool-org/actions/variables/FULLSEND_FOREIGN_E2E_REPOS" && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(orgVariableResponse{ + Name: "FULLSEND_FOREIGN_E2E_REPOS", + Value: "fullsend-ai/fullsend", + }) + case r.URL.Path == "/repos/pool-org/e2e-lock/installation" && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(installationResponse{ + ID: 999, Account: struct { + Login string `json:"login"` + }{Login: "pool-org"}, + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer github.Close() + env.handler.githubBaseURL = github.URL + + body := `{"role":"e2e","target_org":"pool-org","repos":["e2e-lock"]}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/token", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var resp mintResponse + json.NewDecoder(rec.Body).Decode(&resp) + if resp.Token != "ghs_e2e_token" { + t.Fatalf("expected e2e token, got %q", resp.Token) + } +} + +func TestHandler_CrossOrgForeignDenied(t *testing.T) { + t.Setenv("ALLOWED_ORGS", "test-org,evil-org") + t.Setenv("ROLE_APP_IDS", `{"pool-org/e2e":"300"}`) + + pemData, err := generateTestRSAKey() + if err != nil { + t.Fatalf("generating test key: %v", err) + } + + env := newTestOIDCEnv(t, &fakePEMAccessor{pems: map[string][]byte{"e2e": pemData}}) + token := env.signToken(t, map[string]interface{}{ + "repository": "evil-org/.fullsend", + "repository_owner": "evil-org", + "job_workflow_ref": "evil-org/.fullsend/.github/workflows/code.yml@refs/heads/main", + }) + + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/orgs/pool-org/installation" && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(installationResponse{ID: 999, Account: struct { + Login string `json:"login"` + }{Login: "pool-org"}}) + case r.URL.Path == "/app/installations/999/access_tokens" && r.Method == http.MethodPost: + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(installationTokenResponse{Token: "ghs_policy_token"}) + case r.URL.Path == "/orgs/pool-org/actions/variables/FULLSEND_FOREIGN_E2E_REPOS" && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(orgVariableResponse{ + Name: "FULLSEND_FOREIGN_E2E_REPOS", + Value: "fullsend-ai/fullsend", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer github.Close() + env.handler.githubBaseURL = github.URL + + body := `{"role":"e2e","target_org":"pool-org","repos":["e2e-lock"]}` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/token", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + env.handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", rec.Code, rec.Body.String()) + } +} From 6e4a2e4cb88c84e8fb793baded03dd61aeaecc6f Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Mon, 22 Jun 2026 11:22:05 +0300 Subject: [PATCH 02/26] docs: renumber cross-org mint ADR from 0046 to 0051 Avoids duplicate ADR 0046 with host-side API server design on main. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md | 2 +- ... 0051-cross-org-mint-authorization-via-org-variables.md} | 6 +++--- docs/guides/dev/e2e-testing.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) rename docs/ADRs/{0046-cross-org-mint-authorization-via-org-variables.md => 0051-cross-org-mint-authorization-via-org-variables.md} (95%) diff --git a/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md b/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md index 426b5a3fad..af753f3359 100644 --- a/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md +++ b/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md @@ -70,7 +70,7 @@ slice in the test code. No architectural changes are needed. staleness check. - Each pool org must install the `fullsend-ai-e2e` app and authorize CI via `FULLSEND_FOREIGN_E2E_REPOS` on the target org — see - [ADR 0046](0046-cross-org-mint-authorization-via-org-variables.md). + [ADR 0051](0051-cross-org-mint-authorization-via-org-variables.md). - CI acquires per-org tokens via cross-org mint ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)); local runs use a user token with pool-org admin access (`gh auth login`). - Pool expansion is an operational task (provision org, update one slice diff --git a/docs/ADRs/0046-cross-org-mint-authorization-via-org-variables.md b/docs/ADRs/0051-cross-org-mint-authorization-via-org-variables.md similarity index 95% rename from docs/ADRs/0046-cross-org-mint-authorization-via-org-variables.md rename to docs/ADRs/0051-cross-org-mint-authorization-via-org-variables.md index 90274a14ac..b0869a7c79 100644 --- a/docs/ADRs/0046-cross-org-mint-authorization-via-org-variables.md +++ b/docs/ADRs/0051-cross-org-mint-authorization-via-org-variables.md @@ -1,5 +1,5 @@ --- -title: "46. Cross-org mint authorization via org variables" +title: "51. Cross-org mint authorization via org variables" status: Accepted relates_to: - agent-infrastructure @@ -11,7 +11,7 @@ topics: - cross-org --- -# 46. Cross-org mint authorization via org variables +# 51. Cross-org mint authorization via org variables Date: 2026-06-07 @@ -39,7 +39,7 @@ orgs control over their own policy. ## Decision 1. **Optional `target_org` on mint requests.** When omitted, or when equal to the caller's - `repository_owner` (case-insensitive), behavior is unchanged from pre-0046 mint: same + `repository_owner` (case-insensitive), behavior is unchanged from pre-0051 mint: same `mintToken` path, repo-based installation lookup, no FOREIGN check. 2. **Cross-org path** applies only when `target_org` is set and differs from the caller org: diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index ecce503286..44f8f6adad 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -3,7 +3,7 @@ Guide for running and debugging fullsend admin e2e tests locally and in CI. Related ADRs: [0040](../../ADRs/0040-org-pool-for-parallel-e2e-tests.md) (org pool), -[0046](../../ADRs/0046-cross-org-mint-authorization-via-org-variables.md) (cross-org mint), +[0051](../../ADRs/0051-cross-org-mint-authorization-via-org-variables.md) (cross-org mint), [0009](../../ADRs/0009-pull-request-target-in-shim-workflows.md) (pull_request_target security model for shims; e2e uses a separate gate pattern documented below). Historical ADRs [0010](../../ADRs/0010-stored-session-for-e2e-browser-auth.md) (browser session) and @@ -38,7 +38,7 @@ In GitHub Actions, tests mint a cross-org installation token via the mint servic 1. Workflow requests a GHA OIDC token (`id-token: write`) 2. `resolveE2EToken` POSTs to `E2E_MINT_URL/v1/token` with `{role: "e2e", target_org: "", repos: [...]}` -3. Mint verifies the caller against `FULLSEND_FOREIGN_E2E_REPOS` on the target org ([ADR 0046](../../ADRs/0046-cross-org-mint-authorization-via-org-variables.md)) +3. Mint verifies the caller against `FULLSEND_FOREIGN_E2E_REPOS` on the target org ([ADR 0051](../../ADRs/0051-cross-org-mint-authorization-via-org-variables.md)) Required repository secrets: From dbb1e03d7459a06d26da59aea15006a07f06b895 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Mon, 22 Jun 2026 11:52:52 +0300 Subject: [PATCH 03/26] fix(cli): use org-wide visibility for FOREIGN allowlist variables foreign allow/revoke used selected visibility with no repos, so the e2e app installation token could not read FULLSEND_FOREIGN_* via the org API. Re-running foreign allow is idempotent and repairs visibility in place. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/foreign.go | 23 +++++++++----- internal/forge/fake.go | 4 +++ internal/forge/forge.go | 3 ++ internal/forge/github/github.go | 31 +++++++++++++++++++ internal/forge/github/github_test.go | 46 ++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/internal/cli/foreign.go b/internal/cli/foreign.go index 2357b1d892..f38e0c7d0e 100644 --- a/internal/cli/foreign.go +++ b/internal/cli/foreign.go @@ -67,19 +67,26 @@ func newForeignAllowCmd() *cobra.Command { return err } - if containsForeignCaller(allowlist, caller) { - printer.StepDone(fmt.Sprintf("%s already lists %q", varName, caller)) - return nil + alreadyListed := containsForeignCaller(allowlist, caller) + if !alreadyListed { + allowlist = append(allowlist, caller) } - allowlist = append(allowlist, caller) value := strings.Join(allowlist, ", ") - printer.StepStart(fmt.Sprintf("Updating %s on %s", varName, org)) - if err := client.CreateOrUpdateOrgVariable(ctx, org, varName, value, nil); err != nil { + if alreadyListed { + printer.StepStart(fmt.Sprintf("Ensuring %s is org-wide on %s", varName, org)) + } else { + printer.StepStart(fmt.Sprintf("Updating %s on %s", varName, org)) + } + if err := client.CreateOrUpdateOrgVariableAll(ctx, org, varName, value); err != nil { printer.StepFail(fmt.Sprintf("Failed to update %s", varName)) return err } - printer.StepDone(fmt.Sprintf("Added %q to %s", caller, varName)) + if alreadyListed { + printer.StepDone(fmt.Sprintf("%s already lists %q (org-wide visibility ensured)", varName, caller)) + } else { + printer.StepDone(fmt.Sprintf("Added %q to %s", caller, varName)) + } return nil }, } @@ -233,7 +240,7 @@ func newForeignRevokeCmd() *cobra.Command { return nil } value := strings.Join(updated, ", ") - if err := client.CreateOrUpdateOrgVariable(ctx, org, varName, value, nil); err != nil { + if err := client.CreateOrUpdateOrgVariableAll(ctx, org, varName, value); err != nil { printer.StepFail(fmt.Sprintf("Failed to update %s", varName)) return err } diff --git a/internal/forge/fake.go b/internal/forge/fake.go index 058a5cc5b9..e9fcff4c6d 100644 --- a/internal/forge/fake.go +++ b/internal/forge/fake.go @@ -1237,6 +1237,10 @@ func (f *FakeClient) CreateOrUpdateOrgVariable(_ context.Context, org, name, val return nil } +func (f *FakeClient) CreateOrUpdateOrgVariableAll(ctx context.Context, org, name, value string) error { + return f.CreateOrUpdateOrgVariable(ctx, org, name, value, nil) +} + func (f *FakeClient) OrgVariableExists(ctx context.Context, org, name string) (bool, error) { f.mu.Lock() e := f.err("OrgVariableExists") diff --git a/internal/forge/forge.go b/internal/forge/forge.go index f16c3f84f4..195da10041 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -274,6 +274,9 @@ type Client interface { // Org-level variables (for dispatch function URL) CreateOrUpdateOrgVariable(ctx context.Context, org, name, value string, selectedRepoIDs []int64) error + // CreateOrUpdateOrgVariableAll creates or updates an org-wide Actions variable + // (visibility all). Used for mint FOREIGN policy variables read via the org API. + CreateOrUpdateOrgVariableAll(ctx context.Context, org, name, value string) error OrgVariableExists(ctx context.Context, org, name string) (bool, error) GetOrgVariable(ctx context.Context, org, name string) (value string, exists bool, err error) ListOrgVariables(ctx context.Context, org string) ([]OrgVariable, error) diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 4edb1383b9..25a79255d0 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -2448,6 +2448,37 @@ func (c *LiveClient) CreateOrUpdateOrgVariable(ctx context.Context, org, name, v return nil } +// CreateOrUpdateOrgVariableAll creates or updates an org-level Actions variable +// visible to all repositories in the org (visibility all). +func (c *LiveClient) CreateOrUpdateOrgVariableAll(ctx context.Context, org, name, value string) error { + patchPayload := map[string]any{ + "value": value, + "visibility": "all", + } + + resp, err := c.patch(ctx, fmt.Sprintf("/orgs/%s/actions/variables/%s", org, name), patchPayload) + if err == nil { + resp.Body.Close() + return nil + } + + if !isNotFound(err) { + return fmt.Errorf("update org variable %s: %w", name, err) + } + + createPayload := map[string]any{ + "name": name, + "value": value, + "visibility": "all", + } + resp2, err := c.post(ctx, fmt.Sprintf("/orgs/%s/actions/variables", org), createPayload) + if err != nil { + return fmt.Errorf("create org variable %s: %w", name, err) + } + resp2.Body.Close() + return nil +} + // OrgVariableExists checks if an org-level variable exists. func (c *LiveClient) OrgVariableExists(ctx context.Context, org, name string) (bool, error) { _, exists, err := c.GetOrgVariable(ctx, org, name) diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index c92bdf9997..a90fe36481 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -1217,6 +1217,52 @@ func TestCreateOrUpdateOrgVariable_NilRepoIDs(t *testing.T) { require.NoError(t, err) } +func TestCreateOrUpdateOrgVariableAll_Create(t *testing.T) { + callNum := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callNum++ + switch callNum { + case 1: + assert.Equal(t, "PATCH", r.Method) + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]any{"message": "Not Found"}) + case 2: + assert.Equal(t, "POST", r.Method) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "FULLSEND_FOREIGN_E2E_REPOS", body["name"]) + assert.Equal(t, "fullsend-ai/fullsend", body["value"]) + assert.Equal(t, "all", body["visibility"]) + _, hasRepoIDs := body["selected_repository_ids"] + assert.False(t, hasRepoIDs) + w.WriteHeader(http.StatusCreated) + } + })) + defer srv.Close() + + client := newTestClient(t, srv) + err := client.CreateOrUpdateOrgVariableAll(context.Background(), "myorg", "FULLSEND_FOREIGN_E2E_REPOS", "fullsend-ai/fullsend") + require.NoError(t, err) +} + +func TestCreateOrUpdateOrgVariableAll_Update(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "PATCH", r.Method) + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "fullsend-ai/fullsend", body["value"]) + assert.Equal(t, "all", body["visibility"]) + _, hasRepoIDs := body["selected_repository_ids"] + assert.False(t, hasRepoIDs) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + client := newTestClient(t, srv) + err := client.CreateOrUpdateOrgVariableAll(context.Background(), "myorg", "FULLSEND_FOREIGN_E2E_REPOS", "fullsend-ai/fullsend") + require.NoError(t, err) +} + func TestOrgVariableExists(t *testing.T) { t.Run("exists", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From d658530fdc4772d7e48254dbaf26ddd8a357aa90 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Mon, 22 Jun 2026 12:03:34 +0300 Subject: [PATCH 04/26] refactor(e2e): reuse mintclient for cross-org tokens and fix mint repo scope Factor org-variable PATCH/POST into createOrUpdateOrgVariable with a visibility parameter. Extend mintclient with optional target_org for cross-org minting. E2e auth delegates to mintclient and requests test-repo, .fullsend, and e2e-lock on pool orgs. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/guides/dev/e2e-testing.md | 2 +- e2e/admin/auth.go | 118 +++---------------------- internal/forge/github/github.go | 66 +++++--------- internal/mintclient/mintclient.go | 23 ++--- internal/mintclient/mintclient_test.go | 59 +++++++++++++ 5 files changed, 110 insertions(+), 158 deletions(-) diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index 44f8f6adad..87546a6e24 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -37,7 +37,7 @@ Tests acquire an exclusive lock on one org from the pool (`halfsend-01` … In GitHub Actions, tests mint a cross-org installation token via the mint service: 1. Workflow requests a GHA OIDC token (`id-token: write`) -2. `resolveE2EToken` POSTs to `E2E_MINT_URL/v1/token` with `{role: "e2e", target_org: "", repos: [...]}` +2. `mintclient.MintToken` POSTs to `E2E_MINT_URL/v1/token` with `{role: "e2e", target_org: "", repos: ["test-repo", ".fullsend", "e2e-lock"]}` 3. Mint verifies the caller against `FULLSEND_FOREIGN_E2E_REPOS` on the target org ([ADR 0051](../../ADRs/0051-cross-org-mint-authorization-via-org-variables.md)) Required repository secrets: diff --git a/e2e/admin/auth.go b/e2e/admin/auth.go index 4754c3e5c5..7ee467d04a 100644 --- a/e2e/admin/auth.go +++ b/e2e/admin/auth.go @@ -3,30 +3,15 @@ package admin import ( - "bytes" "context" - "encoding/json" "fmt" - "io" - "net/http" "os" "os/exec" "strings" - "time" -) - -const defaultMintAudience = "fullsend-mint" -type mintTokenRequest struct { - Role string `json:"role"` - TargetOrg string `json:"target_org"` - Repos []string `json:"repos"` -} - -type mintTokenResponse struct { - Token string `json:"token"` - Error string `json:"error"` -} + "github.com/fullsend-ai/fullsend/internal/forge" + "github.com/fullsend-ai/fullsend/internal/mintclient" +) // resolveLocalToken returns a user token from env or gh auth. func resolveLocalToken() (string, error) { @@ -51,108 +36,33 @@ func runningInGitHubActions() bool { return os.Getenv("GITHUB_ACTIONS") == "true" } -// requestGitHubOIDCToken obtains a GHA OIDC token for the mint audience. -func requestGitHubOIDCToken(ctx context.Context) (string, error) { - reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") - reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") - if reqURL == "" || reqToken == "" { - return "", fmt.Errorf("ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN not set") - } - - fullURL := reqURL - if !strings.Contains(fullURL, "audience=") { - sep := "?" - if strings.Contains(fullURL, "?") { - sep = "&" - } - fullURL = fullURL + sep + "audience=" + defaultMintAudience - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) - if err != nil { - return "", fmt.Errorf("creating OIDC request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+reqToken) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("requesting OIDC token: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return "", fmt.Errorf("OIDC request returned %d: %s", resp.StatusCode, body) - } - - var payload struct { - Value string `json:"value"` - } - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", fmt.Errorf("decoding OIDC response: %w", err) - } - if payload.Value == "" { - return "", fmt.Errorf("empty OIDC token") - } - return payload.Value, nil +// poolMintRepos returns repo names for cross-org e2e mint tokens. +// test-repo is first for installation lookup; .fullsend is required for admin install tests. +func poolMintRepos() []string { + return []string{testRepo, forge.ConfigRepoName, lockRepo} } // resolveE2EToken mints a cross-org e2e installation token for targetOrg. -func resolveE2EToken(ctx context.Context, mintURL, targetOrg string, repos []string) (string, error) { +func resolveE2EToken(ctx context.Context, mintURL, targetOrg string) (string, error) { if mintURL == "" { return "", fmt.Errorf("E2E_MINT_URL not set") } - oidcToken, err := requestGitHubOIDCToken(ctx) - if err != nil { - return "", err - } - - body, err := json.Marshal(mintTokenRequest{ + result, err := mintclient.MintToken(ctx, mintclient.MintRequest{ + MintURL: mintURL, Role: "e2e", TargetOrg: targetOrg, - Repos: repos, + Repos: poolMintRepos(), }) if err != nil { - return "", fmt.Errorf("marshaling mint request: %w", err) - } - - mintEndpoint := strings.TrimRight(mintURL, "/") + "/v1/token" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, mintEndpoint, bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("creating mint request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+oidcToken) - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 60 * time.Second} - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("calling mint: %w", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) - if err != nil { - return "", fmt.Errorf("reading mint response: %w", err) - } - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("mint returned %d: %s", resp.StatusCode, respBody) - } - - var mintResp mintTokenResponse - if err := json.Unmarshal(respBody, &mintResp); err != nil { - return "", fmt.Errorf("decoding mint response: %w", err) - } - if mintResp.Token == "" { - return "", fmt.Errorf("mint returned empty token") + return "", err } - return mintResp.Token, nil + return result.Token, nil } // tokenForOrg returns an API token for operating on a pool org. func tokenForOrg(ctx context.Context, cfg envConfig, org string) (string, error) { if cfg.useMint { - return resolveE2EToken(ctx, cfg.mintURL, org, []string{lockRepo, testRepo}) + return resolveE2EToken(ctx, cfg.mintURL, org) } return resolveLocalToken() } diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 25a79255d0..79f248c5e9 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -2412,35 +2412,27 @@ func (c *LiveClient) SetOrgSecretRepos(ctx context.Context, org, name string, re // CreateOrUpdateOrgVariable creates or updates an org-level Actions variable // scoped to the given repository IDs. func (c *LiveClient) CreateOrUpdateOrgVariable(ctx context.Context, org, name, value string, selectedRepoIDs []int64) error { - if selectedRepoIDs == nil { - selectedRepoIDs = []int64{} - } + return c.createOrUpdateOrgVariable(ctx, org, name, value, "selected", selectedRepoIDs) +} - // Try PATCH first (update existing). - patchPayload := map[string]any{ - "value": value, - "visibility": "selected", - "selected_repository_ids": selectedRepoIDs, - } +// CreateOrUpdateOrgVariableAll creates or updates an org-level Actions variable +// visible to all repositories in the org (visibility all). +func (c *LiveClient) CreateOrUpdateOrgVariableAll(ctx context.Context, org, name, value string) error { + return c.createOrUpdateOrgVariable(ctx, org, name, value, "all", nil) +} - resp, err := c.patch(ctx, fmt.Sprintf("/orgs/%s/actions/variables/%s", org, name), patchPayload) +func (c *LiveClient) createOrUpdateOrgVariable(ctx context.Context, org, name, value, visibility string, selectedRepoIDs []int64) error { + resp, err := c.patch(ctx, fmt.Sprintf("/orgs/%s/actions/variables/%s", org, name), orgVariableBody("", value, visibility, selectedRepoIDs)) if err == nil { resp.Body.Close() return nil } - // If the variable doesn't exist (404), create it. if !isNotFound(err) { return fmt.Errorf("update org variable %s: %w", name, err) } - createPayload := map[string]any{ - "name": name, - "value": value, - "visibility": "selected", - "selected_repository_ids": selectedRepoIDs, - } - resp2, err := c.post(ctx, fmt.Sprintf("/orgs/%s/actions/variables", org), createPayload) + resp2, err := c.post(ctx, fmt.Sprintf("/orgs/%s/actions/variables", org), orgVariableBody(name, value, visibility, selectedRepoIDs)) if err != nil { return fmt.Errorf("create org variable %s: %w", name, err) } @@ -2448,35 +2440,23 @@ func (c *LiveClient) CreateOrUpdateOrgVariable(ctx context.Context, org, name, v return nil } -// CreateOrUpdateOrgVariableAll creates or updates an org-level Actions variable -// visible to all repositories in the org (visibility all). -func (c *LiveClient) CreateOrUpdateOrgVariableAll(ctx context.Context, org, name, value string) error { - patchPayload := map[string]any{ +// orgVariableBody builds a GitHub org Actions variable request body. +// name is included only for create (POST) requests. +func orgVariableBody(name, value, visibility string, selectedRepoIDs []int64) map[string]any { + body := map[string]any{ "value": value, - "visibility": "all", + "visibility": visibility, } - - resp, err := c.patch(ctx, fmt.Sprintf("/orgs/%s/actions/variables/%s", org, name), patchPayload) - if err == nil { - resp.Body.Close() - return nil - } - - if !isNotFound(err) { - return fmt.Errorf("update org variable %s: %w", name, err) + if name != "" { + body["name"] = name } - - createPayload := map[string]any{ - "name": name, - "value": value, - "visibility": "all", - } - resp2, err := c.post(ctx, fmt.Sprintf("/orgs/%s/actions/variables", org), createPayload) - if err != nil { - return fmt.Errorf("create org variable %s: %w", name, err) + if visibility == "selected" { + if selectedRepoIDs == nil { + selectedRepoIDs = []int64{} + } + body["selected_repository_ids"] = selectedRepoIDs } - resp2.Body.Close() - return nil + return body } // OrgVariableExists checks if an org-level variable exists. diff --git a/internal/mintclient/mintclient.go b/internal/mintclient/mintclient.go index d057fda218..63e6d29181 100644 --- a/internal/mintclient/mintclient.go +++ b/internal/mintclient/mintclient.go @@ -24,10 +24,11 @@ const defaultAudience = "fullsend-mint" // MintRequest holds the parameters for minting a token via the fullsend mint service. type MintRequest struct { - MintURL string - Role string - Repos []string - Audience string + MintURL string + Role string + Repos []string + TargetOrg string // optional: cross-org mint when set and differs from caller org + Audience string } // MintResult holds the minted token and its expiry. @@ -74,7 +75,7 @@ func MintToken(ctx context.Context, req MintRequest) (*MintResult, error) { return nil, fmt.Errorf("fetching OIDC JWT: %w", err) } - result, err := callMint(ctx, req.MintURL, oidcJWT, req.Role, req.Repos) + result, err := callMint(ctx, req.MintURL, oidcJWT, req) if err != nil { return nil, fmt.Errorf("calling mint service: %w", err) } @@ -154,14 +155,16 @@ func fetchOIDCJWT(ctx context.Context, audience string) (string, error) { } type mintRequestBody struct { - Role string `json:"role"` - Repos []string `json:"repos"` + Role string `json:"role"` + TargetOrg string `json:"target_org,omitempty"` + Repos []string `json:"repos"` } -func callMint(ctx context.Context, mintURL, oidcJWT, role string, repos []string) (*MintResult, error) { +func callMint(ctx context.Context, mintURL, oidcJWT string, req MintRequest) (*MintResult, error) { reqBody := mintRequestBody{ - Role: role, - Repos: repos, + Role: req.Role, + TargetOrg: req.TargetOrg, + Repos: req.Repos, } bodyBytes, err := json.Marshal(reqBody) diff --git a/internal/mintclient/mintclient_test.go b/internal/mintclient/mintclient_test.go index db2c3c5dfe..379c962f82 100644 --- a/internal/mintclient/mintclient_test.go +++ b/internal/mintclient/mintclient_test.go @@ -81,6 +81,65 @@ func TestMintToken_HappyPath(t *testing.T) { } } +func TestMintToken_CrossOrgTarget(t *testing.T) { + oidcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(oidcTokenResponse{Value: "oidc-jwt-value"}) + })) + defer oidcServer.Close() + + mintServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body mintRequestBody + json.NewDecoder(r.Body).Decode(&body) + if body.Role != "e2e" { + t.Errorf("role = %q, want %q", body.Role, "e2e") + } + if body.TargetOrg != "halfsend-01" { + t.Errorf("target_org = %q, want %q", body.TargetOrg, "halfsend-01") + } + wantRepos := []string{"test-repo", ".fullsend", "e2e-lock"} + if len(body.Repos) != len(wantRepos) { + t.Fatalf("repos = %v, want %v", body.Repos, wantRepos) + } + for i, repo := range wantRepos { + if body.Repos[i] != repo { + t.Errorf("repos[%d] = %q, want %q", i, body.Repos[i], repo) + } + } + + json.NewEncoder(w).Encode(MintResult{ + Token: "ghu_e2e_token", + ExpiresAt: "2026-06-11T23:30:00Z", + }) + })) + defer mintServer.Close() + + origEnv := envLookup + envLookup = func(key string) string { + switch key { + case "ACTIONS_ID_TOKEN_REQUEST_URL": + return oidcServer.URL + "?dummy=1" + case "ACTIONS_ID_TOKEN_REQUEST_TOKEN": + return "test-request-token" + default: + return "" + } + } + defer func() { envLookup = origEnv }() + + result, err := MintToken(context.Background(), MintRequest{ + MintURL: mintServer.URL, + Role: "e2e", + TargetOrg: "halfsend-01", + Repos: []string{"test-repo", ".fullsend", "e2e-lock"}, + }) + if err != nil { + t.Fatalf("MintToken() error = %v", err) + } + if result.Token != "ghu_e2e_token" { + t.Errorf("token = %q, want %q", result.Token, "ghu_e2e_token") + } +} + func TestMintToken_CustomAudience(t *testing.T) { var gotAudience string oidcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 6e0bfedbf94282ea70cefc77a74a650b3fc8dc89 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Mon, 22 Jun 2026 12:21:05 +0300 Subject: [PATCH 05/26] feat(mint): grant e2e app org variables permission for FOREIGN reads Cross-org mint reads FULLSEND_FOREIGN_* via GET /orgs/{org}/actions/variables, which requires organization_actions_variables (not repository actions_variables). Add the permission to the e2e app manifest and mint policy token requests. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/guides/dev/e2e-testing.md | 4 ++-- .../gcf/mintsrc/mintcore/github.go.embed | 11 ++++++--- internal/forge/github/types.go | 24 +++++++++++-------- internal/forge/github/types_test.go | 4 ++++ internal/mintcore/github.go | 11 ++++++--- internal/mintcore/github_test.go | 1 + 6 files changed, 37 insertions(+), 18 deletions(-) diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index 87546a6e24..8141256fa9 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -57,8 +57,8 @@ Each pool org must be provisioned before e2e can use it: 1. Org exists with `botsend` as owner 2. `test-repo` and `e2e-lock` repos (lock created at runtime) -3. All role apps installed, including `fullsend-ai-e2e` -4. `FULLSEND_FOREIGN_E2E_REPOS` includes `fullsend-ai/fullsend` (authorizes CI workflows) +3. All role apps installed, including `fullsend-ai-e2e` with **Organization → Variables: Read** (`organization_actions_variables`) in addition to repository permissions +4. `FULLSEND_FOREIGN_E2E_REPOS` includes `fullsend-ai/fullsend` with org-wide visibility (`visibility: all`) 5. Mint enrolled: org in `ALLOWED_ORGS`, `${ORG}/e2e` in `ROLE_APP_IDS`, e2e app PEM enrolled Use the idempotent setup script: diff --git a/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed index c5c7e2f666..5ece0059db 100644 --- a/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed +++ b/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed @@ -67,8 +67,8 @@ var canonicalRolePermissions = map[string]map[string]string{ "e2e": { "actions": "write", "actions_variables": "read", "administration": "write", "contents": "write", "issues": "write", "members": "write", "metadata": "read", - "organization_administration": "write", "pull_requests": "write", - "secrets": "write", "workflows": "write", + "organization_actions_variables": "read", "organization_administration": "write", + "pull_requests": "write", "secrets": "write", "workflows": "write", }, } @@ -277,6 +277,11 @@ func GetOrgVariable(ctx context.Context, httpClient HTTPDoer, githubBaseURL, ins return varResp.Value, true, nil } +// foreignPolicyPermissions are requested when reading FULLSEND_FOREIGN_* org variables. +var foreignPolicyPermissions = map[string]string{ + "organization_actions_variables": "read", +} + // createInstallationTokenWithPermissions creates an installation access token with explicit permissions. func createInstallationTokenWithPermissions(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, perms map[string]string, repos []string) (string, error) { tokenReqBody := map[string]interface{}{ @@ -324,7 +329,7 @@ func createInstallationTokenWithPermissions(ctx context.Context, httpClient HTTP // ReadForeignAllowlist reads FULLSEND_FOREIGN__REPOS from the target org. func ReadForeignAllowlist(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, targetOrg, role string) ([]string, error) { policyToken, err := createInstallationTokenWithPermissions(ctx, httpClient, githubBaseURL, jwt, installationID, - map[string]string{"actions_variables": "read"}, nil) + foreignPolicyPermissions, nil) if err != nil { return nil, fmt.Errorf("creating policy check token: %w", err) } diff --git a/internal/forge/github/types.go b/internal/forge/github/types.go index 0bf9caf1d0..cd15ae0cf6 100644 --- a/internal/forge/github/types.go +++ b/internal/forge/github/types.go @@ -15,6 +15,9 @@ type AppPermissions struct { Members string `json:"members,omitempty"` OrganizationProjects string `json:"organization_projects,omitempty"` OrganizationAdministration string `json:"organization_administration,omitempty"` + // OrganizationActionsVariables is org-level Actions variables (distinct from + // repository actions_variables). Required to read FULLSEND_FOREIGN_* via the org API. + OrganizationActionsVariables string `json:"organization_actions_variables,omitempty"` Secrets string `json:"secrets,omitempty"` } @@ -144,16 +147,17 @@ func AgentAppConfig(org, role, appSet string) AppConfig { case "e2e": base.Description = fmt.Sprintf("Fullsend e2e pool testing for %s", org) base.Permissions = AppPermissions{ - Actions: "write", - Variables: "read", - Administration: "write", - Contents: "write", - Issues: "write", - Members: "write", - OrganizationAdministration: "write", - PullRequests: "write", - Secrets: "write", - Workflows: "write", + Actions: "write", + Variables: "read", + OrganizationActionsVariables: "read", + Administration: "write", + Contents: "write", + Issues: "write", + Members: "write", + OrganizationAdministration: "write", + PullRequests: "write", + Secrets: "write", + Workflows: "write", } // Pool tests are API/mint driven; no webhook events required. base.Events = []string{} diff --git a/internal/forge/github/types_test.go b/internal/forge/github/types_test.go index 4d0a2a0bf7..288e890a1b 100644 --- a/internal/forge/github/types_test.go +++ b/internal/forge/github/types_test.go @@ -110,6 +110,7 @@ func TestAgentAppConfig_E2e(t *testing.T) { assert.Equal(t, "fullsend-ai-e2e", cfg.Name) assert.Equal(t, "write", cfg.Permissions.Actions) assert.Equal(t, "read", cfg.Permissions.Variables) + assert.Equal(t, "read", cfg.Permissions.OrganizationActionsVariables) assert.Equal(t, "write", cfg.Permissions.Administration) assert.Equal(t, "write", cfg.Permissions.Contents) assert.Equal(t, "write", cfg.Permissions.Issues) @@ -157,6 +158,9 @@ func appPermissionsAsMap(p AppPermissions) map[string]string { if p.OrganizationAdministration != "" { out["organization_administration"] = p.OrganizationAdministration } + if p.OrganizationActionsVariables != "" { + out["organization_actions_variables"] = p.OrganizationActionsVariables + } if p.Secrets != "" { out["secrets"] = p.Secrets } diff --git a/internal/mintcore/github.go b/internal/mintcore/github.go index c5c7e2f666..5ece0059db 100644 --- a/internal/mintcore/github.go +++ b/internal/mintcore/github.go @@ -67,8 +67,8 @@ var canonicalRolePermissions = map[string]map[string]string{ "e2e": { "actions": "write", "actions_variables": "read", "administration": "write", "contents": "write", "issues": "write", "members": "write", "metadata": "read", - "organization_administration": "write", "pull_requests": "write", - "secrets": "write", "workflows": "write", + "organization_actions_variables": "read", "organization_administration": "write", + "pull_requests": "write", "secrets": "write", "workflows": "write", }, } @@ -277,6 +277,11 @@ func GetOrgVariable(ctx context.Context, httpClient HTTPDoer, githubBaseURL, ins return varResp.Value, true, nil } +// foreignPolicyPermissions are requested when reading FULLSEND_FOREIGN_* org variables. +var foreignPolicyPermissions = map[string]string{ + "organization_actions_variables": "read", +} + // createInstallationTokenWithPermissions creates an installation access token with explicit permissions. func createInstallationTokenWithPermissions(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, perms map[string]string, repos []string) (string, error) { tokenReqBody := map[string]interface{}{ @@ -324,7 +329,7 @@ func createInstallationTokenWithPermissions(ctx context.Context, httpClient HTTP // ReadForeignAllowlist reads FULLSEND_FOREIGN__REPOS from the target org. func ReadForeignAllowlist(ctx context.Context, httpClient HTTPDoer, githubBaseURL, jwt string, installationID int64, targetOrg, role string) ([]string, error) { policyToken, err := createInstallationTokenWithPermissions(ctx, httpClient, githubBaseURL, jwt, installationID, - map[string]string{"actions_variables": "read"}, nil) + foreignPolicyPermissions, nil) if err != nil { return nil, fmt.Errorf("creating policy check token: %w", err) } diff --git a/internal/mintcore/github_test.go b/internal/mintcore/github_test.go index ce3339d486..f27e4d2511 100644 --- a/internal/mintcore/github_test.go +++ b/internal/mintcore/github_test.go @@ -120,6 +120,7 @@ func TestRolePermissions_E2e(t *testing.T) { require.NotNil(t, perms) assert.Equal(t, "write", perms["actions"]) assert.Equal(t, "read", perms["actions_variables"]) + assert.Equal(t, "read", perms["organization_actions_variables"]) assert.Equal(t, "write", perms["administration"]) assert.Equal(t, "write", perms["contents"]) assert.Equal(t, "write", perms["issues"]) From ea97fef88ae75a43d860b8c45c1bc424ad4357b1 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Mon, 22 Jun 2026 13:43:21 +0300 Subject: [PATCH 06/26] feat(mint): allow installation-wide mint tokens when repos are omitted Omitting repos on /v1/token issues an unscoped installation token via FindOrgInstallation, with WARNING logs on request and grant. E2e cross-org mint uses this so pool tests can create e2e-lock and .fullsend at runtime. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/guides/dev/e2e-testing.md | 2 +- e2e/admin/auth.go | 10 +-- .../gcf/mintsrc/mintcore/github.go.embed | 6 +- .../gcf/mintsrc/mintcore/handler.go.embed | 22 ++++--- internal/mintclient/mintclient.go | 10 +-- internal/mintclient/mintclient_test.go | 50 ++++++++++++--- internal/mintcore/github.go | 6 +- internal/mintcore/github_test.go | 42 +++++++++++++ internal/mintcore/handler.go | 22 ++++--- internal/mintcore/handler_test.go | 61 ++++++++++++++++--- 10 files changed, 177 insertions(+), 54 deletions(-) diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index 8141256fa9..42720e1c15 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -37,7 +37,7 @@ Tests acquire an exclusive lock on one org from the pool (`halfsend-01` … In GitHub Actions, tests mint a cross-org installation token via the mint service: 1. Workflow requests a GHA OIDC token (`id-token: write`) -2. `mintclient.MintToken` POSTs to `E2E_MINT_URL/v1/token` with `{role: "e2e", target_org: "", repos: ["test-repo", ".fullsend", "e2e-lock"]}` +2. `mintclient.MintToken` POSTs to `E2E_MINT_URL/v1/token` with `{role: "e2e", target_org: ""}` (repos omitted for installation-wide access) 3. Mint verifies the caller against `FULLSEND_FOREIGN_E2E_REPOS` on the target org ([ADR 0051](../../ADRs/0051-cross-org-mint-authorization-via-org-variables.md)) Required repository secrets: diff --git a/e2e/admin/auth.go b/e2e/admin/auth.go index 7ee467d04a..9c7efba57c 100644 --- a/e2e/admin/auth.go +++ b/e2e/admin/auth.go @@ -9,7 +9,6 @@ import ( "os/exec" "strings" - "github.com/fullsend-ai/fullsend/internal/forge" "github.com/fullsend-ai/fullsend/internal/mintclient" ) @@ -36,13 +35,9 @@ func runningInGitHubActions() bool { return os.Getenv("GITHUB_ACTIONS") == "true" } -// poolMintRepos returns repo names for cross-org e2e mint tokens. -// test-repo is first for installation lookup; .fullsend is required for admin install tests. -func poolMintRepos() []string { - return []string{testRepo, forge.ConfigRepoName, lockRepo} -} - // resolveE2EToken mints a cross-org e2e installation token for targetOrg. +// Repos are omitted so the token covers the full installation (needed to +// create and operate on e2e-lock and .fullsend at runtime). func resolveE2EToken(ctx context.Context, mintURL, targetOrg string) (string, error) { if mintURL == "" { return "", fmt.Errorf("E2E_MINT_URL not set") @@ -51,7 +46,6 @@ func resolveE2EToken(ctx context.Context, mintURL, targetOrg string) (string, er MintURL: mintURL, Role: "e2e", TargetOrg: targetOrg, - Repos: poolMintRepos(), }) if err != nil { return "", err diff --git a/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed index 5ece0059db..372b7f75be 100644 --- a/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed +++ b/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed @@ -352,8 +352,10 @@ func CreateInstallationToken(ctx context.Context, httpClient HTTPDoer, githubBas return "", "", nil, fmt.Errorf("no permissions defined for role %q", role) } tokenReqBody := map[string]interface{}{ - "repositories": repos, - "permissions": perms, + "permissions": perms, + } + if len(repos) > 0 { + tokenReqBody["repositories"] = repos } tokenReqBytes, err := json.Marshal(tokenReqBody) diff --git a/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed index 57ee49f307..3abaf653cf 100644 --- a/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed +++ b/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed @@ -190,11 +190,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - if len(req.Repos) == 0 { - writeError(w, http.StatusBadRequest, "repos is required (at least one repo must be specified)") - return - } - if len(req.Repos) > maxRepos { writeError(w, http.StatusBadRequest, fmt.Sprintf("too many repos (max %d)", maxRepos)) return @@ -228,6 +223,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { targetOrg = callerOrg } + if len(req.Repos) == 0 { + log.Printf("WARNING: mint request omitted repos; issuing installation-wide token for target_org=%s role=%s caller_org=%s source_repo=%s", + targetOrg, req.Role, callerOrg, claims.Repository) + } + var token, expiresAt string var granted *GrantedScope @@ -252,7 +252,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { callerOrg, targetOrg, req.Role, granted.AppID, granted.InstallationID, req.Repos, claims.Repository, claims.JobWorkflowRef) log.Printf("granted scope: repos=%v permissions=%v repo_selection=%s", granted.Repos, granted.Permissions, granted.RepoSelection) - if granted.RepoSelection == "all" { + if len(req.Repos) == 0 { + log.Printf("WARNING: installation-wide token granted for target_org=%s role=%s repo_selection=%s", + targetOrg, req.Role, granted.RepoSelection) + } else if granted.RepoSelection == "all" { log.Printf("WARNING: token granted with repository_selection=all (requested specific repos: %v)", req.Repos) } requested := RolePermissionsFor(req.Role) @@ -336,7 +339,12 @@ func (h *Handler) mintToken(ctx context.Context, org, role string, repos []strin return "", "", nil, &mintError{status: http.StatusInternalServerError, msg: fmt.Sprintf("generating app JWT: %v", err)} } - installationID, err := FindInstallation(ctx, h.httpClient, h.githubBaseURL, jwt, org, repos[0]) + var installationID int64 + if len(repos) == 0 { + installationID, err = FindOrgInstallation(ctx, h.httpClient, h.githubBaseURL, jwt, org) + } else { + installationID, err = FindInstallation(ctx, h.httpClient, h.githubBaseURL, jwt, org, repos[0]) + } if err != nil { return "", "", nil, &mintError{status: http.StatusBadGateway, msg: err.Error()} } diff --git a/internal/mintclient/mintclient.go b/internal/mintclient/mintclient.go index 63e6d29181..32a1f4cb1c 100644 --- a/internal/mintclient/mintclient.go +++ b/internal/mintclient/mintclient.go @@ -26,8 +26,8 @@ const defaultAudience = "fullsend-mint" type MintRequest struct { MintURL string Role string - Repos []string - TargetOrg string // optional: cross-org mint when set and differs from caller org + Repos []string // optional: omit for installation-wide token (all repos on the installation) + TargetOrg string // optional: cross-org mint when set and differs from caller org Audience string } @@ -61,10 +61,6 @@ func MintToken(ctx context.Context, req MintRequest) (*MintResult, error) { if req.Role == "" { return nil, fmt.Errorf("role is required") } - if len(req.Repos) == 0 { - return nil, fmt.Errorf("at least one repo is required") - } - audience := req.Audience if audience == "" { audience = defaultAudience @@ -157,7 +153,7 @@ func fetchOIDCJWT(ctx context.Context, audience string) (string, error) { type mintRequestBody struct { Role string `json:"role"` TargetOrg string `json:"target_org,omitempty"` - Repos []string `json:"repos"` + Repos []string `json:"repos,omitempty"` } func callMint(ctx context.Context, mintURL, oidcJWT string, req MintRequest) (*MintResult, error) { diff --git a/internal/mintclient/mintclient_test.go b/internal/mintclient/mintclient_test.go index 379c962f82..ec275f1d9a 100644 --- a/internal/mintclient/mintclient_test.go +++ b/internal/mintclient/mintclient_test.go @@ -96,14 +96,8 @@ func TestMintToken_CrossOrgTarget(t *testing.T) { if body.TargetOrg != "halfsend-01" { t.Errorf("target_org = %q, want %q", body.TargetOrg, "halfsend-01") } - wantRepos := []string{"test-repo", ".fullsend", "e2e-lock"} - if len(body.Repos) != len(wantRepos) { - t.Fatalf("repos = %v, want %v", body.Repos, wantRepos) - } - for i, repo := range wantRepos { - if body.Repos[i] != repo { - t.Errorf("repos[%d] = %q, want %q", i, body.Repos[i], repo) - } + if len(body.Repos) != 0 { + t.Errorf("repos = %v, want omitted for installation-wide e2e token", body.Repos) } json.NewEncoder(w).Encode(MintResult{ @@ -130,7 +124,6 @@ func TestMintToken_CrossOrgTarget(t *testing.T) { MintURL: mintServer.URL, Role: "e2e", TargetOrg: "halfsend-01", - Repos: []string{"test-repo", ".fullsend", "e2e-lock"}, }) if err != nil { t.Fatalf("MintToken() error = %v", err) @@ -140,6 +133,44 @@ func TestMintToken_CrossOrgTarget(t *testing.T) { } } +func TestMintToken_OmittedRepos(t *testing.T) { + oidcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(oidcTokenResponse{Value: "oidc-jwt-value"}) + })) + defer oidcServer.Close() + + var gotBody mintRequestBody + mintServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&gotBody) + json.NewEncoder(w).Encode(MintResult{Token: "tok", ExpiresAt: "2026-01-01T00:00:00Z"}) + })) + defer mintServer.Close() + + origEnv := envLookup + envLookup = func(key string) string { + switch key { + case "ACTIONS_ID_TOKEN_REQUEST_URL": + return oidcServer.URL + "?dummy=1" + case "ACTIONS_ID_TOKEN_REQUEST_TOKEN": + return "test-request-token" + default: + return "" + } + } + defer func() { envLookup = origEnv }() + + _, err := MintToken(context.Background(), MintRequest{ + MintURL: mintServer.URL, + Role: "triage", + }) + if err != nil { + t.Fatalf("MintToken() error = %v", err) + } + if len(gotBody.Repos) != 0 { + t.Errorf("repos = %v, want omitted", gotBody.Repos) + } +} + func TestMintToken_CustomAudience(t *testing.T) { var gotAudience string oidcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -189,7 +220,6 @@ func TestMintToken_ValidationErrors(t *testing.T) { {"empty mint URL", MintRequest{Role: "triage", Repos: []string{"r"}}, "mint URL is required"}, {"non-HTTPS mint URL", MintRequest{MintURL: "http://example.com", Role: "triage", Repos: []string{"r"}}, "mint URL must use HTTPS"}, {"empty role", MintRequest{MintURL: "https://mint.example.com", Repos: []string{"r"}}, "role is required"}, - {"no repos", MintRequest{MintURL: "https://mint.example.com", Role: "triage"}, "at least one repo is required"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/mintcore/github.go b/internal/mintcore/github.go index 5ece0059db..372b7f75be 100644 --- a/internal/mintcore/github.go +++ b/internal/mintcore/github.go @@ -352,8 +352,10 @@ func CreateInstallationToken(ctx context.Context, httpClient HTTPDoer, githubBas return "", "", nil, fmt.Errorf("no permissions defined for role %q", role) } tokenReqBody := map[string]interface{}{ - "repositories": repos, - "permissions": perms, + "permissions": perms, + } + if len(repos) > 0 { + tokenReqBody["repositories"] = repos } tokenReqBytes, err := json.Marshal(tokenReqBody) diff --git a/internal/mintcore/github_test.go b/internal/mintcore/github_test.go index f27e4d2511..30ad73a3e2 100644 --- a/internal/mintcore/github_test.go +++ b/internal/mintcore/github_test.go @@ -76,6 +76,48 @@ func TestFindInstallation_OrgMismatch(t *testing.T) { assert.Contains(t, err.Error(), "belongs to other-org") } +func TestCreateInstallationToken_Unscoped(t *testing.T) { + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/app/installations/42/access_tokens", r.URL.Path) + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + assert.Contains(t, body, "permissions") + assert.NotContains(t, body, "repositories") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(installationTokenResponse{ + Token: "ghs_test_token", + ExpiresAt: "2099-01-01T00:00:00Z", + RepositorySelection: "all", + }) + })) + defer mockGH.Close() + + token, expiresAt, granted, err := CreateInstallationToken(t.Context(), http.DefaultClient, mockGH.URL, "fake-jwt", 42, "coder", nil) + require.NoError(t, err) + assert.Equal(t, "ghs_test_token", token) + assert.Equal(t, "2099-01-01T00:00:00Z", expiresAt) + require.NotNil(t, granted) + assert.Equal(t, "all", granted.RepoSelection) +} + +func TestFindOrgInstallation(t *testing.T) { + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/orgs/myorg/installation", r.URL.Path) + assert.Contains(t, r.Header.Get("Authorization"), "Bearer ") + json.NewEncoder(w).Encode(installationResponse{ + ID: 42, + Account: struct { + Login string `json:"login"` + }{Login: "myorg"}, + }) + })) + defer mockGH.Close() + + id, err := FindOrgInstallation(t.Context(), http.DefaultClient, mockGH.URL, "fake-jwt", "myorg") + require.NoError(t, err) + assert.Equal(t, int64(42), id) +} + func TestCreateInstallationToken(t *testing.T) { mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/app/installations/42/access_tokens", r.URL.Path) diff --git a/internal/mintcore/handler.go b/internal/mintcore/handler.go index 57ee49f307..3abaf653cf 100644 --- a/internal/mintcore/handler.go +++ b/internal/mintcore/handler.go @@ -190,11 +190,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - if len(req.Repos) == 0 { - writeError(w, http.StatusBadRequest, "repos is required (at least one repo must be specified)") - return - } - if len(req.Repos) > maxRepos { writeError(w, http.StatusBadRequest, fmt.Sprintf("too many repos (max %d)", maxRepos)) return @@ -228,6 +223,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { targetOrg = callerOrg } + if len(req.Repos) == 0 { + log.Printf("WARNING: mint request omitted repos; issuing installation-wide token for target_org=%s role=%s caller_org=%s source_repo=%s", + targetOrg, req.Role, callerOrg, claims.Repository) + } + var token, expiresAt string var granted *GrantedScope @@ -252,7 +252,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { callerOrg, targetOrg, req.Role, granted.AppID, granted.InstallationID, req.Repos, claims.Repository, claims.JobWorkflowRef) log.Printf("granted scope: repos=%v permissions=%v repo_selection=%s", granted.Repos, granted.Permissions, granted.RepoSelection) - if granted.RepoSelection == "all" { + if len(req.Repos) == 0 { + log.Printf("WARNING: installation-wide token granted for target_org=%s role=%s repo_selection=%s", + targetOrg, req.Role, granted.RepoSelection) + } else if granted.RepoSelection == "all" { log.Printf("WARNING: token granted with repository_selection=all (requested specific repos: %v)", req.Repos) } requested := RolePermissionsFor(req.Role) @@ -336,7 +339,12 @@ func (h *Handler) mintToken(ctx context.Context, org, role string, repos []strin return "", "", nil, &mintError{status: http.StatusInternalServerError, msg: fmt.Sprintf("generating app JWT: %v", err)} } - installationID, err := FindInstallation(ctx, h.httpClient, h.githubBaseURL, jwt, org, repos[0]) + var installationID int64 + if len(repos) == 0 { + installationID, err = FindOrgInstallation(ctx, h.httpClient, h.githubBaseURL, jwt, org) + } else { + installationID, err = FindInstallation(ctx, h.httpClient, h.githubBaseURL, jwt, org, repos[0]) + } if err != nil { return "", "", nil, &mintError{status: http.StatusBadGateway, msg: err.Error()} } diff --git a/internal/mintcore/handler_test.go b/internal/mintcore/handler_test.go index 78b488bdcd..215c872de1 100644 --- a/internal/mintcore/handler_test.go +++ b/internal/mintcore/handler_test.go @@ -549,24 +549,65 @@ func TestHandler_InvalidRepoName(t *testing.T) { } } -func TestHandler_EmptyRepos(t *testing.T) { - t.Setenv("ALLOWED_ROLES", "coder") +func TestHandler_EmptyRepos_FullOrgToken(t *testing.T) { t.Setenv("ROLE_APP_IDS", `{"coder":"200"}`) - h := mustNewHandler(t, &fakePEMAccessor{}, &fakeOIDCVerifier{}) + + pemData, err := generateTestRSAKey() + if err != nil { + t.Fatalf("generating test key: %v", err) + } + + env := newTestOIDCEnv(t, &fakePEMAccessor{ + pems: map[string][]byte{"coder": pemData}, + }) + token := env.signToken(t, nil) + + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/orgs/test-org/installation" && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(installationResponse{ + ID: 12345, Account: struct { + Login string `json:"login"` + }{Login: "test-org"}, + }) + case strings.HasPrefix(r.URL.Path, "/app/installations/12345/access_tokens") && r.Method == http.MethodPost: + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["repositories"]; ok { + t.Error("expected installation token request to omit repositories") + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(installationTokenResponse{ + Token: "ghs_full_org_token", + ExpiresAt: "2026-05-06T12:00:00Z", + Permissions: map[string]string{"contents": "write", "metadata": "read"}, + RepositorySelection: "all", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer github.Close() + env.handler.githubBaseURL = github.URL body := `{"role":"coder"}` rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/token", strings.NewReader(body)) - req.Header.Set("Authorization", "Bearer test-token") - h.ServeHTTP(rec, req) + req.Header.Set("Authorization", "Bearer "+token) + env.handler.ServeHTTP(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", rec.Code) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } - var resp map[string]string + + var resp mintResponse json.NewDecoder(rec.Body).Decode(&resp) - if !strings.Contains(resp["error"], "repos is required") { - t.Fatalf("expected repos required error, got: %s", resp["error"]) + if resp.Token != "ghs_full_org_token" { + t.Fatalf("expected token=ghs_full_org_token, got %s", resp.Token) + } + if resp.RepoSelection != "all" { + t.Fatalf("expected repository_selection=all, got %s", resp.RepoSelection) } } From 9d7d41567ad55d505c1bebce29cb67e54f9dc03c Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Mon, 22 Jun 2026 13:53:02 +0300 Subject: [PATCH 07/26] chore: fix gofmt alignment in AppPermissions struct Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/forge/github/types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/forge/github/types.go b/internal/forge/github/types.go index cd15ae0cf6..bc06f0f8ea 100644 --- a/internal/forge/github/types.go +++ b/internal/forge/github/types.go @@ -18,7 +18,7 @@ type AppPermissions struct { // OrganizationActionsVariables is org-level Actions variables (distinct from // repository actions_variables). Required to read FULLSEND_FOREIGN_* via the org API. OrganizationActionsVariables string `json:"organization_actions_variables,omitempty"` - Secrets string `json:"secrets,omitempty"` + Secrets string `json:"secrets,omitempty"` } // HookAttributes configures the webhook for a GitHub App. From 4b1123aa8082d03429b205147056b61981d67313 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Mon, 22 Jun 2026 16:32:24 +0300 Subject: [PATCH 08/26] fix(cli): detect installation tokens before OAuth scope preflight Probe GET /installation/repositories so minted e2e tokens skip scope checks without masking real GetTokenScopes failures on PATs. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/admin.go | 10 ++++ internal/cli/admin_test.go | 20 +++++++ internal/cli/tokenscope.go | 19 ++++--- internal/cli/tokenscope_test.go | 5 +- internal/forge/fake.go | 12 ++++ internal/forge/forge.go | 5 ++ internal/forge/github/github.go | 39 +++++++++++++ internal/forge/github/token_test.go | 85 +++++++++++++++++++++++++++++ internal/layers/preflight.go | 18 ++++-- internal/layers/preflight_test.go | 15 +++++ 10 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 internal/forge/github/token_test.go diff --git a/internal/cli/admin.go b/internal/cli/admin.go index a1d5f4d3be..d5d563910f 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -2676,6 +2676,16 @@ func checkPerRepoScopes(ctx context.Context, client forge.Client, printer *ui.Pr func checkTokenScopes(ctx context.Context, client forge.Client, printer *ui.Printer, required []string) error { printer.StepStart("Checking token permissions") + isInstallation, err := client.IsInstallationToken(ctx) + if err != nil { + printer.StepFail("Could not verify token permissions") + return fmt.Errorf("detecting installation token: %w", err) + } + if isInstallation { + printer.StepWarn("Preflight skipped: installation token (OAuth scopes do not apply)") + return nil + } + granted, err := client.GetTokenScopes(ctx) if err != nil { printer.StepFail("Could not verify token permissions") diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 4ca124b61d..a9e68361bf 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1200,6 +1200,16 @@ func TestCheckInstallScopes_FineGrainedToken(t *testing.T) { require.NoError(t, err) } +func TestCheckInstallScopes_InstallationToken(t *testing.T) { + client := &forge.FakeClient{ + InstallationToken: true, + } + printer := ui.New(&discardWriter{}) + + err := checkInstallScopes(context.Background(), client, printer) + require.NoError(t, err) +} + func TestCheckInstallScopes_GetTokenScopesError(t *testing.T) { client := &forge.FakeClient{ Errors: map[string]error{"GetTokenScopes": errors.New("network error")}, @@ -1262,6 +1272,16 @@ func TestCheckPerRepoScopes_FineGrainedToken(t *testing.T) { require.NoError(t, err) } +func TestCheckPerRepoScopes_InstallationToken(t *testing.T) { + client := &forge.FakeClient{ + InstallationToken: true, + } + printer := ui.New(&discardWriter{}) + + err := checkPerRepoScopes(context.Background(), client, printer) + require.NoError(t, err) +} + func TestCheckPerRepoScopes_GetTokenScopesError(t *testing.T) { client := &forge.FakeClient{ Errors: map[string]error{"GetTokenScopes": errors.New("network error")}, diff --git a/internal/cli/tokenscope.go b/internal/cli/tokenscope.go index 940fa91f72..445e229756 100644 --- a/internal/cli/tokenscope.go +++ b/internal/cli/tokenscope.go @@ -7,13 +7,16 @@ import ( "io" "net/http" "time" + + gh "github.com/fullsend-ai/fullsend/internal/forge/github" ) var tokenScopeClient = &http.Client{Timeout: 10 * time.Second} // fetchTokenScope introspects a GitHub installation token by calling // GET /installation/repositories and returning the full_name of each -// accessible repo. Returns (nil, nil) if the token is empty. +// accessible repo. Returns (nil, nil) when the token is empty or not an +// installation token. func fetchTokenScope(ctx context.Context, token, baseURL string) ([]string, error) { if token == "" { return nil, nil @@ -22,6 +25,14 @@ func fetchTokenScope(ctx context.Context, token, baseURL string) ([]string, erro ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() + isInstallation, err := gh.ProbeInstallationToken(ctx, tokenScopeClient, baseURL, token) + if err != nil { + return nil, fmt.Errorf("probing installation token: %w", err) + } + if !isInstallation { + return nil, nil + } + url := baseURL + "/installation/repositories?per_page=100" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -36,12 +47,6 @@ func fetchTokenScope(ctx context.Context, token, baseURL string) ([]string, erro } defer resp.Body.Close() - if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized { - // PATs and GITHUB_TOKENs can't call /installation/repositories. - // Not an error — just means this isn't an installation token. - io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) - return nil, nil - } if resp.StatusCode != http.StatusOK { io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) return nil, fmt.Errorf("token scope check returned status %d", resp.StatusCode) diff --git a/internal/cli/tokenscope_test.go b/internal/cli/tokenscope_test.go index b600db6ef6..85d4f0d3ac 100644 --- a/internal/cli/tokenscope_test.go +++ b/internal/cli/tokenscope_test.go @@ -14,7 +14,8 @@ import ( func TestFetchTokenScope_ReturnsRepoNames(t *testing.T) { github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/installation/repositories", r.URL.Path) - assert.Equal(t, "100", r.URL.Query().Get("per_page")) + perPage := r.URL.Query().Get("per_page") + assert.Contains(t, []string{"1", "100"}, perPage) assert.Equal(t, "Bearer ghs_test_token", r.Header.Get("Authorization")) json.NewEncoder(w).Encode(map[string]interface{}{ @@ -94,7 +95,7 @@ func TestFetchTokenScope_UnexpectedStatus(t *testing.T) { repos, err := fetchTokenScope(context.Background(), "ghs_token", github.URL) assert.Error(t, err) - assert.Contains(t, err.Error(), "status 500") + assert.Contains(t, err.Error(), "500") assert.Nil(t, repos) } diff --git a/internal/forge/fake.go b/internal/forge/fake.go index e9fcff4c6d..4ef01d179e 100644 --- a/internal/forge/fake.go +++ b/internal/forge/fake.go @@ -119,6 +119,7 @@ type FakeClient struct { Secrets map[string]bool // key: "owner/repo/name" PullRequests map[string][]ChangeProposal // key: "owner/repo" TokenScopes []string // scopes returned by GetTokenScopes + InstallationToken bool // IsInstallationToken return value VariablesExist map[string]bool // key: "owner/repo/name" VariableValues map[string]string // key: "owner/repo/name" @@ -638,6 +639,17 @@ func (f *FakeClient) GetTokenScopes(_ context.Context) ([]string, error) { return f.TokenScopes, nil } +func (f *FakeClient) IsInstallationToken(_ context.Context) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if e := f.err("IsInstallationToken"); e != nil { + return false, e + } + + return f.InstallationToken, nil +} + func (f *FakeClient) CreateRepoSecret(_ context.Context, owner, repo, name, value string) error { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/forge/forge.go b/internal/forge/forge.go index 195da10041..8cb296ed46 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -256,6 +256,11 @@ type Client interface { // Returns nil (not an error) if the forge doesn't support scope introspection. GetTokenScopes(ctx context.Context) ([]string, error) + // IsInstallationToken reports whether the current token is a GitHub App + // installation access token (as opposed to a user PAT or OAuth token). + // Used to skip OAuth scope preflight, which does not apply to installation tokens. + IsInstallationToken(ctx context.Context) (bool, error) + // Secrets and variables CreateRepoSecret(ctx context.Context, owner, repo, name, value string) error RepoSecretExists(ctx context.Context, owner, repo, name string) (bool, error) diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 79f248c5e9..496b2dbbdf 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -1336,6 +1336,45 @@ func (c *LiveClient) GetAuthenticatedUser(ctx context.Context) (string, error) { return app.Slug + "[bot]", nil } +// ProbeInstallationToken reports whether token is a GitHub App installation +// access token by calling GET /installation/repositories. PATs and OAuth +// tokens receive 401/403 on that endpoint. +func ProbeInstallationToken(ctx context.Context, httpClient *http.Client, baseURL, token string) (bool, error) { + if token == "" { + return false, nil + } + + url := baseURL + "/installation/repositories?per_page=1" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, fmt.Errorf("creating installation token probe request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := httpClient.Do(req) + if err != nil { + return false, fmt.Errorf("probing installation token: %w", err) + } + defer resp.Body.Close() + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + + switch resp.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusUnauthorized, http.StatusForbidden: + return false, nil + default: + return false, &APIError{StatusCode: resp.StatusCode, Message: "installation token probe failed"} + } +} + +// IsInstallationToken reports whether the client's token is a GitHub App +// installation access token. +func (c *LiveClient) IsInstallationToken(ctx context.Context) (bool, error) { + return ProbeInstallationToken(ctx, c.http, c.baseURL, c.token) +} + // GetTokenScopes returns the OAuth scopes granted to the current token // by inspecting the X-OAuth-Scopes header from a lightweight API call. // diff --git a/internal/forge/github/token_test.go b/internal/forge/github/token_test.go new file mode 100644 index 0000000000..9d91797d51 --- /dev/null +++ b/internal/forge/github/token_test.go @@ -0,0 +1,85 @@ +package github + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestProbeInstallationToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status int + want bool + wantErr bool + }{ + {name: "installation token", status: http.StatusOK, want: true}, + {name: "forbidden", status: http.StatusForbidden, want: false}, + {name: "unauthorized", status: http.StatusUnauthorized, want: false}, + {name: "server error", status: http.StatusInternalServerError, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/installation/repositories" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("per_page"); got != "1" { + t.Fatalf("per_page = %q, want 1", got) + } + w.WriteHeader(tt.status) + })) + defer srv.Close() + + got, err := ProbeInstallationToken(context.Background(), srv.Client(), srv.URL, "ghs_test") + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("ProbeInstallationToken() error = %v", err) + } + if got != tt.want { + t.Fatalf("ProbeInstallationToken() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProbeInstallationToken_emptyToken(t *testing.T) { + t.Parallel() + + got, err := ProbeInstallationToken(context.Background(), http.DefaultClient, "http://example.com", "") + if err != nil { + t.Fatalf("ProbeInstallationToken() error = %v", err) + } + if got { + t.Fatal("expected false for empty token") + } +} + +func TestLiveClient_IsInstallationToken(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client := New("ghs_test").WithBaseURL(srv.URL) + got, err := client.IsInstallationToken(context.Background()) + if err != nil { + t.Fatalf("IsInstallationToken() error = %v", err) + } + if !got { + t.Fatal("IsInstallationToken() = false, want true") + } +} diff --git a/internal/layers/preflight.go b/internal/layers/preflight.go index 6b09563768..7af2421857 100644 --- a/internal/layers/preflight.go +++ b/internal/layers/preflight.go @@ -64,17 +64,25 @@ func (r *PreflightResult) Error() string { // required by the stack's layers for the given operation. It returns a // PreflightResult describing what was found. // -// If the forge doesn't support scope introspection (e.g., fine-grained -// tokens, GitHub App tokens), Preflight returns a result with OK() == true -// and logs that scope checking was skipped. We can't validate what we -// can't see, so we let the operation proceed and fail at the point of -// use if scopes are actually missing. +// If the token is a GitHub App installation token, or scope introspection +// is unavailable (e.g., fine-grained PATs), Preflight returns a result with +// OK() == true and Skipped set. OAuth scope preflight does not apply to +// installation tokens; for tokens we cannot introspect we let the operation +// proceed and fail at the point of use if permissions are actually missing. func (s *Stack) Preflight(ctx context.Context, op Operation, client forge.Client) (*PreflightResult, error) { required := s.CollectRequiredScopes(op) if len(required) == 0 { return &PreflightResult{}, nil } + isInstallation, err := client.IsInstallationToken(ctx) + if err != nil { + return nil, fmt.Errorf("detecting installation token: %w", err) + } + if isInstallation { + return &PreflightResult{Required: required, Skipped: true}, nil + } + granted, err := client.GetTokenScopes(ctx) if err != nil { return nil, fmt.Errorf("checking token scopes: %w", err) diff --git a/internal/layers/preflight_test.go b/internal/layers/preflight_test.go index 67fa789a4b..30739f4207 100644 --- a/internal/layers/preflight_test.go +++ b/internal/layers/preflight_test.go @@ -53,6 +53,21 @@ func TestPreflight_NoScopesRequired(t *testing.T) { assert.True(t, result.OK()) } +func TestPreflight_InstallationToken(t *testing.T) { + client := &forge.FakeClient{ + InstallationToken: true, + } + stack := NewStack( + &mockLayer{name: "a", scopes: map[Operation][]string{OpInstall: {"repo", "workflow"}}}, + ) + + result, err := stack.Preflight(context.Background(), OpInstall, client) + require.NoError(t, err) + assert.True(t, result.OK(), "installation tokens should skip OAuth scope preflight") + assert.True(t, result.Skipped) + assert.Equal(t, []string{"repo", "workflow"}, result.Required) +} + func TestPreflight_NilScopes_FineGrainedToken(t *testing.T) { // Fine-grained tokens return nil for GetTokenScopes. // Preflight should let the operation proceed (we can't validate). From 40edec67dbfbac2abf1a3cd0dfa46ba32b90cb1a Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Mon, 22 Jun 2026 21:21:39 +0300 Subject: [PATCH 09/26] fix(forge): resolve installation token identity via GraphQL viewer GET /app requires an app JWT, so minted e2e tokens now fall back to viewer.login for CODEOWNERS and other admin install paths. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/forge/github/github.go | 86 ++++++++++++++++++++++------ internal/forge/github/github_test.go | 35 +++++++++++ 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 496b2dbbdf..e096bca252 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -1299,9 +1299,9 @@ func (c *LiveClient) GetOrgPlan(ctx context.Context, org string) (string, error) // GetAuthenticatedUser returns the login of the authenticated user. // // For classic PATs and OAuth tokens the identity comes from GET /user. -// GitHub App installation tokens cannot call /user, so when that call -// fails the method falls back to GET /app and constructs the -// conventional bot login "{slug}[bot]". +// GitHub App JWTs fall back to GET /app and derive "{slug}[bot]". +// Installation access tokens cannot use either REST endpoint; those fall +// back to a GraphQL viewer query, which returns the bot login directly. func (c *LiveClient) GetAuthenticatedUser(ctx context.Context) (string, error) { resp, err := c.get(ctx, "/user") if err == nil { @@ -1314,26 +1314,78 @@ func (c *LiveClient) GetAuthenticatedUser(ctx context.Context) (string, error) { return user.Login, nil } - // /user is not available for GitHub App installation tokens. - // Fall back to /app which returns the app's metadata including - // its slug, from which we derive the bot login. + userErr := err + + // App JWT auth can resolve the bot identity from GET /app. appResp, appErr := c.get(ctx, "/app") - if appErr != nil { - // Neither endpoint worked — return the original /user error - // because that is the more common path. - return "", fmt.Errorf("get authenticated user: %w (app fallback: %v)", err, appErr) + if appErr == nil { + var app struct { + Slug string `json:"slug"` + } + if decodeErr := decodeJSON(appResp, &app); decodeErr != nil { + return "", fmt.Errorf("decode app: %w", decodeErr) + } + if app.Slug == "" { + return "", fmt.Errorf("get authenticated user: /app returned empty slug") + } + return app.Slug + "[bot]", nil } - var app struct { - Slug string `json:"slug"` + // Installation tokens reject /user and /app but support GraphQL viewer. + login, graphErr := c.graphqlViewerLogin(ctx) + if graphErr == nil { + return login, nil + } + + return "", fmt.Errorf("get authenticated user: %w (app fallback: %v; graphql fallback: %v)", userErr, appErr, graphErr) +} + +const graphqlViewerLoginQuery = `query { viewer { login } }` + +func (c *LiveClient) graphqlViewerLogin(ctx context.Context) (string, error) { + resp, err := c.do(ctx, http.MethodPost, "/graphql", map[string]string{ + "query": graphqlViewerLoginQuery, + }) + if err != nil { + return "", fmt.Errorf("graphql viewer query: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return "", fmt.Errorf("read graphql viewer response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp struct { + Message string `json:"message"` + } + if json.Unmarshal(body, &errResp) == nil && errResp.Message != "" { + return "", &APIError{StatusCode: resp.StatusCode, Message: errResp.Message} + } + return "", &APIError{StatusCode: resp.StatusCode, Message: "graphql viewer query failed"} + } + + var result struct { + Data struct { + Viewer struct { + Login string `json:"login"` + } `json:"viewer"` + } `json:"data"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("decode graphql viewer response: %w", err) } - if appErr := decodeJSON(appResp, &app); appErr != nil { - return "", fmt.Errorf("decode app: %w", appErr) + if len(result.Errors) > 0 { + return "", fmt.Errorf("graphql viewer query: %s", result.Errors[0].Message) } - if app.Slug == "" { - return "", fmt.Errorf("get authenticated user: /app returned empty slug") + if result.Data.Viewer.Login == "" { + return "", fmt.Errorf("graphql viewer query returned empty login") } - return app.Slug + "[bot]", nil + return result.Data.Viewer.Login, nil } // ProbeInstallationToken reports whether token is a GitHub App installation diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index a90fe36481..e7981d9745 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -337,6 +337,40 @@ func TestGetAuthenticatedUser_FallbackToApp(t *testing.T) { assert.Equal(t, "fullsend-ai-review[bot]", user) } +func TestGetAuthenticatedUser_FallbackToGraphQL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/user": + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]any{ + "message": "Resource not accessible by integration", + }) + case "/app": + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]any{ + "message": "A JSON web token could not be decoded", + }) + case "/graphql": + assert.Equal(t, http.MethodPost, r.Method) + json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "viewer": map[string]any{ + "login": "fullsend-e2e[bot]", + }, + }, + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + client := newTestClient(t, srv) + user, err := client.GetAuthenticatedUser(context.Background()) + require.NoError(t, err) + assert.Equal(t, "fullsend-e2e[bot]", user) +} + func TestGetAuthenticatedUser_BothFail(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) @@ -350,6 +384,7 @@ func TestGetAuthenticatedUser_BothFail(t *testing.T) { _, err := client.GetAuthenticatedUser(context.Background()) require.Error(t, err) assert.Contains(t, err.Error(), "get authenticated user") + assert.Contains(t, err.Error(), "graphql fallback") } func TestGetAuthenticatedUser_AppEmptySlug(t *testing.T) { From a10b6e630712c75df62d81775725504653a1af14 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 23 Jun 2026 09:21:04 +0300 Subject: [PATCH 10/26] feat(mint): grant e2e role repository variables write permission Align AgentAppConfig, mintcore token downscope, and embed so minted e2e tokens can set FULLSEND_GCP_REGION during admin install. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/guides/dev/e2e-testing.md | 2 +- internal/dispatch/gcf/mintsrc/mintcore/github.go.embed | 2 +- internal/forge/github/types.go | 2 +- internal/forge/github/types_test.go | 2 +- internal/mintcore/github.go | 2 +- internal/mintcore/github_test.go | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index 42720e1c15..6314af60a4 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -57,7 +57,7 @@ Each pool org must be provisioned before e2e can use it: 1. Org exists with `botsend` as owner 2. `test-repo` and `e2e-lock` repos (lock created at runtime) -3. All role apps installed, including `fullsend-ai-e2e` with **Organization → Variables: Read** (`organization_actions_variables`) in addition to repository permissions +3. All role apps installed, including `fullsend-ai-e2e` with **Repository → Variables: Read and write** (`actions_variables`) and **Organization → Variables: Read** (`organization_actions_variables`) 4. `FULLSEND_FOREIGN_E2E_REPOS` includes `fullsend-ai/fullsend` with org-wide visibility (`visibility: all`) 5. Mint enrolled: org in `ALLOWED_ORGS`, `${ORG}/e2e` in `ROLE_APP_IDS`, e2e app PEM enrolled diff --git a/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed index 372b7f75be..89d38c87bf 100644 --- a/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed +++ b/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed @@ -65,7 +65,7 @@ var canonicalRolePermissions = map[string]map[string]string{ "prioritize": {"contents": "read", "issues": "write", "organization_projects": "write", "metadata": "read"}, "fullsend": {"actions": "write", "actions_variables": "read", "contents": "write", "pull_requests": "write", "workflows": "write", "metadata": "read"}, "e2e": { - "actions": "write", "actions_variables": "read", "administration": "write", + "actions": "write", "actions_variables": "write", "administration": "write", "contents": "write", "issues": "write", "members": "write", "metadata": "read", "organization_actions_variables": "read", "organization_administration": "write", "pull_requests": "write", "secrets": "write", "workflows": "write", diff --git a/internal/forge/github/types.go b/internal/forge/github/types.go index bc06f0f8ea..06ae2b7a8d 100644 --- a/internal/forge/github/types.go +++ b/internal/forge/github/types.go @@ -148,7 +148,7 @@ func AgentAppConfig(org, role, appSet string) AppConfig { base.Description = fmt.Sprintf("Fullsend e2e pool testing for %s", org) base.Permissions = AppPermissions{ Actions: "write", - Variables: "read", + Variables: "write", OrganizationActionsVariables: "read", Administration: "write", Contents: "write", diff --git a/internal/forge/github/types_test.go b/internal/forge/github/types_test.go index 288e890a1b..0a6067f238 100644 --- a/internal/forge/github/types_test.go +++ b/internal/forge/github/types_test.go @@ -109,7 +109,7 @@ func TestAgentAppConfig_E2e(t *testing.T) { assert.Equal(t, "fullsend-ai-e2e", cfg.Name) assert.Equal(t, "write", cfg.Permissions.Actions) - assert.Equal(t, "read", cfg.Permissions.Variables) + assert.Equal(t, "write", cfg.Permissions.Variables) assert.Equal(t, "read", cfg.Permissions.OrganizationActionsVariables) assert.Equal(t, "write", cfg.Permissions.Administration) assert.Equal(t, "write", cfg.Permissions.Contents) diff --git a/internal/mintcore/github.go b/internal/mintcore/github.go index 372b7f75be..89d38c87bf 100644 --- a/internal/mintcore/github.go +++ b/internal/mintcore/github.go @@ -65,7 +65,7 @@ var canonicalRolePermissions = map[string]map[string]string{ "prioritize": {"contents": "read", "issues": "write", "organization_projects": "write", "metadata": "read"}, "fullsend": {"actions": "write", "actions_variables": "read", "contents": "write", "pull_requests": "write", "workflows": "write", "metadata": "read"}, "e2e": { - "actions": "write", "actions_variables": "read", "administration": "write", + "actions": "write", "actions_variables": "write", "administration": "write", "contents": "write", "issues": "write", "members": "write", "metadata": "read", "organization_actions_variables": "read", "organization_administration": "write", "pull_requests": "write", "secrets": "write", "workflows": "write", diff --git a/internal/mintcore/github_test.go b/internal/mintcore/github_test.go index 30ad73a3e2..402d4a7cbb 100644 --- a/internal/mintcore/github_test.go +++ b/internal/mintcore/github_test.go @@ -161,7 +161,7 @@ func TestRolePermissions_E2e(t *testing.T) { perms := RolePermissionsFor("e2e") require.NotNil(t, perms) assert.Equal(t, "write", perms["actions"]) - assert.Equal(t, "read", perms["actions_variables"]) + assert.Equal(t, "write", perms["actions_variables"]) assert.Equal(t, "read", perms["organization_actions_variables"]) assert.Equal(t, "write", perms["administration"]) assert.Equal(t, "write", perms["contents"]) From 93e5c4d5d1065df8a6c2cb8905d2bddf8762be84 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 23 Jun 2026 10:05:50 +0300 Subject: [PATCH 11/26] feat(mint): grant e2e role organization variables write permission Allow minted e2e tokens to set FULLSEND_MINT_URL on pool orgs during admin install dispatch layer setup. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/guides/dev/e2e-testing.md | 2 +- internal/dispatch/gcf/mintsrc/mintcore/github.go.embed | 2 +- internal/forge/github/types.go | 2 +- internal/forge/github/types_test.go | 2 +- internal/mintcore/github.go | 2 +- internal/mintcore/github_test.go | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index 6314af60a4..5c587bd22b 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -57,7 +57,7 @@ Each pool org must be provisioned before e2e can use it: 1. Org exists with `botsend` as owner 2. `test-repo` and `e2e-lock` repos (lock created at runtime) -3. All role apps installed, including `fullsend-ai-e2e` with **Repository → Variables: Read and write** (`actions_variables`) and **Organization → Variables: Read** (`organization_actions_variables`) +3. All role apps installed, including `fullsend-ai-e2e` with **Repository → Variables: Read and write** (`actions_variables`) and **Organization → Variables: Read and write** (`organization_actions_variables`) 4. `FULLSEND_FOREIGN_E2E_REPOS` includes `fullsend-ai/fullsend` with org-wide visibility (`visibility: all`) 5. Mint enrolled: org in `ALLOWED_ORGS`, `${ORG}/e2e` in `ROLE_APP_IDS`, e2e app PEM enrolled diff --git a/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed index 89d38c87bf..1f5230169f 100644 --- a/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed +++ b/internal/dispatch/gcf/mintsrc/mintcore/github.go.embed @@ -67,7 +67,7 @@ var canonicalRolePermissions = map[string]map[string]string{ "e2e": { "actions": "write", "actions_variables": "write", "administration": "write", "contents": "write", "issues": "write", "members": "write", "metadata": "read", - "organization_actions_variables": "read", "organization_administration": "write", + "organization_actions_variables": "write", "organization_administration": "write", "pull_requests": "write", "secrets": "write", "workflows": "write", }, } diff --git a/internal/forge/github/types.go b/internal/forge/github/types.go index 06ae2b7a8d..b0fcdff590 100644 --- a/internal/forge/github/types.go +++ b/internal/forge/github/types.go @@ -149,7 +149,7 @@ func AgentAppConfig(org, role, appSet string) AppConfig { base.Permissions = AppPermissions{ Actions: "write", Variables: "write", - OrganizationActionsVariables: "read", + OrganizationActionsVariables: "write", Administration: "write", Contents: "write", Issues: "write", diff --git a/internal/forge/github/types_test.go b/internal/forge/github/types_test.go index 0a6067f238..8a26cee9b5 100644 --- a/internal/forge/github/types_test.go +++ b/internal/forge/github/types_test.go @@ -110,7 +110,7 @@ func TestAgentAppConfig_E2e(t *testing.T) { assert.Equal(t, "fullsend-ai-e2e", cfg.Name) assert.Equal(t, "write", cfg.Permissions.Actions) assert.Equal(t, "write", cfg.Permissions.Variables) - assert.Equal(t, "read", cfg.Permissions.OrganizationActionsVariables) + assert.Equal(t, "write", cfg.Permissions.OrganizationActionsVariables) assert.Equal(t, "write", cfg.Permissions.Administration) assert.Equal(t, "write", cfg.Permissions.Contents) assert.Equal(t, "write", cfg.Permissions.Issues) diff --git a/internal/mintcore/github.go b/internal/mintcore/github.go index 89d38c87bf..1f5230169f 100644 --- a/internal/mintcore/github.go +++ b/internal/mintcore/github.go @@ -67,7 +67,7 @@ var canonicalRolePermissions = map[string]map[string]string{ "e2e": { "actions": "write", "actions_variables": "write", "administration": "write", "contents": "write", "issues": "write", "members": "write", "metadata": "read", - "organization_actions_variables": "read", "organization_administration": "write", + "organization_actions_variables": "write", "organization_administration": "write", "pull_requests": "write", "secrets": "write", "workflows": "write", }, } diff --git a/internal/mintcore/github_test.go b/internal/mintcore/github_test.go index 402d4a7cbb..ddf221c5b1 100644 --- a/internal/mintcore/github_test.go +++ b/internal/mintcore/github_test.go @@ -162,7 +162,7 @@ func TestRolePermissions_E2e(t *testing.T) { require.NotNil(t, perms) assert.Equal(t, "write", perms["actions"]) assert.Equal(t, "write", perms["actions_variables"]) - assert.Equal(t, "read", perms["organization_actions_variables"]) + assert.Equal(t, "write", perms["organization_actions_variables"]) assert.Equal(t, "write", perms["administration"]) assert.Equal(t, "write", perms["contents"]) assert.Equal(t, "write", perms["issues"]) From 1704870825e4bd082779818f1aaf86630c7cbdae Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 23 Jun 2026 11:48:02 +0300 Subject: [PATCH 12/26] fix(mint): allow installation-wide tokens on same-org path Symmetric with cross-org: empty repos yields installation-wide installation tokens. Authorization relies on WIF/OIDC enrollment for trusted workflows rather than an asymmetric repos-required guard. Update ADR 0054 and architecture.md accordingly. Signed-off-by: Barak Korren Co-authored-by: Cursor --- ...rg-mint-authorization-via-org-variables.md | 23 +++++----- docs/architecture.md | 2 +- .../gcf/mintsrc/mintcore/handler.go.embed | 6 --- internal/mintcore/handler.go | 6 --- internal/mintcore/handler_test.go | 44 +++++++++++++++++-- 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/docs/ADRs/0054-cross-org-mint-authorization-via-org-variables.md b/docs/ADRs/0054-cross-org-mint-authorization-via-org-variables.md index 396829a570..29f85380e1 100644 --- a/docs/ADRs/0054-cross-org-mint-authorization-via-org-variables.md +++ b/docs/ADRs/0054-cross-org-mint-authorization-via-org-variables.md @@ -39,8 +39,10 @@ orgs control over their own policy. ## Decision 1. **Optional `target_org` on mint requests.** When omitted, or when equal to the caller's - `repository_owner` (case-insensitive), behavior is unchanged from pre-0054 mint: same - `mintToken` path, repo-based installation lookup, **repos required**, no FOREIGN check. + `repository_owner` (case-insensitive), behavior uses the same `mintToken` path with no + FOREIGN check. When `repos` is omitted, the mint issues an installation-wide token via + org-level installation lookup (same as the cross-org path). Callers are authenticated via + WIF/OIDC; only enrolled workflows that pass mint enrollment checks can reach the handler. 2. **Cross-org path** applies only when `target_org` is set and differs from the caller org: - Resolve the requested role's App installation on `target_org` via org-level installation lookup. @@ -48,9 +50,9 @@ orgs control over their own policy. token (`actions_variables: read`). - Deny if installation lookup fails, the variable is missing/empty, or the OIDC caller (`repository` or bare `repository_owner`) is not on the allowlist. - - Mint a scoped installation token for the requested repos on the target org. When `repos` - is omitted, mint an installation-wide token (org-level installation lookup only). This - is intended for the `e2e` role acting on pool orgs from CI ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)). + - Mint an installation token for the requested repos on the target org, or installation-wide + when `repos` is omitted. The `e2e` role acting on pool orgs from CI is the first consumer + ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)). 3. **Variable format.** Org-level GitHub Actions variable on the **target** org: - Name: `FULLSEND_FOREIGN__REPOS` (uppercase role suffix, per [ADR 0014](0014-admin-install-github-apps-secrets-v1.md)) @@ -71,9 +73,10 @@ orgs control over their own policy. `organization_actions_variables: write` so pool tests can set repo/org variables during install flows; these writes are scoped to pool orgs that explicitly authorize CI via `FULLSEND_FOREIGN_E2E_REPOS`. -- Installation-wide tokens (empty `repos`) are permitted only on the cross-org path after - FOREIGN authorization; same-org callers must always name at least one repo. -- Target orgs opt in by installing the role App and setting the FOREIGN allowlist. -- Same-org mint for enrolled orgs is unchanged aside from requiring `repos`: zero FOREIGN - API calls or permission changes for typical agent workflows. +- Installation-wide tokens (empty `repos`) are permitted on both same-org and cross-org + paths. Cross-org requests additionally require FOREIGN authorization on the target org. + Same-org elevation relies on WIF/OIDC enrollment: only trusted workflows can call the mint. +- Target orgs opt in by installing the role App and setting the FOREIGN allowlist (cross-org). +- Same-org mint for enrolled orgs adds zero FOREIGN API calls; optional `repos` omission uses + org-level installation lookup when callers need installation-wide scope. - Pool org provisioning must install the e2e App and set `FULLSEND_FOREIGN_E2E_REPOS` for CI callers. diff --git a/docs/architecture.md b/docs/architecture.md index 105282d0c0..605b03794e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,7 +136,7 @@ Identity is not the same as trust. An agent's identity lets it authenticate to e - Credential delivery model: four tiers — (1) prefetch + post-process for agents with enumerable inputs (zero credential access), (2) OpenShell providers + L7 egress policies for static token auth (credentials never enter sandbox), (3) host-side REST server for operations providers cannot handle — long-running operations, sandbox capability gaps, credentials in request bodies, response transformation, and multi-step atomic operations (see [ADR 0046](ADRs/0046-host-side-api-server-design.md)), (4) host files + L7 policies for complex auth requiring in-sandbox credential files. L7 policies enforce both method + path and binary-level restrictions. Providers are preferred over REST servers when viable ([ADR 0017](ADRs/0017-credential-isolation-for-sandboxed-agents.md), extended by [ADR 0025](ADRs/0025-provider-credential-delivery-for-sandboxed-agents.md)). - Host-side API server design: Tier 3 servers follow a uniform process contract (`--port`, `--token`, `--bind-address`, `/healthz`, `/tools.json`, `SIGTERM`). Network access is controlled via composable provider profiles — atomic capability profiles composed per-harness. Per-run UUID bearer tokens are delivered through OpenShell provider placeholders. File transfer uses `openshell sandbox upload/download` ([ADR 0046](ADRs/0046-host-side-api-server-design.md)). - Per-role GitHub Apps with manifest-based creation. Each agent role gets its own app with scoped permissions. PEMs stored in Secret Manager as `fullsend-{role}-app-pem` — one secret per role, shared across orgs on a mint. `ROLE_APP_IDS` uses the same shared-per-role model (`coder` → app ID). Org isolation is enforced via `ALLOWED_ORGS`, WIF conditions, and installation verification ([ADR 0007](ADRs/0007-per-role-github-apps.md), [ADR 0033](ADRs/0033-per-repo-installation-mode.md)). -- Cross-org mint authorization: workflows may request tokens for a different org via optional `target_org` when the target org installs the role App and sets `FULLSEND_FOREIGN__REPOS`. Same-org mint still requires explicit repos; installation-wide tokens are allowed only on the authorized cross-org path ([ADR 0054](ADRs/0054-cross-org-mint-authorization-via-org-variables.md)). +- Cross-org mint authorization: workflows may request tokens for a different org via optional `target_org` when the target org installs the role App and sets `FULLSEND_FOREIGN__REPOS`. Empty `repos` yields installation-wide tokens on either path; cross-org adds FOREIGN gating, same-org relies on WIF/OIDC enrollment ([ADR 0054](ADRs/0054-cross-org-mint-authorization-via-org-variables.md)). One concrete implementation option is [`oidcx`](https://github.com/oxidecomputer/oidcx): a service that accepts OIDC identity tokens and exchanges them for short-lived access tokens. It can mint tokens scoped to selected GitHub repositories and permissions, or to selected Oxide silos and permissions, and it also ships with a GitHub Action wrapper. In a Fullsend deployment, this can be used by the sandbox entrypoint to narrow a broad GitHub App identity down to only the specific permissions an agent needs for the current run. diff --git a/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed index 7c643b729f..3abaf653cf 100644 --- a/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed +++ b/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed @@ -223,12 +223,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { targetOrg = callerOrg } - sameOrg := strings.EqualFold(targetOrg, callerOrg) - if sameOrg && len(req.Repos) == 0 { - writeError(w, http.StatusBadRequest, "repos is required") - return - } - if len(req.Repos) == 0 { log.Printf("WARNING: mint request omitted repos; issuing installation-wide token for target_org=%s role=%s caller_org=%s source_repo=%s", targetOrg, req.Role, callerOrg, claims.Repository) diff --git a/internal/mintcore/handler.go b/internal/mintcore/handler.go index 7c643b729f..3abaf653cf 100644 --- a/internal/mintcore/handler.go +++ b/internal/mintcore/handler.go @@ -223,12 +223,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { targetOrg = callerOrg } - sameOrg := strings.EqualFold(targetOrg, callerOrg) - if sameOrg && len(req.Repos) == 0 { - writeError(w, http.StatusBadRequest, "repos is required") - return - } - if len(req.Repos) == 0 { log.Printf("WARNING: mint request omitted repos; issuing installation-wide token for target_org=%s role=%s caller_org=%s source_repo=%s", targetOrg, req.Role, callerOrg, claims.Repository) diff --git a/internal/mintcore/handler_test.go b/internal/mintcore/handler_test.go index f9eda646e6..bc1e6594c3 100644 --- a/internal/mintcore/handler_test.go +++ b/internal/mintcore/handler_test.go @@ -549,7 +549,7 @@ func TestHandler_InvalidRepoName(t *testing.T) { } } -func TestHandler_EmptyRepos_SameOrgRejected(t *testing.T) { +func TestHandler_EmptyRepos_FullOrgToken(t *testing.T) { t.Setenv("ROLE_APP_IDS", `{"coder":"200"}`) pemData, err := generateTestRSAKey() @@ -562,14 +562,52 @@ func TestHandler_EmptyRepos_SameOrgRejected(t *testing.T) { }) token := env.signToken(t, nil) + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/orgs/test-org/installation" && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(installationResponse{ + ID: 12345, Account: struct { + Login string `json:"login"` + }{Login: "test-org"}, + }) + case strings.HasPrefix(r.URL.Path, "/app/installations/12345/access_tokens") && r.Method == http.MethodPost: + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["repositories"]; ok { + t.Error("expected installation token request to omit repositories") + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(installationTokenResponse{ + Token: "ghs_full_org_token", + ExpiresAt: "2026-05-06T12:00:00Z", + Permissions: map[string]string{"contents": "write", "metadata": "read"}, + RepositorySelection: "all", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer github.Close() + env.handler.githubBaseURL = github.URL + body := `{"role":"coder"}` rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/token", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+token) env.handler.ServeHTTP(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp mintResponse + json.NewDecoder(rec.Body).Decode(&resp) + if resp.Token != "ghs_full_org_token" { + t.Fatalf("expected token=ghs_full_org_token, got %s", resp.Token) + } + if resp.RepoSelection != "all" { + t.Fatalf("expected repository_selection=all, got %s", resp.RepoSelection) } } From 8bd58ff2e11229758f0dd874e51c26b41debbb25 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 23 Jun 2026 12:03:49 +0300 Subject: [PATCH 13/26] test: improve patch coverage for foreign mint and CLI paths Add httptest-backed foreign allow/list/revoke command tests, loadForeignAllowlist coverage, checkTokenScopes installation-token skip tests, mintcore ReadForeignAllowlist/GetOrgVariable tests, and ListOrgVariables forge test. Introduce newGitHubLiveClient helper honoring GITHUB_API_URL for tests. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/admin_test.go | 22 +++ internal/cli/foreign.go | 6 +- internal/cli/foreign_test.go | 245 ++++++++++++++++++++++++++- internal/cli/github_client.go | 17 ++ internal/forge/github/github_test.go | 19 +++ internal/mintcore/foreign_test.go | 9 + internal/mintcore/github_test.go | 89 ++++++++++ 7 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 internal/cli/github_client.go diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 010ee2024f..7ae961b96c 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -2726,6 +2726,28 @@ func TestLoadKnownSlugs_RoleWithoutSlug_WarnsAndSkips(t *testing.T) { assert.Contains(t, buf.String(), "both must be set") } +func TestCheckTokenScopes_InstallationTokenSkipped(t *testing.T) { + client := forge.NewFakeClient() + client.InstallationToken = true + + var buf bytes.Buffer + printer := ui.New(&buf) + err := checkTokenScopes(context.Background(), client, printer, []string{"repo", "delete_repo", "workflow"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "installation token") +} + +func TestCheckTokenScopes_MissingScopes(t *testing.T) { + client := forge.NewFakeClient() + client.TokenScopes = []string{"repo"} + + var buf bytes.Buffer + printer := ui.New(&buf) + err := checkTokenScopes(context.Background(), client, printer, []string{"repo", "delete_repo"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "delete_repo") +} + func TestLoadKnownSlugs_HardError_ReturnsNil(t *testing.T) { client := forge.NewFakeClient() client.Errors["ListDirectoryContents"] = fmt.Errorf("network timeout") diff --git a/internal/cli/foreign.go b/internal/cli/foreign.go index f38e0c7d0e..0ff57650ea 100644 --- a/internal/cli/foreign.go +++ b/internal/cli/foreign.go @@ -57,7 +57,7 @@ func newForeignAllowCmd() *cobra.Command { if err != nil { return err } - client := gh.New(token) + client := newGitHubLiveClient(token) printer := ui.New(os.Stdout) ctx := cmd.Context() @@ -121,7 +121,7 @@ func newForeignListCmd() *cobra.Command { if err != nil { return err } - client := gh.New(token) + client := newGitHubLiveClient(token) printer := ui.New(os.Stdout) ctx := cmd.Context() @@ -214,7 +214,7 @@ func newForeignRevokeCmd() *cobra.Command { if err != nil { return err } - client := gh.New(token) + client := newGitHubLiveClient(token) printer := ui.New(os.Stdout) ctx := cmd.Context() diff --git a/internal/cli/foreign_test.go b/internal/cli/foreign_test.go index 06b1328bac..863fc0f9c9 100644 --- a/internal/cli/foreign_test.go +++ b/internal/cli/foreign_test.go @@ -1,6 +1,20 @@ package cli -import "testing" +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gh "github.com/fullsend-ai/fullsend/internal/forge/github" +) func TestParseForeignVariableName(t *testing.T) { role, ok := parseForeignVariableName("FULLSEND_FOREIGN_E2E_REPOS") @@ -10,6 +24,9 @@ func TestParseForeignVariableName(t *testing.T) { if _, ok := parseForeignVariableName("FULLSEND_MINT_URL"); ok { t.Fatal("expected non-foreign name to fail") } + if _, ok := parseForeignVariableName("FULLSEND_FOREIGN_123_REPOS"); ok { + t.Fatal("expected invalid role suffix to fail") + } } func TestValidateForeignCaller(t *testing.T) { @@ -22,15 +39,239 @@ func TestValidateForeignCaller(t *testing.T) { if err := validateForeignCaller("bad org/repo"); err == nil { t.Fatal("expected invalid caller") } + if err := validateForeignCaller(""); err == nil { + t.Fatal("expected empty caller error") + } } -func TestForeignAllowRevoke(t *testing.T) { +func TestForeignAllowRevokeHelpers(t *testing.T) { list := []string{"a/b", "c"} if !containsForeignCaller(list, "a/b") { t.Fatal("expected contains") } + if containsForeignCaller(list, "missing") { + t.Fatal("expected missing") + } updated, changed := removeForeignCaller(list, "a/b") if !changed || len(updated) != 1 || updated[0] != "c" { t.Fatalf("got %v changed=%v", updated, changed) } + _, changed = removeForeignCaller(list, "missing") + if changed { + t.Fatal("expected no change") + } +} + +func TestLoadForeignAllowlist(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/FULLSEND_FOREIGN_E2E_REPOS"): + json.NewEncoder(w).Encode(map[string]string{ + "name": "FULLSEND_FOREIGN_E2E_REPOS", + "value": "fullsend-ai/fullsend, fullsend-ai", + }) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := gh.New("token").WithBaseURL(srv.URL) + got, err := loadForeignAllowlist(context.Background(), client, "pool-org", "FULLSEND_FOREIGN_E2E_REPOS") + require.NoError(t, err) + assert.Equal(t, []string{"fullsend-ai/fullsend", "fullsend-ai"}, got) +} + +func TestLoadForeignAllowlist_NotSet(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + client := gh.New("token").WithBaseURL(srv.URL) + got, err := loadForeignAllowlist(context.Background(), client, "pool-org", "FULLSEND_FOREIGN_E2E_REPOS") + require.NoError(t, err) + assert.Nil(t, got) +} + +type foreignVarState struct { + mu sync.Mutex + vars map[string]string + deleted []string +} + +func (s *foreignVarState) handler(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/actions/variables/FULLSEND_FOREIGN_"): + name := strings.TrimPrefix(r.URL.Path, "/orgs/pool-org/actions/variables/") + if val, ok := s.vars[name]; ok { + json.NewEncoder(w).Encode(map[string]string{"name": name, "value": val}) + return + } + w.WriteHeader(http.StatusNotFound) + case r.Method == http.MethodGet && r.URL.Path == "/orgs/pool-org/actions/variables": + var out []map[string]string + for name, val := range s.vars { + out = append(out, map[string]string{"name": name, "value": val}) + } + json.NewEncoder(w).Encode(map[string]any{ + "total_count": len(out), + "variables": out, + }) + case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/actions/variables/"): + name := strings.TrimPrefix(r.URL.Path, "/orgs/pool-org/actions/variables/") + var body struct { + Value string `json:"value"` + } + json.NewDecoder(r.Body).Decode(&body) + s.vars[name] = body.Value + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && r.URL.Path == "/orgs/pool-org/actions/variables": + var body struct { + Name string `json:"name"` + Value string `json:"value"` + } + json.NewDecoder(r.Body).Decode(&body) + s.vars[body.Name] = body.Value + w.WriteHeader(http.StatusCreated) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/actions/variables/"): + name := strings.TrimPrefix(r.URL.Path, "/orgs/pool-org/actions/variables/") + delete(s.vars, name) + s.deleted = append(s.deleted, name) + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + } +} + +func runForeignCmd(t *testing.T, srvURL string, args ...string) (string, error) { + t.Helper() + t.Setenv("GH_TOKEN", "test-token") + t.Setenv("GITHUB_API_URL", srvURL) + + var buf bytes.Buffer + root := newRootCmd() + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(append([]string{"admin", "foreign"}, args...)) + err := root.Execute() + return buf.String(), err +} + +func TestForeignAllowCmd_CreatesVariable(t *testing.T) { + state := &foreignVarState{vars: map[string]string{}} + srv := httptest.NewServer(state.handler(t)) + defer srv.Close() + + out, err := runForeignCmd(t, srv.URL, "allow", "--org", "pool-org", "--role", "e2e", "--caller", "fullsend-ai/fullsend") + require.NoError(t, err) + _ = out + assert.Equal(t, "fullsend-ai/fullsend", state.vars["FULLSEND_FOREIGN_E2E_REPOS"]) +} + +func TestForeignAllowCmd_AppendsCaller(t *testing.T) { + state := &foreignVarState{vars: map[string]string{ + "FULLSEND_FOREIGN_E2E_REPOS": "konflux-ci", + }} + srv := httptest.NewServer(state.handler(t)) + defer srv.Close() + + _, err := runForeignCmd(t, srv.URL, "allow", "--org", "pool-org", "--role", "e2e", "--caller", "fullsend-ai/fullsend") + require.NoError(t, err) + assert.Contains(t, state.vars["FULLSEND_FOREIGN_E2E_REPOS"], "konflux-ci") + assert.Contains(t, state.vars["FULLSEND_FOREIGN_E2E_REPOS"], "fullsend-ai/fullsend") +} + +func TestForeignAllowCmd_AlreadyListed(t *testing.T) { + state := &foreignVarState{vars: map[string]string{ + "FULLSEND_FOREIGN_E2E_REPOS": "fullsend-ai/fullsend", + }} + srv := httptest.NewServer(state.handler(t)) + defer srv.Close() + + _, err := runForeignCmd(t, srv.URL, "allow", "--org", "pool-org", "--role", "e2e", "--caller", "fullsend-ai/fullsend") + require.NoError(t, err) + assert.Equal(t, "fullsend-ai/fullsend", state.vars["FULLSEND_FOREIGN_E2E_REPOS"]) +} + +func TestForeignListCmd_SingleRole(t *testing.T) { + state := &foreignVarState{vars: map[string]string{ + "FULLSEND_FOREIGN_E2E_REPOS": "fullsend-ai/fullsend", + }} + srv := httptest.NewServer(state.handler(t)) + defer srv.Close() + + _, err := runForeignCmd(t, srv.URL, "list", "--org", "pool-org", "--role", "e2e") + require.NoError(t, err) +} + +func TestForeignListCmd_AllForeignVariables(t *testing.T) { + state := &foreignVarState{vars: map[string]string{ + "FULLSEND_FOREIGN_E2E_REPOS": "fullsend-ai", + "FULLSEND_MINT_URL": "https://example.com", + }} + srv := httptest.NewServer(state.handler(t)) + defer srv.Close() + + _, err := runForeignCmd(t, srv.URL, "list", "--org", "pool-org") + require.NoError(t, err) +} + +func TestForeignListCmd_RoleNotSet(t *testing.T) { + state := &foreignVarState{vars: map[string]string{}} + srv := httptest.NewServer(state.handler(t)) + defer srv.Close() + + _, err := runForeignCmd(t, srv.URL, "list", "--org", "pool-org", "--role", "e2e") + require.NoError(t, err) +} + +func TestForeignRevokeCmd_RemovesCaller(t *testing.T) { + state := &foreignVarState{vars: map[string]string{ + "FULLSEND_FOREIGN_E2E_REPOS": "fullsend-ai/fullsend, konflux-ci", + }} + srv := httptest.NewServer(state.handler(t)) + defer srv.Close() + + _, err := runForeignCmd(t, srv.URL, "revoke", "--org", "pool-org", "--role", "e2e", "--caller", "konflux-ci") + require.NoError(t, err) + assert.Equal(t, "fullsend-ai/fullsend", state.vars["FULLSEND_FOREIGN_E2E_REPOS"]) +} + +func TestForeignRevokeCmd_DeletesEmptyVariable(t *testing.T) { + state := &foreignVarState{vars: map[string]string{ + "FULLSEND_FOREIGN_E2E_REPOS": "fullsend-ai/fullsend", + }} + srv := httptest.NewServer(state.handler(t)) + defer srv.Close() + + _, err := runForeignCmd(t, srv.URL, "revoke", "--org", "pool-org", "--role", "e2e", "--caller", "fullsend-ai/fullsend") + require.NoError(t, err) + assert.NotContains(t, state.vars, "FULLSEND_FOREIGN_E2E_REPOS") + assert.Equal(t, []string{"FULLSEND_FOREIGN_E2E_REPOS"}, state.deleted) +} + +func TestForeignRevokeCmd_NotPresent(t *testing.T) { + state := &foreignVarState{vars: map[string]string{ + "FULLSEND_FOREIGN_E2E_REPOS": "fullsend-ai/fullsend", + }} + srv := httptest.NewServer(state.handler(t)) + defer srv.Close() + + _, err := runForeignCmd(t, srv.URL, "revoke", "--org", "pool-org", "--role", "e2e", "--caller", "missing/repo") + require.NoError(t, err) + assert.Equal(t, "fullsend-ai/fullsend", state.vars["FULLSEND_FOREIGN_E2E_REPOS"]) +} + +func TestForeignCmd_ValidationErrors(t *testing.T) { + _, err := runForeignCmd(t, "http://unused", "allow", "--role", "e2e", "--caller", "fullsend-ai/fullsend") + require.Error(t, err) + assert.Contains(t, err.Error(), "--org is required") } diff --git a/internal/cli/github_client.go b/internal/cli/github_client.go new file mode 100644 index 0000000000..23ab487dd8 --- /dev/null +++ b/internal/cli/github_client.go @@ -0,0 +1,17 @@ +package cli + +import ( + "os" + "strings" + + gh "github.com/fullsend-ai/fullsend/internal/forge/github" +) + +// newGitHubLiveClient builds a GitHub API client, honoring GITHUB_API_URL for tests. +func newGitHubLiveClient(token string) *gh.LiveClient { + client := gh.New(token) + if base := strings.TrimSpace(os.Getenv("GITHUB_API_URL")); base != "" { + client = client.WithBaseURL(base) + } + return client +} diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index e7981d9745..4c7c6c1a0f 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -1883,3 +1883,22 @@ func TestDeleteIssueComment(t *testing.T) { err := client.DeleteIssueComment(context.Background(), "org", "repo", 42) require.NoError(t, err) } + +func TestListOrgVariables(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/orgs/myorg/actions/variables", r.URL.Path) + json.NewEncoder(w).Encode(map[string]any{ + "total_count": 2, + "variables": []map[string]string{ + {"name": "FULLSEND_FOREIGN_E2E_REPOS", "value": "fullsend-ai"}, + {"name": "OTHER", "value": "x"}, + }, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv) + vars, err := client.ListOrgVariables(context.Background(), "myorg") + require.NoError(t, err) + require.Len(t, vars, 2) +} diff --git a/internal/mintcore/foreign_test.go b/internal/mintcore/foreign_test.go index dc62c03048..0405d25567 100644 --- a/internal/mintcore/foreign_test.go +++ b/internal/mintcore/foreign_test.go @@ -21,6 +21,15 @@ func TestParseForeignAllowlist(t *testing.T) { } } +func TestValidateTargetOrg(t *testing.T) { + if err := validateTargetOrg("halfsend-01"); err != nil { + t.Fatalf("valid org: %v", err) + } + if err := validateTargetOrg("bad--org"); err == nil { + t.Fatal("expected invalid org") + } +} + func TestCallerAllowed(t *testing.T) { list := []string{"fullsend-ai/fullsend", "konflux-ci"} if !CallerAllowed(list, "fullsend-ai/fullsend", "fullsend-ai") { diff --git a/internal/mintcore/github_test.go b/internal/mintcore/github_test.go index ddf221c5b1..385eab17e9 100644 --- a/internal/mintcore/github_test.go +++ b/internal/mintcore/github_test.go @@ -9,6 +9,7 @@ import ( "encoding/pem" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -194,3 +195,91 @@ func TestHasRole(t *testing.T) { assert.True(t, HasRole("coder")) assert.False(t, HasRole("nonexistent")) } + +func TestFindOrgInstallation_OrgMismatch(t *testing.T) { + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(installationResponse{ + ID: 99, + Account: struct { + Login string `json:"login"` + }{Login: "other-org"}, + }) + })) + defer mockGH.Close() + + _, err := FindOrgInstallation(t.Context(), http.DefaultClient, mockGH.URL, "fake-jwt", "myorg") + require.Error(t, err) + assert.Contains(t, err.Error(), "belongs to other-org") +} + +func TestGetOrgVariable(t *testing.T) { + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/orgs/pool-org/actions/variables/FULLSEND_FOREIGN_E2E_REPOS", r.URL.Path) + json.NewEncoder(w).Encode(orgVariableResponse{ + Name: "FULLSEND_FOREIGN_E2E_REPOS", + Value: "fullsend-ai/fullsend", + }) + })) + defer mockGH.Close() + + value, exists, err := GetOrgVariable(t.Context(), http.DefaultClient, mockGH.URL, "ghs_policy", "pool-org", "FULLSEND_FOREIGN_E2E_REPOS") + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, "fullsend-ai/fullsend", value) +} + +func TestGetOrgVariable_NotFound(t *testing.T) { + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer mockGH.Close() + + _, exists, err := GetOrgVariable(t.Context(), http.DefaultClient, mockGH.URL, "ghs_policy", "pool-org", "FULLSEND_FOREIGN_E2E_REPOS") + require.NoError(t, err) + assert.False(t, exists) +} + +func TestReadForeignAllowlist(t *testing.T) { + var tokenCalls int + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/app/installations/42/access_tokens") && r.Method == http.MethodPost: + tokenCalls++ + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(installationTokenResponse{Token: "ghs_policy"}) + case r.URL.Path == "/orgs/pool-org/actions/variables/FULLSEND_FOREIGN_E2E_REPOS": + json.NewEncoder(w).Encode(orgVariableResponse{ + Name: "FULLSEND_FOREIGN_E2E_REPOS", + Value: "fullsend-ai/fullsend, fullsend-ai", + }) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer mockGH.Close() + + got, err := ReadForeignAllowlist(t.Context(), http.DefaultClient, mockGH.URL, "app-jwt", 42, "pool-org", "e2e") + require.NoError(t, err) + assert.Equal(t, []string{"fullsend-ai/fullsend", "fullsend-ai"}, got) + assert.Equal(t, 1, tokenCalls) +} + +func TestReadForeignAllowlist_EmptyVariable(t *testing.T) { + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/app/installations/42/access_tokens"): + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(installationTokenResponse{Token: "ghs_policy"}) + case strings.Contains(r.URL.Path, "/actions/variables/"): + w.WriteHeader(http.StatusNotFound) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer mockGH.Close() + + got, err := ReadForeignAllowlist(t.Context(), http.DefaultClient, mockGH.URL, "app-jwt", 42, "pool-org", "e2e") + require.NoError(t, err) + assert.Nil(t, got) +} From c5e38d62d34c1c2bfb7c301e19c7357bdc94e510 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 23 Jun 2026 12:14:52 +0300 Subject: [PATCH 14/26] chore: gofmt foreign_test.go Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/foreign_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/foreign_test.go b/internal/cli/foreign_test.go index 863fc0f9c9..334cf9623e 100644 --- a/internal/cli/foreign_test.go +++ b/internal/cli/foreign_test.go @@ -96,8 +96,8 @@ func TestLoadForeignAllowlist_NotSet(t *testing.T) { } type foreignVarState struct { - mu sync.Mutex - vars map[string]string + mu sync.Mutex + vars map[string]string deleted []string } From cfa6ec83eb353f9b6498f0bd78b4232ba460d246 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 23 Jun 2026 12:35:55 +0300 Subject: [PATCH 15/26] test: raise patch coverage for foreign mint paths Cover fake org-variable helpers, FOREIGN allowlist cache, graphql viewer fallback errors, foreign CLI failure paths, and checkTokenScopes probe errors. Signed-off-by: Barak Korren Co-authored-by: Cursor --- internal/cli/admin_test.go | 11 +++++ internal/cli/foreign_test.go | 23 ++++++++++ internal/forge/fake_test.go | 54 +++++++++++++++++++++++ internal/forge/github/github_test.go | 27 ++++++++++++ internal/mintcore/github_test.go | 22 ++++++++++ internal/mintcore/handler_test.go | 66 ++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+) diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 7ae961b96c..65a320c78a 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -2748,6 +2748,17 @@ func TestCheckTokenScopes_MissingScopes(t *testing.T) { assert.Contains(t, err.Error(), "delete_repo") } +func TestCheckTokenScopes_InstallationTokenProbeFails(t *testing.T) { + client := forge.NewFakeClient() + client.Errors["IsInstallationToken"] = fmt.Errorf("network down") + + var buf bytes.Buffer + printer := ui.New(&buf) + err := checkTokenScopes(context.Background(), client, printer, []string{"repo"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "detecting installation token") +} + func TestLoadKnownSlugs_HardError_ReturnsNil(t *testing.T) { client := forge.NewFakeClient() client.Errors["ListDirectoryContents"] = fmt.Errorf("network timeout") diff --git a/internal/cli/foreign_test.go b/internal/cli/foreign_test.go index 334cf9623e..07abb2cf8b 100644 --- a/internal/cli/foreign_test.go +++ b/internal/cli/foreign_test.go @@ -270,6 +270,29 @@ func TestForeignRevokeCmd_NotPresent(t *testing.T) { assert.Equal(t, "fullsend-ai/fullsend", state.vars["FULLSEND_FOREIGN_E2E_REPOS"]) } +func TestForeignListCmd_NoForeignVariables(t *testing.T) { + state := &foreignVarState{vars: map[string]string{"OTHER_VAR": "x"}} + srv := httptest.NewServer(state.handler(t)) + defer srv.Close() + + _, err := runForeignCmd(t, srv.URL, "list", "--org", "pool-org") + require.NoError(t, err) +} + +func TestForeignAllowCmd_UpdateError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := runForeignCmd(t, srv.URL, "allow", "--org", "pool-org", "--role", "e2e", "--caller", "fullsend-ai/fullsend") + require.Error(t, err) +} + func TestForeignCmd_ValidationErrors(t *testing.T) { _, err := runForeignCmd(t, "http://unused", "allow", "--role", "e2e", "--caller", "fullsend-ai/fullsend") require.Error(t, err) diff --git a/internal/forge/fake_test.go b/internal/forge/fake_test.go index f860a3600c..97763092a6 100644 --- a/internal/forge/fake_test.go +++ b/internal/forge/fake_test.go @@ -420,6 +420,57 @@ func TestFakeClient_CreateOrUpdateOrgVariable(t *testing.T) { assert.True(t, exists) } +func TestFakeClient_GetOrgVariable(t *testing.T) { + ctx := context.Background() + fc := &FakeClient{ + OrgVariables: map[string]bool{"myorg/FOREIGN": true}, + OrgVariableValues: map[string]string{"myorg/FOREIGN": "caller/repo"}, + } + + value, exists, err := fc.GetOrgVariable(ctx, "myorg", "FOREIGN") + require.NoError(t, err) + assert.True(t, exists) + assert.Equal(t, "caller/repo", value) + + _, exists, err = fc.GetOrgVariable(ctx, "myorg", "MISSING") + require.NoError(t, err) + assert.False(t, exists) +} + +func TestFakeClient_ListOrgVariables(t *testing.T) { + ctx := context.Background() + fc := &FakeClient{ + OrgVariables: map[string]bool{"myorg/A": true, "myorg/B": true, "other/C": true}, + OrgVariableValues: map[string]string{"myorg/A": "1", "myorg/B": "2"}, + } + + vars, err := fc.ListOrgVariables(ctx, "myorg") + require.NoError(t, err) + require.Len(t, vars, 2) +} + +func TestFakeClient_IsInstallationToken(t *testing.T) { + ctx := context.Background() + fc := &FakeClient{InstallationToken: true} + ok, err := fc.IsInstallationToken(ctx) + require.NoError(t, err) + assert.True(t, ok) + + fc.InstallationToken = false + ok, err = fc.IsInstallationToken(ctx) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestFakeClient_CreateOrUpdateOrgVariableAll(t *testing.T) { + ctx := context.Background() + fc := &FakeClient{} + require.NoError(t, fc.CreateOrUpdateOrgVariableAll(ctx, "myorg", "FOREIGN", "caller")) + exists, err := fc.OrgVariableExists(ctx, "myorg", "FOREIGN") + require.NoError(t, err) + assert.True(t, exists) +} + func TestFakeClient_DeleteOrgVariable(t *testing.T) { ctx := context.Background() fc := &FakeClient{} @@ -489,6 +540,9 @@ func TestFakeClient_ErrorInjection(t *testing.T) { _, err := fc.OrgVariableExists(ctx, "o", "n") return err }}, + {"GetOrgVariable", func(fc *FakeClient) error { _, _, err := fc.GetOrgVariable(ctx, "o", "n"); return err }}, + {"ListOrgVariables", func(fc *FakeClient) error { _, err := fc.ListOrgVariables(ctx, "o"); return err }}, + {"IsInstallationToken", func(fc *FakeClient) error { _, err := fc.IsInstallationToken(ctx); return err }}, {"DeleteOrgVariable", func(fc *FakeClient) error { return fc.DeleteOrgVariable(ctx, "o", "n") }}, diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index 4c7c6c1a0f..d3f2f30384 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -371,6 +371,33 @@ func TestGetAuthenticatedUser_FallbackToGraphQL(t *testing.T) { assert.Equal(t, "fullsend-e2e[bot]", user) } +func TestGraphQLViewerLogin_GraphQLErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/graphql", r.URL.Path) + json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]string{{"message": "insufficient permissions"}}, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv) + _, err := client.graphqlViewerLogin(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "insufficient permissions") +} + +func TestGraphQLViewerLogin_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{"message": "nope"}) + })) + defer srv.Close() + + client := newTestClient(t, srv) + _, err := client.graphqlViewerLogin(context.Background()) + require.Error(t, err) +} + func TestGetAuthenticatedUser_BothFail(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) diff --git a/internal/mintcore/github_test.go b/internal/mintcore/github_test.go index 385eab17e9..acb1a6aa1d 100644 --- a/internal/mintcore/github_test.go +++ b/internal/mintcore/github_test.go @@ -283,3 +283,25 @@ func TestReadForeignAllowlist_EmptyVariable(t *testing.T) { require.NoError(t, err) assert.Nil(t, got) } + +func TestFindOrgInstallation_NotFound(t *testing.T) { + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer mockGH.Close() + + _, err := FindOrgInstallation(t.Context(), http.DefaultClient, mockGH.URL, "fake-jwt", "myorg") + require.Error(t, err) + assert.Contains(t, err.Error(), "status 404") +} + +func TestGetOrgVariable_ErrorStatus(t *testing.T) { + mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer mockGH.Close() + + _, _, err := GetOrgVariable(t.Context(), http.DefaultClient, mockGH.URL, "ghs_policy", "pool-org", "VAR") + require.Error(t, err) + assert.Contains(t, err.Error(), "status 403") +} diff --git a/internal/mintcore/handler_test.go b/internal/mintcore/handler_test.go index bc1e6594c3..e76b0ba265 100644 --- a/internal/mintcore/handler_test.go +++ b/internal/mintcore/handler_test.go @@ -2246,6 +2246,72 @@ func TestHandler_CrossOrgFullFlow(t *testing.T) { } } +func TestHandler_ForeignAllowlistCached(t *testing.T) { + t.Setenv("ALLOWED_ORGS", "test-org,fullsend-ai") + t.Setenv("ROLE_APP_IDS", `{"e2e":"300"}`) + + pemData, err := generateTestRSAKey() + if err != nil { + t.Fatalf("generating test key: %v", err) + } + + env := newTestOIDCEnv(t, &fakePEMAccessor{pems: map[string][]byte{"e2e": pemData}}) + token := env.signToken(t, map[string]interface{}{ + "repository": "fullsend-ai/fullsend", + "repository_owner": "fullsend-ai", + "job_workflow_ref": "fullsend-ai/fullsend/.github/workflows/e2e.yml@refs/heads/main", + }) + + var foreignReads int + var tokenCalls int + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/orgs/pool-org/installation" && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(installationResponse{ + ID: 999, Account: struct { + Login string `json:"login"` + }{Login: "pool-org"}, + }) + case r.URL.Path == "/app/installations/999/access_tokens" && r.Method == http.MethodPost: + tokenCalls++ + w.WriteHeader(http.StatusCreated) + if tokenCalls%2 == 1 { + json.NewEncoder(w).Encode(installationTokenResponse{Token: "ghs_policy_token"}) + return + } + json.NewEncoder(w).Encode(installationTokenResponse{ + Token: "ghs_e2e_token", + ExpiresAt: "2026-05-06T12:00:00Z", + }) + case r.URL.Path == "/orgs/pool-org/actions/variables/FULLSEND_FOREIGN_E2E_REPOS" && r.Method == http.MethodGet: + foreignReads++ + json.NewEncoder(w).Encode(orgVariableResponse{ + Name: "FULLSEND_FOREIGN_E2E_REPOS", + Value: "fullsend-ai/fullsend", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer github.Close() + env.handler.githubBaseURL = github.URL + + body := `{"role":"e2e","target_org":"pool-org"}` + for i := 0; i < 2; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/token", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + env.handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("request %d: expected 200, got %d: %s", i+1, rec.Code, rec.Body.String()) + } + } + if foreignReads != 1 { + t.Fatalf("expected 1 FOREIGN variable read (cached), got %d", foreignReads) + } +} + func TestHandler_CrossOrgForeignDenied(t *testing.T) { t.Setenv("ALLOWED_ORGS", "test-org,evil-org") t.Setenv("ROLE_APP_IDS", `{"e2e":"300"}`) From dc589e6ebe375ba350283364de17e819b486b760 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Wed, 24 Jun 2026 18:07:39 +0300 Subject: [PATCH 16/26] docs: renumber cross-org mint ADR to 0055 Main added ADR 0054 (dispatch authorization). Renumber cross-org mint ADR and update all references to avoid duplicate ADR numbers. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/ADRs/0010-stored-session-for-e2e-browser-auth.md | 2 +- docs/ADRs/0039-totp-automation-for-e2e-2fa.md | 2 +- docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md | 2 +- ...=> 0055-cross-org-mint-authorization-via-org-variables.md} | 4 ++-- docs/architecture.md | 2 +- docs/guides/dev/e2e-testing.md | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) rename docs/ADRs/{0054-cross-org-mint-authorization-via-org-variables.md => 0055-cross-org-mint-authorization-via-org-variables.md} (97%) diff --git a/docs/ADRs/0010-stored-session-for-e2e-browser-auth.md b/docs/ADRs/0010-stored-session-for-e2e-browser-auth.md index 440aab313f..b7d01a3584 100644 --- a/docs/ADRs/0010-stored-session-for-e2e-browser-auth.md +++ b/docs/ADRs/0010-stored-session-for-e2e-browser-auth.md @@ -16,7 +16,7 @@ Date: 2026-04-03 ## Status -Superseded by [ADR 0054](0054-cross-org-mint-authorization-via-org-variables.md) and the e2e mint/OIDC refactor ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)). Playwright session export is no longer used for CI authentication. +Superseded by [ADR 0055](0055-cross-org-mint-authorization-via-org-variables.md) and the e2e mint/OIDC refactor ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)). Playwright session export is no longer used for CI authentication. Extended by [ADR 0039](0039-totp-automation-for-e2e-2fa.md) (also superseded). diff --git a/docs/ADRs/0039-totp-automation-for-e2e-2fa.md b/docs/ADRs/0039-totp-automation-for-e2e-2fa.md index 87af02d471..3d0afe9235 100644 --- a/docs/ADRs/0039-totp-automation-for-e2e-2fa.md +++ b/docs/ADRs/0039-totp-automation-for-e2e-2fa.md @@ -16,7 +16,7 @@ Date: 2026-05-19 ## Status -Superseded by [ADR 0054](0054-cross-org-mint-authorization-via-org-variables.md) and the e2e mint/OIDC refactor ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)). TOTP automation for Playwright login is no longer used. +Superseded by [ADR 0055](0055-cross-org-mint-authorization-via-org-variables.md) and the e2e mint/OIDC refactor ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)). TOTP automation for Playwright login is no longer used. Extends [ADR 0010](0010-stored-session-for-e2e-browser-auth.md) (also superseded). diff --git a/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md b/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md index 9ad3813186..4ef9794706 100644 --- a/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md +++ b/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md @@ -70,7 +70,7 @@ slice in the test code. No architectural changes are needed. staleness check. - Each pool org must install the `fullsend-ai-e2e` app and authorize CI via `FULLSEND_FOREIGN_E2E_REPOS` on the target org — see - [ADR 0054](0054-cross-org-mint-authorization-via-org-variables.md). + [ADR 0055](0055-cross-org-mint-authorization-via-org-variables.md). - CI acquires per-org tokens via cross-org mint ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)); local runs use a user token with pool-org admin access (`gh auth login`). - Pool expansion is an operational task (provision org, update one slice diff --git a/docs/ADRs/0054-cross-org-mint-authorization-via-org-variables.md b/docs/ADRs/0055-cross-org-mint-authorization-via-org-variables.md similarity index 97% rename from docs/ADRs/0054-cross-org-mint-authorization-via-org-variables.md rename to docs/ADRs/0055-cross-org-mint-authorization-via-org-variables.md index d7dea2215c..8df91dc65c 100644 --- a/docs/ADRs/0054-cross-org-mint-authorization-via-org-variables.md +++ b/docs/ADRs/0055-cross-org-mint-authorization-via-org-variables.md @@ -1,5 +1,5 @@ --- -title: "54. Cross-org mint authorization via org variables" +title: "55. Cross-org mint authorization via org variables" status: Accepted relates_to: - agent-infrastructure @@ -11,7 +11,7 @@ topics: - cross-org --- -# 54. Cross-org mint authorization via org variables +# 55. Cross-org mint authorization via org variables Date: 2026-06-07 diff --git a/docs/architecture.md b/docs/architecture.md index 98ff483ccc..19aa78912c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,7 +136,7 @@ Identity is not the same as trust. An agent's identity lets it authenticate to e - Credential delivery model: four tiers — (1) prefetch + post-process for agents with enumerable inputs (zero credential access), (2) OpenShell providers + L7 egress policies for static token auth (credentials never enter sandbox), (3) host-side REST server for operations providers cannot handle — long-running operations, sandbox capability gaps, credentials in request bodies, response transformation, and multi-step atomic operations (see [ADR 0046](ADRs/0046-host-side-api-server-design.md)), (4) host files + L7 policies for complex auth requiring in-sandbox credential files. L7 policies enforce both method + path and binary-level restrictions. Providers are preferred over REST servers when viable ([ADR 0017](ADRs/0017-credential-isolation-for-sandboxed-agents.md), extended by [ADR 0025](ADRs/0025-provider-credential-delivery-for-sandboxed-agents.md)). - Host-side API server design: Tier 3 servers follow a uniform process contract (`--port`, `--token`, `--bind-address`, `/healthz`, `/tools.json`, `SIGTERM`). Network access is controlled via composable provider profiles — atomic capability profiles composed per-harness. Per-run UUID bearer tokens are delivered through OpenShell provider placeholders. File transfer uses `openshell sandbox upload/download` ([ADR 0046](ADRs/0046-host-side-api-server-design.md)). - Per-role GitHub Apps with manifest-based creation. Each agent role gets its own app with scoped permissions. PEMs stored in Secret Manager as `fullsend-{role}-app-pem` — one secret per role, shared across orgs on a mint. `ROLE_APP_IDS` uses the same shared-per-role model (`coder` → app ID). Org isolation is enforced via `ALLOWED_ORGS`, WIF conditions, and installation verification ([ADR 0007](ADRs/0007-per-role-github-apps.md), [ADR 0033](ADRs/0033-per-repo-installation-mode.md)). -- Cross-org mint authorization: workflows may request tokens for a different org via optional `target_org` when the target org installs the role App and sets `FULLSEND_FOREIGN__REPOS`. Empty `repos` yields installation-wide tokens on either path; cross-org adds FOREIGN gating, same-org relies on WIF/OIDC enrollment ([ADR 0054](ADRs/0054-cross-org-mint-authorization-via-org-variables.md)). +- Cross-org mint authorization: workflows may request tokens for a different org via optional `target_org` when the target org installs the role App and sets `FULLSEND_FOREIGN__REPOS`. Empty `repos` yields installation-wide tokens on either path; cross-org adds FOREIGN gating, same-org relies on WIF/OIDC enrollment ([ADR 0055](ADRs/0055-cross-org-mint-authorization-via-org-variables.md)). One concrete implementation option is [`oidcx`](https://github.com/oxidecomputer/oidcx): a service that accepts OIDC identity tokens and exchanges them for short-lived access tokens. It can mint tokens scoped to selected GitHub repositories and permissions, or to selected Oxide silos and permissions, and it also ships with a GitHub Action wrapper. In a Fullsend deployment, this can be used by the sandbox entrypoint to narrow a broad GitHub App identity down to only the specific permissions an agent needs for the current run. diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index 2faa3a3c8a..0670856f87 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -3,7 +3,7 @@ Guide for running and debugging fullsend admin e2e tests locally and in CI. Related ADRs: [0040](../../ADRs/0040-org-pool-for-parallel-e2e-tests.md) (org pool), -[0054](../../ADRs/0054-cross-org-mint-authorization-via-org-variables.md) (cross-org mint), +[0055](../../ADRs/0055-cross-org-mint-authorization-via-org-variables.md) (cross-org mint), [0009](../../ADRs/0009-pull-request-target-in-shim-workflows.md) (pull_request_target security model for shims; e2e uses a separate gate pattern documented below). Historical ADRs [0010](../../ADRs/0010-stored-session-for-e2e-browser-auth.md) (browser session) and @@ -46,7 +46,7 @@ In GitHub Actions, tests mint a cross-org installation token via the mint servic 1. Workflow requests a GHA OIDC token (`id-token: write`) 2. `mintclient.MintToken` POSTs to `E2E_MINT_URL/v1/token` with `{role: "e2e", target_org: ""}` (repos omitted for installation-wide access) -3. Mint verifies the caller against `FULLSEND_FOREIGN_E2E_REPOS` on the target org ([ADR 0054](../../ADRs/0054-cross-org-mint-authorization-via-org-variables.md)) +3. Mint verifies the caller against `FULLSEND_FOREIGN_E2E_REPOS` on the target org ([ADR 0055](../../ADRs/0055-cross-org-mint-authorization-via-org-variables.md)) Required repository secrets: From 838e2bf4606d22f2a9cea96db6530a537a71c171 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Wed, 24 Jun 2026 18:25:40 +0300 Subject: [PATCH 17/26] fix(e2e): open triage test issues with user token for ADR 0054 Mint installation tokens create issues as bots; dispatch now requires write permission on the issue opener, so triage never fired in CI. Use E2E_ISSUE_AUTHOR_TOKEN in GHA (local runs keep gh auth token). Signed-off-by: Barak Korren Co-authored-by: Cursor --- .github/workflows/e2e.yml | 1 + docs/guides/dev/e2e-testing.md | 6 ++++-- e2e/admin/admin_test.go | 9 +++++++-- e2e/admin/auth.go | 13 +++++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bd2c55cd3b..45d6f3ccf7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -142,6 +142,7 @@ jobs: env: E2E_SCREENSHOT_DIR: ${{ runner.temp }}/e2e-screenshots E2E_MINT_URL: ${{ secrets.E2E_MINT_URL }} + E2E_ISSUE_AUTHOR_TOKEN: ${{ secrets.E2E_ISSUE_AUTHOR_TOKEN }} E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }} - name: Upload debug screenshots diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index 0670856f87..b18616f976 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -17,7 +17,7 @@ Before running e2e locally or in CI: 1. **Pool orgs** (`halfsend-01` … `halfsend-06`) provisioned per [Pool org provisioning](#pool-org-provisioning) below 2. **Mint** deployed with `e2e` role enrolled and `ALLOWED_ORGS` including `fullsend-ai` -3. **CI only:** `E2E_MINT_URL` repository secret and pool orgs with `FULLSEND_FOREIGN_E2E_REPOS` authorizing `fullsend-ai/fullsend` +3. **CI only:** `E2E_MINT_URL` and `E2E_ISSUE_AUTHOR_TOKEN` repository secrets; pool orgs with `FULLSEND_FOREIGN_E2E_REPOS` authorizing `fullsend-ai/fullsend` 4. **Local only:** `gh auth login` (or `GH_TOKEN` / `GITHUB_TOKEN`) with admin access on pool orgs ## Local runs @@ -33,7 +33,8 @@ Optional environment variables: | Variable | Purpose | |----------|---------| -| `GH_TOKEN` / `GITHUB_TOKEN` | Override token source for local runs | +| `GH_TOKEN` / `GITHUB_TOKEN` | Override token source for local runs (also used to open triage test issues) | +| `E2E_ISSUE_AUTHOR_TOKEN` | User PAT with write access on pool orgs (CI only; local runs use `GH_TOKEN` / `gh auth`) | | `E2E_LOCK_TIMEOUT` | Max wait for a free pool org (default 10m) | | `E2E_GCP_PROJECT_ID` | GCP project for inference-related setup (if needed) | @@ -53,6 +54,7 @@ Required repository secrets: | Secret | Purpose | |--------|---------| | `E2E_MINT_URL` | Mint service base URL | +| `E2E_ISSUE_AUTHOR_TOKEN` | User PAT (e.g. `botsend`) with write access on pool org repos — opens triage test issues as a human actor ([ADR 0054](../../ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md); mint tokens create bot-authored issues that dispatch rejects) | | `E2E_GCP_WIF_PROVIDER` | GCP WIF provider (inference / auxiliary GCP access) | | `E2E_GCP_SERVICE_ACCOUNT` | GCP service account for WIF | | `E2E_GCP_PROJECT_ID` | GCP project ID | diff --git a/e2e/admin/admin_test.go b/e2e/admin/admin_test.go index fd9e34d86a..d35123c114 100644 --- a/e2e/admin/admin_test.go +++ b/e2e/admin/admin_test.go @@ -312,12 +312,17 @@ Segmentation fault (core dumped) **Additional context:** This started happening after the v2.3.0 -> v2.3.1 upgrade. Files under 64KB save fine. Files over 64KB save fine if they contain only ASCII characters.` - issue, err := env.client.CreateIssue(ctx, env.org, testRepo, issueTitle, issueBody) + // Issues must be opened by a user with write access so dispatch authorizes + // triage (ADR 0054). Mint/installation tokens create issues as bots. + authorToken, err := issueAuthorToken(env.cfg) + require.NoError(t, err, "resolving issue author token") + issueClient := newLiveClient(authorToken) + issue, err := issueClient.CreateIssue(ctx, env.org, testRepo, issueTitle, issueBody) require.NoError(t, err, "creating test issue") t.Logf("Created test issue #%d: %s", issue.Number, issue.URL) t.Cleanup(func() { t.Log("Closing test issue...") - if closeErr := env.client.CloseIssue(ctx, env.org, testRepo, issue.Number); closeErr != nil { + if closeErr := issueClient.CloseIssue(ctx, env.org, testRepo, issue.Number); closeErr != nil { t.Logf("warning: could not close test issue: %v", closeErr) } }) diff --git a/e2e/admin/auth.go b/e2e/admin/auth.go index 6a1221140a..8c557398a9 100644 --- a/e2e/admin/auth.go +++ b/e2e/admin/auth.go @@ -65,3 +65,16 @@ func tokenForOrg(ctx context.Context, cfg envConfig, org string) (string, error) } return resolveLocalToken() } + +// issueAuthorToken returns a user OAuth token for actions that must appear as a +// human with repository write access. Installation tokens create issues as bots, +// which fail ADR 0054 dispatch authorization (has_write_permission on open). +func issueAuthorToken(cfg envConfig) (string, error) { + if token := os.Getenv("E2E_ISSUE_AUTHOR_TOKEN"); token != "" { + return token, nil + } + if !cfg.useMint { + return resolveLocalToken() + } + return "", fmt.Errorf("E2E_ISSUE_AUTHOR_TOKEN not set: CI mint tokens create issues as bots, which dispatch rejects for triage") +} From d92663a94f902dc6c1fb44617e8ccc6b18bfd6bd Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Wed, 24 Jun 2026 18:31:27 +0300 Subject: [PATCH 18/26] fix(e2e): fall back to E2E_GITHUB_PASSWORD for issue author in CI pull_request_target runs the base-branch e2e.yml until merge; it already injects E2E_GITHUB_PASSWORD. Accept it as a user PAT fallback so triage smoke tests work before the slim workflow lands. Signed-off-by: Barak Korren Co-authored-by: Cursor --- docs/guides/dev/e2e-testing.md | 5 +++-- e2e/admin/auth.go | 12 +++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index b18616f976..af2425f17b 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -34,7 +34,8 @@ Optional environment variables: | Variable | Purpose | |----------|---------| | `GH_TOKEN` / `GITHUB_TOKEN` | Override token source for local runs (also used to open triage test issues) | -| `E2E_ISSUE_AUTHOR_TOKEN` | User PAT with write access on pool orgs (CI only; local runs use `GH_TOKEN` / `gh auth`) | +| `E2E_ISSUE_AUTHOR_TOKEN` | User PAT with write access on pool orgs (CI; preferred name after this PR merges) | +| `E2E_GITHUB_PASSWORD` | Legacy CI secret name — until the slim `e2e.yml` merges, `pull_request_target` runs the base workflow which injects this env var; store a user PAT here (not a login password) | | `E2E_LOCK_TIMEOUT` | Max wait for a free pool org (default 10m) | | `E2E_GCP_PROJECT_ID` | GCP project for inference-related setup (if needed) | @@ -54,7 +55,7 @@ Required repository secrets: | Secret | Purpose | |--------|---------| | `E2E_MINT_URL` | Mint service base URL | -| `E2E_ISSUE_AUTHOR_TOKEN` | User PAT (e.g. `botsend`) with write access on pool org repos — opens triage test issues as a human actor ([ADR 0054](../../ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md); mint tokens create bot-authored issues that dispatch rejects) | +| `E2E_ISSUE_AUTHOR_TOKEN` | User PAT (e.g. `botsend` or an org admin) with write access on pool org repos — opens triage test issues as a human actor ([ADR 0054](../../ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md); mint tokens create bot-authored issues that dispatch rejects). Until this PR merges, CI still runs the base-branch workflow: set the same PAT on legacy `E2E_GITHUB_PASSWORD` (main injects it today). | | `E2E_GCP_WIF_PROVIDER` | GCP WIF provider (inference / auxiliary GCP access) | | `E2E_GCP_SERVICE_ACCOUNT` | GCP service account for WIF | | `E2E_GCP_PROJECT_ID` | GCP project ID | diff --git a/e2e/admin/auth.go b/e2e/admin/auth.go index 8c557398a9..293c8729b6 100644 --- a/e2e/admin/auth.go +++ b/e2e/admin/auth.go @@ -69,12 +69,18 @@ func tokenForOrg(ctx context.Context, cfg envConfig, org string) (string, error) // issueAuthorToken returns a user OAuth token for actions that must appear as a // human with repository write access. Installation tokens create issues as bots, // which fail ADR 0054 dispatch authorization (has_write_permission on open). +// +// In pull_request_target CI, the workflow file comes from the base branch until +// this PR merges, so we also accept E2E_GITHUB_PASSWORD (legacy secret name that +// main's e2e.yml already injects; value should be a user PAT with pool-org write). func issueAuthorToken(cfg envConfig) (string, error) { - if token := os.Getenv("E2E_ISSUE_AUTHOR_TOKEN"); token != "" { - return token, nil + for _, env := range []string{"E2E_ISSUE_AUTHOR_TOKEN", "E2E_GITHUB_PASSWORD"} { + if token := os.Getenv(env); token != "" { + return token, nil + } } if !cfg.useMint { return resolveLocalToken() } - return "", fmt.Errorf("E2E_ISSUE_AUTHOR_TOKEN not set: CI mint tokens create issues as bots, which dispatch rejects for triage") + return "", fmt.Errorf("no issue author token: set E2E_ISSUE_AUTHOR_TOKEN or E2E_GITHUB_PASSWORD (user PAT with write on pool org repos)") } From 33aac552d2dc1512355dc14e98be14b020ccc176 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 25 Jun 2026 08:40:54 +0300 Subject: [PATCH 19/26] docs(e2e): keep E2E_GITHUB_PASSWORD as interim issue-author secret Slim e2e.yml injects E2E_GITHUB_PASSWORD instead of E2E_ISSUE_AUTHOR_TOKEN; Signed-off-by: Barak Korren Co-authored-by: Cursor #2641 may remove the need for either once bot dispatch auth lands. Signed-off-by: Barak Korren --- .github/workflows/e2e.yml | 2 +- docs/guides/dev/e2e-testing.md | 7 +++---- e2e/admin/auth.go | 9 ++++----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 45d6f3ccf7..73c1b17d09 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -142,7 +142,7 @@ jobs: env: E2E_SCREENSHOT_DIR: ${{ runner.temp }}/e2e-screenshots E2E_MINT_URL: ${{ secrets.E2E_MINT_URL }} - E2E_ISSUE_AUTHOR_TOKEN: ${{ secrets.E2E_ISSUE_AUTHOR_TOKEN }} + E2E_GITHUB_PASSWORD: ${{ secrets.E2E_GITHUB_PASSWORD }} E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }} - name: Upload debug screenshots diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index af2425f17b..4eeb9104cd 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -17,7 +17,7 @@ Before running e2e locally or in CI: 1. **Pool orgs** (`halfsend-01` … `halfsend-06`) provisioned per [Pool org provisioning](#pool-org-provisioning) below 2. **Mint** deployed with `e2e` role enrolled and `ALLOWED_ORGS` including `fullsend-ai` -3. **CI only:** `E2E_MINT_URL` and `E2E_ISSUE_AUTHOR_TOKEN` repository secrets; pool orgs with `FULLSEND_FOREIGN_E2E_REPOS` authorizing `fullsend-ai/fullsend` +3. **CI only:** `E2E_MINT_URL` and `E2E_GITHUB_PASSWORD` repository secrets; pool orgs with `FULLSEND_FOREIGN_E2E_REPOS` authorizing `fullsend-ai/fullsend` 4. **Local only:** `gh auth login` (or `GH_TOKEN` / `GITHUB_TOKEN`) with admin access on pool orgs ## Local runs @@ -34,8 +34,7 @@ Optional environment variables: | Variable | Purpose | |----------|---------| | `GH_TOKEN` / `GITHUB_TOKEN` | Override token source for local runs (also used to open triage test issues) | -| `E2E_ISSUE_AUTHOR_TOKEN` | User PAT with write access on pool orgs (CI; preferred name after this PR merges) | -| `E2E_GITHUB_PASSWORD` | Legacy CI secret name — until the slim `e2e.yml` merges, `pull_request_target` runs the base workflow which injects this env var; store a user PAT here (not a login password) | +| `E2E_GITHUB_PASSWORD` | User PAT with write access on pool orgs (CI interim for triage smoke test; see [#2641](https://github.com/fullsend-ai/fullsend/issues/2641)) | | `E2E_LOCK_TIMEOUT` | Max wait for a free pool org (default 10m) | | `E2E_GCP_PROJECT_ID` | GCP project for inference-related setup (if needed) | @@ -55,7 +54,7 @@ Required repository secrets: | Secret | Purpose | |--------|---------| | `E2E_MINT_URL` | Mint service base URL | -| `E2E_ISSUE_AUTHOR_TOKEN` | User PAT (e.g. `botsend` or an org admin) with write access on pool org repos — opens triage test issues as a human actor ([ADR 0054](../../ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md); mint tokens create bot-authored issues that dispatch rejects). Until this PR merges, CI still runs the base-branch workflow: set the same PAT on legacy `E2E_GITHUB_PASSWORD` (main injects it today). | +| `E2E_GITHUB_PASSWORD` | User PAT (e.g. pool org admin) with write access on pool org repos — interim: opens triage test issues as a human actor ([ADR 0054](../../ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md); mint tokens create bot-authored issues that dispatch rejects). Removed when [#2641](https://github.com/fullsend-ai/fullsend/issues/2641) lands. Value is a PAT, not a login password. | | `E2E_GCP_WIF_PROVIDER` | GCP WIF provider (inference / auxiliary GCP access) | | `E2E_GCP_SERVICE_ACCOUNT` | GCP service account for WIF | | `E2E_GCP_PROJECT_ID` | GCP project ID | diff --git a/e2e/admin/auth.go b/e2e/admin/auth.go index 293c8729b6..e3785f61d7 100644 --- a/e2e/admin/auth.go +++ b/e2e/admin/auth.go @@ -70,11 +70,10 @@ func tokenForOrg(ctx context.Context, cfg envConfig, org string) (string, error) // human with repository write access. Installation tokens create issues as bots, // which fail ADR 0054 dispatch authorization (has_write_permission on open). // -// In pull_request_target CI, the workflow file comes from the base branch until -// this PR merges, so we also accept E2E_GITHUB_PASSWORD (legacy secret name that -// main's e2e.yml already injects; value should be a user PAT with pool-org write). +// Interim until #2641: CI stores a user PAT in E2E_GITHUB_PASSWORD (legacy secret +// name; not a login password). func issueAuthorToken(cfg envConfig) (string, error) { - for _, env := range []string{"E2E_ISSUE_AUTHOR_TOKEN", "E2E_GITHUB_PASSWORD"} { + for _, env := range []string{"E2E_GITHUB_PASSWORD", "E2E_ISSUE_AUTHOR_TOKEN"} { if token := os.Getenv(env); token != "" { return token, nil } @@ -82,5 +81,5 @@ func issueAuthorToken(cfg envConfig) (string, error) { if !cfg.useMint { return resolveLocalToken() } - return "", fmt.Errorf("no issue author token: set E2E_ISSUE_AUTHOR_TOKEN or E2E_GITHUB_PASSWORD (user PAT with write on pool org repos)") + return "", fmt.Errorf("no issue author token: set E2E_GITHUB_PASSWORD (user PAT with write on pool org repos)") } From aa01014998be18388712986b4c49c0c0762d3e95 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 25 Jun 2026 08:50:17 +0300 Subject: [PATCH 20/26] refactor(e2e): use FULLSEND_MINT_URL / hosted default instead of E2E_MINT_URL Align e2e mint endpoint with fullsend admin and agent run paths; drop the dedicated E2E_MINT_URL repo secret and workflow secrets-check gate. Signed-off-by: Barak Korren Co-authored-by: Cursor --- .github/workflows/e2e.yml | 20 +++----------------- AGENTS.md | 4 ++-- docs/guides/dev/e2e-testing.md | 8 ++++---- e2e/admin/auth.go | 12 +++++++++++- e2e/admin/testutil.go | 5 +---- skills/e2e-health/SKILL.md | 2 +- 6 files changed, 22 insertions(+), 29 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 73c1b17d09..9e45c9be27 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -116,37 +116,23 @@ jobs: with: go-version-file: go.mod - - name: Check for secrets - if: steps.changes.outputs.relevant != 'false' - id: secrets-check - run: | - if [ -z "$E2E_MINT_URL" ]; then - echo "::warning::E2E_MINT_URL is not configured. Skipping e2e tests." - echo "available=false" >> "$GITHUB_OUTPUT" - else - echo "available=true" >> "$GITHUB_OUTPUT" - fi - env: - E2E_MINT_URL: ${{ secrets.E2E_MINT_URL }} - - name: Authenticate to GCP - if: steps.changes.outputs.relevant != 'false' && steps.secrets-check.outputs.available == 'true' + if: steps.changes.outputs.relevant != 'false' uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 with: workload_identity_provider: ${{ secrets.E2E_GCP_WIF_PROVIDER }} service_account: ${{ secrets.E2E_GCP_SERVICE_ACCOUNT }} - name: Run e2e tests - if: steps.changes.outputs.relevant != 'false' && steps.secrets-check.outputs.available == 'true' + if: steps.changes.outputs.relevant != 'false' run: make e2e-test env: E2E_SCREENSHOT_DIR: ${{ runner.temp }}/e2e-screenshots - E2E_MINT_URL: ${{ secrets.E2E_MINT_URL }} E2E_GITHUB_PASSWORD: ${{ secrets.E2E_GITHUB_PASSWORD }} E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }} - name: Upload debug screenshots - if: always() && steps.changes.outputs.relevant != 'false' && steps.secrets-check.outputs.available == 'true' + if: always() && steps.changes.outputs.relevant != 'false' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-screenshots-${{ github.event_name == 'pull_request_target' && github.event.pull_request.number || github.run_id }} diff --git a/AGENTS.md b/AGENTS.md index f162d6cc22..12bef4a7d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,8 +40,8 @@ When making changes to Go code under `cmd/` or `internal/`: The e2e tests mint short-lived GitHub App installation tokens via the central token mint. Credentials are never stored in the repo. -- **CI:** Set `E2E_MINT_URL` (org variable on `fullsend-ai`) and use the workflow's OIDC identity. The e2e workflow exchanges the OIDC JWT for an `e2e`-role installation token on the pool org. -- **Local:** Run `gh auth login` (or set `GH_TOKEN` to a token with sufficient scopes) so `e2e/admin/auth.go` can call the mint with your identity. Set `E2E_MINT_URL` to the deployed mint endpoint. +- **CI:** Uses the hosted public mint (same default as `fullsend admin --mint-url`) with the workflow's OIDC identity. The e2e workflow exchanges the OIDC JWT for an `e2e`-role installation token on the pool org. Override with `FULLSEND_MINT_URL` if needed. +- **Local:** Run `gh auth login` (or set `GH_TOKEN` to a token with sufficient scopes) for pool-org admin operations. Mint uses `FULLSEND_MINT_URL` or the hosted default. See `docs/guides/dev/e2e-testing.md` and `make help` for pool org setup and troubleshooting. diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index 4eeb9104cd..0831f38c9b 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -17,7 +17,7 @@ Before running e2e locally or in CI: 1. **Pool orgs** (`halfsend-01` … `halfsend-06`) provisioned per [Pool org provisioning](#pool-org-provisioning) below 2. **Mint** deployed with `e2e` role enrolled and `ALLOWED_ORGS` including `fullsend-ai` -3. **CI only:** `E2E_MINT_URL` and `E2E_GITHUB_PASSWORD` repository secrets; pool orgs with `FULLSEND_FOREIGN_E2E_REPOS` authorizing `fullsend-ai/fullsend` +3. **CI only:** `E2E_GITHUB_PASSWORD` repository secret (interim triage PAT); pool orgs with `FULLSEND_FOREIGN_E2E_REPOS` authorizing `fullsend-ai/fullsend` 4. **Local only:** `gh auth login` (or `GH_TOKEN` / `GITHUB_TOKEN`) with admin access on pool orgs ## Local runs @@ -35,6 +35,7 @@ Optional environment variables: |----------|---------| | `GH_TOKEN` / `GITHUB_TOKEN` | Override token source for local runs (also used to open triage test issues) | | `E2E_GITHUB_PASSWORD` | User PAT with write access on pool orgs (CI interim for triage smoke test; see [#2641](https://github.com/fullsend-ai/fullsend/issues/2641)) | +| `FULLSEND_MINT_URL` | Override mint endpoint (default: hosted public mint, same as `fullsend admin --mint-url`) | | `E2E_LOCK_TIMEOUT` | Max wait for a free pool org (default 10m) | | `E2E_GCP_PROJECT_ID` | GCP project for inference-related setup (if needed) | @@ -46,20 +47,19 @@ Tests acquire an exclusive lock on one org from the pool (`halfsend-01` … In GitHub Actions, tests mint a cross-org installation token via the mint service: 1. Workflow requests a GHA OIDC token (`id-token: write`) -2. `mintclient.MintToken` POSTs to `E2E_MINT_URL/v1/token` with `{role: "e2e", target_org: ""}` (repos omitted for installation-wide access) +2. `mintclient.MintToken` POSTs to `{FULLSEND_MINT_URL or hosted default}/v1/token` with `{role: "e2e", target_org: ""}` (repos omitted for installation-wide access) 3. Mint verifies the caller against `FULLSEND_FOREIGN_E2E_REPOS` on the target org ([ADR 0055](../../ADRs/0055-cross-org-mint-authorization-via-org-variables.md)) Required repository secrets: | Secret | Purpose | |--------|---------| -| `E2E_MINT_URL` | Mint service base URL | | `E2E_GITHUB_PASSWORD` | User PAT (e.g. pool org admin) with write access on pool org repos — interim: opens triage test issues as a human actor ([ADR 0054](../../ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md); mint tokens create bot-authored issues that dispatch rejects). Removed when [#2641](https://github.com/fullsend-ai/fullsend/issues/2641) lands. Value is a PAT, not a login password. | | `E2E_GCP_WIF_PROVIDER` | GCP WIF provider (inference / auxiliary GCP access) | | `E2E_GCP_SERVICE_ACCOUNT` | GCP service account for WIF | | `E2E_GCP_PROJECT_ID` | GCP project ID | -If `E2E_MINT_URL` is unset, the e2e job skips with a warning. +Mint URL uses the hosted public endpoint by default (same as `fullsend admin --mint-url`). Override with org/repo variable `FULLSEND_MINT_URL` if needed; no separate e2e secret. ## Pool org provisioning diff --git a/e2e/admin/auth.go b/e2e/admin/auth.go index e3785f61d7..51cc394ea0 100644 --- a/e2e/admin/auth.go +++ b/e2e/admin/auth.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/fullsend-ai/fullsend/internal/cli" "github.com/fullsend-ai/fullsend/internal/mintclient" ) @@ -40,12 +41,21 @@ func runningInGitHubActions() bool { return os.Getenv("GITHUB_ACTIONS") == "true" } +// resolveMintURL returns the mint endpoint from FULLSEND_MINT_URL or the hosted +// default (same as fullsend admin --mint-url). +func resolveMintURL() string { + if u := os.Getenv("FULLSEND_MINT_URL"); u != "" { + return u + } + return cli.DefaultMintURL +} + // resolveE2EToken mints a cross-org e2e installation token for targetOrg. // Repos are omitted so the token covers the full installation (needed to // create and operate on e2e-lock and .fullsend at runtime). func resolveE2EToken(ctx context.Context, mintURL, targetOrg string) (string, error) { if mintURL == "" { - return "", fmt.Errorf("E2E_MINT_URL not set") + return "", fmt.Errorf("mint URL not configured") } result, err := mintclient.MintToken(ctx, mintclient.MintRequest{ MintURL: mintURL, diff --git a/e2e/admin/testutil.go b/e2e/admin/testutil.go index 2e41bcc96f..be9b769600 100644 --- a/e2e/admin/testutil.go +++ b/e2e/admin/testutil.go @@ -248,11 +248,8 @@ type envConfig struct { func loadEnvConfig(t *testing.T) envConfig { t.Helper() - mintURL := os.Getenv("E2E_MINT_URL") + mintURL := resolveMintURL() useMint := runningInGitHubActions() - if useMint && mintURL == "" { - t.Skip("E2E_MINT_URL not set, skipping e2e test in CI") - } if !useMint { if _, err := resolveLocalToken(); err != nil { t.Skip("no local GitHub token (gh auth login), skipping e2e test") diff --git a/skills/e2e-health/SKILL.md b/skills/e2e-health/SKILL.md index acd2dbb6f7..60d377159a 100644 --- a/skills/e2e-health/SKILL.md +++ b/skills/e2e-health/SKILL.md @@ -43,7 +43,7 @@ gh run view --log-failed 2>&1 | grep -iE "(FAIL|--- FAIL|Error|panic|ti Read the matched lines and provide a brief explanation of why the run failed. Common failure categories: - **Flaky test** — timing-dependent or non-deterministic failure -- **Mint / auth** — OIDC or mint token exchange failure, missing `E2E_MINT_URL`, or FOREIGN allowlist misconfiguration +- **Mint / auth** — OIDC or mint token exchange failure, FOREIGN allowlist misconfiguration, or missing interim `E2E_GITHUB_PASSWORD` for triage smoke tests - **Infrastructure** — GCP auth, runner issues, pool org lock contention - **Real regression** — a code change broke e2e behavior From f4ad9ccf0b8445d79cee465e4b07c8070378f55c Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 25 Jun 2026 08:50:31 +0300 Subject: [PATCH 21/26] docs(make): update e2e-test help for hosted mint default Signed-off-by: Barak Korren Co-authored-by: Cursor --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6d4babe53a..c9ba73fb9a 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ help: @echo " lint-md-links - Check markdown files for broken in-repo links and anchors" @echo " script-test - Run shell script tests (post-triage, post-code, post-review, pre-fetch-prior-review, reconcile-repos, validate-output-schema)" @echo " test - Run all checks: lint-all, go-test, script-test, lint-eval-cases" - @echo " e2e-test - Run admin e2e tests (CI: E2E_MINT_URL; local: gh auth login or GH_TOKEN)" + @echo " e2e-test - Run admin e2e tests (CI: OIDC mint; local: gh auth login or GH_TOKEN)" @echo " lint-eval-cases - Lint eval case definitions (annotations.yaml completeness)" @echo " functional-tests - Run functional agent tests (requires EVAL_ORG, FULLSEND_DIR, GH_TOKEN, GCP creds)" From b324850f7381841fdcc3cb41791cdedf7eada210 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Thu, 25 Jun 2026 09:02:59 +0300 Subject: [PATCH 22/26] docs: document interim E2E_GITHUB_PASSWORD PAT in AGENTS.md Reinstate agent guidance for the triage smoke-test secret alongside mint/OIDC auth; clarify value is a PAT, not a login password. Signed-off-by: Barak Korren Co-authored-by: Cursor --- AGENTS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 12bef4a7d4..685c5e9b6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,10 +38,11 @@ When making changes to Go code under `cmd/` or `internal/`: ### Running e2e tests -The e2e tests mint short-lived GitHub App installation tokens via the central token mint. Credentials are never stored in the repo. +The e2e tests mint short-lived GitHub App installation tokens via the central token mint. Pool-org admin operations use mint/OIDC in CI and do not require a dedicated mint URL secret. -- **CI:** Uses the hosted public mint (same default as `fullsend admin --mint-url`) with the workflow's OIDC identity. The e2e workflow exchanges the OIDC JWT for an `e2e`-role installation token on the pool org. Override with `FULLSEND_MINT_URL` if needed. -- **Local:** Run `gh auth login` (or set `GH_TOKEN` to a token with sufficient scopes) for pool-org admin operations. Mint uses `FULLSEND_MINT_URL` or the hosted default. +- **CI (mint):** Uses the hosted public mint (same default as `fullsend admin --mint-url`) with the workflow's OIDC identity. The e2e workflow exchanges the OIDC JWT for an `e2e`-role installation token on the pool org. Override with `FULLSEND_MINT_URL` if needed. +- **CI (triage smoke test, interim):** Repository secret `E2E_GITHUB_PASSWORD` must hold a **user PAT** with write access on pool org repos — the legacy secret name is retained, but the value is a PAT, not a login password. Mint tokens create bot-authored issues that [ADR 0054](docs/ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md) dispatch rejects for auto-triage; the test opens issues as a human actor instead. Removal tracked in [#2641](https://github.com/fullsend-ai/fullsend/issues/2641). +- **Local:** Run `gh auth login` (or set `GH_TOKEN` / `GITHUB_TOKEN` with pool-org admin access) for mint and triage test issues. Mint uses `FULLSEND_MINT_URL` or the hosted default. See `docs/guides/dev/e2e-testing.md` and `make help` for pool org setup and troubleshooting. From d2f03fcc128ea025e8fa248e89d4aafff1d85da2 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 30 Jun 2026 08:35:24 +0300 Subject: [PATCH 23/26] fix(e2e): trigger triage via ready-for-triage label dispatch Adopt main's #2679 bot-to-bot triage path: mint token creates the issue, then applies ready-for-triage in a separate API call so issues.labeled fires. Removes the interim E2E_GITHUB_PASSWORD PAT workaround. Signed-off-by: Barak Korren Co-authored-by: Cursor --- .github/workflows/e2e.yml | 1 - AGENTS.md | 3 +- docs/guides/dev/e2e-testing.md | 6 ++-- e2e/admin/admin_test.go | 22 ++++++------- e2e/admin/auth.go | 18 ----------- e2e/admin/testutil.go | 56 ++++++++++++++++++++++++++++++++++ skills/e2e-health/SKILL.md | 2 +- 7 files changed, 71 insertions(+), 37 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index aac96c18a5..7e0d132640 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -146,7 +146,6 @@ jobs: run: make e2e-test env: E2E_SCREENSHOT_DIR: ${{ runner.temp }}/e2e-screenshots - E2E_GITHUB_PASSWORD: ${{ secrets.E2E_GITHUB_PASSWORD }} E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }} - name: Upload debug screenshots diff --git a/AGENTS.md b/AGENTS.md index ac49bcadb3..5afb834ecb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,8 +43,7 @@ When making changes to Go code under `cmd/` or `internal/`: The e2e tests mint short-lived GitHub App installation tokens via the central token mint. Pool-org admin operations use mint/OIDC in CI and do not require a dedicated mint URL secret. - **CI (mint):** Uses the hosted public mint (same default as `fullsend admin --mint-url`) with the workflow's OIDC identity. The e2e workflow exchanges the OIDC JWT for an `e2e`-role installation token on the pool org. Override with `FULLSEND_MINT_URL` if needed. -- **CI (triage smoke test, interim):** Repository secret `E2E_GITHUB_PASSWORD` must hold a **user PAT** with write access on pool org repos — the legacy secret name is retained, but the value is a PAT, not a login password. Mint tokens create bot-authored issues that [ADR 0054](docs/ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md) dispatch rejects for auto-triage; the test opens issues as a human actor instead. Removal tracked in [#2641](https://github.com/fullsend-ai/fullsend/issues/2641). -- **Local:** Run `gh auth login` (or set `GH_TOKEN` / `GITHUB_TOKEN` with pool-org admin access) for mint and triage test issues. Mint uses `FULLSEND_MINT_URL` or the hosted default. +- **Local:** Run `gh auth login` (or set `GH_TOKEN` / `GITHUB_TOKEN` with pool-org admin access). Mint uses `FULLSEND_MINT_URL` or the hosted default. See `docs/guides/dev/e2e-testing.md` and `make help` for pool org setup and troubleshooting. diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index 0831f38c9b..bf9e5fc447 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -17,7 +17,7 @@ Before running e2e locally or in CI: 1. **Pool orgs** (`halfsend-01` … `halfsend-06`) provisioned per [Pool org provisioning](#pool-org-provisioning) below 2. **Mint** deployed with `e2e` role enrolled and `ALLOWED_ORGS` including `fullsend-ai` -3. **CI only:** `E2E_GITHUB_PASSWORD` repository secret (interim triage PAT); pool orgs with `FULLSEND_FOREIGN_E2E_REPOS` authorizing `fullsend-ai/fullsend` +3. **CI only:** pool orgs with `FULLSEND_FOREIGN_E2E_REPOS` authorizing `fullsend-ai/fullsend` 4. **Local only:** `gh auth login` (or `GH_TOKEN` / `GITHUB_TOKEN`) with admin access on pool orgs ## Local runs @@ -33,8 +33,7 @@ Optional environment variables: | Variable | Purpose | |----------|---------| -| `GH_TOKEN` / `GITHUB_TOKEN` | Override token source for local runs (also used to open triage test issues) | -| `E2E_GITHUB_PASSWORD` | User PAT with write access on pool orgs (CI interim for triage smoke test; see [#2641](https://github.com/fullsend-ai/fullsend/issues/2641)) | +| `GH_TOKEN` / `GITHUB_TOKEN` | Override token source for local runs | | `FULLSEND_MINT_URL` | Override mint endpoint (default: hosted public mint, same as `fullsend admin --mint-url`) | | `E2E_LOCK_TIMEOUT` | Max wait for a free pool org (default 10m) | | `E2E_GCP_PROJECT_ID` | GCP project for inference-related setup (if needed) | @@ -54,7 +53,6 @@ Required repository secrets: | Secret | Purpose | |--------|---------| -| `E2E_GITHUB_PASSWORD` | User PAT (e.g. pool org admin) with write access on pool org repos — interim: opens triage test issues as a human actor ([ADR 0054](../../ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md); mint tokens create bot-authored issues that dispatch rejects). Removed when [#2641](https://github.com/fullsend-ai/fullsend/issues/2641) lands. Value is a PAT, not a login password. | | `E2E_GCP_WIF_PROVIDER` | GCP WIF provider (inference / auxiliary GCP access) | | `E2E_GCP_SERVICE_ACCOUNT` | GCP service account for WIF | | `E2E_GCP_PROJECT_ID` | GCP project ID | diff --git a/e2e/admin/admin_test.go b/e2e/admin/admin_test.go index b766149536..95512e08a3 100644 --- a/e2e/admin/admin_test.go +++ b/e2e/admin/admin_test.go @@ -477,27 +477,27 @@ Segmentation fault (core dumped) **Additional context:** This started happening after the v2.3.0 -> v2.3.1 upgrade. Files under 64KB save fine. Files over 64KB save fine if they contain only ASCII characters.` - // Issues must be opened by a user with write access so dispatch authorizes - // triage (ADR 0054). Mint/installation tokens create issues as bots. - authorToken, err := issueAuthorToken(env.cfg) - require.NoError(t, err, "resolving issue author token") - issueClient := newLiveClient(authorToken) - issue, err := issueClient.CreateIssue(ctx, env.org, testRepo, issueTitle, issueBody) + // Bot-authored issues skip issues.opened dispatch (ADR 0054). Apply + // ready-for-triage in a follow-up call so the shim receives issues.labeled + // (#2636). + issue, err := env.client.CreateIssue(ctx, env.org, testRepo, issueTitle, issueBody) require.NoError(t, err, "creating test issue") t.Logf("Created test issue #%d: %s", issue.Number, issue.URL) + require.NoError(t, ensureRepoLabel(ctx, env.token, env.org, testRepo, "ready-for-triage")) + triggerTime := time.Now() + require.NoError(t, addIssueLabel(ctx, env.token, env.org, testRepo, issue.Number, "ready-for-triage")) t.Cleanup(func() { t.Log("Closing test issue...") - if closeErr := issueClient.CloseIssue(ctx, env.org, testRepo, issue.Number); closeErr != nil { + if closeErr := env.client.CloseIssue(ctx, env.org, testRepo, issue.Number); closeErr != nil { t.Logf("warning: could not close test issue: %v", closeErr) } }) // Wait for the triage workflow to be dispatched in .fullsend. - // The shim fires on issues:opened and dispatches to triage.yml. - // The shim typically fires within ~5s of the issue being created, + // The shim fires on issues.labeled with ready-for-triage and dispatches to triage.yml. + // The shim typically fires within ~5s of the label being applied, // so 12 attempts at 5s intervals (60s total) is generous. // Filter by CreatedAt to avoid false positives from previous runs. - issueCreatedAt := time.Now() t.Log("Waiting for triage workflow to be dispatched...") var triageRun *forge.WorkflowRun for attempt := 0; attempt < 12; attempt++ { @@ -513,7 +513,7 @@ Files over 64KB save fine if they contain only ASCII characters.` t.Logf("Attempt %d: run %d has unparseable CreatedAt %q: %v", attempt+1, run.ID, run.CreatedAt, parseErr) continue } - if runTime.Before(issueCreatedAt) { + if runTime.Before(triggerTime) { t.Logf("Attempt %d: run %d created at %s is from before our issue, skipping", attempt+1, run.ID, run.CreatedAt) continue } diff --git a/e2e/admin/auth.go b/e2e/admin/auth.go index 51cc394ea0..6182849457 100644 --- a/e2e/admin/auth.go +++ b/e2e/admin/auth.go @@ -75,21 +75,3 @@ func tokenForOrg(ctx context.Context, cfg envConfig, org string) (string, error) } return resolveLocalToken() } - -// issueAuthorToken returns a user OAuth token for actions that must appear as a -// human with repository write access. Installation tokens create issues as bots, -// which fail ADR 0054 dispatch authorization (has_write_permission on open). -// -// Interim until #2641: CI stores a user PAT in E2E_GITHUB_PASSWORD (legacy secret -// name; not a login password). -func issueAuthorToken(cfg envConfig) (string, error) { - for _, env := range []string{"E2E_GITHUB_PASSWORD", "E2E_ISSUE_AUTHOR_TOKEN"} { - if token := os.Getenv(env); token != "" { - return token, nil - } - } - if !cfg.useMint { - return resolveLocalToken() - } - return "", fmt.Errorf("no issue author token: set E2E_GITHUB_PASSWORD (user PAT with write on pool org repos)") -} diff --git a/e2e/admin/testutil.go b/e2e/admin/testutil.go index be9b769600..4a5f19cb86 100644 --- a/e2e/admin/testutil.go +++ b/e2e/admin/testutil.go @@ -3,6 +3,7 @@ package admin import ( + "bytes" "context" "encoding/json" "errors" @@ -313,6 +314,61 @@ func getRepoCreatedAt(ctx context.Context, token, org, repo string) (time.Time, return result.CreatedAt, nil } +func ensureRepoLabel(ctx context.Context, token, owner, repo, label string) error { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/labels", owner, repo) + payload, err := json.Marshal(map[string]string{ + "name": label, + "color": "5319e7", + }) + if err != nil { + return fmt.Errorf("encoding label payload: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("creating label request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("creating repo label: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusUnprocessableEntity { + return nil + } + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d creating label %q: %s", resp.StatusCode, label, body) +} + +func addIssueLabel(ctx context.Context, token, owner, repo string, issueNum int, label string) error { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%d/labels", owner, repo, issueNum) + payload, err := json.Marshal(map[string][]string{"labels": {label}}) + if err != nil { + return fmt.Errorf("encoding issue label payload: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("creating issue label request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("adding issue label: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d adding label %q: %s", resp.StatusCode, label, body) +} + // buildCLIBinary compiles the fullsend CLI binary once per test run. func buildCLIBinary(t *testing.T) string { t.Helper() diff --git a/skills/e2e-health/SKILL.md b/skills/e2e-health/SKILL.md index 60d377159a..8972891fcb 100644 --- a/skills/e2e-health/SKILL.md +++ b/skills/e2e-health/SKILL.md @@ -43,7 +43,7 @@ gh run view --log-failed 2>&1 | grep -iE "(FAIL|--- FAIL|Error|panic|ti Read the matched lines and provide a brief explanation of why the run failed. Common failure categories: - **Flaky test** — timing-dependent or non-deterministic failure -- **Mint / auth** — OIDC or mint token exchange failure, FOREIGN allowlist misconfiguration, or missing interim `E2E_GITHUB_PASSWORD` for triage smoke tests +- **Mint / auth** — OIDC or mint token exchange failure, FOREIGN allowlist misconfiguration, or triage dispatch not firing (check `ready-for-triage` label path) - **Infrastructure** — GCP auth, runner issues, pool org lock contention - **Real regression** — a code change broke e2e behavior From eef461202007d5c18ef7576c06ea54af4ccab5bb Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 30 Jun 2026 09:04:48 +0300 Subject: [PATCH 24/26] fix(mint,cli): address review feedback and renumber cross-org ADR to 0059 Renumber cross-org mint ADR to 0059 (main owns 0055 unified env delivery). Deduplicate concurrent FOREIGN allowlist fetches, fix permission names in the ADR, allow dot-prefixed repo callers, and use a single installation repositories API call for token scope introspection. Signed-off-by: Barak Korren Co-authored-by: Cursor --- ...010-stored-session-for-e2e-browser-auth.md | 2 +- docs/ADRs/0039-totp-automation-for-e2e-2fa.md | 2 +- .../0040-org-pool-for-parallel-e2e-tests.md | 2 +- ...g-mint-authorization-via-org-variables.md} | 8 +- docs/architecture.md | 2 +- docs/guides/dev/e2e-testing.md | 4 +- internal/cli/admin.go | 5 +- internal/cli/foreign_test.go | 3 + internal/cli/tokenscope.go | 43 +--------- internal/cli/tokenscope_test.go | 6 +- .../gcf/mintsrc/mintcore/handler.go.embed | 45 +++++++++- internal/forge/github/github.go | 52 +++++++++--- internal/forge/github/token_test.go | 13 ++- internal/mintcore/handler.go | 45 +++++++++- internal/mintcore/handler_test.go | 82 +++++++++++++++++++ 15 files changed, 240 insertions(+), 74 deletions(-) rename docs/ADRs/{0055-cross-org-mint-authorization-via-org-variables.md => 0059-cross-org-mint-authorization-via-org-variables.md} (94%) diff --git a/docs/ADRs/0010-stored-session-for-e2e-browser-auth.md b/docs/ADRs/0010-stored-session-for-e2e-browser-auth.md index b7d01a3584..a2f42f445e 100644 --- a/docs/ADRs/0010-stored-session-for-e2e-browser-auth.md +++ b/docs/ADRs/0010-stored-session-for-e2e-browser-auth.md @@ -16,7 +16,7 @@ Date: 2026-04-03 ## Status -Superseded by [ADR 0055](0055-cross-org-mint-authorization-via-org-variables.md) and the e2e mint/OIDC refactor ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)). Playwright session export is no longer used for CI authentication. +Superseded by [ADR 0059](0059-cross-org-mint-authorization-via-org-variables.md) and the e2e mint/OIDC refactor ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)). Playwright session export is no longer used for CI authentication. Extended by [ADR 0039](0039-totp-automation-for-e2e-2fa.md) (also superseded). diff --git a/docs/ADRs/0039-totp-automation-for-e2e-2fa.md b/docs/ADRs/0039-totp-automation-for-e2e-2fa.md index 3d0afe9235..501899a99b 100644 --- a/docs/ADRs/0039-totp-automation-for-e2e-2fa.md +++ b/docs/ADRs/0039-totp-automation-for-e2e-2fa.md @@ -16,7 +16,7 @@ Date: 2026-05-19 ## Status -Superseded by [ADR 0055](0055-cross-org-mint-authorization-via-org-variables.md) and the e2e mint/OIDC refactor ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)). TOTP automation for Playwright login is no longer used. +Superseded by [ADR 0059](0059-cross-org-mint-authorization-via-org-variables.md) and the e2e mint/OIDC refactor ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)). TOTP automation for Playwright login is no longer used. Extends [ADR 0010](0010-stored-session-for-e2e-browser-auth.md) (also superseded). diff --git a/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md b/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md index 4ef9794706..35a7dbbb75 100644 --- a/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md +++ b/docs/ADRs/0040-org-pool-for-parallel-e2e-tests.md @@ -70,7 +70,7 @@ slice in the test code. No architectural changes are needed. staleness check. - Each pool org must install the `fullsend-ai-e2e` app and authorize CI via `FULLSEND_FOREIGN_E2E_REPOS` on the target org — see - [ADR 0055](0055-cross-org-mint-authorization-via-org-variables.md). + [ADR 0059](0059-cross-org-mint-authorization-via-org-variables.md). - CI acquires per-org tokens via cross-org mint ([#2155](https://github.com/fullsend-ai/fullsend/issues/2155)); local runs use a user token with pool-org admin access (`gh auth login`). - Pool expansion is an operational task (provision org, update one slice diff --git a/docs/ADRs/0055-cross-org-mint-authorization-via-org-variables.md b/docs/ADRs/0059-cross-org-mint-authorization-via-org-variables.md similarity index 94% rename from docs/ADRs/0055-cross-org-mint-authorization-via-org-variables.md rename to docs/ADRs/0059-cross-org-mint-authorization-via-org-variables.md index 8df91dc65c..18cc621ba4 100644 --- a/docs/ADRs/0055-cross-org-mint-authorization-via-org-variables.md +++ b/docs/ADRs/0059-cross-org-mint-authorization-via-org-variables.md @@ -1,5 +1,5 @@ --- -title: "55. Cross-org mint authorization via org variables" +title: "59. Cross-org mint authorization via org variables" status: Accepted relates_to: - agent-infrastructure @@ -11,7 +11,7 @@ topics: - cross-org --- -# 55. Cross-org mint authorization via org variables +# 59. Cross-org mint authorization via org variables Date: 2026-06-07 @@ -47,7 +47,7 @@ orgs control over their own policy. 2. **Cross-org path** applies only when `target_org` is set and differs from the caller org: - Resolve the requested role's App installation on `target_org` via org-level installation lookup. - Read `FULLSEND_FOREIGN__REPOS` on the target org using that role's App installation - token (`actions_variables: read`). + token (`organization_actions_variables: read`). - Deny if installation lookup fails, the variable is missing/empty, or the OIDC caller (`repository` or bare `repository_owner`) is not on the allowlist. - Mint an installation token for the requested repos on the target org, or installation-wide @@ -71,7 +71,7 @@ orgs control over their own policy. per mint instance (key: `target_org/role`, TTL 60s). Cache entries include empty/missing allowlists (negative cache) so revoked or unset variables may take up to one TTL window to take effect. Cardinality is bounded by enrolled orgs × roles; no explicit eviction beyond TTL. -- Roles used on the cross-org path need `actions_variables: read` on their App permissions. +- Roles used on the cross-org path need `organization_actions_variables: read` on their App permissions. - The `e2e` role additionally needs `actions_variables: write` and `organization_actions_variables: write` so pool tests can set repo/org variables during install flows; these writes are scoped to pool orgs that explicitly authorize CI via diff --git a/docs/architecture.md b/docs/architecture.md index 51f57b366d..1d345a83a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,7 +142,7 @@ Identity is not the same as trust. An agent's identity lets it authenticate to e - Credential delivery model: four tiers — (1) prefetch + post-process for agents with enumerable inputs (zero credential access), (2) OpenShell providers + L7 egress policies for static token auth (credentials never enter sandbox), (3) host-side REST server for operations providers cannot handle — long-running operations, sandbox capability gaps, credentials in request bodies, response transformation, and multi-step atomic operations (see [ADR 0046](ADRs/0046-host-side-api-server-design.md)), (4) host files + L7 policies for complex auth requiring in-sandbox credential files. L7 policies enforce both method + path and binary-level restrictions. Providers are preferred over REST servers when viable ([ADR 0017](ADRs/0017-credential-isolation-for-sandboxed-agents.md), extended by [ADR 0025](ADRs/0025-provider-credential-delivery-for-sandboxed-agents.md)). - Host-side API server design: Tier 3 servers follow a uniform process contract (`--port`, `--token`, `--bind-address`, `/healthz`, `/tools.json`, `SIGTERM`). Network access is controlled via composable provider profiles — atomic capability profiles composed per-harness. Per-run UUID bearer tokens are delivered through OpenShell provider placeholders. File transfer uses `openshell sandbox upload/download` ([ADR 0046](ADRs/0046-host-side-api-server-design.md)). - Per-role GitHub Apps with manifest-based creation. Each agent role gets its own app with scoped permissions. PEMs stored in Secret Manager as `fullsend-{role}-app-pem` — one secret per role, shared across orgs on a mint. `ROLE_APP_IDS` uses the same shared-per-role model (`coder` → app ID). Org isolation is enforced via `ALLOWED_ORGS`, WIF conditions, and installation verification ([ADR 0007](ADRs/0007-per-role-github-apps.md), [ADR 0033](ADRs/0033-per-repo-installation-mode.md)). -- Cross-org mint authorization: workflows may request tokens for a different org via optional `target_org` when the target org installs the role App and sets `FULLSEND_FOREIGN__REPOS`. Empty `repos` yields installation-wide tokens on either path; cross-org adds FOREIGN gating, same-org relies on WIF/OIDC enrollment ([ADR 0055](ADRs/0055-cross-org-mint-authorization-via-org-variables.md)). +- Cross-org mint authorization: workflows may request tokens for a different org via optional `target_org` when the target org installs the role App and sets `FULLSEND_FOREIGN__REPOS`. Empty `repos` yields installation-wide tokens on either path; cross-org adds FOREIGN gating, same-org relies on WIF/OIDC enrollment ([ADR 0059](ADRs/0059-cross-org-mint-authorization-via-org-variables.md)). - Standalone mint deployment: `cmd/mint/` provides a self-contained HTTP server that uses direct JWKS verification and filesystem PEM storage instead of GCP infrastructure. It shares the `internal/mintcore/` library with the GCF mint and adds support for custom role permissions and a fallback proxy to an upstream mint. Custom role permissions live in mintcore (not `cmd/mint/`) so that `RolePermissionsFor`, `HasRole`, and `CreateInstallationToken` return a unified view without callers needing to distinguish built-in from custom roles. The GCF mint never calls `RegisterCustomRolePermissions`, so the code is inert there. See the [standalone mint guide](guides/infrastructure/standalone-mint.md). One concrete implementation option is [`oidcx`](https://github.com/oxidecomputer/oidcx): a service that accepts OIDC identity tokens and exchanges them for short-lived access tokens. It can mint tokens scoped to selected GitHub repositories and permissions, or to selected Oxide silos and permissions, and it also ships with a GitHub Action wrapper. In a Fullsend deployment, this can be used by the sandbox entrypoint to narrow a broad GitHub App identity down to only the specific permissions an agent needs for the current run. diff --git a/docs/guides/dev/e2e-testing.md b/docs/guides/dev/e2e-testing.md index bf9e5fc447..d0c2e05877 100644 --- a/docs/guides/dev/e2e-testing.md +++ b/docs/guides/dev/e2e-testing.md @@ -3,7 +3,7 @@ Guide for running and debugging fullsend admin e2e tests locally and in CI. Related ADRs: [0040](../../ADRs/0040-org-pool-for-parallel-e2e-tests.md) (org pool), -[0055](../../ADRs/0055-cross-org-mint-authorization-via-org-variables.md) (cross-org mint), +[0059](../../ADRs/0059-cross-org-mint-authorization-via-org-variables.md) (cross-org mint), [0009](../../ADRs/0009-pull-request-target-in-shim-workflows.md) (pull_request_target security model for shims; e2e uses a separate gate pattern documented below). Historical ADRs [0010](../../ADRs/0010-stored-session-for-e2e-browser-auth.md) (browser session) and @@ -47,7 +47,7 @@ In GitHub Actions, tests mint a cross-org installation token via the mint servic 1. Workflow requests a GHA OIDC token (`id-token: write`) 2. `mintclient.MintToken` POSTs to `{FULLSEND_MINT_URL or hosted default}/v1/token` with `{role: "e2e", target_org: ""}` (repos omitted for installation-wide access) -3. Mint verifies the caller against `FULLSEND_FOREIGN_E2E_REPOS` on the target org ([ADR 0055](../../ADRs/0055-cross-org-mint-authorization-via-org-variables.md)) +3. Mint verifies the caller against `FULLSEND_FOREIGN_E2E_REPOS` on the target org ([ADR 0059](../../ADRs/0059-cross-org-mint-authorization-via-org-variables.md)) Required repository secrets: diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 8ffd8d1f1b..6ab50562af 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -110,8 +110,9 @@ func validateOrgName(org string) error { var githubOwnerPattern = regexp.MustCompile(`^[a-zA-Z0-9](-?[a-zA-Z0-9])*$`) // githubRepoPattern matches valid GitHub repository names -// (alphanumeric, hyphens, dots, and underscores). -var githubRepoPattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`) +// (alphanumeric, hyphens, dots, and underscores). Dot-prefixed repos such as +// .fullsend (config repo convention) are allowed. +var githubRepoPattern = regexp.MustCompile(`^(?:\.[a-zA-Z][a-zA-Z0-9._-]*|[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?)$`) // perOrgOnlyFlags are flags that only apply to per-org mode. var perOrgOnlyFlags = []string{ diff --git a/internal/cli/foreign_test.go b/internal/cli/foreign_test.go index 07abb2cf8b..c964c738c7 100644 --- a/internal/cli/foreign_test.go +++ b/internal/cli/foreign_test.go @@ -36,6 +36,9 @@ func TestValidateForeignCaller(t *testing.T) { if err := validateForeignCaller("fullsend-ai"); err != nil { t.Fatalf("bare org: %v", err) } + if err := validateForeignCaller("halfsend-01/.fullsend"); err != nil { + t.Fatalf("dot-prefixed config repo: %v", err) + } if err := validateForeignCaller("bad org/repo"); err == nil { t.Fatal("expected invalid caller") } diff --git a/internal/cli/tokenscope.go b/internal/cli/tokenscope.go index 445e229756..323adfacb9 100644 --- a/internal/cli/tokenscope.go +++ b/internal/cli/tokenscope.go @@ -2,9 +2,7 @@ package cli import ( "context" - "encoding/json" "fmt" - "io" "net/http" "time" @@ -25,50 +23,17 @@ func fetchTokenScope(ctx context.Context, token, baseURL string) ([]string, erro ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - isInstallation, err := gh.ProbeInstallationToken(ctx, tokenScopeClient, baseURL, token) + repos, totalCount, isInstallation, err := gh.ListInstallationRepositories(ctx, tokenScopeClient, baseURL, token, 100) if err != nil { - return nil, fmt.Errorf("probing installation token: %w", err) + return nil, fmt.Errorf("fetching token scope: %w", err) } if !isInstallation { return nil, nil } - url := baseURL + "/installation/repositories?per_page=100" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("creating scope request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/vnd.github+json") - - resp, err := tokenScopeClient.Do(req) - if err != nil { - return nil, fmt.Errorf("fetching token scope: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) - return nil, fmt.Errorf("token scope check returned status %d", resp.StatusCode) - } - - var result struct { - TotalCount int `json:"total_count"` - Repositories []struct { - FullName string `json:"full_name"` - } `json:"repositories"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("decoding token scope: %w", err) - } - - repos := make([]string, len(result.Repositories)) - for i, r := range result.Repositories { - repos[i] = r.FullName - } - if result.TotalCount > len(result.Repositories) { + if totalCount > len(repos) { repos = append(repos, fmt.Sprintf("... and %d more (%d total)", - result.TotalCount-len(result.Repositories), result.TotalCount)) + totalCount-len(repos), totalCount)) } return repos, nil } diff --git a/internal/cli/tokenscope_test.go b/internal/cli/tokenscope_test.go index 85d4f0d3ac..702dbf403b 100644 --- a/internal/cli/tokenscope_test.go +++ b/internal/cli/tokenscope_test.go @@ -12,10 +12,11 @@ import ( ) func TestFetchTokenScope_ReturnsRepoNames(t *testing.T) { + var requestCount int github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ assert.Equal(t, "/installation/repositories", r.URL.Path) - perPage := r.URL.Query().Get("per_page") - assert.Contains(t, []string{"1", "100"}, perPage) + assert.Equal(t, "100", r.URL.Query().Get("per_page")) assert.Equal(t, "Bearer ghs_test_token", r.Header.Get("Authorization")) json.NewEncoder(w).Encode(map[string]interface{}{ @@ -31,6 +32,7 @@ func TestFetchTokenScope_ReturnsRepoNames(t *testing.T) { repos, err := fetchTokenScope(context.Background(), "ghs_test_token", github.URL) require.NoError(t, err) assert.Equal(t, []string{"org-a/repo-one", "org-a/repo-two"}, repos) + assert.Equal(t, 1, requestCount, "fetchTokenScope should use a single API round trip") } func TestFetchTokenScope_Truncated(t *testing.T) { diff --git a/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed b/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed index 3abaf653cf..be74f6525b 100644 --- a/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed +++ b/internal/dispatch/gcf/mintsrc/mintcore/handler.go.embed @@ -59,10 +59,17 @@ type Handler struct { legacyAppIDsOnly bool // ROLE_APP_IDS has org/role keys but no role-only keys foreignCache map[string]foreignCacheEntry + foreignInflight map[string]*foreignInflight foreignCacheTTL time.Duration foreignCacheMu sync.Mutex } +type foreignInflight struct { + wg sync.WaitGroup + allowlist []string + err error +} + // NewHandler creates a Handler with the given dependencies. // Environment variables for handler-level config (ROLE_APP_IDS, ALLOWED_ROLES) // are read once at construction time. The OIDCVerifier is injected by the caller @@ -78,6 +85,7 @@ func NewHandler(pemAccessor PEMAccessor, oidcVerifier OIDCVerifier) (*Handler, e oidcVerifier: oidcVerifier, githubBaseURL: "https://api.github.com", foreignCache: make(map[string]foreignCacheEntry), + foreignInflight: make(map[string]*foreignInflight), foreignCacheTTL: defaultForeignCacheTTL, } @@ -386,8 +394,41 @@ func (h *Handler) loadForeignAllowlist(ctx context.Context, targetOrg, role stri h.foreignCacheMu.Unlock() return allowlist, nil } + if inflight, ok := h.foreignInflight[key]; ok { + h.foreignCacheMu.Unlock() + inflight.wg.Wait() + if inflight.err != nil { + return nil, inflight.err + } + return append([]string(nil), inflight.allowlist...), nil + } + inflight := &foreignInflight{} + inflight.wg.Add(1) + h.foreignInflight[key] = inflight h.foreignCacheMu.Unlock() + allowlist, err := h.fetchForeignAllowlist(ctx, targetOrg, role) + + h.foreignCacheMu.Lock() + delete(h.foreignInflight, key) + if err == nil { + h.foreignCache[key] = foreignCacheEntry{ + allowlist: append([]string(nil), allowlist...), + fetchedAt: time.Now(), + } + } + inflight.allowlist = allowlist + inflight.err = err + inflight.wg.Done() + h.foreignCacheMu.Unlock() + + if err != nil { + return nil, err + } + return allowlist, nil +} + +func (h *Handler) fetchForeignAllowlist(ctx context.Context, targetOrg, role string) ([]string, error) { appID, err := h.lookupRoleAppID(role) if err != nil { return nil, fmt.Errorf("looking up app ID for role %s: %v", role, err) @@ -418,10 +459,6 @@ func (h *Handler) loadForeignAllowlist(ctx context.Context, targetOrg, role stri return nil, err } - h.foreignCacheMu.Lock() - h.foreignCache[key] = foreignCacheEntry{allowlist: append([]string(nil), allowlist...), fetchedAt: time.Now()} - h.foreignCacheMu.Unlock() - return allowlist, nil } diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index d1aeb8189e..df8aa17573 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -1476,37 +1476,65 @@ func (c *LiveClient) graphqlViewerLogin(ctx context.Context) (string, error) { return result.Data.Viewer.Login, nil } -// ProbeInstallationToken reports whether token is a GitHub App installation -// access token by calling GET /installation/repositories. PATs and OAuth -// tokens receive 401/403 on that endpoint. -func ProbeInstallationToken(ctx context.Context, httpClient *http.Client, baseURL, token string) (bool, error) { +// ListInstallationRepositories returns repository full names when token is a GitHub +// App installation token (HTTP 200). PATs and OAuth tokens that lack installation +// access receive 401/403 and return (nil, 0, false, nil). +func ListInstallationRepositories(ctx context.Context, httpClient *http.Client, baseURL, token string, perPage int) (repos []string, totalCount int, ok bool, err error) { if token == "" { - return false, nil + return nil, 0, false, nil + } + if perPage <= 0 { + perPage = 100 } - url := baseURL + "/installation/repositories?per_page=1" + url := fmt.Sprintf("%s/installation/repositories?per_page=%d", baseURL, perPage) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return false, fmt.Errorf("creating installation token probe request: %w", err) + return nil, 0, false, fmt.Errorf("creating installation repositories request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/vnd.github+json") resp, err := httpClient.Do(req) if err != nil { - return false, fmt.Errorf("probing installation token: %w", err) + return nil, 0, false, fmt.Errorf("listing installation repositories: %w", err) } defer resp.Body.Close() - io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) switch resp.StatusCode { case http.StatusOK: - return true, nil + var result struct { + TotalCount int `json:"total_count"` + Repositories []struct { + FullName string `json:"full_name"` + } `json:"repositories"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, 0, false, fmt.Errorf("decoding installation repositories: %w", err) + } + repos = make([]string, len(result.Repositories)) + for i, r := range result.Repositories { + repos[i] = r.FullName + } + return repos, result.TotalCount, true, nil case http.StatusUnauthorized, http.StatusForbidden: - return false, nil + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return nil, 0, false, nil default: - return false, &APIError{StatusCode: resp.StatusCode, Message: "installation token probe failed"} + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return nil, 0, false, &APIError{StatusCode: resp.StatusCode, Message: "installation repositories request failed"} + } +} + +// ProbeInstallationToken reports whether token is a GitHub App installation +// access token by calling GET /installation/repositories. PATs and OAuth +// tokens receive 401/403 on that endpoint. +func ProbeInstallationToken(ctx context.Context, httpClient *http.Client, baseURL, token string) (bool, error) { + _, _, ok, err := ListInstallationRepositories(ctx, httpClient, baseURL, token, 1) + if err != nil { + return false, err } + return ok, nil } // IsInstallationToken reports whether the client's token is a GitHub App diff --git a/internal/forge/github/token_test.go b/internal/forge/github/token_test.go index 9d91797d51..55aa40235d 100644 --- a/internal/forge/github/token_test.go +++ b/internal/forge/github/token_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -33,6 +34,13 @@ func TestProbeInstallationToken(t *testing.T) { if got := r.URL.Query().Get("per_page"); got != "1" { t.Fatalf("per_page = %q, want 1", got) } + if tt.status == http.StatusOK { + json.NewEncoder(w).Encode(map[string]any{ + "total_count": 0, + "repositories": []any{}, + }) + return + } w.WriteHeader(tt.status) })) defer srv.Close() @@ -70,7 +78,10 @@ func TestLiveClient_IsInstallationToken(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "total_count": 0, + "repositories": []any{}, + }) })) defer srv.Close() diff --git a/internal/mintcore/handler.go b/internal/mintcore/handler.go index 3abaf653cf..be74f6525b 100644 --- a/internal/mintcore/handler.go +++ b/internal/mintcore/handler.go @@ -59,10 +59,17 @@ type Handler struct { legacyAppIDsOnly bool // ROLE_APP_IDS has org/role keys but no role-only keys foreignCache map[string]foreignCacheEntry + foreignInflight map[string]*foreignInflight foreignCacheTTL time.Duration foreignCacheMu sync.Mutex } +type foreignInflight struct { + wg sync.WaitGroup + allowlist []string + err error +} + // NewHandler creates a Handler with the given dependencies. // Environment variables for handler-level config (ROLE_APP_IDS, ALLOWED_ROLES) // are read once at construction time. The OIDCVerifier is injected by the caller @@ -78,6 +85,7 @@ func NewHandler(pemAccessor PEMAccessor, oidcVerifier OIDCVerifier) (*Handler, e oidcVerifier: oidcVerifier, githubBaseURL: "https://api.github.com", foreignCache: make(map[string]foreignCacheEntry), + foreignInflight: make(map[string]*foreignInflight), foreignCacheTTL: defaultForeignCacheTTL, } @@ -386,8 +394,41 @@ func (h *Handler) loadForeignAllowlist(ctx context.Context, targetOrg, role stri h.foreignCacheMu.Unlock() return allowlist, nil } + if inflight, ok := h.foreignInflight[key]; ok { + h.foreignCacheMu.Unlock() + inflight.wg.Wait() + if inflight.err != nil { + return nil, inflight.err + } + return append([]string(nil), inflight.allowlist...), nil + } + inflight := &foreignInflight{} + inflight.wg.Add(1) + h.foreignInflight[key] = inflight h.foreignCacheMu.Unlock() + allowlist, err := h.fetchForeignAllowlist(ctx, targetOrg, role) + + h.foreignCacheMu.Lock() + delete(h.foreignInflight, key) + if err == nil { + h.foreignCache[key] = foreignCacheEntry{ + allowlist: append([]string(nil), allowlist...), + fetchedAt: time.Now(), + } + } + inflight.allowlist = allowlist + inflight.err = err + inflight.wg.Done() + h.foreignCacheMu.Unlock() + + if err != nil { + return nil, err + } + return allowlist, nil +} + +func (h *Handler) fetchForeignAllowlist(ctx context.Context, targetOrg, role string) ([]string, error) { appID, err := h.lookupRoleAppID(role) if err != nil { return nil, fmt.Errorf("looking up app ID for role %s: %v", role, err) @@ -418,10 +459,6 @@ func (h *Handler) loadForeignAllowlist(ctx context.Context, targetOrg, role stri return nil, err } - h.foreignCacheMu.Lock() - h.foreignCache[key] = foreignCacheEntry{allowlist: append([]string(nil), allowlist...), fetchedAt: time.Now()} - h.foreignCacheMu.Unlock() - return allowlist, nil } diff --git a/internal/mintcore/handler_test.go b/internal/mintcore/handler_test.go index baaec6a78e..952c9dc8a6 100644 --- a/internal/mintcore/handler_test.go +++ b/internal/mintcore/handler_test.go @@ -18,6 +18,7 @@ import ( "net/http/httptest" "os" "strings" + "sync" "testing" "time" ) @@ -2312,6 +2313,87 @@ func TestHandler_ForeignAllowlistCached(t *testing.T) { } } +func TestHandler_ForeignAllowlistConcurrent(t *testing.T) { + t.Setenv("ALLOWED_ORGS", "test-org,fullsend-ai") + t.Setenv("ROLE_APP_IDS", `{"e2e":"300"}`) + + pemData, err := generateTestRSAKey() + if err != nil { + t.Fatalf("generating test key: %v", err) + } + + env := newTestOIDCEnv(t, &fakePEMAccessor{pems: map[string][]byte{"e2e": pemData}}) + token := env.signToken(t, map[string]interface{}{ + "repository": "fullsend-ai/fullsend", + "repository_owner": "fullsend-ai", + "job_workflow_ref": "fullsend-ai/fullsend/.github/workflows/e2e.yml@refs/heads/main", + }) + + var foreignReads int + var tokenCalls int + var mu sync.Mutex + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/orgs/pool-org/installation" && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(installationResponse{ + ID: 999, Account: struct { + Login string `json:"login"` + }{Login: "pool-org"}, + }) + case r.URL.Path == "/app/installations/999/access_tokens" && r.Method == http.MethodPost: + mu.Lock() + tokenCalls++ + call := tokenCalls + mu.Unlock() + w.WriteHeader(http.StatusCreated) + if call%2 == 1 { + json.NewEncoder(w).Encode(installationTokenResponse{Token: "ghs_policy_token"}) + return + } + json.NewEncoder(w).Encode(installationTokenResponse{ + Token: "ghs_e2e_token", + ExpiresAt: "2026-05-06T12:00:00Z", + }) + case r.URL.Path == "/orgs/pool-org/actions/variables/FULLSEND_FOREIGN_E2E_REPOS" && r.Method == http.MethodGet: + mu.Lock() + foreignReads++ + mu.Unlock() + time.Sleep(50 * time.Millisecond) + json.NewEncoder(w).Encode(orgVariableResponse{ + Name: "FULLSEND_FOREIGN_E2E_REPOS", + Value: "fullsend-ai/fullsend", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer github.Close() + env.handler.githubBaseURL = github.URL + + body := `{"role":"e2e","target_org":"pool-org"}` + const workers = 8 + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/v1/token", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + env.handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + }() + } + wg.Wait() + + if foreignReads != 1 { + t.Fatalf("expected 1 concurrent FOREIGN variable read, got %d", foreignReads) + } +} + func TestHandler_CrossOrgForeignVariableMissing(t *testing.T) { t.Setenv("ALLOWED_ORGS", "test-org,fullsend-ai") t.Setenv("ROLE_APP_IDS", `{"e2e":"300"}`) From 8f18daeb577eabcef3ab730a228e7e3980c2832f Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 30 Jun 2026 09:09:34 +0300 Subject: [PATCH 25/26] fix(ci,e2e): gofmt token test and clean stale .fullsend forks CI lint failed on gofmt for token_test.go. E2e install failed creating scaffold PRs when stale .fullsend-N fork repos remained from prior runs; cleanup now closes open config-repo PRs and deletes all .fullsend* repos. Signed-off-by: Barak Korren Co-authored-by: Cursor --- e2e/admin/cleanup.go | 75 ++++++++++++++++++++++++++--- internal/forge/github/token_test.go | 4 +- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/e2e/admin/cleanup.go b/e2e/admin/cleanup.go index e858acc30d..315002b942 100644 --- a/e2e/admin/cleanup.go +++ b/e2e/admin/cleanup.go @@ -19,12 +19,25 @@ func cleanupStaleResources(ctx context.Context, client forge.Client, token, org t.Helper() t.Log("[cleanup] Scanning for stale resources from previous runs...") - // 1. Delete .fullsend repo if it exists. - _, err := client.GetRepo(ctx, org, forge.ConfigRepoName) - if err == nil { - t.Logf("[cleanup] Deleting stale %s repo", forge.ConfigRepoName) - if delErr := client.DeleteRepo(ctx, org, forge.ConfigRepoName); delErr != nil { - t.Logf("[cleanup] Warning: could not delete %s: %v", forge.ConfigRepoName, delErr) + // 1. Close open PRs on .fullsend, then delete the config repo and any + // numbered forks (.fullsend-1, …) left from prior PR-based install runs. + if _, err := client.GetRepo(ctx, org, forge.ConfigRepoName); err == nil { + prs, listErr := client.ListRepoPullRequests(ctx, org, forge.ConfigRepoName) + if listErr != nil { + t.Logf("[cleanup] Warning: could not list PRs on %s: %v", forge.ConfigRepoName, listErr) + } else { + for _, pr := range prs { + t.Logf("[cleanup] Closing stale PR #%d on %s: %s", pr.Number, forge.ConfigRepoName, pr.Title) + closePR(ctx, token, org, forge.ConfigRepoName, pr.Number, t) + } + } + } + for _, name := range listOrgRepoNames(ctx, token, org, t) { + if strings.HasPrefix(name, ".fullsend") { + t.Logf("[cleanup] Deleting stale repo %s", name) + if delErr := client.DeleteRepo(ctx, org, name); delErr != nil { + t.Logf("[cleanup] Warning: could not delete %s: %v", name, delErr) + } } } @@ -42,7 +55,7 @@ func cleanupStaleResources(ctx context.Context, client forge.Client, token, org // 3. Ensure test-repo exists and has at least one commit (needed for // enrollment testing). An empty repo (no commits) causes the // reconcile-repos script to fail with "Could not get default branch tree". - _, err = client.GetRepo(ctx, org, testRepo) + _, err := client.GetRepo(ctx, org, testRepo) if forge.IsNotFound(err) { t.Logf("[cleanup] Creating missing %s repo", testRepo) if _, createErr := client.CreateRepo(ctx, org, testRepo, "E2E test repo", false); createErr != nil { @@ -82,6 +95,54 @@ func cleanupStaleResources(ctx context.Context, client forge.Client, token, org t.Log("[cleanup] Stale resource scan complete") } +// listOrgRepoNames returns repository names in an org via the GitHub REST API. +func listOrgRepoNames(ctx context.Context, token, org string, t *testing.T) []string { + t.Helper() + var names []string + page := 1 + for { + url := fmt.Sprintf("https://api.github.com/orgs/%s/repos?per_page=100&page=%d&type=all", org, page) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Logf("[cleanup] Warning: could not list org repos: %v", err) + return names + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Logf("[cleanup] Warning: could not list org repos: %v", err) + return names + } + + var batch []struct { + Name string `json:"name"` + } + decodeErr := json.NewDecoder(resp.Body).Decode(&batch) + resp.Body.Close() + if decodeErr != nil { + t.Logf("[cleanup] Warning: could not decode org repo list: %v", decodeErr) + return names + } + if resp.StatusCode != http.StatusOK { + t.Logf("[cleanup] Warning: list org repos returned status %d", resp.StatusCode) + return names + } + if len(batch) == 0 { + break + } + for _, repo := range batch { + names = append(names, repo.Name) + } + if len(batch) < 100 { + break + } + page++ + } + return names +} + // deleteBranch deletes a branch from a repo using the GitHub API directly // (forge.Client doesn't have DeleteBranch). func deleteBranch(ctx context.Context, token, org, repo, branch string, t *testing.T) { diff --git a/internal/forge/github/token_test.go b/internal/forge/github/token_test.go index 55aa40235d..84f8906fa1 100644 --- a/internal/forge/github/token_test.go +++ b/internal/forge/github/token_test.go @@ -36,7 +36,7 @@ func TestProbeInstallationToken(t *testing.T) { } if tt.status == http.StatusOK { json.NewEncoder(w).Encode(map[string]any{ - "total_count": 0, + "total_count": 0, "repositories": []any{}, }) return @@ -79,7 +79,7 @@ func TestLiveClient_IsInstallationToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ - "total_count": 0, + "total_count": 0, "repositories": []any{}, }) })) From 7e411e6fd12173a181ff48e5073055a0123cc9b4 Mon Sep 17 00:00:00 2001 From: Barak Korren Date: Tue, 30 Jun 2026 09:13:51 +0300 Subject: [PATCH 26/26] fix(e2e): use --direct scaffold delivery for mint tokens in CI Installation tokens authenticate as the App bot, so the default fork-based scaffold PR path fails with 422 in GitHub Actions. Direct push preserves the full install/triage smoke path; PR delivery remains tested locally. Signed-off-by: Barak Korren Co-authored-by: Cursor --- e2e/admin/admin_test.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/e2e/admin/admin_test.go b/e2e/admin/admin_test.go index 95512e08a3..b271c08e3e 100644 --- a/e2e/admin/admin_test.go +++ b/e2e/admin/admin_test.go @@ -97,6 +97,14 @@ func TestAdminInstallUninstall(t *testing.T) { if env.cfg.gcpProjectID != "" { installArgs = append(installArgs, "--inference-project", env.cfg.gcpProjectID) } + // Mint installation tokens authenticate as the App bot, not the org owner. + // The default PR path forks within the org and fails PR creation (422) in CI. + // Direct push still exercises install; PR-based delivery is covered on main + // and in local runs with a user token (gh auth login). + useDirectScaffold := env.cfg.useMint + if useDirectScaffold { + installArgs = append(installArgs, "--direct") + } runCLI(t, env.binary, env.token, installArgs...) // Verify install artifacts that exist regardless of delivery mode. @@ -109,11 +117,13 @@ func TestAdminInstallUninstall(t *testing.T) { // Register .fullsend cleanup (in case later phases fail). registerRepoCleanup(t, env.client, env.org, forge.ConfigRepoName) - // Phase 1.5: Merge the scaffold PR. - // Default install mode creates a PR instead of pushing directly. - // Merge it so scaffold files land on the default branch. - t.Log("=== Phase 1.5: Merge Scaffold PR ===") - mergeScaffoldPR(t, env) + if !useDirectScaffold { + // Phase 1.5: Merge the scaffold PR. + // Default install mode creates a PR instead of pushing directly. + // Merge it so scaffold files land on the default branch. + t.Log("=== Phase 1.5: Merge Scaffold PR ===") + mergeScaffoldPR(t, env) + } // Verify scaffold files on the default branch after merge. cfgData, err := env.client.GetFileContent(ctx, env.org, forge.ConfigRepoName, "config.yaml")