Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,10 @@ devagent run --ticket LINEAR-204 --worker claude-code # or opencode | both

### Environment variables (credentials only — FR-OPS-02)

`LINEAR_API_KEY`, `GITHUB_TOKEN` (scoped to contents:write + pull-requests:write on target repos), `DEVAGENT_HOME` (run state/logs).
`LINEAR_API_KEY`, `GITHUB_TOKEN` (scoped to contents:write + pull-requests:write
on target repos), `DEVAGENT_HOME` (run state/logs). `GITHUB_TOKEN` env wins; the
`gh auth token` keyring fallback (issue #234) resolves with a 5s timeout and
tests stub the resolver seam rather than exec gh (#262).

## 13. Integrations

Expand Down Expand Up @@ -1469,11 +1472,10 @@ Master tracker with definition of done: [#207](https://github.com/FreePeak/devag

---

*Last updated: 2026-09-09 (TUI conventions-research polish wave landed: NO_COLOR/TERM=dumb
mono degradation, PAUSED aggregate + hero attention banner, `/` log search with n/N match
walking, selection-following viewports with hidden-count indicators, OSC-2 title, grouped
help, IUTF8, live version in the upgrade overlay — commit f3d21f9, research in
docs/research/tui-conventions.md. Supersedes the 09-09 all-four-open-issues stamp: #248
worker observability (WatchdogSink default-wired, omp NDJSON events, stream metrics,
productive-wall-kill classification, PR #266); #206 scoreboard closed; #181 desktop app
v1 (PR #267); #146 TUI polish FR-TUI-P-01..12 (PR #268).)*
*Last updated: 2026-09-09 (#262) — test hardening for the `gh auth token`
credential fallback: `ghAuthToken` is now an injectable seam
(`ghAuthTokenExec` keeps the real 5s-timeout exec; no production behavior
change), trimming moved into `resolveGithubToken`, and the previously
PATH-shim-based token tests stub the seam so the 5s exec timeout can no longer
flake under machine load; real-exec coverage retained for gh-absent and
non-zero-exit paths.*
11 changes: 7 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,20 +662,23 @@ func resolveGithubToken() string {
return tok
}
githubTokenOnce.Do(func() {
githubTokenOnce.value = ghAuthToken()
githubTokenOnce.value = strings.TrimSpace(ghAuthToken())
})
return githubTokenOnce.value
}

func ghAuthToken() string {
// ghAuthToken is the seam tests substitute to avoid a real exec; it defaults
// to the real `gh auth token` invocation (issue #262).
var ghAuthToken = ghAuthTokenExec

func ghAuthTokenExec() string {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "gh", "auth", "token").Output()
if err != nil {
return ""
}
tok := strings.TrimSpace(string(out))
return tok
return string(out)
}

// CredentialStatus reports which credentials are present without ever
Expand Down
51 changes: 29 additions & 22 deletions internal/config/github_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ func installFakeGh(t *testing.T, dir, token string, exit int) string {
return counter
}

// stubGhAuthToken replaces the gh resolver seam with a function returning
// token (or empty) and counting invocations in *int. Restores the real
// implementation on cleanup and resets the process-level cache (issue #262:
// no real exec under test, so the 5s timeout cannot flake under load).
func stubGhAuthToken(t *testing.T, token string, calls *int) {
t.Helper()
old := ghAuthToken
ghAuthToken = func() string {
*calls++
return token
}
t.Cleanup(func() { ghAuthToken = old })
resetGithubTokenCacheForTest()
}

// unsetGithubTokenForTest makes GITHUB_TOKEN genuinely absent (not just
// empty) for the test and restores the original state afterwards.
func unsetGithubTokenForTest(t *testing.T) {
Expand All @@ -45,26 +60,22 @@ func unsetGithubTokenForTest(t *testing.T) {
}

func TestResolveGithubTokenEnvWins(t *testing.T) {
dir := t.TempDir()
counter := installFakeGh(t, dir, "gh-must-not-run", 0)
t.Setenv("PATH", dir)
calls := 0
stubGhAuthToken(t, "gh-must-not-run", &calls)
t.Setenv("GITHUB_TOKEN", "env-token")
resetGithubTokenCacheForTest()

if got := LoadCredentials().GithubToken; got != "env-token" {
t.Fatalf("GithubToken = %q, want env value", got)
}
if _, err := os.Stat(counter); !os.IsNotExist(err) {
t.Fatalf("gh was invoked despite env GITHUB_TOKEN (counter: %v)", err)
if calls != 0 {
t.Fatalf("gh resolver invoked %d times despite env GITHUB_TOKEN, want 0", calls)
}
}

func TestResolveGithubTokenGhFallback(t *testing.T) {
dir := t.TempDir()
installFakeGh(t, dir, " gh-keyring-token ", 0) // padded: resolution must trim
t.Setenv("PATH", dir)
calls := 0
stubGhAuthToken(t, " gh-keyring-token ", &calls) // padded: resolution must trim
unsetGithubTokenForTest(t)
resetGithubTokenCacheForTest()

if got := LoadCredentials().GithubToken; got != "gh-keyring-token" {
t.Fatalf("GithubToken = %q, want trimmed gh output", got)
Expand Down Expand Up @@ -94,40 +105,36 @@ func TestResolveGithubTokenGhFailure(t *testing.T) {
}

func TestResolveGithubTokenEmptyEnvFallsThrough(t *testing.T) {
dir := t.TempDir()
installFakeGh(t, dir, "gh-empty-env", 0)
t.Setenv("PATH", dir)
calls := 0
stubGhAuthToken(t, "gh-empty-env", &calls)
t.Setenv("GITHUB_TOKEN", "") // empty counts as unset
resetGithubTokenCacheForTest()

if got := LoadCredentials().GithubToken; got != "gh-empty-env" {
t.Fatalf("GithubToken = %q, want gh fallback for empty env", got)
}
}

func TestResolveGithubTokenCachedPerProcess(t *testing.T) {
dir := t.TempDir()
counter := installFakeGh(t, dir, "gh-cached", 0)
t.Setenv("PATH", dir)
calls := 0
stubGhAuthToken(t, "gh-cached", &calls)
unsetGithubTokenForTest(t)
resetGithubTokenCacheForTest()

if got := LoadCredentials().GithubToken; got != "gh-cached" {
t.Fatalf("first call = %q, want gh token", got)
}
if got := LoadCredentials().GithubToken; got != "gh-cached" {
t.Fatalf("second call = %q, want cached gh token", got)
}
if data, err := os.ReadFile(counter); err != nil || string(data) != "x" {
t.Fatalf("gh invocation count wrong: data=%q err=%v, want exactly one exec", data, err)
if calls != 1 {
t.Fatalf("gh resolver invoked %d times, want exactly one exec", calls)
}

// A later-set env GITHUB_TOKEN still wins over the primed cache.
t.Setenv("GITHUB_TOKEN", "later-env")
if got := LoadCredentials().GithubToken; got != "later-env" {
t.Fatalf("GithubToken = %q, want later env value over cache", got)
}
if data, err := os.ReadFile(counter); err != nil || string(data) != "x" {
t.Fatalf("env-wins check re-executed gh: data=%q err=%v", data, err)
if calls != 1 {
t.Fatalf("env-wins check re-executed gh: %d calls", calls)
}
}
Loading