Skip to content

[AI-2696] Let the app-server runtime answer approvals in an envelope-sourced session - #877

Open
realtonyyoung wants to merge 5 commits into
mainfrom
tonyyoung/ai-2696-approval-timeout
Open

[AI-2696] Let the app-server runtime answer approvals in an envelope-sourced session#877
realtonyyoung wants to merge 5 commits into
mainfrom
tonyyoung/ai-2696-approval-timeout

Conversation

@realtonyyoung

Copy link
Copy Markdown
Collaborator

No GitHub issue — AI-2696

What & why

In a hosted app-server Codex session, an approval never failed closed: Codex runs the user's PermissionRequest hook before it asks its client, and that hook bounced the request through the daemon's LocalPermissionBridge — the Claude channel, deliberately built with no deadline — so the runtime's requestApproval (and CodexApprovalBridge's 45 s decline) never happened. The hook now yields in an envelope-sourced session, keyed on the same KCAP_HOSTED_APPSERVER marker that already suppresses its transcript watcher there. Separately, the server keeps an interaction open until something answers it and nothing told it when the daemon stopped waiting, so a timed-out request stayed a pending card for the rest of the session; the cancelled wait now resolves the entry as cancel before the cancellation propagates.

Where to look

The hook change is one guard at the top of HandlePermissionRequest; everything hangs on the marker being set only for envelope-sourced launches (CodexHostedAgentRuntimeFactory.BuildEnv). ResolveAbandonedInteractionAsync reuses the existing RespondToPermission hub call — the hub already routes an ACP request id to acpTracker.TryComplete — with the canonical 32-hex thread id, because the hub does not normalise that argument.

Verification

  • Live (v1.0.1, daemon with KCAP_ACP_DEBUG_FRAMES=1, approval timeout 15 s): a full approval cycle showed no requestApproval JSON-RPC frame at all — only hook/started frames and LocalPermissionBridge → RequestPermissionAsync → PendingPermissionRegistry stacks; Codex's rollout shows the exec cell blocked until the dashboard answered (:2 ran 80 ms after a late allow, 150 s in).
  • CodexHookCommandTests 32/32 incl. the new yield test; mutant (marker never matches) fails it plus the two guard-1 tests that share the check. ServerConnectionAbandonedInteractionTests 1/1; mutant (resolve never reaches the server) fails with a 5 s timeout. CodexApprovalTimeoutChainTests pins that bridge + real registry + real retry decline at the deadline (they did; the bridge was simply never asked).

…ssion

Codex runs the user's PermissionRequest hook before it asks its client, and
in a hosted session that hook bounced the approval through the daemon bridge
— a channel built for Claude with no deadline — so the runtime's requestApproval,
and the fail-closed decline it carries, never happened.

The hook now yields whenever the session is envelope-sourced, keyed on the
same marker that already suppresses its transcript watcher there.
The server holds an interaction open until something answers it; nothing told
it when this side gave up, so a timed-out approval stayed a pending card for the
rest of the session. The cancelled wait now resolves the entry best-effort before
the cancellation propagates.
@linear-code

linear-code Bot commented Sep 11, 2026

Copy link
Copy Markdown

AI-2696

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Route hosted Codex approvals through the app-server runtime

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Route envelope-sourced Codex approvals through the deadline-bound app-server runtime.
• Cancel abandoned server interactions when daemon-side approval waits end.
• Cover hook routing, timeout decline, and server cleanup with focused unit tests.
Diagram

sequenceDiagram
    participant C as Codex
    participant H as Permission Hook
    participant B as Approval Bridge
    participant D as Server Connection
    participant S as App Server
    actor U as Dashboard User
    C->>H: PermissionRequest
    H-->>C: Yield empty decision
    C->>B: requestApproval
    B->>D: Request interaction
    D->>S: Open interaction
    S->>U: Show approval
    alt User answers
        U->>S: Submit decision
        S-->>D: Decision push
        D-->>B: Decision
        B-->>C: Approval response
    else Approval timeout
        B--xD: Cancel wait
        D->>S: Resolve cancel
        B-->>C: Decline
    end
Loading
High-Level Assessment

The current approach is appropriate: it reuses the launch marker that already identifies envelope-sourced sessions, preserves one authoritative approval path, and uses the existing permission-response hub contract for cleanup. Adding a deadline to LocalPermissionBridge or introducing a second server cancellation API would retain competing responders or duplicate established protocol behavior.

Files changed (5) +163 / -13

Bug fix (2) +54 / -13
ServerConnection.csResolve abandoned ACP interactions after local cancellation +30/-1

Resolve abandoned ACP interactions after local cancellation

• Routes interaction waits through a cancellation-aware helper. When a wait ends, it canonicalizes the session ID and best-effort records a 'cancel' response on the server before propagating cancellation.

src/Capacitor.Cli.Daemon/Services/ServerConnection.cs

CodexHookCommand.csYield hosted app-server approvals back to Codex +24/-12

Yield hosted app-server approvals back to Codex

• Skips the deadline-free daemon permission bridge when 'KCAP_HOSTED_APPSERVER' identifies an envelope-sourced session. Extracts the empty decision response into a shared helper so Codex can continue its native approval flow.

src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs

Tests (3) +109 / -0
CodexApprovalTimeoutChainTests.csVerify unanswered approvals decline through the real timeout chain +36/-0

Verify unanswered approvals decline through the real timeout chain

• Composes the approval bridge with the real retry helper and pending-interaction registry. Confirms an unanswered request becomes a decline at the bridge deadline.

test/Capacitor.Cli.Daemon.Tests.Unit/Harness/Codex/CodexApprovalTimeoutChainTests.cs

ServerConnectionAbandonedInteractionTests.csVerify cancelled waits resolve server interactions +46/-0

Verify cancelled waits resolve server interactions

• Confirms cancellation propagates while asynchronously sending one 'cancel' response. Also verifies UUID session IDs are converted to the server's canonical 32-hex format.

test/Capacitor.Cli.Daemon.Tests.Unit/Services/ServerConnectionAbandonedInteractionTests.cs

CodexHookCommandTests.csVerify envelope-sourced permission hooks bypass the daemon bridge +27/-0

Verify envelope-sourced permission hooks bypass the daemon bridge

• Sets the hosted app-server marker and asserts the hook emits an empty decision successfully without posting to the configured loopback bridge.

test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs

@qodo-code-review

qodo-code-review Bot commented Sep 11, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Shutdown leaves approval cards pending ✓ Resolved 🐞 Bug ☼ Reliability
Description
AwaitInteractionDecisionAsync starts abandoned-interaction cleanup, but RespondToPermissionAsync
always sends that cleanup using the server connection's application-stopping token. During daemon
shutdown that token is cancelled before runtime disposal cancels the pending ACP wait, so the hub
invocation cannot resolve the server interaction and the pending entry can remain open.
Code

src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[1127]

+            _ = ResolveAbandonedInteractionAsync(request, requestId);
Relevance

●●● Strong

Recent shutdown precedent accepts fixes for RPCs bound to already-canceled lifetime tokens.

PR-#734

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Production assigns ServerConnection._ct from ApplicationStopping, while ACP waits are cancelled
later by runtime disposal and the orchestrator is disposed before the server connection. The new
cleanup therefore reaches RespondToPermissionAsync with _ct already cancelled; that method uses
_ct for the hub invocation and converts its failure into a non-propagating failed outcome.

src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[1079-1086]
src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[1122-1144]
src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[443-445]
src/Capacitor.Cli.Daemon/DaemonRunner.cs[693-701]
src/Capacitor.Cli.Daemon/DaemonRunner.cs[932-940]
src/Capacitor.Cli.Daemon/Services/AcpHostedAgentRuntime.cs[1923-1937]
src/Capacitor.Cli.Daemon/Services/PendingAcpInteractionRegistry.cs[37-48]

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

## Issue description
Shutdown cancellation launches abandoned-interaction cleanup after the server connection's application-stopping token has already been cancelled. Because `RespondToPermissionAsync` uses that token, the cleanup cannot notify the server and the interaction remains unresolved.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[1079-1086]
- src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[1122-1144]
- src/Capacitor.Cli.Daemon/Services/ServerConnection.cs[1644-1704]

## Recommended Fix
Allow the abandonment response to use a short-lived cancellation token independent of `ApplicationStopping`, await or track the bounded cleanup before propagating cancellation, and ensure connection disposal waits for any tracked cleanup before disposing the hub.

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



Remediation recommended

2. Approval test skips environment helper ✓ Resolved 📘 Rule violation ☼ Reliability
Description
PermissionRequest_in_an_envelope_sourced_hosted_session_yields_to_codex_without_posting_to_the_bridge
calls Environment.SetEnvironmentVariable directly and manually restores the marker instead of
using EnvScope.Exclusive. The command reads the real process environment, so the helper's scoped
restoration and exclusivity safeguards are bypassed for both setup and cleanup.
Code

test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs[600]

+            Environment.SetEnvironmentVariable("KCAP_HOSTED_APPSERVER", "1");
Relevance

●●● Strong

Team accepts environment-isolation fixes and explicitly enforces EnvScope for process environment
mutations.

PR-#635
PR-#357

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Compliance rule 2821221 requires every test environment mutation to use EnvScope, with
EnvScope.Exclusive when the real process environment is read. The added test directly sets and
restores KCAP_HOSTED_APPSERVER through Environment.SetEnvironmentVariable.

Rule 2821221: Use EnvScope for all environment variable mutations in tests
test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs[597-609]

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

## Issue description
The new approval test mutates `KCAP_HOSTED_APPSERVER` directly even though test environment mutations must use `EnvScope`.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs[597-609]

## Recommended Fix
Replace the saved value and `try`/`finally` mutation pattern with a disposable `EnvScope.Exclusive("KCAP_HOSTED_APPSERVER", "1")`, retaining the test's bare `NotInParallel` annotation.

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


3. Codex hook stays outside its harness ✗ Dismissed 📘 Rule violation ⌂ Architecture
Description
HandlePermissionRequest adds Codex-specific approval routing in
src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs, outside the required
Capacitor.Cli/Harness/Codex/ directory. A later Codex harness change must search both the generic
command folder and the designated vendor folder, leaving the vendor boundary incomplete.
Code

src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs[R490-493]

+    async Task<int> HandlePermissionRequest(JsonNode node) {
+        // An envelope-sourced hosted session answers approvals over the app-server protocol itself:
+        // Codex's requestApproval reaches the daemon's CodexApprovalBridge, which surfaces it to the
+        // user and fails closed on its own deadline. Bouncing this hook to the daemon bridge would
Relevance

●●● Strong

Recent precedent accepts moving vendor-specific harness logic into designated vendor boundaries.

PR-#670
PR-#626

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Compliance rule 2762984 requires vendor-specific commands and other CLI harness logic to reside
under Capacitor.Cli/Harness/<Vendor>/. The modified method implements Codex-specific approval
behavior while remaining under the generic Commands/Harness path.

Rule 2762984: Place vendor-specific harness code only in the correct Harness/&lt;Vendor&gt;/ assembly and directory
src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs[490-503]

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

## Issue description
The modified Codex-specific hook command remains outside the designated Codex harness directory.

## Fix Focus Areas
- src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs[490-503]

## Recommended Fix
Move `CodexHookCommand.cs` to `src/Capacitor.Cli/Harness/Codex/`, update its namespace to match the new directory, and update all imports and references to the type.

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


Grey Divider

Context sources
✅ Compliance rules (platform): 64 rules
✅ Cross-repo context — repo relationships
  Explored: repo: kurrent-io/kcap-server (sha: 01e241aa)
Review mode: ⚖️ Balanced: This changes approval control flow, cancellation cleanup, session identification, and server interaction resolution across runtime and hook paths, creating genuine behavioral and integration risk, but not enough independent logic density to warrant extended review.

Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/Capacitor.Cli/Commands/Harness/CodexHookCommand.cs
Comment thread test/Capacitor.Cli.Tests.Unit/Commands/Harness/CodexHookCommandTests.cs Outdated
Comment thread src/Capacitor.Cli.Daemon/Services/ServerConnection.cs Outdated
The hub is already stopping when daemon shutdown cancels the wait, so the resolve
could not land; session end closes the entry on the server instead.
The server answers an abandoned interaction anyway — with the cancel this side
asked for — and request ids are a per-server sequence, so buffering that echo
would hand a stale cancel to whichever later request reuses the id after a
server restart. A fresh wait on the id supersedes the mark.
A cleared mark left its order entry behind, so the list grew without bound and
an id abandoned a second time could be evicted by the age of its first mark.
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.

1 participant