Skip to content

Apply auth-expiry hook handling to the non-Claude agent hooks#260

Merged
alexeyzimarev merged 2 commits into
mainfrom
worktree-issue-184-nonclaude-auth-hooks
Jul 5, 2026
Merged

Apply auth-expiry hook handling to the non-Claude agent hooks#260
alexeyzimarev merged 2 commits into
mainfrom
worktree-issue-184-nonclaude-auth-hooks

Conversation

@realtonyyoung

Copy link
Copy Markdown
Contributor

What & why

Follow-up to #183. That PR taught the Claude hook to skip the doomed POST and stop nagging when auth has lapsed (expired refresh credential, or never logged in). The other 7 agent hooks all still built their client with CreateAuthenticatedClientAsync and POSTed blindly, so an expired/absent token meant (a) a guaranteed-to-401 POST, and (b) a misleading per-turn [kcap] <agent>-hook <endpoint>: HTTP 401 stderr line — for the fail-open agents it also drained the Cursor spool into 401→Drop, discarding the backlog.

How

  • New AgentHookPoster shared helper — builds the client via CreateClientWithAuthStatusAsync and returns HookPostOutcome { Posted, AuthLapsed, Failed }. On AuthLapsed it skips the POST entirely (no request, no stderr); Failed keeps the prior stderr line + exit code; Posted is unchanged. Single tested seam for the "skip the doomed POST" decision.
  • Codex, Gemini, Copilot, Pi, Kiro, OpenCodePostHookAsync delegates to the helper. Session-start handlers skip the watcher and exit cleanly on AuthLapsed (would only 401 too); Codex still emits its required {"continue":true} stdout. Failed/Posted behaviour is unchanged (real failures keep their exit codes; Kiro/OpenCode keep their fail-open 0).
  • Gemini/Copilot per-turn Notification and Cursor's HandleInternal use AgentHookPoster.IsAuthLapsed to skip. Cursor additionally skips the spool drain on a lapse, so a 401 can't Drop the backlog — it replays after kcap login.
  • No re-login nudge: none of these agents has a safe user-facing stdout channel (all emit nothing or a JSON decision/context channel), so per the issue's caveat we soft-exit silently; kcap status already reports ✗ token expired (run: kcap login).

Acceptance criteria

  • No agent hook sends a POST it knows will 401 due to expired/missing auth.
  • No agent hook produces a misleading per-turn HTTP 401 stderr line (or error banner) solely because auth lapsed.
  • Re-login nudge surfaced only where a real user-facing channel exists — N/A for these agents (documented), so silent soft-exit.
  • No-op for the None auth provider and unchanged when authenticated.
  • dotnet publish -c Release shows no IL3050/IL2026 warnings; unit tests pass.

Testing

  • New AgentHookPosterTests (WireMock + injected (client, status) factory): auth-lapse skips the POST (server sees 0 requests), Ok/NoAuthRequired post the body, server error → Failed.
  • Full unit suite green (2180 tests). AOT publish clean (no IL30xx/20xx) for the CLI.

Known follow-up (out of scope, pre-existing)

On session-end during a lapse, the pre-POST drain path (InlineDrainAsync, GeminiSubagentTeardown.DrainAsync, SpawnCopilotFinalizeDrain) still uses CreateAuthenticatedClientAsync internally, so it emits the "expired" stderr line and fires a doomed drain POST. This lives in the watcher/drain layer (not the hooks), is unchanged by this PR, and fires once per session (not per-turn). Worth gating on auth status in a follow-up.

No README/CLI-surface change: internal hook behaviour only (no new command, flag, default, or prerequisite).

Closes #184
Linear: AI-993

Follow-up to #183, which taught the Claude hook to skip the doomed POST and stop nagging when
auth has lapsed. The other agent hooks all still built their client with
CreateAuthenticatedClientAsync and POSTed blindly, so an expired/absent token meant a
guaranteed-to-401 POST plus a misleading per-turn "[kcap] <agent>-hook ...: HTTP 401" stderr line.

- New AgentHookPoster shared helper: builds the client via CreateClientWithAuthStatusAsync and
  returns HookPostOutcome { Posted, AuthLapsed, Failed }. On AuthLapsed it skips the POST (no
  request, no stderr); Failed keeps the prior stderr line + exit code; Posted is unchanged.
- Codex, Gemini, Copilot, Pi, Kiro, OpenCode: PostHookAsync delegates to the helper. Session-start
  handlers skip the watcher and exit cleanly on AuthLapsed (Codex still emits its required
  {"continue":true} stdout); Failed/Posted behaviour is unchanged.
- Gemini/Copilot per-turn Notification paths and Cursor's HandleInternal use IsAuthLapsed to skip
  (Cursor also skips the spool drain, so a 401 can't Drop the backlog — it replays after re-login).
- No user-facing re-login nudge: none of these agents has a safe stdout notice channel (all emit
  nothing or a JSON decision/context channel), so per the issue we soft-exit silently; the expired
  state is surfaced by `kcap status`.

No-op for the None provider; unchanged when authenticated. Adds AgentHookPosterTests.

Closes #184
Linear: AI-993

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Skip non-Claude agent hook POSTs when auth has lapsed

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Introduce shared auth-aware hook POST helper that skips requests on expired/missing auth.
• Update non-Claude agent hooks to exit cleanly on auth lapse and avoid misleading 401 stderr.
• Prevent Cursor spool draining during auth lapse so backlog is replayed after re-login.
Diagram

graph TD
Hooks["Non-Claude hooks"] --> Poster["AgentHookPoster"] --> Http["HttpClientExtensions"] --> Api{{"/hooks API"}}
Http --> Token["Token store"]
Cursor["Cursor hook"] --> Http
Hooks --> Watcher["Watcher/spool work"]
Cursor --> Watcher
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Fold skip/quiet behavior into CreateAuthenticatedClientAsync
  • ➕ Fewer call-site edits (reuse existing API everywhere)
  • ➕ Single place to control stderr output and exit behavior
  • ➖ Conflates interactive CLI needs (actionable stderr) with hook needs (silence)
  • ➖ Harder to express nuanced outcomes (Posted vs AuthLapsed vs Failed) cleanly
2. Use an HttpClient DelegatingHandler for auth-lapse short-circuiting
  • ➕ Centralizes decision-making at transport layer
  • ➕ Could be reused across other HTTP call sites (watcher/drain follow-up)
  • ➖ More indirection; outcome signaling (AuthLapsed) still needed for callers
  • ➖ Harder to unit test without over-mocking HttpClient pipeline

Recommendation: The PR’s approach is the best fit: AgentHookPoster provides an explicit, testable outcome contract (Posted/AuthLapsed/Failed) that lets hooks skip POST-dependent work without emitting misleading per-turn stderr. It also preserves existing failure semantics (exit codes and fail-open behavior) while avoiding mixing hook-specific UX rules into the general authenticated client helper.

Files changed (9) +287 / -137

Bug fix (7) +93 / -137
CodexHookCommand.csDelegate Codex hook POSTs to AgentHookPoster and skip watcher on lapse +11/-19

Delegate Codex hook POSTs to AgentHookPoster and skip watcher on lapse

• Replaces inline POST logic with AgentHookPoster and switches to outcome-based handling. On AuthLapsed, prints Codex-required {"continue":true} then exits cleanly without spawning the watcher; on Failed, returns non-zero as before.

src/Capacitor.Cli/Commands/CodexHookCommand.cs

CopilotHookCommand.csApply auth-lapse skipping to Copilot session hooks and notifications +20/-24

Apply auth-lapse skipping to Copilot session hooks and notifications

• Uses AgentHookPoster for session start/end recording and exits cleanly on AuthLapsed while keeping prior non-zero behavior on real failures. Updates the per-turn notification path to use CreateClientWithAuthStatusAsync and skip the POST silently when auth is lapsed.

src/Capacitor.Cli/Commands/CopilotHookCommand.cs

CursorHookCommand.csAvoid Cursor spool drain and POSTs when auth is lapsed +10/-1

Avoid Cursor spool drain and POSTs when auth is lapsed

• Switches to CreateClientWithAuthStatusAsync and returns early on auth lapse to prevent both doomed POSTs and draining the spool into 401→Drop behavior. Ensures backlog remains intact for replay after re-login.

src/Capacitor.Cli/Commands/CursorHookCommand.cs

GeminiHookCommand.csApply auth-lapse skipping to Gemini session hooks and notifications +20/-24

Apply auth-lapse skipping to Gemini session hooks and notifications

• Uses AgentHookPoster for session start/end recording and skips watcher startup on AuthLapsed while preserving failure exits for real errors. Updates notification posting to consult auth status and skip silently during lapse to avoid per-turn stderr.

src/Capacitor.Cli/Commands/GeminiHookCommand.cs

KiroHookCommand.csUse shared hook poster while preserving Kiro fail-open semantics +10/-24

Use shared hook poster while preserving Kiro fail-open semantics

• Replaces inline POST with AgentHookPoster and treats both AuthLapsed and Failed as fail-open outcomes (exit 0 and skip watcher) while keeping stderr logging for real failures. Removes now-unneeded System.Text import due to centralized StringContent construction.

src/Capacitor.Cli/Commands/KiroHookCommand.cs

OpenCodeHookCommand.csUse shared hook poster while preserving OpenCode fail-open semantics +10/-23

Use shared hook poster while preserving OpenCode fail-open semantics

• Replaces inline POST with AgentHookPoster and continues to fail-open (exit 0) when posting fails or auth has lapsed, skipping watcher startup in those cases. Removes now-unneeded System.Text import.

src/Capacitor.Cli/Commands/OpenCodeHookCommand.cs

PiHookCommand.csDelegate Pi hook POSTs to AgentHookPoster and skip watcher on lapse +12/-22

Delegate Pi hook POSTs to AgentHookPoster and skip watcher on lapse

• Switches session start/end POST logic to use AgentHookPoster outcomes. Exits cleanly on AuthLapsed without spawning the watcher, while keeping prior non-zero exits for real failures; removes unused System.Text import.

src/Capacitor.Cli/Commands/PiHookCommand.cs

Refactor (1) +90 / -0
AgentHookPoster.csAdd shared hook POST helper with AuthLapsed short-circuit +90/-0

Add shared hook POST helper with AuthLapsed short-circuit

• Introduces HookPostOutcome and AgentHookPoster to build an auth-aware HttpClient and conditionally skip hook POSTs when auth is expired or missing. Preserves existing stderr/exit behavior for real failures while avoiding per-turn 401 noise on auth lapse.

src/Capacitor.Cli/Commands/AgentHookPoster.cs

Tests (1) +104 / -0
AgentHookPosterTests.csAdd unit tests for auth-lapse POST skipping behavior +104/-0

Add unit tests for auth-lapse POST skipping behavior

• Adds WireMock-backed tests verifying that Expired/NotAuthenticated auth statuses skip sending any POST and return AuthLapsed. Covers successful posting for Ok/NoAuthRequired and verifies Failed outcome on server error, plus unit coverage for IsAuthLapsed.

test/Capacitor.Cli.Tests.Unit/AgentHookPosterTests.cs

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Context used

Grey Divider


Remediation recommended

1. Response not disposed 🐞 Bug ☼ Reliability
Description
AgentHookPoster.PostAsync does not dispose the HttpResponseMessage returned by PostWithRetryAsync,
which can retain response streams/connections longer than necessary and increase resource pressure
in frequently-invoked hooks. Other parts of the codebase dispose PostWithRetryAsync responses
explicitly, so this is an inconsistency introduced by the new helper.
Code

src/Capacitor.Cli/Commands/AgentHookPoster.cs[R75-84]

+            try {
+                var resp = await client.PostWithRetryAsync($"{baseUrl}/hooks/{endpoint}", content);
+
+                if (!resp.IsSuccessStatusCode) {
+                    Console.Error.WriteLine($"[kcap] {agentTag} {endpoint}: HTTP {(int)resp.StatusCode}");
+                    return HookPostOutcome.Failed;
+                }
+
+                return HookPostOutcome.Posted;
+            } catch (HttpRequestException ex) {
Evidence
The helper currently stores the response in a variable and checks status without disposing it, while
other call sites in the repo wrap PostWithRetryAsync responses in using var to ensure cleanup.

src/Capacitor.Cli/Commands/AgentHookPoster.cs[73-84]
src/Capacitor.Cli.Core/Eval/EvalService.cs[663-673]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`AgentHookPoster.PostAsync` awaits `client.PostWithRetryAsync(...)` into a local `resp` variable but never disposes it. This can delay releasing response resources and connections.

### Issue Context
This helper is used by multiple agent hook commands and may run frequently (per-turn/session events), so explicit disposal is important for predictable resource cleanup.

### Fix Focus Areas
- Update the helper to dispose the response:
 - `src/Capacitor.Cli/Commands/AgentHookPoster.cs[73-84]`

### Suggested change
Use `using var resp = await client.PostWithRetryAsync(...);` (or `await using` if you switch to an async-disposable response pattern) so the response is disposed on both success and failure paths.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/Capacitor.Cli/Commands/AgentHookPoster.cs
@realtonyyoung

Copy link
Copy Markdown
Contributor Author

Independent review (Codex)

No actionable findings — the PR looks sound against the stated auth-lapse contract. Key checks:

  • AgentHookPoster.PostAsync disposes the client on every path; Expired/NotAuthenticated return AuthLapsed before content creation / POST / stderr; NoAuthRequired posts normally; HTTP failure preserves the old [kcap] <agent>-hook …: HTTP <code> stderr shape.
  • Codex preserves failed exit 1; on AuthLapsed it writes {"continue":true} before returning 0, and the watcher call is after that return.
  • Gemini/Copilot/Pi only spawn watchers after Posted and preserve failed exit 1; Kiro/OpenCode only spawn after Posted and still fold failures to 0.
  • Cursor HandleInternal returns 0 on lapse before spool migration/reap/drain/core, with client disposal in finally.
  • No other missed hook-dispatch recording POST still on CreateAuthenticatedClientAsync — remaining hits are imports / MCP / interactive commands, or the accepted watcher/drain/finalizer layer (the session-end drain follow-up called out in the PR description).

Coverage gaps (non-blocking, worth a follow-up): AgentHookPosterTests assert no-request-on-lapse, posted body, NoAuthRequired, and server-error→Failed, but not: no-stderr-on-lapse, HttpRequestException → Failed + WriteUnreachableError, client disposal, or the per-hook watcher/stdout/spool caller behavior.

Verification: full TUnit suite 2180/2180. AOT: no IL3050/IL2026 warnings observed before an environmental MSBuild task-host failure in the reviewer's sandbox — the CI AOT publish check job is the source of truth.

PostWithRetryAsync's HttpResponseMessage was stored in a local and never disposed; wrap it in `using var` so response streams/connections are released on every path (these hooks run per turn/session). Matches the disposal pattern used elsewhere for PostWithRetryAsync responses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@alexeyzimarev alexeyzimarev merged commit f704e52 into main Jul 5, 2026
5 checks passed
@alexeyzimarev alexeyzimarev deleted the worktree-issue-184-nonclaude-auth-hooks branch July 5, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Apply auth-expiry hook handling to the non-Claude agent hooks (follow-up to #183)

2 participants