From 248dd2278f826d6d6aae1a27690151d4f76cb5ff Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Tue, 7 Jul 2026 12:37:46 -0400 Subject: [PATCH 1/5] fix: improve fine-grained PAT guidance in preflight and setup When a fine-grained token is detected during preflight, the CLI now lists the specific scopes that could not be verified along with their equivalent fine-grained permissions, instead of a generic skip message. When an org blocks classic PATs and the GetRepo call returns a 403, the CLI now detects the specific error and prints actionable guidance: token resolution order, required fine-grained permissions, and the export command to use a fine-grained PAT. Adds token resolution and fine-grained permission documentation to docs/guides/getting-started/configuring-github.md. Closes #3368 Assisted-by: Claude Signed-off-by: Wayne Sun --- .../getting-started/configuring-github.md | 33 ++++++++++++ internal/cli/admin.go | 52 +++++++++++++++++++ internal/cli/admin_test.go | 16 +++++- internal/forge/github/github.go | 13 +++++ internal/forge/github/github_test.go | 46 ++++++++++++++++ internal/layers/preflight.go | 29 +++++++++++ internal/layers/preflight_test.go | 30 +++++++++++ 7 files changed, 217 insertions(+), 2 deletions(-) diff --git a/docs/guides/getting-started/configuring-github.md b/docs/guides/getting-started/configuring-github.md index 8427e34c8..d0698a11a 100644 --- a/docs/guides/getting-started/configuring-github.md +++ b/docs/guides/getting-started/configuring-github.md @@ -13,6 +13,39 @@ The goal of this document is that you configure Fullsend for your GitHub reposit * Download the latest [fullsend](https://github.com/fullsend-ai/fullsend/releases) CLI. * Download the latest [gh](https://cli.github.com/) CLI and authenticate with it. +### Token resolution + +The `fullsend` CLI resolves a GitHub token in this order: + +1. `GH_TOKEN` environment variable +2. `GITHUB_TOKEN` environment variable +3. `gh auth token` (GitHub CLI) + +For most organizations, the token from `gh auth login` works. However, if +your organization restricts personal access token types (Settings → Personal +access tokens → Restrict access via PAT type), `gh auth login` may produce a +token that GitHub rejects with a 403 error. + +In that case, create a **fine-grained personal access token** at + scoped to the +target repository with these permissions: + +| Permission | Level | Why | +|---|---|---| +| Contents | Read and write | Commits `.fullsend/config.yaml` and scaffold files | +| Workflows | Read and write | Writes/updates files under `.github/workflows/` | +| Secrets | Read and write | Sets `FULLSEND_GCP_PROJECT_ID` / `FULLSEND_GCP_WIF_PROVIDER` | +| Variables | Read and write | Sets `FULLSEND_MINT_URL` / `FULLSEND_GCP_REGION` | +| Metadata | Read-only | GitHub-required baseline | +| Pull requests | Read and write | Only needed without `--direct` | + +Export it before running setup so it takes priority: + +```bash +export GH_TOKEN=github_pat_... +fullsend github setup / ... +``` + ## Installing GitHub Applications Install the following agent applications to your organization diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 65d2fd1fe..a29c00650 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -995,6 +995,12 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { scaffoldCommitFn := func(ctx context.Context, owner, repo string, files []forge.TreeFile, direct bool) error { targetRepo, repoErr := client.GetRepo(ctx, owner, repo) if repoErr != nil { + if gh.IsPATForbiddenError(repoErr) { + printer.StepFail("Classic PAT rejected by organization") + printer.Blank() + printer.ErrorBox("Token not accepted", patForbiddenGuidance(owner, repo)) + return fmt.Errorf("organization forbids classic PATs: %w", repoErr) + } return fmt.Errorf("getting repo info: %w", repoErr) } commitMsg := fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version) @@ -1166,6 +1172,12 @@ func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui. targetRepo, err := client.GetRepo(ctx, owner, repo) if err != nil { + if gh.IsPATForbiddenError(err) { + printer.StepFail("Classic PAT rejected by organization") + printer.Blank() + printer.ErrorBox("Token not accepted", patForbiddenGuidance(owner, repo)) + return fmt.Errorf("organization forbids classic PATs: %w", err) + } return fmt.Errorf("getting repo info: %w", err) } commitMsg := fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version) @@ -2069,6 +2081,13 @@ func runPreflight(ctx context.Context, stack *layers.Stack, op layers.Operation, if result.Skipped { printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)") + if guidance := result.SkipGuidance(); guidance != "" { + for _, line := range strings.Split(guidance, "\n") { + if line != "" { + printer.StepInfo(line) + } + } + } } else { printer.StepDone("Token permissions verified") } @@ -2879,6 +2898,14 @@ func checkTokenScopes(ctx context.Context, client forge.Client, printer *ui.Prin if granted == nil { printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)") + result := &layers.PreflightResult{Required: required, Skipped: true} + if guidance := result.SkipGuidance(); guidance != "" { + for _, line := range strings.Split(guidance, "\n") { + if line != "" { + printer.StepInfo(line) + } + } + } return nil } @@ -2910,6 +2937,31 @@ func checkTokenScopes(ctx context.Context, client forge.Client, printer *ui.Prin return nil } +// patForbiddenGuidance returns a formatted error message when an org +// blocks classic PATs. It explains the token resolution order and how +// to create a fine-grained PAT with the required permissions. +func patForbiddenGuidance(owner, repo string) string { + var b strings.Builder + fmt.Fprintf(&b, "Organization %q forbids classic personal access tokens.\n\n", owner) + b.WriteString("The CLI resolves tokens in this order:\n") + b.WriteString(" 1. GH_TOKEN environment variable\n") + b.WriteString(" 2. GITHUB_TOKEN environment variable\n") + b.WriteString(" 3. gh auth token (GitHub CLI)\n\n") + b.WriteString("The token that resolved was rejected. To fix this, create a\n") + b.WriteString("fine-grained PAT at https://github.com/settings/personal-access-tokens/new\n") + fmt.Fprintf(&b, "scoped to %s/%s with these permissions:\n\n", owner, repo) + b.WriteString(" • Contents: read/write\n") + b.WriteString(" • Workflows: read/write\n") + b.WriteString(" • Secrets: read/write\n") + b.WriteString(" • Variables: read/write\n") + b.WriteString(" • Pull requests: read/write (without --direct)\n") + b.WriteString(" • Metadata: read-only (required by GitHub)\n\n") + b.WriteString("Then export it before running setup:\n") + fmt.Fprintf(&b, " export GH_TOKEN=github_pat_...\n") + fmt.Fprintf(&b, " fullsend github setup %s/%s", owner, repo) + return b.String() +} + // Helper functions. func repoNameList(repos []forge.Repository) []string { diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index e6c4296b2..3af823f91 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1256,10 +1256,16 @@ func TestCheckInstallScopes_FineGrainedToken(t *testing.T) { client := &forge.FakeClient{ TokenScopes: nil, } - printer := ui.New(&discardWriter{}) + var buf bytes.Buffer + printer := ui.New(&buf) err := checkInstallScopes(context.Background(), client, printer) require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "fine-grained token detected") + assert.Contains(t, output, "repo") + assert.Contains(t, output, "workflow") + assert.Contains(t, output, "admin:org") } func TestCheckInstallScopes_InstallationToken(t *testing.T) { @@ -1328,10 +1334,16 @@ func TestCheckPerRepoScopes_FineGrainedToken(t *testing.T) { client := &forge.FakeClient{ TokenScopes: nil, } - printer := ui.New(&discardWriter{}) + var buf bytes.Buffer + printer := ui.New(&buf) err := checkPerRepoScopes(context.Background(), client, printer) require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "fine-grained token detected") + assert.Contains(t, output, "repo") + assert.Contains(t, output, "workflow") + assert.NotContains(t, output, "admin:org") } func TestCheckPerRepoScopes_InstallationToken(t *testing.T) { diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 0db4ed9b1..42e42c0d1 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -118,6 +118,19 @@ func IsRateLimitError(err error) bool { return false } +// IsPATForbiddenError reports whether err is a GitHub 403 indicating +// that the org forbids classic personal access tokens. This specific +// message means the caller needs a fine-grained PAT, GitHub App, or +// OAuth App token instead. +func IsPATForbiddenError(err error) bool { + var apiErr *APIError + if !errors.As(err, &apiErr) { + return false + } + return apiErr.StatusCode == http.StatusForbidden && + strings.Contains(strings.ToLower(apiErr.Message), "forbids access via a personal access token") +} + const maxRetries = 5 // do performs an HTTP request against the GitHub API with retry on rate limits. diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index 8be03fe69..678ccf94c 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -1057,6 +1057,52 @@ func TestAPIError_ErrorStringWithDetails(t *testing.T) { assert.Contains(t, err.Error(), "name already exists on this account") } +func TestIsPATForbiddenError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "classic PAT forbidden by org", + err: &APIError{ + StatusCode: 403, + Message: `"test-org" forbids access via a personal access token (classic). Please use a GitHub App, OAuth App, or a personal access token with fine-grained permissions.`, + }, + want: true, + }, + { + name: "wrapped error", + err: fmt.Errorf("get repo: %w", &APIError{ + StatusCode: 403, + Message: `"test-org" forbids access via a personal access token (classic)`, + }), + want: true, + }, + { + name: "generic 403", + err: &APIError{StatusCode: 403, Message: "Resource not accessible by integration"}, + want: false, + }, + { + name: "non-API error", + err: fmt.Errorf("network error"), + want: false, + }, + { + name: "nil error", + err: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsPATForbiddenError(tt.err)) + }) + } +} + func TestIsBranchProtectionError(t *testing.T) { tests := []struct { name string diff --git a/internal/layers/preflight.go b/internal/layers/preflight.go index 7af242185..40cfa02ed 100644 --- a/internal/layers/preflight.go +++ b/internal/layers/preflight.go @@ -36,6 +36,35 @@ var scopeDescriptions = map[string]string{ "delete_repo": "delete the .fullsend config repository (uninstall only)", } +// fineGrainedEquivalents maps OAuth scopes to equivalent fine-grained +// PAT permissions. Used in skip guidance when scopes cannot be verified. +var fineGrainedEquivalents = map[string]string{ + "repo": "Contents (read/write), Secrets (read/write), Variables (read/write)", + "workflow": "Workflows (read/write)", + "admin:org": "Organization administration (read/write)", + "delete_repo": "Administration (read/write)", +} + +// SkipGuidance returns a human-readable message listing the scopes +// that could not be verified and their fine-grained equivalents. +func (r *PreflightResult) SkipGuidance() string { + if len(r.Required) == 0 { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "Could not verify scopes: %s\n", strings.Join(r.Required, ", ")) + b.WriteString("Ensure your token has these fine-grained permissions:\n") + for _, scope := range r.Required { + if equiv, ok := fineGrainedEquivalents[scope]; ok { + fmt.Fprintf(&b, " • %s → %s\n", scope, equiv) + } else { + fmt.Fprintf(&b, " • %s\n", scope) + } + } + b.WriteString(" • Metadata (read-only) — required by GitHub for all tokens") + return b.String() +} + // Error returns a human-readable error describing missing scopes and // how to fix the problem. func (r *PreflightResult) Error() string { diff --git a/internal/layers/preflight_test.go b/internal/layers/preflight_test.go index 30739f420..824f2bad2 100644 --- a/internal/layers/preflight_test.go +++ b/internal/layers/preflight_test.go @@ -123,6 +123,36 @@ func TestCollectRequiredScopes(t *testing.T) { assert.ElementsMatch(t, []string{"repo", "workflow", "delete_repo"}, scopes) } +func TestPreflightResult_SkipGuidance(t *testing.T) { + t.Run("lists required scopes and fine-grained equivalents", func(t *testing.T) { + r := &PreflightResult{ + Required: []string{"repo", "workflow"}, + Skipped: true, + } + guidance := r.SkipGuidance() + assert.Contains(t, guidance, "repo") + assert.Contains(t, guidance, "workflow") + assert.Contains(t, guidance, "Contents (read/write)") + assert.Contains(t, guidance, "Workflows (read/write)") + assert.Contains(t, guidance, "Metadata (read-only)") + }) + + t.Run("includes admin:org for install scopes", func(t *testing.T) { + r := &PreflightResult{ + Required: []string{"repo", "workflow", "admin:org"}, + Skipped: true, + } + guidance := r.SkipGuidance() + assert.Contains(t, guidance, "admin:org") + assert.Contains(t, guidance, "Organization administration") + }) + + t.Run("returns empty for no required scopes", func(t *testing.T) { + r := &PreflightResult{Skipped: true} + assert.Empty(t, r.SkipGuidance()) + }) +} + func TestPreflightResult_Error(t *testing.T) { r := &PreflightResult{ Required: []string{"repo", "delete_repo", "workflow"}, From bf0b3c4a95f5767d9a51bdf0898ace12faff387f Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Tue, 7 Jul 2026 12:45:59 -0400 Subject: [PATCH 2/5] fix: address review findings from multi-agent review - Add SkippedReason to PreflightResult to distinguish installation tokens from fine-grained PATs; runPreflight now shows the correct message for each token type instead of always saying "fine-grained" - Extract printSkipGuidance helper to deduplicate the guidance-printing loop that appeared in both runPreflight and checkTokenScopes - Add Pull requests (read/write) to fineGrainedEquivalents so SkipGuidance output is consistent with patForbiddenGuidance and docs - Add test for patForbiddenGuidance verifying owner/repo, all permission bullets, token resolution order, and export command Assisted-by: Claude (review, fix), Gemini (review) Signed-off-by: Wayne Sun --- internal/cli/admin.go | 34 +++++++++++++++++-------------- internal/cli/admin_test.go | 18 ++++++++++++++++ internal/layers/preflight.go | 21 ++++++++++++++++--- internal/layers/preflight_test.go | 3 +++ 4 files changed, 58 insertions(+), 18 deletions(-) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index a29c00650..8cfb17439 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -2080,13 +2080,12 @@ func runPreflight(ctx context.Context, stack *layers.Stack, op layers.Operation, } if result.Skipped { - printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)") - if guidance := result.SkipGuidance(); guidance != "" { - for _, line := range strings.Split(guidance, "\n") { - if line != "" { - printer.StepInfo(line) - } - } + switch result.SkippedReason { + case layers.SkipInstallationToken: + printer.StepWarn("Preflight skipped: installation token (OAuth scopes do not apply)") + default: + printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)") + printSkipGuidance(printer, result) } } else { printer.StepDone("Token permissions verified") @@ -2898,14 +2897,7 @@ func checkTokenScopes(ctx context.Context, client forge.Client, printer *ui.Prin if granted == nil { printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)") - result := &layers.PreflightResult{Required: required, Skipped: true} - if guidance := result.SkipGuidance(); guidance != "" { - for _, line := range strings.Split(guidance, "\n") { - if line != "" { - printer.StepInfo(line) - } - } - } + printSkipGuidance(printer, &layers.PreflightResult{Required: required, Skipped: true}) return nil } @@ -2937,6 +2929,18 @@ func checkTokenScopes(ctx context.Context, client forge.Client, printer *ui.Prin return nil } +// printSkipGuidance prints fine-grained permission guidance when +// preflight is skipped due to a fine-grained token. +func printSkipGuidance(printer *ui.Printer, result *layers.PreflightResult) { + if guidance := result.SkipGuidance(); guidance != "" { + for _, line := range strings.Split(guidance, "\n") { + if line != "" { + printer.StepInfo(line) + } + } + } +} + // patForbiddenGuidance returns a formatted error message when an org // blocks classic PATs. It explains the token resolution order and how // to create a fine-grained PAT with the required permissions. diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 3af823f91..3654edbc6 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -1378,6 +1378,24 @@ func TestCheckPerRepoScopes_DoesNotRequireAdminOrg(t *testing.T) { require.NoError(t, err, "per-repo should not require admin:org scope") } +func TestPatForbiddenGuidance(t *testing.T) { + guidance := patForbiddenGuidance("test-org", "test-repo") + assert.Contains(t, guidance, `"test-org"`) + assert.Contains(t, guidance, "test-org/test-repo") + assert.Contains(t, guidance, "GH_TOKEN") + assert.Contains(t, guidance, "GITHUB_TOKEN") + assert.Contains(t, guidance, "gh auth token") + assert.Contains(t, guidance, "Contents:") + assert.Contains(t, guidance, "Workflows:") + assert.Contains(t, guidance, "Secrets:") + assert.Contains(t, guidance, "Variables:") + assert.Contains(t, guidance, "Pull requests:") + assert.Contains(t, guidance, "Metadata:") + assert.Contains(t, guidance, "https://github.com/settings/personal-access-tokens/new") + assert.Contains(t, guidance, "export GH_TOKEN=github_pat_") + assert.Contains(t, guidance, "fullsend github setup test-org/test-repo") +} + func TestPerRepoRequiredScopes_SubsetOfInstallScopes(t *testing.T) { installSet := make(map[string]bool) for _, s := range installRequiredScopes { diff --git a/internal/layers/preflight.go b/internal/layers/preflight.go index 40cfa02ed..0073badd5 100644 --- a/internal/layers/preflight.go +++ b/internal/layers/preflight.go @@ -8,6 +8,19 @@ import ( "github.com/fullsend-ai/fullsend/internal/forge" ) +// SkipReason describes why a preflight check was skipped. +type SkipReason string + +const ( + // SkipNone means the preflight was not skipped. + SkipNone SkipReason = "" + // SkipInstallationToken means the token is a GitHub App installation token. + SkipInstallationToken SkipReason = "installation" + // SkipFineGrained means the token is a fine-grained PAT whose + // permissions cannot be introspected. + SkipFineGrained SkipReason = "fine-grained" +) + // PreflightResult describes what a preflight check found. type PreflightResult struct { // Required is the set of scopes the operation needs. @@ -19,6 +32,8 @@ type PreflightResult struct { // Skipped is true when scope introspection was unavailable // (e.g., fine-grained tokens that don't report scopes). Skipped bool + // SkippedReason indicates why the preflight was skipped. + SkippedReason SkipReason } // OK returns true if no scopes are missing. @@ -39,7 +54,7 @@ var scopeDescriptions = map[string]string{ // fineGrainedEquivalents maps OAuth scopes to equivalent fine-grained // PAT permissions. Used in skip guidance when scopes cannot be verified. var fineGrainedEquivalents = map[string]string{ - "repo": "Contents (read/write), Secrets (read/write), Variables (read/write)", + "repo": "Contents (read/write), Secrets (read/write), Variables (read/write), Pull requests (read/write)", "workflow": "Workflows (read/write)", "admin:org": "Organization administration (read/write)", "delete_repo": "Administration (read/write)", @@ -109,7 +124,7 @@ func (s *Stack) Preflight(ctx context.Context, op Operation, client forge.Client return nil, fmt.Errorf("detecting installation token: %w", err) } if isInstallation { - return &PreflightResult{Required: required, Skipped: true}, nil + return &PreflightResult{Required: required, Skipped: true, SkippedReason: SkipInstallationToken}, nil } granted, err := client.GetTokenScopes(ctx) @@ -120,7 +135,7 @@ func (s *Stack) Preflight(ctx context.Context, op Operation, client forge.Client // If the forge can't report scopes (fine-grained tokens return nil), // we can't validate. Let the operation proceed but warn the caller. if granted == nil { - return &PreflightResult{Required: required, Skipped: true}, nil + return &PreflightResult{Required: required, Skipped: true, SkippedReason: SkipFineGrained}, nil } grantedSet := make(map[string]bool, len(granted)) diff --git a/internal/layers/preflight_test.go b/internal/layers/preflight_test.go index 824f2bad2..5db40d93b 100644 --- a/internal/layers/preflight_test.go +++ b/internal/layers/preflight_test.go @@ -65,6 +65,7 @@ func TestPreflight_InstallationToken(t *testing.T) { require.NoError(t, err) assert.True(t, result.OK(), "installation tokens should skip OAuth scope preflight") assert.True(t, result.Skipped) + assert.Equal(t, SkipInstallationToken, result.SkippedReason) assert.Equal(t, []string{"repo", "workflow"}, result.Required) } @@ -82,6 +83,7 @@ func TestPreflight_NilScopes_FineGrainedToken(t *testing.T) { require.NoError(t, err) assert.True(t, result.OK(), "should pass when scopes can't be introspected") assert.True(t, result.Skipped, "should indicate preflight was skipped") + assert.Equal(t, SkipFineGrained, result.SkippedReason) } func TestPreflight_GetTokenScopesError(t *testing.T) { @@ -133,6 +135,7 @@ func TestPreflightResult_SkipGuidance(t *testing.T) { assert.Contains(t, guidance, "repo") assert.Contains(t, guidance, "workflow") assert.Contains(t, guidance, "Contents (read/write)") + assert.Contains(t, guidance, "Pull requests (read/write)") assert.Contains(t, guidance, "Workflows (read/write)") assert.Contains(t, guidance, "Metadata (read-only)") }) From 998e9a509143212f7af7995fd48826b1afb16c73 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Tue, 7 Jul 2026 13:05:43 -0400 Subject: [PATCH 3/5] docs: condense token section into a note block The token resolution details are not a prerequisite for most users. Collapse the section into a compact note so it does not dominate the prerequisites list. Assisted-by: Claude Signed-off-by: Wayne Sun --- .../getting-started/configuring-github.md | 40 ++++--------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/docs/guides/getting-started/configuring-github.md b/docs/guides/getting-started/configuring-github.md index d0698a11a..39b8e5e6c 100644 --- a/docs/guides/getting-started/configuring-github.md +++ b/docs/guides/getting-started/configuring-github.md @@ -13,38 +13,14 @@ The goal of this document is that you configure Fullsend for your GitHub reposit * Download the latest [fullsend](https://github.com/fullsend-ai/fullsend/releases) CLI. * Download the latest [gh](https://cli.github.com/) CLI and authenticate with it. -### Token resolution - -The `fullsend` CLI resolves a GitHub token in this order: - -1. `GH_TOKEN` environment variable -2. `GITHUB_TOKEN` environment variable -3. `gh auth token` (GitHub CLI) - -For most organizations, the token from `gh auth login` works. However, if -your organization restricts personal access token types (Settings → Personal -access tokens → Restrict access via PAT type), `gh auth login` may produce a -token that GitHub rejects with a 403 error. - -In that case, create a **fine-grained personal access token** at - scoped to the -target repository with these permissions: - -| Permission | Level | Why | -|---|---|---| -| Contents | Read and write | Commits `.fullsend/config.yaml` and scaffold files | -| Workflows | Read and write | Writes/updates files under `.github/workflows/` | -| Secrets | Read and write | Sets `FULLSEND_GCP_PROJECT_ID` / `FULLSEND_GCP_WIF_PROVIDER` | -| Variables | Read and write | Sets `FULLSEND_MINT_URL` / `FULLSEND_GCP_REGION` | -| Metadata | Read-only | GitHub-required baseline | -| Pull requests | Read and write | Only needed without `--direct` | - -Export it before running setup so it takes priority: - -```bash -export GH_TOKEN=github_pat_... -fullsend github setup / ... -``` +> **Note:** If your organization restricts classic personal access tokens, +> `gh auth login` may produce a token that GitHub rejects with a 403. Create a +> [fine-grained PAT](https://github.com/settings/personal-access-tokens/new) +> scoped to the target repository with **Contents**, **Workflows**, **Secrets**, +> **Variables** (read/write), **Pull requests** (read/write — not needed with +> `--direct`), and **Metadata** (read-only), then `export GH_TOKEN=github_pat_...` +> before running setup. The CLI checks `GH_TOKEN` → `GITHUB_TOKEN` → `gh auth token`, +> in that order. ## Installing GitHub Applications From aa5d6f8801cd94d5e02f4fd0ff57551f0b4c203e Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Tue, 7 Jul 2026 13:15:38 -0400 Subject: [PATCH 4/5] fix: address round 2 review findings for preflight PAT handling - Explicitly match SkipFineGrained in switch instead of using default, preventing future SkipReason variants from getting wrong guidance - Guard SkipGuidance() to only generate output for fine-grained tokens, making it safe to call unconditionally - Extract handlePATForbidden helper to deduplicate PAT rejection handling at two GetRepo call sites - Set SkippedReason in checkTokenScopes when constructing PreflightResult - Add test for SkipGuidance returning empty for installation tokens Assisted-by: Claude Signed-off-by: Wayne Sun --- internal/cli/admin.go | 26 +++++++++++++------------- internal/layers/preflight.go | 2 +- internal/layers/preflight_test.go | 21 ++++++++++++++++----- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 8cfb17439..c1207cd40 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -996,10 +996,7 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error { targetRepo, repoErr := client.GetRepo(ctx, owner, repo) if repoErr != nil { if gh.IsPATForbiddenError(repoErr) { - printer.StepFail("Classic PAT rejected by organization") - printer.Blank() - printer.ErrorBox("Token not accepted", patForbiddenGuidance(owner, repo)) - return fmt.Errorf("organization forbids classic PATs: %w", repoErr) + return handlePATForbidden(printer, owner, repo, repoErr) } return fmt.Errorf("getting repo info: %w", repoErr) } @@ -1173,10 +1170,7 @@ func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui. targetRepo, err := client.GetRepo(ctx, owner, repo) if err != nil { if gh.IsPATForbiddenError(err) { - printer.StepFail("Classic PAT rejected by organization") - printer.Blank() - printer.ErrorBox("Token not accepted", patForbiddenGuidance(owner, repo)) - return fmt.Errorf("organization forbids classic PATs: %w", err) + return handlePATForbidden(printer, owner, repo, err) } return fmt.Errorf("getting repo info: %w", err) } @@ -2083,9 +2077,11 @@ func runPreflight(ctx context.Context, stack *layers.Stack, op layers.Operation, switch result.SkippedReason { case layers.SkipInstallationToken: printer.StepWarn("Preflight skipped: installation token (OAuth scopes do not apply)") - default: + case layers.SkipFineGrained: printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)") printSkipGuidance(printer, result) + default: + printer.StepWarn(fmt.Sprintf("Preflight skipped: %s", result.SkippedReason)) } } else { printer.StepDone("Token permissions verified") @@ -2897,7 +2893,7 @@ func checkTokenScopes(ctx context.Context, client forge.Client, printer *ui.Prin if granted == nil { printer.StepWarn("Preflight skipped: fine-grained token detected (scopes cannot be verified)") - printSkipGuidance(printer, &layers.PreflightResult{Required: required, Skipped: true}) + printSkipGuidance(printer, &layers.PreflightResult{Required: required, Skipped: true, SkippedReason: layers.SkipFineGrained}) return nil } @@ -2941,9 +2937,13 @@ func printSkipGuidance(printer *ui.Printer, result *layers.PreflightResult) { } } -// patForbiddenGuidance returns a formatted error message when an org -// blocks classic PATs. It explains the token resolution order and how -// to create a fine-grained PAT with the required permissions. +func handlePATForbidden(printer *ui.Printer, owner, repo string, err error) error { + printer.StepFail("Classic PAT rejected by organization") + printer.Blank() + printer.ErrorBox("Token not accepted", patForbiddenGuidance(owner, repo)) + return fmt.Errorf("organization forbids classic PATs: %w", err) +} + func patForbiddenGuidance(owner, repo string) string { var b strings.Builder fmt.Fprintf(&b, "Organization %q forbids classic personal access tokens.\n\n", owner) diff --git a/internal/layers/preflight.go b/internal/layers/preflight.go index 0073badd5..6e2005893 100644 --- a/internal/layers/preflight.go +++ b/internal/layers/preflight.go @@ -63,7 +63,7 @@ var fineGrainedEquivalents = map[string]string{ // SkipGuidance returns a human-readable message listing the scopes // that could not be verified and their fine-grained equivalents. func (r *PreflightResult) SkipGuidance() string { - if len(r.Required) == 0 { + if r.SkippedReason != SkipFineGrained || len(r.Required) == 0 { return "" } var b strings.Builder diff --git a/internal/layers/preflight_test.go b/internal/layers/preflight_test.go index 5db40d93b..3c70212f2 100644 --- a/internal/layers/preflight_test.go +++ b/internal/layers/preflight_test.go @@ -128,8 +128,9 @@ func TestCollectRequiredScopes(t *testing.T) { func TestPreflightResult_SkipGuidance(t *testing.T) { t.Run("lists required scopes and fine-grained equivalents", func(t *testing.T) { r := &PreflightResult{ - Required: []string{"repo", "workflow"}, - Skipped: true, + Required: []string{"repo", "workflow"}, + Skipped: true, + SkippedReason: SkipFineGrained, } guidance := r.SkipGuidance() assert.Contains(t, guidance, "repo") @@ -142,8 +143,9 @@ func TestPreflightResult_SkipGuidance(t *testing.T) { t.Run("includes admin:org for install scopes", func(t *testing.T) { r := &PreflightResult{ - Required: []string{"repo", "workflow", "admin:org"}, - Skipped: true, + Required: []string{"repo", "workflow", "admin:org"}, + Skipped: true, + SkippedReason: SkipFineGrained, } guidance := r.SkipGuidance() assert.Contains(t, guidance, "admin:org") @@ -151,7 +153,16 @@ func TestPreflightResult_SkipGuidance(t *testing.T) { }) t.Run("returns empty for no required scopes", func(t *testing.T) { - r := &PreflightResult{Skipped: true} + r := &PreflightResult{Skipped: true, SkippedReason: SkipFineGrained} + assert.Empty(t, r.SkipGuidance()) + }) + + t.Run("returns empty for installation token", func(t *testing.T) { + r := &PreflightResult{ + Required: []string{"repo", "workflow"}, + Skipped: true, + SkippedReason: SkipInstallationToken, + } assert.Empty(t, r.SkipGuidance()) }) } From 45ff7fecc660234ccc73ce8276b965919bbf3d70 Mon Sep 17 00:00:00 2001 From: Wayne Sun Date: Tue, 7 Jul 2026 16:57:58 -0400 Subject: [PATCH 5/5] fix: disable fork option for fine-grained PATs in setup Fine-grained PATs are scoped to a single GitHub org and cannot create cross-org forks. When the CLI detects a fine-grained token (nil scopes from GetTokenScopes), it now hides the [f] fork option and presents only the upstream [u] path with a confirmation prompt explaining that a PR with scaffolding files will be created. - Add isFineGrainedToken() to detect token type via scope introspection - Add promptUpstreamOnly() for fine-grained-specific confirmation UX - Update commitScaffoldViaPR to route fine-grained tokens to upstream - Add fork limitation note to configuring-github.md - Add tests for fine-grained interactive/non-interactive/decline paths Relates-to: #3368 Assisted-by: Claude Signed-off-by: Wayne Sun --- .../getting-started/configuring-github.md | 9 ++ internal/layers/commit.go | 77 +++++++++++- internal/layers/commit_test.go | 117 ++++++++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) diff --git a/docs/guides/getting-started/configuring-github.md b/docs/guides/getting-started/configuring-github.md index 39b8e5e6c..a2d3d32bc 100644 --- a/docs/guides/getting-started/configuring-github.md +++ b/docs/guides/getting-started/configuring-github.md @@ -21,6 +21,15 @@ The goal of this document is that you configure Fullsend for your GitHub reposit > `--direct`), and **Metadata** (read-only), then `export GH_TOKEN=github_pat_...` > before running setup. The CLI checks `GH_TOKEN` → `GITHUB_TOKEN` → `gh auth token`, > in that order. +> +> **Fork limitation:** Fine-grained PATs are scoped to a single GitHub +> organization and cannot create forks across org boundaries. When the CLI +> detects a fine-grained token, the fork delivery option (`[f]`) is +> unavailable. The CLI will offer the upstream option (`[u]`), which pushes +> a branch to the target repository and creates a PR containing the fullsend +> scaffolding files. No changes are made to the default branch until the PR +> is merged. This limitation only applies to the initial setup — after setup, +> fullsend agents use their own GitHub App tokens and do not require a fork. ## Installing GitHub Applications diff --git a/internal/layers/commit.go b/internal/layers/commit.go index 3d64f126a..58e53a08d 100644 --- a/internal/layers/commit.go +++ b/internal/layers/commit.go @@ -80,8 +80,28 @@ func commitScaffoldViaPR(ctx context.Context, client forge.Client, printer *ui.P } // No existing fork — decide whether to create one. + // Fine-grained PATs are scoped to a single org and cannot create cross-org + // forks, so the fork option is unavailable when one is detected. + isFineGrained, fgErr := isFineGrainedToken(ctx, client) + if fgErr != nil { + printer.StepWarn(fmt.Sprintf("Could not detect token type: %v", fgErr)) + } + useFork := true - if in != nil { + if isFineGrained { + if in != nil { + confirmed, promptErr := promptUpstreamOnly(printer, in, owner, repo) + if promptErr != nil { + return false, fmt.Errorf("reading delivery choice: %w", promptErr) + } + if !confirmed { + return false, fmt.Errorf("upstream delivery declined by user") + } + } else { + printer.StepInfo("Non-interactive mode: fine-grained token detected, pushing to upstream") + } + useFork = false + } else if in != nil { choice, promptErr := promptForkChoice(printer, in) if promptErr != nil { return false, fmt.Errorf("reading fork choice: %w", promptErr) @@ -314,6 +334,61 @@ func promptForkChoice(printer *ui.Printer, in io.Reader) (bool, error) { return true, fmt.Errorf("too many invalid attempts") } +// isFineGrainedToken reports whether the current token is a fine-grained PAT. +// Fine-grained PATs return nil from GetTokenScopes because GitHub doesn't +// populate the X-OAuth-Scopes header for them. +func isFineGrainedToken(ctx context.Context, client forge.Client) (bool, error) { + isInstall, err := client.IsInstallationToken(ctx) + if err != nil { + return false, err + } + if isInstall { + return false, nil + } + + scopes, err := client.GetTokenScopes(ctx) + if err != nil { + return false, err + } + return scopes == nil, nil +} + +// promptUpstreamOnly explains that the fork option is unavailable with a +// fine-grained PAT and asks the user to confirm pushing to upstream. The +// upstream path creates a PR with the fullsend scaffolding files — it does +// not push directly to the default branch. +func promptUpstreamOnly(printer *ui.Printer, in io.Reader, owner, repo string) (bool, error) { + printer.Blank() + printer.StepWarn("Fine-grained token detected — fork option is not available.") + printer.StepInfo("Fine-grained PATs are scoped to a single organization and cannot") + printer.StepInfo("create forks in other accounts. This is a GitHub platform limitation.") + printer.Blank() + printer.StepInfo(fmt.Sprintf("Option [u] will push a branch to %s/%s and create a PR", owner, repo)) + printer.StepInfo("containing the fullsend scaffolding files (config, workflows, secrets).") + printer.StepInfo("No changes are made to the default branch until the PR is merged.") + printer.Blank() + + const maxAttempts = 5 + reader := bufio.NewReader(in) + for i := 0; i < maxAttempts; i++ { + printer.StepInfo("Proceed with upstream PR? [y/n]: ") + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return false, err + } + choice := strings.TrimSpace(strings.ToLower(line)) + switch choice { + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + default: + printer.StepWarn("Invalid choice. Please enter 'y' or 'n'.") + } + } + return false, fmt.Errorf("too many invalid attempts") +} + // commitScaffoldDirect pushes files directly to the default branch, falling // back to a PR when branch protection blocks the push. func commitScaffoldDirect(ctx context.Context, client forge.Client, printer *ui.Printer, diff --git a/internal/layers/commit_test.go b/internal/layers/commit_test.go index 347c55f71..f8392d1c1 100644 --- a/internal/layers/commit_test.go +++ b/internal/layers/commit_test.go @@ -81,6 +81,7 @@ func TestCommitScaffoldViaPR_ExistingForkReused(t *testing.T) { func TestCommitScaffoldViaPR_NonInteractiveForksByDefault(t *testing.T) { client := forge.NewFakeClient() client.AuthenticatedUser = "contributor" + client.TokenScopes = []string{"repo", "workflow"} client.Repos = append(client.Repos, forge.Repository{ FullName: "contributor/widget", DefaultBranch: "main", }) @@ -100,6 +101,7 @@ func TestCommitScaffoldViaPR_NonInteractiveForksByDefault(t *testing.T) { func TestCommitScaffoldViaPR_InteractiveForkChoice(t *testing.T) { client := forge.NewFakeClient() client.AuthenticatedUser = "contributor" + client.TokenScopes = []string{"repo", "workflow"} client.Repos = append(client.Repos, forge.Repository{ FullName: "contributor/widget", DefaultBranch: "main", }) @@ -117,6 +119,7 @@ func TestCommitScaffoldViaPR_InteractiveForkChoice(t *testing.T) { func TestCommitScaffoldViaPR_InteractiveUpstreamChoice(t *testing.T) { client := forge.NewFakeClient() client.AuthenticatedUser = "contributor" + client.TokenScopes = []string{"repo", "workflow"} printer, _ := newTestPrinter() // Simulate user choosing upstream. @@ -134,6 +137,7 @@ func TestCommitScaffoldViaPR_InteractiveUpstreamChoice(t *testing.T) { func TestCommitScaffoldViaPR_UpstreamForbidden(t *testing.T) { client := forge.NewFakeClient() client.AuthenticatedUser = "contributor" + client.TokenScopes = []string{"repo", "workflow"} client.CreateBranchErrors = map[string]error{ "acme/widget": fmt.Errorf("API error: %w", forge.ErrForbidden), } @@ -171,6 +175,7 @@ func TestCommitScaffoldViaPR_CrossForkPRHead(t *testing.T) { func TestCommitScaffoldViaPR_FindExistingForkError(t *testing.T) { client := forge.NewFakeClient() client.AuthenticatedUser = "contributor" + client.TokenScopes = []string{"repo", "workflow"} client.Errors = map[string]error{ "FindExistingFork": fmt.Errorf("API error"), } @@ -191,6 +196,7 @@ func TestCommitScaffoldViaPR_FindExistingForkError(t *testing.T) { func TestCommitScaffoldViaPR_CreateForkError(t *testing.T) { client := forge.NewFakeClient() client.AuthenticatedUser = "contributor" + client.TokenScopes = []string{"repo", "workflow"} client.Errors = map[string]error{ "CreateFork": fmt.Errorf("rate limited"), } @@ -264,6 +270,117 @@ func TestCommitScaffoldDirect_NonFastForwardRetryFails(t *testing.T) { assert.Contains(t, err.Error(), "network error") } +func TestCommitScaffoldViaPR_FineGrainedSkipsFork_Interactive(t *testing.T) { + client := forge.NewFakeClient() + client.AuthenticatedUser = "contributor" + // TokenScopes nil = fine-grained PAT + printer, buf := newTestPrinter() + + // Simulate user confirming upstream. + in := strings.NewReader("y\n") + _, err := CommitScaffoldFiles(context.Background(), client, printer, + "acme", "widget", "main", "msg", "title", "body", testFiles, false, in) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "Fine-grained token detected") + assert.Contains(t, output, "fork option is not available") + assert.Contains(t, output, "scaffolding files") + assert.Empty(t, client.CreatedForks, "should not attempt to fork") + // Branch created on upstream. + require.Len(t, client.CreatedBranches, 1) + assert.Equal(t, "acme/widget/fullsend/scaffold-install", client.CreatedBranches[0]) +} + +func TestCommitScaffoldViaPR_FineGrainedDeclined(t *testing.T) { + client := forge.NewFakeClient() + client.AuthenticatedUser = "contributor" + printer, _ := newTestPrinter() + + in := strings.NewReader("n\n") + _, err := CommitScaffoldFiles(context.Background(), client, printer, + "acme", "widget", "main", "msg", "title", "body", testFiles, false, in) + require.Error(t, err) + assert.Contains(t, err.Error(), "upstream delivery declined") +} + +func TestCommitScaffoldViaPR_FineGrainedNonInteractive(t *testing.T) { + client := forge.NewFakeClient() + client.AuthenticatedUser = "contributor" + printer, buf := newTestPrinter() + + // nil reader = non-interactive → should auto-upstream (not fork). + _, err := CommitScaffoldFiles(context.Background(), client, printer, + "acme", "widget", "main", "msg", "title", "body", testFiles, false, nil) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "fine-grained token detected") + assert.Contains(t, output, "pushing to upstream") + assert.Empty(t, client.CreatedForks, "should not attempt to fork") + require.Len(t, client.CreatedBranches, 1) + assert.Equal(t, "acme/widget/fullsend/scaffold-install", client.CreatedBranches[0]) +} + +func TestPromptUpstreamOnly_Confirm(t *testing.T) { + printer, buf := newTestPrinter() + in := strings.NewReader("y\n") + confirmed, err := promptUpstreamOnly(printer, in, "acme", "widget") + require.NoError(t, err) + assert.True(t, confirmed) + assert.Contains(t, buf.String(), "acme/widget") + assert.Contains(t, buf.String(), "scaffolding files") +} + +func TestPromptUpstreamOnly_Decline(t *testing.T) { + printer, _ := newTestPrinter() + in := strings.NewReader("n\n") + confirmed, err := promptUpstreamOnly(printer, in, "acme", "widget") + require.NoError(t, err) + assert.False(t, confirmed) +} + +func TestPromptUpstreamOnly_InvalidThenConfirm(t *testing.T) { + printer, _ := newTestPrinter() + in := strings.NewReader("x\ny\n") + confirmed, err := promptUpstreamOnly(printer, in, "acme", "widget") + require.NoError(t, err) + assert.True(t, confirmed) +} + +func TestPromptUpstreamOnly_MaxRetries(t *testing.T) { + printer, _ := newTestPrinter() + in := strings.NewReader("x\nx\nx\nx\nx\n") + _, err := promptUpstreamOnly(printer, in, "acme", "widget") + require.Error(t, err) + assert.Contains(t, err.Error(), "too many invalid attempts") +} + +func TestIsFineGrainedToken(t *testing.T) { + t.Run("nil scopes = fine-grained", func(t *testing.T) { + client := forge.NewFakeClient() + fg, err := isFineGrainedToken(context.Background(), client) + require.NoError(t, err) + assert.True(t, fg) + }) + + t.Run("non-nil scopes = classic PAT", func(t *testing.T) { + client := forge.NewFakeClient() + client.TokenScopes = []string{"repo", "workflow"} + fg, err := isFineGrainedToken(context.Background(), client) + require.NoError(t, err) + assert.False(t, fg) + }) + + t.Run("installation token = not fine-grained", func(t *testing.T) { + client := forge.NewFakeClient() + client.InstallationToken = true + fg, err := isFineGrainedToken(context.Background(), client) + require.NoError(t, err) + assert.False(t, fg) + }) +} + func TestPromptForkChoice_DefaultIsFork(t *testing.T) { printer, _ := newTestPrinter() in := strings.NewReader("\n")