Repository navigation
Conversation
9d71383 to
5213965
Compare
|
cc: @babakks kindly asking for a review as y'all are gearing up for 2.93.0 this is a change that i genuinely need in order to use the gh cli, and i don't want to have to maintain a long-term fork of |
|
Hey @Iron-Ham! 👋 Sorry for the delay on this one. This has been on my list but had to punt it a bit due to other priorities. I'll try to have a look this week and will let you know. Need to deep dive for which your thorough investigation is going to be super helpful. |
|
Totally understandable, just wanted to make sure it hadn't fallen off the list. Hope you and the team have been well! |
|
By the way, I don't think it'll get into the next release. |
|
@Iron-Ham, @LaurMo said you are having to work off a long lived branch due to this? The impression I got first time I glanced at this was that it was a one off/rare occurrence that was suppressing the error and causing an issue an you had to address. It seemed like this PR didn't actually address the issue, but did make you more aware of the step you needed to take to resolve it. Am I missing something? Would help me prioritise this higher if I understood there was more friction. Thanks. |
|
I left a longer comment here: #13317 (comment) |
When ActiveToken's keyring lookup fails with a non-ErrNotFound error (typically *keyring.TimeoutError on macOS Keychain), the error was silently discarded and AddAuthTokenHeader.RoundTrip then sent the request without an Authorization header. Users saw a confusing HTTP 401 (REST) or HTTP 403 "API rate limit exceeded for <IP>" (GraphQL) with no signal that the local keyring was the cause. Add ActiveTokenWithError to gh.AuthConfig that surfaces these errors. Refactor ActiveToken as a thin wrapper that calls ActiveTokenWithError and discards the error so existing callers keep their signature. Require the new method on api/http_client.go's tokenGetter interface and update RoundTrip to fail the request with the resolution error rather than send unauthenticated. When the user-scoped keyring lookup fails with a real error and the legacy unkeyed fallback then returns ErrNotFound, preserve the original error rather than letting the fallback's "not found" mask it. Otherwise a timeout or permission failure on the canonical lookup would be reported as if no token were configured. ErrNotFound continues to be the legitimate "no token configured for this host" signal and is not surfaced as an error so genuinely anonymous requests against unconfigured hosts keep working. Fixes cli#13317
5213965 to
7e8a674
Compare
…omment (#46) review-craft ruled PR descriptions out of scope on two reasons. The first was sound: a description is a durable record and must not inherit the 150-word review budget. The second was false — it said a description "cannot collapse anything". GitHub renders <details><summary> in PR bodies and in every kind of comment; the probe is dated in house-style.md (cli/cli#13318, whose body_html returns real <details> elements). The false reason did the damage. It ruled out the one mechanism that reconciles complete with short, so "never budget a durable record" stood alone and was read as "length does not matter". By 18 Sep 2026 that had produced eleven agent-authored PRs on Trilogy-Care/tc-portal carrying 9,000-39,000 character descriptions with zero folds — 307,000 characters against a budget of 41,000, in a repo that squash-merges with PR_BODY as the commit message. The worst, #9309, was 17,638 characters for 50 changed lines, one of them application code. A human reviewer flagged it; nothing in the skill would have. What replaces it is not a shorter description. It is a destination: - The description is the record of the decision — why, what changed in shape, what a reviewer should look at, risks, references — budgeted at min(4000, max(800, 12 x changed lines)) RAW characters of the body as stored, excluding the Claude Code attribution footer. Raw, because the squash copies the body verbatim and because that is the unit all three constants were measured in. - The working — the evidence census, the queries and their output, the method, the alternatives rejected — moves whole and verbatim into a PR comment headed "## Working notes", which the description links to in one line. Identified by that heading and never by position: the working moves out late, so it is the newest comment. Nothing is deleted; it moves. The constants are measured, not chosen: slope 12 from the 10.6-character median across 45 recent human-authored tc-portal PRs, ceiling 4,000 from the p89 of 5,557 PR bodies, floor 800 from just above the p25. The file says plainly where the ceiling is tighter than current practice rather than hiding it. issue-craft carried the same false claim inside the paraphrase scar. The scar and its rule stand — budget artefacts consumed in a feed, never budget a durable record — with a dated correction: the scar's real lesson was that the detail had nowhere to go, and a budget with a destination is a move while a budget without one is a paraphrase machine. Adds skills/review-craft/pr_lint.py beside review_lint.py: takes --repo/--pr via gh or a local draft with an explicit --changed-lines (it will not guess a diff size), and checks the body against the budget for that diff, a long body with no headings, unfilled template placeholders, rotting relative-date words, oversized <details> a squash will copy raw, and that a comment headed "## Working notes" exists when the body links to one. Mechanics only; exit 0 pass, 1 errors, 2 could not run. scripts/test-pr-lint.sh covers it in 43 cases. SKILL.md, dispatcher workers implement.md and review.md, and agents/engineer.md now carry the rule and require pr_lint to pass before a PR is called ready. Stack children inherit it through implement.md. Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Fixes #13317.
Summary
gh apiandgh api graphqlwere silently sending requests without anAuthorizationheader whengh's keyring lookup failed — typically when the 3-second timeout ininternal/keyring/keyring.gofired against a macOS Keychain entry whose ACL had drifted (e.g. an entry written by an olderghthat now requires an interactive approval the non-TTY child process can't surface). Users saw onlyHTTP 401(REST) orHTTP 403 "API rate limit exceeded for <IP>"(GraphQL) with no signal that the local keyring was the cause.The bug was a silent error discard at two layers:
(*config.AuthConfig).ActiveTokenswallowed keyring errors fromTokenFromKeyringForUser/TokenFromKeyringand returned("", source).AddAuthTokenHeader.RoundTripthen saw an empty token and proceeded with no header — indistinguishable from a legitimate anonymous request.What changed
ActiveTokenWithError(hostname) (token, source, err)togh.AuthConfigand*config.AuthConfigthat surfaces non-ErrNotFoundkeyring errors.(*AuthConfig).ActiveTokenas a thin wrapper that callsActiveTokenWithErrorand discards the error, preserving its existing signature for every current caller.ActiveTokenWithErroronapi/http_client.go's localtokenGetterinterface; updated thecfgadapter ininternal/authflow/flow.goand thetinyConfigtest mock to match.AddAuthTokenHeader.RoundTripnow callsActiveTokenWithError. On non-nil error it fails the request with that error; on nil error + empty token it proceeds anonymously (preserves anonymous-endpoint behavior); on nil error + non-empty token it attaches theAuthorizationheader as before.keyring.ErrNotFoundcontinues to be the legitimate "no token configured for this host" signal and is not surfaced as an error, so anonymous requests against unconfigured hosts keep working unchanged.What stays the same
gh.AuthConfig.ActiveTokensignature and behavior for every existing caller.tokenGetter(e.g.stubAuthConfiginpkg/cmd/attestation/trustedroot/trustedroot_test.go) —stubAuthConfigembedsconfig.AuthConfigso it inherits the new method automatically.gh api zenagainst a host with no configured user still proceeds without anAuthorizationheader).Alternatives considered
Opt-in type-assertion shim (avoid changing
gh.AuthConfig)Initially this PR added an unexported
errorTokenGetterinterface inapi/http_client.gothatRoundTripwould type-assert against, with*config.AuthConfigopportunistically satisfying it. The advantage was zero churn togh.AuthConfig— no interface change, no mock updates for downstream consumers.Rejected because the new error-aware behavior would only fire by accident of static type propagation, the contract would be untestable from the interface seam (any future code constructing
HTTPClientOptionsfrom agh.AuthConfigvalue rather than the concrete type would silently get the legacy path), and an opt-in shim is brittle for a fix to a silent-failure bug. AddingActiveTokenWithErrorto the interface makes the contract explicit and verifiable.Naming:
ActiveTokenEvs.ActiveTokenWithErrorvs.ResolveActiveTokenActiveTokenE(single-letterEsuffix) is concise and used in some Go projects, but has no precedent in cli/cli — would be inventing a convention.ResolveActiveTokendiscriminates well fromActiveToken, but pairs awkwardly with the existing method-on-noun naming throughoutgh.AuthConfig(ActiveUser,TokenFromKeyring, etc.).Chose
ActiveTokenWithErrorbecause it composes naturally withActiveToken(clear sibling relationship), is self-documenting in error-checking call sites, and matches the existing explicit-suffix precedent in this codebase (e.g.keyring.MockInitWithErrorininternal/keyring/keyring.go).Logging from inside
ActiveToken(surface error without signature change)A smaller-footprint alternative: keep
ActiveTokenunchanged but write a warning toos.Stderrwhen both keyring lookups fail with non-ErrNotFounderrors. The user would see a "warning: keyring access failed" line followed by the downstream 401/403, which is at least diagnosable.Rejected because (1) logging from a config-layer getter is the wrong architectural seam — it doesn't compose with structured loggers or with cli/cli's
--verboseplumbing, (2) the request still goes out unauthenticated, so scripts and CI consumers still get misleading exit codes, and (3) it leaves the underlying contract bug in place for every future caller. Returning the error is the right fix; logging would be a band-aid.Marking
ActiveTokenas// Deprecated:in this PRCould be added as a one-line annotation on the existing method to steer new code toward
ActiveTokenWithError.Deferred to a follow-up PR because adding the marker now would generate
SA1019warnings on every existing call site (pkg/cmd/auth/status/status.go:238,pkg/cmd/auth/refresh/refresh.go:178,229,pkg/cmd/auth/token/token.go:81,pkg/cmd/auth/shared/writeable.go:10,pkg/cmd/config/get/get.go:58, plus internal usages) without giving them a migration path in the same PR. Better to land this fix first, then a follow-up that migrates the call sites and adds the marker together. Happy to do both in one PR if maintainers prefer.Follow-ups (not in this PR)
Other call sites of
ActiveTokenwould benefit from migrating toActiveTokenWithErrorso the same class of silent failure can't recur in those code paths. Left for a follow-up to keep this PR focused:pkg/cmd/auth/status/status.go:238—gh auth statuscan mis-report keyring failures as "no token"pkg/cmd/auth/refresh/refresh.go:178,229pkg/cmd/auth/token/token.go:81pkg/cmd/auth/shared/writeable.go:10pkg/cmd/config/get/get.go:58Adding
// Deprecated:toActiveTokenis also deferred to that follow-up — adding it now would generateSA1019warnings on every existing call site without giving users a migration path.Test plan
go vet ./...clean.go test ./... -shortpasses (full suite).internal/config/auth_config_test.go:TestActiveTokenWithErrorSurfacesKeyringFailure— keyring error is wrapped and returned.TestActiveTokenSilentlyDiscardsKeyringFailure—ActiveToken(the wrapper) preserves its no-error contract.TestActiveTokenWithErrorDoesNotSurfaceErrNotFound—ErrNotFoundreturns(token="", err=nil).TestActiveTokenWithErrorReturnsEnvTokenWithoutKeyring— env-var path short-circuits keyring entirely.TestActiveTokenWithErrorReturnsKeyringToken— successful keyring resolution returns(token, "keyring", nil).TestAddAuthTokenHeaderResolutionPathsinapi/http_client_test.gocovering: error fails the request, resolved token is attached, empty token without error proceeds anonymously.gh api userreturnsHTTP/2 200, x-ratelimit-limit: 5000, resource: core; same for GraphQL).