Skip to content

[AI-2027] Open the browser from setup and poll a machine pairing - #605

Merged
George-Payne merged 5 commits into
mainfrom
ai-2027/setup-opens-the-browser
Aug 19, 2026
Merged

George-Payne merged 5 commits into
mainfrom
ai-2027/setup-opens-the-browser

Conversation

@George-Payne

Copy link
Copy Markdown
Member

The CLI half of the machine-pairing channel. kcap setup <tenant> (or --server-url) now mints a pairing on that server, prints the human code beside a fallback link, opens the browser, and waits for someone to approve before going on to sign-in.

  • Auth/BrowserPairingFlow — mint, show, open, poll. The code is printed in the terminal every time: the browser showing a code is only half a comparison, and the half that makes it a defence is the one printed by the machine being paired. The fallback link is equally non-optional, because nothing can confirm a browser actually opened.
  • Auth/PairingIdentity — asserts that the account which approved is the account the CLI then authenticates as, and stops setup if it is not. The channel carries approval and never a credential, so nothing else binds the two. Reads the claims each provider actually mints — github_id plus a github|{n} subject for GitHub, sub verbatim for WorkOS — because kapacitor:user_id never goes on the wire; a claims transformation adds it per request. Distinguishes "somebody else approved" from "this build cannot tell", which need different remedies.
  • Auth/PairingPoll — a pure classifier in the shape of ProvisioningPoll, so every branch is covered without a server. 401 is terminal here rather than transient: there is no token source to refresh on the next tick, the secret was minted once.
  • Auth/PairingClient — degrade-don't-throw on TenantProvisioningClient's convention, behind an IPairingChannel seam so the loop is testable.
  • Auth/SystemBrowser — hoisted out of LoopbackBrowser's private opener so there is one, and one place saying why it is best-effort.
  • Commands/SetupCommand — Step 1b between server resolution and login; the identity assertion immediately after login; /complete last, because completion invalidates the secret and so closes the channel this machine reports progress on.

Availability needs no flag and no version check. The routes exist only when the tenant has Features:FirstRunSetup on, so a 404 on mint is the oracle — as are 401/403/405, which a gateway can answer on an unmapped anonymous route and which all mean the same thing and have the same remedy. Headless, --no-prompt and the None provider skip it for the same reason. In every one of those cases setup behaves exactly as it does today.

Reviewed before opening. Three passes (general, architecture, conventions) over the first commit; the second commit is what they found. Worth calling out two:

  • The identity check read claims no token has ever carried, so it would have aborted setup for every user on every GitHub tenant, every time. The tests missed it because they synthesised the claim shape instead of copying it — the fixtures now come from the server's own token construction.
  • Reading claims without an object guard crashed on a valid JWT whose payload is not a JSON object, because TryGetProperty throws there and only Format/Json exceptions were caught.

Also fixed: the poll compared the server's expires_at against the local clock (a fast clock gave up without polling once, on a live pairing); an approval naming no approver returned a result that degraded to "carry on without the check"; /complete discarded the 403 that is the server disagreeing about who approved; a mint missing expires_at aborted setup claiming expiry.

kcap setup gains no new flags, and --no-prompt behaviour is unchanged.

Testing. 30 new tests over the flow, the client wire shape and the identity comparison — the deadline, the back-off, the guards and the header/body casing, none of which had coverage. 1949 pass in Capacitor.Cli.Core.Tests.Unit; AOT publish is clean of IL2026/IL3050.

Deliberately left. The check lives in SetupCommand rather than OnboardingFacade's commit boundary. Moving it means threading an expected identity through LoginAsync, which every caller shares — worth doing when the Avalonia wizard is a real surface, not on this ticket.

Closes #604

AI-2027

`kcap setup <tenant>` now mints a pairing on that server, prints the human
code and the fallback URL, opens the browser, and waits for a human to
approve before carrying on to sign-in.

- The code is printed every time. A browser showing a code is only half a
  comparison; the half that makes it a defence is the one the machine being
  paired prints. The fallback URL is printed for the same reason the opener
  is best-effort - nothing can confirm a browser actually opened
- `PairingIdentity` asserts that the account which approved is the account
  the CLI then authenticates as, and setup stops if it is not. The channel
  carries approval and never a credential, so nothing else binds the two.
  Read from the access token's own claims rather than a round trip; the
  server re-checks the same comparison at /complete and answers 403
- `PairingPoll` is a pure classifier like `ProvisioningPoll`, so every branch
  is covered without a server. 401 is terminal here rather than transient:
  there is no token source to refresh, the secret was minted once
- A 404 on mint is the availability oracle - a server that does not serve the
  routes needs no version check and no flag, it just gets today's path.
  Headless, --no-prompt and the None provider skip it for the same reason
- `SystemBrowser` replaces LoopbackBrowser's private opener so there is one

Closes #604
Three reviewers over the previous commit. The headline is that the identity
check read claims no token has ever carried, so it would have failed for every
user on every GitHub tenant, every time.

`kapacitor:user_id` and `kapacitor:github_id` are added to the ClaimsPrincipal
by a claims transformation at request time; they never go on the wire. The
tenant mints `github_id` and a `sub` of `github|{n}`, so the check fell through
to `sub`, compared "github|4242" against the "github:4242" the poll returns, and
aborted setup telling the user a different account had approved. The tests
missed it because they synthesised the claim shape rather than copying it - the
fixtures are now taken from the server's own token construction, which is the
part that would have caught it.

Also from the reviews:

- Reading claims without an object guard crashed on a valid JWT whose payload
  is not an object: TryGetProperty throws there, and only Format/Json exceptions
  were caught. Uses `JsonElementExtensions.Str`, which the repo already requires
- The poll compared the server's `expires_at` against the local clock, so a
  machine running fast gave up without polling once, on a live pairing. The
  budget is now measured locally from a floor; the server still says 410
- An approval naming no approver, or naming another tenant, returned Failed -
  which degrades to "carry on with sign-in", i.e. exactly the skipped identity
  check the guard exists to prevent. New `Untrusted` result aborts instead
- /complete discarded its status, including the 403 that IS the server
  disagreeing about who approved. It now aborts; everything else stays cosmetic
- /complete ran at sign-in, which invalidates the secret and so closes the
  channel before the steps worth reporting on had run. It is now last, and the
  identity assertion is what runs at sign-in
- A mint missing `expires_at`/`pairing_id`/`setup_url` landed on Expired, which
  aborts setup with a message that misdescribed it; it degrades like the rest
- Interval clamped at both ends, 429 now prints, first poll is immediate
- `IPairingChannel` makes the loop testable; 30 new tests cover the deadline,
  the back-off, the guards and the wire shape, none of which had any
- HttpClients are disposed and given a 15s timeout, and the step catches what
  neither the client nor the flow can, so a new leg cannot crash setup
- IPairingProgress gains WaitEnded (hosts other than Spectre need it), loses a
  dead Notice, and reuses SetupAuthProgress.Indent instead of hardcoding it

Left deliberately: the check lives in SetupCommand rather than
OnboardingFacade's commit boundary. Moving it means threading an expected
identity through LoginAsync, which every caller shares - worth doing when the
Avalonia wizard is real, not on this ticket.

The server-side re-check is a corroboration, not an enforcement boundary:
/complete is anonymous and its comparison only runs when a bearer is sent, so a
build that omits it is not stopped. The prose said otherwise and now doesn't.

Closes #604
@George-Payne George-Payne self-assigned this Aug 19, 2026
@linear-code

linear-code Bot commented Aug 19, 2026

Copy link
Copy Markdown

AI-2027

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add browser-based machine pairing to interactive setup

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Adds browser-based machine approval before interactive tenant login.
• Verifies approver and authenticated identities before completing setup.
• Covers pairing protocols, polling behavior, failure handling, and user guidance.
Diagram

sequenceDiagram
    actor User
    participant CLI as Setup CLI
    participant API as Pairing API
    participant Browser as System Browser
    participant IdP as Login Provider
    participant Tokens as Token Store
    CLI->>API: Mint pairing
    API-->>CLI: Code URL secret
    CLI-->>User: Print code and link
    CLI->>Browser: Open setup URL
    User->>Browser: Compare and approve
    Browser->>API: Submit approval
    loop Until terminal result
        CLI->>API: Poll status
        API-->>CLI: Pairing state
    end
    CLI->>IdP: Authenticate user
    IdP-->>CLI: Access token
    CLI->>Tokens: Store token
    CLI->>Tokens: Read token
    CLI->>CLI: Verify approver identity
    CLI->>API: Complete with bearer
Loading
High-Level Assessment

The PR's approach is appropriate: route availability avoids brittle version or feature negotiation, the pairing channel transfers approval rather than credentials, and post-login claim comparison binds approval to authentication. Injected channel, clock, progress, and browser seams keep the security-sensitive polling workflow deterministic and testable; transferring credentials through pairing or requiring explicit capability flags would increase risk and deployment coupling.

Files changed (20) +1354 / -10

Enhancement (10) +663 / -0
BrowserPairingFlow.csImplement the browser pairing lifecycle +133/-0

Implement the browser pairing lifecycle

• Mints a pairing, displays its verification data, opens the browser, and polls until a terminal result. Handles unavailable routes, clock skew, expiry, throttling, malformed responses, and tenant identity validation.

src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs

JwtPayload.csAdd defensive JWT payload decoding +31/-0

Add defensive JWT payload decoding

• Adds an unvalidated JWT payload reader for inspecting claims in tokens already held by the CLI. Malformed, non-base64, or invalid JSON payloads return null.

src/Capacitor.Cli.Core/Auth/JwtPayload.cs

PairingClient.csAdd the tenant pairing HTTP client +125/-0

Add the tenant pairing HTTP client

• Implements mint, status-poll, and completion requests behind IPairingChannel. Preserves HTTP statuses while degrading transport and serialization failures into outcomes the flow can classify.

src/Capacitor.Cli.Core/Auth/PairingClient.cs

PairingIdentity.csVerify continuity between approval and login identities +81/-0

Verify continuity between approval and login identities

• Extracts canonical user identities from GitHub and WorkOS access-token claims and normalizes legacy forms. Distinguishes matching, mismatching, and indeterminate identity comparisons.

src/Capacitor.Cli.Core/Auth/PairingIdentity.cs

PairingModels.csDefine machine-pairing wire contracts +40/-0

Define machine-pairing wire contracts

• Adds request and response records matching the tenant pairing API's snake_case JSON contract, including machine metadata, secrets, approval state, and approver identity.

src/Capacitor.Cli.Core/Auth/PairingModels.cs

PairingPoll.csClassify pairing poll responses +32/-0

Classify pairing poll responses

• Introduces a pure classifier for approval, denial, expiry, invalid secrets, throttling, and retryable conditions. Treats unauthorized responses as terminal because pairing secrets cannot be refreshed.

src/Capacitor.Cli.Core/Auth/PairingPoll.cs

PairingProgress.csDefine pairing-specific progress reporting +22/-0

Define pairing-specific progress reporting

• Adds a UI abstraction for showing the comparison code, fallback URL, polling activity, and wait completion without conflating pairing codes with device-login codes.

src/Capacitor.Cli.Core/Auth/PairingProgress.cs

PairingResult.csModel terminal pairing outcomes +29/-0

Model terminal pairing outcomes

• Defines approved, denied, expired, unavailable, failed, and untrusted outcomes. Approved results retain the server, approver, pairing ID, and secret required by later setup steps.

src/Capacitor.Cli.Core/Auth/PairingResult.cs

HttpClientExtensions.csDefine the pairing secret header +4/-0

Define the pairing secret header

• Adds the shared X-Kcap-Pairing-Secret header constant used to authenticate pairing status and completion requests.

src/Capacitor.Cli.Core/HttpClientExtensions.cs

SetupCommand.csIntegrate machine pairing into the setup wizard +166/-0

Integrate machine pairing into the setup wizard

• Runs pairing after server resolution, renders approval progress, and handles terminal outcomes before login. It verifies the authenticated account immediately after login and completes the pairing only after all setup steps succeed.

src/Capacitor.Cli/Commands/SetupCommand.cs

Refactor (2) +22 / -10
LoopbackBrowser.csReuse the shared system browser opener +1/-10

Reuse the shared system browser opener

• Replaces LoopbackBrowser's private process-launching implementation with the shared SystemBrowser helper.

src/Capacitor.Cli.Core/Auth/LoopbackBrowser.cs

SystemBrowser.csCentralize best-effort browser launching +21/-0

Centralize best-effort browser launching

• Extracts OS browser launching into a reusable helper. Launch failures are intentionally swallowed because callers always provide a visible fallback URL.

src/Capacitor.Cli.Core/Auth/SystemBrowser.cs

Tests (4) +642 / -0
BrowserPairingFlowTests.csCover pairing flow guards and polling behavior +307/-0

Cover pairing flow guards and polling behavior

• Uses fake channels and time to test availability detection, browser output, outcomes, immediate polling, throttling backoff, interval clamping, clock skew, and expiry.

test/Capacitor.Cli.Core.Tests.Unit/Auth/BrowserPairingFlowTests.cs

PairingClientTests.csVerify the pairing API wire protocol +157/-0

Verify the pairing API wire protocol

• Tests request paths, snake_case payloads, secret and bearer headers, response parsing, status preservation, identifier escaping, and transport degradation with WireMock.

test/Capacitor.Cli.Core.Tests.Unit/Auth/PairingClientTests.cs

PairingIdentityTests.csCover provider identity extraction and continuity +119/-0

Cover provider identity extraction and continuity

• Tests real GitHub and WorkOS token claim shapes, identifier normalization, mismatches, malformed JWTs, non-object payloads, and indeterminate comparisons.

test/Capacitor.Cli.Core.Tests.Unit/Auth/PairingIdentityTests.cs

PairingPollTests.csCover every pairing poll classification +59/-0

Cover every pairing poll classification

• Verifies terminal approval, denial, expiry, and invalid-secret responses alongside retryable pending, transport, server-error, unreadable-body, and throttling cases.

test/Capacitor.Cli.Core.Tests.Unit/Auth/PairingPollTests.cs

Documentation (2) +23 / -0
README.mdDocument browser-based machine approval during setup +14/-0

Document browser-based machine approval during setup

• Explains when browser pairing runs, why users must compare codes, and when the step is skipped. Includes representative terminal output and fallback-link guidance.

README.md

help-setup.txtExplain pairing behavior in setup help +9/-0

Explain pairing behavior in setup help

• Documents browser opening, code comparison, identity continuity, and skip conditions in the built-in setup help.

src/Capacitor.Cli.Core/Resources/help-setup.txt

Other (2) +4 / -0
Models.csRegister pairing contracts for source-generated JSON +3/-0

Register pairing contracts for source-generated JSON

• Adds pairing request and response models to the System.Text.Json source-generation context.

src/Capacitor.Cli.Core/Models.cs

Capacitor.Cli.Core.Tests.Unit.csprojAdd fake time support for pairing tests +1/-0

Add fake time support for pairing tests

• Adds Microsoft.Extensions.TimeProvider.Testing so polling and deadline behavior can be tested deterministically.

test/Capacitor.Cli.Core.Tests.Unit/Capacitor.Cli.Core.Tests.Unit.csproj

This repo files issues in GitHub and CI enforces it. The other references went
in the review pass; this one described a follow-up ticket and read as an
exception rather than an oversight, which is why it survived.
@qodo-code-review

qodo-code-review Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Missing code bypasses comparison ✓ Resolved 🐞 Bug ⛨ Security
Description
BrowserPairingFlow accepts a mint response without validating UserCode, then opens the approval page
while rendering an empty comparison value. This removes the terminal-side value on which the pairing
defense depends.
Code

src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[R49-52]

+        if (minted.Body is not { } pairing
+         || string.IsNullOrEmpty(pairing.Secret)
+         || string.IsNullOrEmpty(pairing.PairingId)
+         || string.IsNullOrEmpty(pairing.SetupUrl)
Evidence
The mint guard checks every operational field except UserCode, while AwaitingApproval receives that
unchecked value. The progress contract explicitly says the code is mandatory, and the terminal
renderer relies on it for the comparison instruction.

src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[49-60]
src/Capacitor.Cli.Core/Auth/PairingProgress.cs[6-14]
src/Capacitor.Cli/Commands/SetupCommand.cs[62-70]

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

## Issue description
Reject mint responses whose `UserCode` is null, empty, or whitespace. Opening the approval page without a usable terminal code defeats the comparison required by the pairing flow.

## Issue Context
The current mint guard validates the secret, pairing ID, and setup URL, but omits `UserCode`, even though progress rendering and the flow's documented security property require it.

## Fix Focus Areas
- src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[49-57]
- test/Capacitor.Cli.Core.Tests.Unit/Auth/BrowserPairingFlowTests.cs[118-158]

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


2. Pairing comments over-explain behavior ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The pairing implementation adds extensive narrative comments that repeatedly describe control flow
and implementation details already expressed by the code. This conflicts with the requirement that
comments remain minimal and focus only on non-obvious rationale or traps.
Code

src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[R6-8]

+/// <para><b>The code is printed in the terminal, always.</b> The defence is a human comparing what
+/// the browser shows against what the machine shows, so a browser code with nothing to check it
+/// against is theatre. The fallback URL is printed for the reason <see cref="SystemBrowser"/> gives.</para>
Evidence
Rule 11 requires comments to be short and focused on non-obvious why. The cited pairing-flow
documentation spans multiple lines to narrate display behavior, while similar lengthy explanations
recur in PairingClient and SetupCommand.

CLAUDE.md: Keep Code Comments Minimal and Focused on Non-Obvious 'Why'
src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[3-34]
src/Capacitor.Cli.Core/Auth/PairingClient.cs[25-40]
src/Capacitor.Cli/Commands/SetupCommand.cs[867-975]

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 pairing implementation contains long narrative comments and XML documentation that explain ordinary control flow or repeat details apparent from the code.

## Issue Context
Retain concise comments for security constraints and genuinely non-obvious behavior, but remove repetition, historical narrative, and step-by-step explanations.

## Fix Focus Areas
- src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[3-34]
- src/Capacitor.Cli.Core/Auth/PairingClient.cs[25-40]
- src/Capacitor.Cli/Commands/SetupCommand.cs[867-975]

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


3. Unvalidated target opened by shell ✓ Resolved 🐞 Bug ⛨ Security
Description
The server-controlled SetupUrl is only checked for emptiness before being passed to Process.Start
with UseShellExecute enabled. A malicious or compromised server can therefore make setup invoke a
local path, custom URI handler, or other non-HTTP target instead of a browser page.
Code

src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[R59-60]

+        progress.AwaitingApproval(pairing.UserCode, pairing.SetupUrl);
+        _openBrowser(pairing.SetupUrl);
Evidence
SetupUrl comes directly from the mint response and is opened without URI validation. SystemBrowser
passes the string directly to ProcessStartInfo with UseShellExecute, while existing server-identity
code explicitly restricts externally supplied locations to absolute HTTP(S) URLs.

src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[49-60]
src/Capacitor.Cli.Core/Auth/SystemBrowser.cs[13-19]
src/Capacitor.Cli.Core/Auth/ServerIdentity.cs[18-24]

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

## Issue description
Validate `SetupUrl` as an admissible absolute HTTP or HTTPS URL before displaying or opening it. Reject local paths, relative URLs, userinfo, and non-web URI schemes.

## Issue Context
`SystemBrowser.Open` delegates the raw value to `Process.Start` with shell execution. The repository's `ServerIdentity` utility demonstrates the existing HTTP(S) URL validation rules, although setup URLs must retain their query string.

## Fix Focus Areas
- src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[49-60]
- src/Capacitor.Cli.Core/Auth/SystemBrowser.cs[13-19]
- src/Capacitor.Cli.Core/Auth/ServerIdentity.cs[18-30]

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



Remediation recommended

4. Expiry permits unbounded polling ✓ Resolved 🐞 Bug ☼ Reliability
Description
The local deadline uses the server-provided ExpiresAt without an upper bound, despite otherwise
treating the mint response as untrusted input. A malformed far-future expiry combined with pending
or transient poll responses can keep interactive setup polling for months or years rather than
enforcing a bounded fallback.
Code

src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[R74-75]

+        var advertised = pairing.ExpiresAt - _clock.GetUtcNow();
+        var deadline   = _clock.GetUtcNow() + Max(advertised, MinPollBudget) + PollGrace;
Evidence
The deadline takes the maximum of the advertised duration and a 16-minute floor but applies no
maximum. Because pending, transport, server-error, and unknown responses continue waiting, a
far-future ExpiresAt defeats the loop's stated purpose of bounding polling when the server never
returns 410.

src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[21-30]
src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[69-78]
src/Capacitor.Cli.Core/Auth/PairingPoll.cs[29-30]

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

## Issue description
Apply a reasonable upper bound to the locally measured polling budget derived from `ExpiresAt`. Preserve the clock-skew floor while preventing malformed future expirations from creating an effectively infinite setup operation.

## Issue Context
The server normally terminates expiry with HTTP 410, but the local deadline explicitly exists for cases where that response never arrives. It cannot provide that fallback while the advertised expiry is accepted without a ceiling.

## Fix Focus Areas
- src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[21-36]
- src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[69-78]
- test/Capacitor.Cli.Core.Tests.Unit/Auth/BrowserPairingFlowTests.cs[272-297]

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


5. Permanent poll failures keep waiting ✓ Resolved 🐞 Bug ☼ Reliability
Description
PairingPoll classifies every unrecognized response—including permanent 400, 403, 405, and 422
responses—as Wait. The CLI consequently retries an unrecoverable request for roughly 17 minutes and
finally reports that the pairing expired instead of reporting the actual protocol or authorization
failure.
Code

src/Capacitor.Cli.Core/Auth/PairingPoll.cs[R27-30]

+        401 or 404                    => PairingVerdict.Gone,
+        429                           => PairingVerdict.SlowDown,
+        // 200 pending, 5xx, and 0 (transport): the browser side may simply not have got there yet.
+        _                             => PairingVerdict.Wait
Evidence
The classifier handles only 401, 404, 410, and 429 specially; its default maps every remaining
status to Wait. BrowserPairingFlow retries Wait until its deadline and then returns Expired, so
permanent HTTP errors are both delayed and misdiagnosed.

src/Capacitor.Cli.Core/Auth/PairingPoll.cs[20-30]
src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[78-88]
src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[120-127]

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

## Issue description
Distinguish transient poll responses from permanent HTTP and protocol failures. Do not classify all otherwise-unrecognized statuses as `Wait` or later misreport them as expiry.

## Issue Context
Transport failures and selected 5xx responses may be retried, but stable client errors and successful responses with unsupported terminal payloads need a failure verdict and actionable message.

## Fix Focus Areas
- src/Capacitor.Cli.Core/Auth/PairingPoll.cs[19-31]
- src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs[88-127]
- test/Capacitor.Cli.Core.Tests.Unit/Auth/PairingPollTests.cs[18-58]

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


Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs Outdated
Comment thread src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs
Comment thread src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs
Comment thread src/Capacitor.Cli.Core/Auth/PairingPoll.cs Outdated
Comment thread src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs Outdated
Qodo's five findings. Four are the same omission in different places: the mint
response was described everywhere as untrusted input and then largely taken at
its word.

- `user_code` was not validated, so an empty one rendered a prompt with nothing
  beside it - removing the code comparison silently, which is the one failure
  mode worse than refusing. It joins the guard
- `setup_url` was checked only for emptiness and then handed to a shell-executed
  open. A `file://` path or a registered custom scheme would have been launched,
  and a URL on another host is where a human would be asked to approve somebody
  else's pairing. Now required to be absolute http/https on the same server the
  pairing was minted on
- The poll budget had a floor but no ceiling, so a far-future `expires_at` kept
  an interactive command polling for months. Clamped at both ends
- Every unrecognised status was "keep waiting", including 400/403/405/422. A
  request the server permanently refuses was retried for seventeen minutes and
  then reported as an expiry that never happened. Non-429 4xx is terminal now,
  bar 408, which is the one that means try again

The fifth is comment volume: the same rationale was restated across four or
five sites. Each invariant now lives on the type that owns it, and the rest
point at it.
Comment thread src/Capacitor.Cli.Core/Auth/BrowserPairingFlow.cs Outdated
Review catch. The check reduced the setup URL to its authority before
comparing, which throws away the path - and ServerIdentity treats the path as
significant precisely because deployments can be path-routed.

With `--server-url https://host/tenant-a` that is wrong in both directions: the
tenant's own `https://host/tenant-a/setup?p=...` reduces to `https://host` and
no longer matches its base, so a legitimate page is refused; and nothing about
the comparison distinguishes a neighbour's `https://host/tenant-b/setup` from
it, so authority-only matching is not a check worth having either way.

Both sides are now canonicalized whole and the target must sit under the base,
with a trailing separator so `/tenant-a` cannot match `/tenant-abc`. The query
and fragment come off before canonicalizing - ServerIdentity refuses a base
carrying a query, and `setup_url` always has one - while the URL that is opened
keeps them.

Userinfo is refused too: the fallback link is printed for a human to read, and
`https://acme.kcap.ai@evil.example.com/` reads as one host and addresses
another.

Regression tests for a path-routed base accepting its own page, rejecting a
neighbour's, rejecting one above the base, rejecting a prefix collision, and
rejecting userinfo.

@realtonyyoung realtonyyoung left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — verified the addressed setup URL validation finding; no additional actionable issues.

@George-Payne
George-Payne merged commit 6f9d643 into main Aug 19, 2026
13 of 16 checks passed
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.

kcap setup should open the browser and poll a machine pairing

2 participants