diff --git a/apps/backend/internal/github/gh_accounts.go b/apps/backend/internal/github/gh_accounts.go index 25801a2c61..756a494e8a 100644 --- a/apps/backend/internal/github/gh_accounts.go +++ b/apps/backend/internal/github/gh_accounts.go @@ -41,7 +41,10 @@ func (systemGHAccountRunner) Run(ctx context.Context, args ...string) (string, e return cmd }) if runErr == nil { - return stdout.String(), nil + if out := stdout.String(); strings.TrimSpace(out) != "" { + return out, nil + } + return stderr.String(), nil } if execCtxErr != nil && (errors.Is(execCtxErr, context.Canceled) || errors.Is(execCtxErr, context.DeadlineExceeded)) { return "", execCtxErr diff --git a/apps/backend/internal/github/gh_accounts_test.go b/apps/backend/internal/github/gh_accounts_test.go index bbe88fab0d..d0816ab188 100644 --- a/apps/backend/internal/github/gh_accounts_test.go +++ b/apps/backend/internal/github/gh_accounts_test.go @@ -3,11 +3,28 @@ package github import ( "context" "errors" + "fmt" + "os" + "path/filepath" "reflect" + "runtime" "strings" "testing" ) +func init() { + if os.Getenv("KANDEV_GH_ACCOUNT_TEST_HELPER") != "1" { + return + } + if len(os.Args) > 3 && os.Args[3] == "--json" { + fmt.Fprintln(os.Stderr, "unknown flag: --json") + os.Exit(1) + } + fmt.Fprintln(os.Stderr, "github.com") + fmt.Fprintln(os.Stderr, " ✓ Logged in to github.com as alice (/home/alice/.config/gh/hosts.yml)") + os.Exit(0) +} + type fakeGHAccountRunner struct { output string err error @@ -38,6 +55,35 @@ func TestListGHAccountsParsesAllStoredLogins(t *testing.T) { } } +func TestSystemGHAccountRunnerUsesStderrWhenStdoutEmpty(t *testing.T) { + testBinary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ghPath := filepath.Join(t.TempDir(), "gh") + if runtime.GOOS == "windows" { + ghPath += ".exe" + } + binary, err := os.ReadFile(testBinary) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(ghPath, binary, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", filepath.Dir(ghPath)+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("KANDEV_GH_ACCOUNT_TEST_HELPER", "1") + + accounts, err := listGHAccounts(context.Background(), systemGHAccountRunner{}) + if err != nil { + t.Fatalf("listGHAccounts() error = %v", err) + } + want := []GHAccount{{Host: "github.com", Login: "alice", Active: true, State: "success"}} + if !reflect.DeepEqual(accounts, want) { + t.Fatalf("accounts = %#v, want %#v", accounts, want) + } +} + type legacyGHAccountRunner struct { output string args [][]string diff --git a/docs/plans/github-cli-account-stderr/plan.md b/docs/plans/github-cli-account-stderr/plan.md new file mode 100644 index 0000000000..b79bcb2f4a --- /dev/null +++ b/docs/plans/github-cli-account-stderr/plan.md @@ -0,0 +1,61 @@ +--- +spec: docs/specs/integrations/github-authentication.md +created: 2026-08-07 +status: done +--- + +# Implementation Plan: GitHub CLI account stderr fallback + +## Overview + +Update the host `gh` account command runner so a successful command with an empty +stdout can return its stderr report to the existing legacy account parser. Keep +non-empty stdout authoritative, preserving JSON account discovery and token +resolution behavior. Add a regression test at the real subprocess boundary. + +## Backend + +### Successful command output selection + +- `apps/backend/internal/github/gh_accounts.go`, `systemGHAccountRunner.Run`: on + a successful `gh` command, return stdout when it contains non-whitespace; if + stdout is empty, return stderr. Preserve existing cancellation, timeout, and + non-zero exit handling. + +## Tests + +- **What:** A legacy `gh auth status` command that exits successfully and emits + its report only on stderr is parsed into the expected `GHAccount`. + **File:** `apps/backend/internal/github/gh_accounts_test.go`. + **How:** Use a temporary executable named `gh` on `PATH`, make structured + status fail with an unsupported-flag exit, emit legacy status on stderr, and + call `listGHAccounts` with `systemGHAccountRunner`. +- **What:** Existing JSON, legacy stdout, token, and environment-sanitization + behaviors remain green. **File:** existing tests in + `apps/backend/internal/github/gh_accounts_test.go`. **How:** run the focused + package test selection and the package's full test suite. + +## Verification Results + +- RED: `rtk go test ./internal/github -run + TestSystemGHAccountRunnerUsesStderrWhenStdoutEmpty -count=1` failed with + `unsupported gh auth status output` before the production change. +- GREEN: the same regression test passed after the fallback was added. +- Focused: `rtk go test ./internal/github -run + 'Test(ListGHAccounts|ResolveGHAccountToken|GitHubCLIEnvironment|SystemGHAccountRunner)' -count=1` + passed 12 tests. +- Full package: `rtk go test ./internal/github -count=1` passed 1,092 tests. +- `rtk git diff --check` passed. + +## Implementation Waves And Parallel Candidates + +Wave 1 (sequential): + +- [x] [task-01-gh-account-stderr](task-01-gh-account-stderr.md) + +No parallel-safe tasks: the production runner and its regression test share one +behavioral boundary. + +## Open Questions + +None. diff --git a/docs/plans/github-cli-account-stderr/task-01-gh-account-stderr.md b/docs/plans/github-cli-account-stderr/task-01-gh-account-stderr.md new file mode 100644 index 0000000000..21881d2cc8 --- /dev/null +++ b/docs/plans/github-cli-account-stderr/task-01-gh-account-stderr.md @@ -0,0 +1,72 @@ +--- +id: "01-gh-account-stderr" +title: "Handle gh account status on stderr" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/integrations/github-authentication.md" +--- + +# Task 01: Handle `gh` account status on stderr + +## Acceptance + +- A successful `systemGHAccountRunner.Run` returns non-empty stdout unchanged, + and returns stderr when stdout is empty. +- `listGHAccounts` discovers a legacy account when the real `gh` command emits + its successful status report only on stderr. +- Existing timeout, cancellation, non-zero exit, JSON, legacy stdout, and token + behaviors remain unchanged. + +## Verification + +- `cd apps/backend && go test ./internal/github -run 'Test(ListGHAccounts|ResolveGHAccountToken|GitHubCLIEnvironment|SystemGHAccountRunner)' -count=1` +- `cd apps/backend && go test ./internal/github -count=1` + +## Files likely touched + +- `apps/backend/internal/github/gh_accounts.go` +- `apps/backend/internal/github/gh_accounts_test.go` +- `docs/specs/integrations/github-authentication.md` +- `docs/plans/github-cli-account-stderr/plan.md` +- `docs/plans/github-cli-account-stderr/task-01-gh-account-stderr.md` + +## Dependencies + +None. + +## Parallelism + +Sequential. The production runner and regression test share the same command +output contract. + +## Inputs + +- The amended named-CLI behavior and scenario in + `docs/specs/integrations/github-authentication.md`. +- The confirmed root cause in `systemGHAccountRunner.Run` and the existing + stderr-first pattern in `GHClient.RunAuthDiagnostics`. +- Issue `kdlbs/kandev#2348`. + +## Output contract + +Report the files changed, exact test commands and outcomes, any remaining risk, +and synchronized task/plan statuses in the primary session. + +## Results + +- RED: `rtk go test ./internal/github -run + TestSystemGHAccountRunnerUsesStderrWhenStdoutEmpty -count=1` failed as + expected with `unsupported gh auth status output` before the production + change. +- GREEN: the same regression test passed after the fallback was added. +- Focused task check: `rtk go test ./internal/github -run + 'Test(ListGHAccounts|ResolveGHAccountToken|GitHubCLIEnvironment|SystemGHAccountRunner)' -count=1` + passed 12 tests. +- Full task check: `rtk go test ./internal/github -count=1` passed 1,092 tests. +- `rtk git diff --check` passed. +- The helper executable is copied into `t.TempDir()` and removed by Go test + cleanup; no temporary files or processes remain. +- Security/trust boundary: the runner still strips ambient `GH_TOKEN` and + `GITHUB_TOKEN`; the fallback only selects command output after a zero exit. diff --git a/docs/specs/integrations/github-authentication.md b/docs/specs/integrations/github-authentication.md index 026ce81b09..3a4ca6ead7 100644 --- a/docs/specs/integrations/github-authentication.md +++ b/docs/specs/integrations/github-authentication.md @@ -1,7 +1,7 @@ --- status: building created: 2026-07-19 -amended: 2026-08-02 +amended: 2026-08-07 owner: Kandev --- @@ -40,6 +40,8 @@ automation under different GitHub Apps without operating separate Kandev deploym - PAT and named CLI automation act as the verified human account. A separate `My GitHub` connection is only offered when workspace automation uses a GitHub App, because App installations are not people and cannot provide authenticated-viewer semantics. +- Named CLI account discovery accepts a successful `gh auth status` report from either stdout or + stderr, while preserving non-empty stdout as the authoritative command result. - User-triggered mutations prefer the workspace's verified personal connection, then a human PAT/CLI automation connection, then the App installation. The UI always identifies the effective actor and never labels an App mutation as human-attributed. @@ -684,8 +686,8 @@ registration and never creates a global default. views the running session, **THEN** the Changes disclosure still shows its launch snapshot; a successful resume records and shows the newly resolved contract. - **GIVEN** an authenticated host GitHub CLI without structured status or named-token flags, - **WHEN** an operator selects its sole account, **THEN** Kandev discovers and validates that - account without requiring a CLI upgrade. + **WHEN** an operator selects its sole account and `gh auth status` reports successfully on + stderr, **THEN** Kandev discovers and validates that account without requiring a CLI upgrade. - **GIVEN** a valid migrated `legacy_shared` connection and a legacy shared managed checkout, **WHEN** a task needs a workspace-isolated checkout, **THEN** Kandev resolves the same automation identity's Git credential and clones into that workspace's managed root without persisting or