From adaa55cef0d13fe74b2e26407eac4b54f674c468 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 21 May 2026 15:43:45 -0400 Subject: [PATCH 1/3] feat(cli): log token scope at start of fullsend run At the start of every fullsend run, introspect the GH_TOKEN by calling GET /installation/repositories and log which repos the token is scoped to. This surfaces cross-org token scoping issues immediately rather than requiring post-hoc debugging. The check is non-fatal: if the token isn't an installation token or the API call fails, a warning is logged and the run continues normally. Refs: #1321 Assisted-by: Claude claude-opus-4-6 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/cli/run.go | 12 +++++++ internal/cli/tokenscope.go | 54 ++++++++++++++++++++++++++++ internal/cli/tokenscope_test.go | 62 +++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 internal/cli/tokenscope.go create mode 100644 internal/cli/tokenscope_test.go diff --git a/internal/cli/run.go b/internal/cli/run.go index 2031dc11e5..944e4209fc 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -190,6 +190,18 @@ func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary str } printer.Blank() + // 1b. Log token scope for debugging cross-org issues (see #1321). + // Non-fatal: if the check fails (e.g., non-installation token), log a + // warning and continue. + if ghToken := os.Getenv("GH_TOKEN"); ghToken != "" { + repos, err := fetchTokenScope(ghToken, "https://api.github.com") + if err != nil { + printer.StepWarn("Token scope check: " + err.Error()) + } else if len(repos) > 0 { + printer.KeyValue("Token scoped to", strings.Join(repos, ", ")) + } + } + // 2. Check openshell availability. openshellStart := time.Now() printer.StepStart("Checking openshell availability") diff --git a/internal/cli/tokenscope.go b/internal/cli/tokenscope.go new file mode 100644 index 0000000000..fa2169a9fa --- /dev/null +++ b/internal/cli/tokenscope.go @@ -0,0 +1,54 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +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. +func fetchTokenScope(token, baseURL string) ([]string, error) { + if token == "" { + return nil, nil + } + + url := baseURL + "/installation/repositories?per_page=100" + req, err := http.NewRequest(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 { + 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 + } + return repos, nil +} diff --git a/internal/cli/tokenscope_test.go b/internal/cli/tokenscope_test.go new file mode 100644 index 0000000000..e3aa8a6468 --- /dev/null +++ b/internal/cli/tokenscope_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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")) + assert.Equal(t, "Bearer ghs_test_token", r.Header.Get("Authorization")) + + json.NewEncoder(w).Encode(map[string]interface{}{ + "total_count": 2, + "repositories": []map[string]string{ + {"full_name": "org-a/repo-one"}, + {"full_name": "org-a/repo-two"}, + }, + }) + })) + defer github.Close() + + repos, err := fetchTokenScope("ghs_test_token", github.URL) + require.NoError(t, err) + assert.Equal(t, []string{"org-a/repo-one", "org-a/repo-two"}, repos) +} + +func TestFetchTokenScope_EmptyToken(t *testing.T) { + repos, err := fetchTokenScope("", "https://unused") + require.NoError(t, err) + assert.Nil(t, repos) +} + +func TestFetchTokenScope_APIError(t *testing.T) { + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer github.Close() + + repos, err := fetchTokenScope("ghs_bad", github.URL) + assert.Error(t, err) + assert.Nil(t, repos) +} + +func TestFetchTokenScope_NonInstallationToken(t *testing.T) { + // Personal access tokens and GITHUB_TOKENs return 403 on + // /installation/repositories. Treat as non-fatal. + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer github.Close() + + repos, err := fetchTokenScope("ghp_personal", github.URL) + assert.Error(t, err) + assert.Nil(t, repos) +} From 3c1080db1ae0e6d3d17893e090ef6a81537207d4 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 21 May 2026 15:48:28 -0400 Subject: [PATCH 2/3] fix(cli): silence token scope check for non-installation tokens PATs and GITHUB_TOKENs return 401/403 on /installation/repositories. Treat these as "not an installation token" and skip silently instead of logging a warning on every run. Assisted-by: Claude claude-opus-4-6 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/cli/tokenscope.go | 6 ++++++ internal/cli/tokenscope_test.go | 24 +++++++++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/cli/tokenscope.go b/internal/cli/tokenscope.go index fa2169a9fa..b1c8d9d207 100644 --- a/internal/cli/tokenscope.go +++ b/internal/cli/tokenscope.go @@ -32,6 +32,12 @@ func fetchTokenScope(token, baseURL string) ([]string, error) { } 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 e3aa8a6468..19a080002a 100644 --- a/internal/cli/tokenscope_test.go +++ b/internal/cli/tokenscope_test.go @@ -37,26 +37,40 @@ func TestFetchTokenScope_EmptyToken(t *testing.T) { assert.Nil(t, repos) } -func TestFetchTokenScope_APIError(t *testing.T) { +func TestFetchTokenScope_NonInstallationToken_401(t *testing.T) { + // Non-installation tokens get 401. Treated as "not an installation + // token" — returns (nil, nil), not an error. github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) })) defer github.Close() repos, err := fetchTokenScope("ghs_bad", github.URL) - assert.Error(t, err) + assert.NoError(t, err) assert.Nil(t, repos) } -func TestFetchTokenScope_NonInstallationToken(t *testing.T) { - // Personal access tokens and GITHUB_TOKENs return 403 on - // /installation/repositories. Treat as non-fatal. +func TestFetchTokenScope_NonInstallationToken_403(t *testing.T) { + // PATs and GITHUB_TOKENs return 403 on /installation/repositories. + // Treated as "not an installation token" — silent, no error. github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) })) defer github.Close() repos, err := fetchTokenScope("ghp_personal", github.URL) + assert.NoError(t, err) + assert.Nil(t, repos) +} + +func TestFetchTokenScope_UnexpectedStatus(t *testing.T) { + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer github.Close() + + repos, err := fetchTokenScope("ghs_token", github.URL) assert.Error(t, err) + assert.Contains(t, err.Error(), "status 500") assert.Nil(t, repos) } From 07cc6ae96465e65f78d07bad6f4f95351a3f021f Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Tue, 26 May 2026 14:31:06 -0400 Subject: [PATCH 3/3] fix: address review feedback on token scope logging (#1333) - Add truncation indicator when total_count > returned repos (HIGH) - Accept context.Context for cancellation support (MEDIUM) - Warn when installation token has access to 0 repositories (MEDIUM) Assisted-by: Claude Opus 4.6 Signed-off-by: Ralph Bean --- internal/cli/run.go | 4 ++- internal/cli/tokenscope.go | 13 +++++++-- internal/cli/tokenscope_test.go | 47 +++++++++++++++++++++++++++++---- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/internal/cli/run.go b/internal/cli/run.go index 944e4209fc..5646fb1802 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -194,11 +194,13 @@ func runAgent(agentName, fullsendDir, outputBase, targetRepo, fullsendBinary str // Non-fatal: if the check fails (e.g., non-installation token), log a // warning and continue. if ghToken := os.Getenv("GH_TOKEN"); ghToken != "" { - repos, err := fetchTokenScope(ghToken, "https://api.github.com") + repos, err := fetchTokenScope(context.Background(), ghToken, "https://api.github.com") if err != nil { printer.StepWarn("Token scope check: " + err.Error()) } else if len(repos) > 0 { printer.KeyValue("Token scoped to", strings.Join(repos, ", ")) + } else if repos != nil { + printer.StepWarn("Token is an installation token but has access to 0 repositories") } } diff --git a/internal/cli/tokenscope.go b/internal/cli/tokenscope.go index b1c8d9d207..940fa91f72 100644 --- a/internal/cli/tokenscope.go +++ b/internal/cli/tokenscope.go @@ -1,6 +1,7 @@ package cli import ( + "context" "encoding/json" "fmt" "io" @@ -13,13 +14,16 @@ 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. -func fetchTokenScope(token, baseURL string) ([]string, error) { +func fetchTokenScope(ctx context.Context, token, baseURL string) ([]string, error) { if token == "" { return nil, nil } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + url := baseURL + "/installation/repositories?per_page=100" - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creating scope request: %w", err) } @@ -44,6 +48,7 @@ func fetchTokenScope(token, baseURL string) ([]string, error) { } var result struct { + TotalCount int `json:"total_count"` Repositories []struct { FullName string `json:"full_name"` } `json:"repositories"` @@ -56,5 +61,9 @@ func fetchTokenScope(token, baseURL string) ([]string, error) { for i, r := range result.Repositories { repos[i] = r.FullName } + if result.TotalCount > len(result.Repositories) { + repos = append(repos, fmt.Sprintf("... and %d more (%d total)", + result.TotalCount-len(result.Repositories), result.TotalCount)) + } return repos, nil } diff --git a/internal/cli/tokenscope_test.go b/internal/cli/tokenscope_test.go index 19a080002a..b600db6ef6 100644 --- a/internal/cli/tokenscope_test.go +++ b/internal/cli/tokenscope_test.go @@ -1,6 +1,7 @@ package cli import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -26,13 +27,35 @@ func TestFetchTokenScope_ReturnsRepoNames(t *testing.T) { })) defer github.Close() - repos, err := fetchTokenScope("ghs_test_token", github.URL) + 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) } +func TestFetchTokenScope_Truncated(t *testing.T) { + // When total_count exceeds the number of returned repos, append a + // summary entry so operators know the list is incomplete. + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "total_count": 150, + "repositories": []map[string]string{ + {"full_name": "org/repo-1"}, + {"full_name": "org/repo-2"}, + }, + }) + })) + defer github.Close() + + repos, err := fetchTokenScope(context.Background(), "ghs_token", github.URL) + require.NoError(t, err) + require.Len(t, repos, 3) + assert.Equal(t, "org/repo-1", repos[0]) + assert.Equal(t, "org/repo-2", repos[1]) + assert.Equal(t, "... and 148 more (150 total)", repos[2]) +} + func TestFetchTokenScope_EmptyToken(t *testing.T) { - repos, err := fetchTokenScope("", "https://unused") + repos, err := fetchTokenScope(context.Background(), "", "https://unused") require.NoError(t, err) assert.Nil(t, repos) } @@ -45,7 +68,7 @@ func TestFetchTokenScope_NonInstallationToken_401(t *testing.T) { })) defer github.Close() - repos, err := fetchTokenScope("ghs_bad", github.URL) + repos, err := fetchTokenScope(context.Background(), "ghs_bad", github.URL) assert.NoError(t, err) assert.Nil(t, repos) } @@ -58,7 +81,7 @@ func TestFetchTokenScope_NonInstallationToken_403(t *testing.T) { })) defer github.Close() - repos, err := fetchTokenScope("ghp_personal", github.URL) + repos, err := fetchTokenScope(context.Background(), "ghp_personal", github.URL) assert.NoError(t, err) assert.Nil(t, repos) } @@ -69,8 +92,22 @@ func TestFetchTokenScope_UnexpectedStatus(t *testing.T) { })) defer github.Close() - repos, err := fetchTokenScope("ghs_token", github.URL) + repos, err := fetchTokenScope(context.Background(), "ghs_token", github.URL) assert.Error(t, err) assert.Contains(t, err.Error(), "status 500") assert.Nil(t, repos) } + +func TestFetchTokenScope_CancelledContext(t *testing.T) { + github := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("request should not have been sent") + })) + defer github.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + repos, err := fetchTokenScope(ctx, "ghs_token", github.URL) + assert.Error(t, err) + assert.Nil(t, repos) +}