diff --git a/docs/reference/browser-local-https-certificate-trust.md b/docs/reference/browser-local-https-certificate-trust.md new file mode 100644 index 000000000..d858e030c --- /dev/null +++ b/docs/reference/browser-local-https-certificate-trust.md @@ -0,0 +1,926 @@ +# Built-in Browser Local HTTPS and Certificate Trust + +## Status + +Implemented reference design for +[issue #8454](https://github.com/stablyai/orca/issues/8454). + +The branch includes scheme-less local-dev URL classification, certificate-specific +failure copy, `Try HTTPS`, exact per-WebContents certificate grants, +`Proceed Anyway (Unsafe)`, local IPC, remote/headless runtime propagation, and +focused unit and real-Electron coverage. A session-level request gate contains +Chromium's cached certificate continuation so approval cannot leak to sibling +tabs, assets, fetches, iframes, or WebSockets. The release gates below remain +the acceptance contract for Electron upgrades and cross-platform validation. + +## Summary + +Orca's built-in browser cannot open a local HTTPS development server when the +server presents an untrusted certificate. A scheme-less local address also +defaults to HTTP, so entering `localhost:3000` does not discover an HTTPS-only +server. + +This design keeps HTTP as the default for the scheme-less local forms that +already use it, adds a visible `Try HTTPS` recovery action after a failed local +HTTP navigation, and adds an explicit `Proceed Anyway (Unsafe)` path for an +untrusted local HTTPS certificate. Certificate approval is narrow and +temporary: it is bound to one browser WebContents, one secure endpoint, the +SHA-256 digest of the exact leaf certificate, and the specific certificate +error. A secure endpoint treats `https` and its companion `wss` scheme as one +TLS endpoint but still includes the canonical hostname and effective port. No +approval state is persisted. + +The certificate decision runs in the process that owns the browser page. Local +desktop webviews use the desktop main process; SSH/headless pages use the remote +runtime process and expose the decision through runtime RPC. + +## Problem + +There are three independent behaviors behind the current failure. + +1. `normalizeBrowserNavigationUrl` and the tab-create entry classifier prepend + `http://` to scheme-less loopback input. Explicit `https://` input is already + preserved. +2. Electron rejects an untrusted server certificate by default. Orca has no + `certificate-error` decision flow, so a self-signed development certificate + fails with a Chromium certificate error such as + `ERR_CERT_AUTHORITY_INVALID (-202)`. +3. Browser load errors are presented as generic connectivity failures. For a + loopback URL, Orca advises the user to check that the server is running even + when the server responded and only certificate verification failed. + +The same trust limitation exists in the offscreen browser backend used by +headless and SSH-owned browser pages. A desktop-only handler would fix the +visible local webview while leaving remote browser ownership inconsistent. + +## Goals + +- Show certificate-specific failure copy for main-frame certificate errors. +- Let a user explicitly approve an untrusted local development certificate. +- Bind approval to the exact browser surface, secure endpoint, leaf certificate + digest, and certificate error. +- Keep approval in memory only and clear it with the owning browser surface. +- Support both desktop webviews and SSH/headless offscreen browser pages. +- Give scheme-less local input a discoverable path to HTTPS without silently + changing the existing HTTP default. +- Preserve the current browser sandbox, navigation allowlist, session profiles, + and mixed-content policy. +- Keep browser failure state accurate across event-order races, early webview + attachment, reload, stale user actions, and remote latency. + +## Non-goals + +- Do not automatically trust a self-signed certificate. +- Do not disable `webSecurity`, enable insecure mixed content, or add + `--ignore-certificate-errors`. +- Do not install a certificate into the operating-system trust store. +- Do not persist certificate exceptions across app restarts, browser surface + recreation, or session-profile changes. +- Do not default every scheme-less localhost URL to HTTPS. +- Do not automatically probe HTTPS and then downgrade to HTTP. +- Do not add a general-purpose exception flow for public websites in v1. +- Do not make every Chromium certificate error overridable in v1. +- Do not extend localhost worktree-label proxying to HTTPS targets. + +## Product Decisions + +### Scheme-less local input continues to use HTTP + +`localhost:3000` remains `http://localhost:3000/`. HTTP is still the common +development-server default, and changing the inference globally would turn +working HTTP servers into certificate or TLS failures. + +When an HTTP navigation to a loopback URL fails, the browser failure overlay +offers `Try HTTPS`. The action replaces only the URL scheme and preserves the +host, explicit non-default port, path, query, and fragment. An absent port, or +an explicit `:80` normalized away by the URL parser, becomes HTTPS's default +port 443. Orca does not automatically retry because an invisible +cross-protocol retry would add latency, send an unexpected second request, and +make downgrade behavior opaque. + +Explicit input remains authoritative: + +- `http://localhost:3000` stays HTTP. +- `https://localhost:3000` stays HTTPS. +- URLs advertised by a workspace process keep their advertised scheme. + +The shared browser URL domain becomes the source of truth for the existing +scheme-less local-dev classification. The tab-create entry classifier calls +that shared logic instead of keeping a second regular expression and scheme +rule. This consolidation must not reuse the stricter certificate-eligibility +predicate: doing so would change current behavior for inputs such as +`0.0.0.0`, bracketed non-loopback IPv6 addresses, and `.localhost` subdomains. + +### V1 approval covers untrusted local authorities only + +The first version offers `Proceed Anyway (Unsafe)` only when all of these are +true: + +- the failure is for the main frame; +- the attempted URL is HTTPS; +- Electron reports `net::ERR_CERT_AUTHORITY_INVALID`, corresponding to + Chromium load error `-202`; +- the host is an eligible loopback host; +- the WebContents is a browser surface managed by Orca; and +- Orca retained a live pending challenge for that browser surface. + +Other certificate errors receive accurate copy but no bypass action. In +particular, v1 does not bypass revoked certificates, malformed certificates, +weak keys, date errors, or hostname/SAN mismatches. Expanding the allowlist +requires a separate security decision and dedicated tests. + +### Eligible loopback hosts + +The shared predicate accepts: + +- `localhost`, case-insensitively, with an optional trailing dot; +- valid subdomains of `.localhost`, also with an optional trailing dot; +- valid IPv4 addresses in `127.0.0.0/8`; and +- IPv6 loopback `::1`. + +The predicate does not treat `0.0.0.0`, `::`, arbitrary bracketed IPv6 +addresses, private LAN addresses, or a DNS name that merely resolves to +loopback as certificate-bypass eligible. Wildcard bind addresses should already +be normalized to a connectable loopback address before navigation. + +SSH local forwards are eligible when their browser-facing URL uses one of these +loopback hosts. Forwarding does not fix certificate names: a URL using +`127.0.0.1` still needs an IP SAN for `127.0.0.1`; a certificate containing only +`localhost` will fail with `ERR_CERT_COMMON_NAME_INVALID`, which v1 does not +bypass. Custom advertised DNS names remain subject to normal system trust in +v1, even if the name maps to loopback. + +### Approval lifetime + +An accepted grant is scoped to: + +```text +browser WebContents ID + secure endpoint + leaf SHA-256 digest + error name +``` + +The secure endpoint canonicalizes `wss:` to its companion `https:` endpoint and +includes the normalized hostname and effective port. This lets an approved +local page load same-endpoint HTTPS assets and its WSS development socket. A +grant for `https://localhost:3000` does not apply to port 3001, to +`https://127.0.0.1:3000`, to another browser tab, or to a replacement +certificate. + +Construct the secure endpoint from the parsed request URL: map `https:` and +`wss:` to `https:`, lowercase the hostname, remove one optional trailing dot, +use the URL parser's canonical IP representation, and use port 443 when the +parsed port is empty. Exclude credentials, path, query, and fragment. Reject +every other scheme. + +Accepted grants survive reloads and same-surface navigation back to the exact +endpoint. They are cleared when the WebContents commits a main-frame navigation +whose canonical secure endpoint differs from the grant's endpoint (reusing the +per-WebContents navigation-sequence tracking), when the WebContents is destroyed +or replaced, the session profile changes, the browser-owning process exits, or +the app restarts. Binding grant lifetime to the granting main-frame document +prevents a later top-level document in the same tab — for example a navigation +to a public site — from silently reusing the loopback grant for a same-endpoint +iframe, fetch, or WSS subrequest. +Clearing a grant affects future requests; it cannot retroactively terminate a +document that already loaded. Grants are not written to settings, browser +history, workspace session state, or logs. + +Chromium caches an accepted `certificate-error` continuation across the +Electron session partition. Orca therefore retains every endpoint/certificate +identity accepted in that partition until the browser-owning process exits and +gates all later HTTPS/WSS requests with `webRequest.onBeforeRequest`. Only the +WebContents holding the matching grant may reach a cached endpoint. A sibling +main-frame navigation receives a synthetic challenge for the known identity; +sibling subresources, fetches, iframes, workers, and WSS requests fail silently. + +Because a preflight request gate cannot inspect the certificate that will be +presented after Chromium's cache is consulted, one endpoint is locked to the +first accepted untrusted leaf identity for that process lifetime. If that +endpoint later presents a different untrusted leaf, strict verification rejects +it and Orca does not offer another bypass until the browser-owning process +restarts. A replacement system-trusted certificate may load normally; the +conservative request gate still keeps sibling WebContents blocked until restart. + +## User Experience + +### Local HTTP failure + +For a failed `http://` loopback navigation, keep the current connectivity +title and recovery hint. Add `Try HTTPS` when an HTTPS form can be constructed. + +Example: + +```text +Can't reach localhost:3000 +We couldn't connect to your local server. + +[Try HTTPS] [Retry] [Copy Address] [Open Externally] +``` + +`Try HTTPS` is the default button because it is the targeted recovery action +for a local HTTP failure. Keep the existing `Copy Address` recovery action; +`Retry` uses `outline`, while `Copy Address` and `Open Externally` stay quiet +secondary actions. `Try HTTPS` does not appear for an already-HTTPS URL or a +non-eligible host. + +### Certificate failure + +For `ERR_CERT_AUTHORITY_INVALID (-202)` on an eligible local endpoint, replace +the generic connectivity content with: + +```text +Connection isn't secure +Orca doesn't trust the authority that issued the certificate for localhost:3000. + +For local development, use a trusted local certificate when possible. + +[Open Externally] [Retry] [Copy Address] [Proceed Anyway (Unsafe)] +``` + +- `Open Externally` is the default safe recovery action when the URL is + reachable from the desktop. +- When `Open Externally` is unavailable, `Retry` becomes the default action. +- `Proceed Anyway (Unsafe)` uses `outline`; `Copy Address` stays quiet. +- `Proceed Anyway (Unsafe)` is always visibly labeled; it is not hidden behind + a tooltip. +- The unsafe action does not use the destructive color. It does not delete or + irreversibly mutate user data, and the explicit label carries the warning. +- Use a muted `ShieldAlert` icon from `lucide-react`; do not add a warning color + or new token. + +For a remote-owned page whose URL is remote localhost, `Open Externally` is +hidden because the desktop system browser cannot reach the remote loopback +address. It remains available for a desktop-owned page or a locally forwarded +URL. + +For certificate errors that v1 does not permit bypassing, show the same +certificate-specific title with error-specific body text, `Retry`, +`Copy Address`, and an eligible `Open Externally` action. `Copy Address` remains +present as a quiet secondary action in every certificate-failure branch, +eligible or not, so only `Try HTTPS` and `Proceed Anyway (Unsafe)` toggle in and +out across cases. The same default-action rule applies as for `-202`: +`Open Externally` is the default when the URL is reachable from the desktop, +otherwise `Retry` becomes the default. Do not show the local-server-running +hint. + +Use this presentation mapping, inserting the display host into the copy: + +| Chromium code | Body copy | Proceed in v1 | +| ----------------------- | -------------------------------------------------------------------------- | ----------------------------- | +| `-200` | `The certificate doesn't match {host}.` | No | +| `-201` | `The certificate for {host} isn't valid at the current date and time.` | No | +| `-202` | `Orca doesn't trust the authority that issued the certificate for {host}.` | Eligible local endpoints only | +| Other certificate error | `Orca couldn't verify the certificate for {host}.` | No | + +Keep the raw error name/code available to diagnostics and optional details, but +do not make users interpret it to understand the primary failure. + +### Interaction behavior + +- The overlay is persistent inline UI because the user must read and act on the + failure; do not use a toast. +- The overlay does not steal focus when it appears. +- Actions are reachable by Tab and have visible focus rings. +- Use `aria-live="polite"`; repeated background failures must not repeatedly + announce the same message. +- Clicking `Proceed Anyway (Unsafe)` disables the action immediately, and also + disables the other overlay actions (`Retry`, `Try HTTPS`, `Copy Address`, + `Open Externally`) for the duration of the approval round-trip so a stale + click cannot race the controller's own `loadURL`. Re-enable them once a + success or typed-failure response returns. +- Map every `proceedCertificate` failure reason to an overlay outcome: + `expired`, `changed`, `ineligible`, and `missing` keep the overlay open and + show the inline recovery message below; `navigated` shows no message because + the overlay for that navigation is already cleared. In every non-success case + `Proceed Anyway (Unsafe)` stays disabled until a fresh pending challenge is + announced rather than re-enabling on its own. +- Delay the spinner by 200 ms, matching the style guide's remote-latency rule. + After that delay, show the canonical `Loader2` with `Connecting…` and use a + fixed button width so the label swap cannot move adjacent actions. +- If approval expired or the certificate changed, keep the overlay open and + show `The certificate changed or the approval expired. Retry the page.` +- A successful main-frame navigation clears the visible certificate failure. + +The UI must use existing `background`, `foreground`, `muted-foreground`, +`border`, and `ring` tokens plus the existing shadcn `Button` primitive. No new +color, radius, or shadow tier is required. Do not apply a blanket opacity to +the overlay's interactive content; use semantic muted text classes so button +and focus-ring contrast remains intact. Put every new user-visible string +through the existing localization catalog. + +## Architecture + +### Ownership + +The browser-owning main process is the only authority that can accept a +certificate challenge. + +- Desktop-owned `` pages are controlled by the desktop main process. +- Headless/SSH-owned pages are controlled by the remote `orca serve` main + process through `OffscreenBrowserBackend`. +- The renderer only presents a pending challenge and requests approval. It + never decides whether a certificate is trusted. + +### New main-process controller + +Add `src/main/browser/browser-certificate-trust-controller.ts` to manage pending +certificate challenges, and +`src/main/browser/browser-certificate-request-guard.ts` to manage grants plus +the session-level cached-certificate request boundary. + +The controller owns: + +```ts +type BrowserCertificateFailure = { + challengeId: string + browserPageId: string + errorCode: number | null + error: string + origin: string + displayHost: string + canProceed: boolean + observedAt: number +} + +type PendingBrowserCertificateChallenge = { + challengeId: string + guestWebContentsId: number + browserPageId: string | null + navigationSequence: number + navigationUrl: string + origin: string + secureEndpoint: string + leafCertificateSha256: string + errorCode: number | null + error: string + expiresAt: number +} + +type BrowserCertificateGrant = { + guestWebContentsId: number + secureEndpoint: string + leafCertificateSha256: string + error: string +} +``` + +Generate challenge IDs with `randomUUID()`. Compute the certificate identity in +the browser-owning main process as SHA-256 over the DER bytes of Electron's leaf +`certificate.data`; do not depend on the undocumented formatting or algorithm +of `certificate.fingerprint`. Renderer and runtime payloads expose no +certificate bytes or digest—only the challenge ID, display host, origin, error +code, error name, `canProceed`, and observation time. + +Pending challenges expire after five minutes and are bounded to 32 entries. +Accepted grants are bounded to 32 entries per browser-owning process and are +also removed with their WebContents. Bounds evict the oldest entry and notify +the affected UI when a live challenge is evicted. Ordinary cleanup should keep +both collections much smaller. Accepted endpoint identities are not evicted: +Chromium's corresponding cache cannot be cleared narrowly, so forgetting one +would reopen cross-WebContents access until process exit. + +### Certificate event handling + +Register exactly one app-level `certificate-error` listener after Electron is +ready but before either a desktop window or the offscreen backend can create a +browser page. Every branch must call Electron's callback exactly once; an +unexpected exception fails closed with `callback(false)`. + +Electron supplies the certificate error as a string on this event, while +`did-fail-load` supplies the Chromium number later. Normalize the string to one +canonical error name and map only known names to numbers for presentation. An +unknown name keeps `errorCode: null` and is never bypass-eligible. + +For every event: + +1. Resolve the WebContents through `BrowserManager` and reject unmanaged, + retired, or popup WebContents. +2. Parse the request URL, normalize its secure endpoint, and compute the leaf + certificate SHA-256 digest. Invalid data fails closed. +3. Compare the normalized error name, endpoint, and digest against an accepted + grant for this exact WebContents. On an exact match, first retain the accepted + identity in the owning Electron session, then call `event.preventDefault()` + and `callback(true)`. +4. If no grant matches, reject non-main-frame failures without creating a + challenge. Subresources and iframes can consume an existing grant but cannot + mint one. +5. For a main-frame failure, record a pending challenge only when the URL and + error are eligible. Notify the owning page when its page ID is known, then + call `callback(false)`. + +Track a monotonically increasing main-frame navigation sequence per +WebContents. A new main-frame navigation invalidates its pending challenge in +both main and renderer state before the next certificate decision. Repeated +identical events within one sequence reuse the challenge ID; a retry or a +different navigation gets a new ID. This prevents an old button or compromised +renderer from approving a page the user has already left. + +The first failure is always rejected. User approval records a grant and reloads +the page; the next certificate event is the one that succeeds. + +Do not leave Electron's callback pending while waiting for user input. Rejecting +first gives Orca a normal load-failure lifecycle, avoids an unbounded blocked +navigation, and makes stale approval cleanup deterministic. + +Do not use `session.setCertificateVerifyProc`. It applies to an entire session +partition, cannot bind a decision to one browser page, and verification results +can be cached by Chromium's network service. + +### Session request gate and Electron upgrade guard + +The pinned Electron 43 binary caches `callback(true)` broadly enough that a +second WebContents in the same partition can load the endpoint without another +`certificate-error` event. The certificate callback alone is therefore not an +isolation boundary. + +Install exactly one `webRequest.onBeforeRequest` listener on every Orca browser +session before its first page loads. Electron permits only one listener for this +event, so the browser session registry owns installation and removal. Once an +endpoint identity has been accepted, the listener blocks every HTTPS/WSS +request to that endpoint unless `details.webContentsId` has the matching grant. +Requests without a WebContents ID, including background/worker traffic, fail +closed. A blocked main-frame request recreates the challenge and certificate +load error; other resource types are canceled without replacing the current +page's overlay. + +The real-Electron integration suite must prove same-tab reload, same-endpoint +HTTPS assets and WSS, different-tab documents and subresources, and +different-port isolation against the exact Electron version in +`pnpm-lock.yaml`. Any Electron upgrade that breaks those tests blocks release +until the design is revised; unit mocks are insufficient. + +### BrowserManager integration + +Add a narrow BrowserManager query API instead of exposing its internal maps: + +```ts +getManagedBrowserGuestContext(webContentsId: number): { + browserPageId: string | null + worktreeId: string | null + sessionProfileId: string | null + owner: 'desktop-webview' | 'offscreen' +} | null +``` + +For guest lifecycle, follow the existing extracted-controller precedent +(`browser-grab-session-controller.ts`, `browser-guest-ui.ts`, +`browser-download-destination.ts`), all of which BrowserManager drives by +calling a plain method on the controller directly from `registerGuest` / +`unregisterGuest`. Have BrowserManager call +`certificateTrustController.onGuestRegistered({ browserPageId, webContentsId })` +and `.onGuestRetired(webContentsId)` from those same bodies, rather than adding a +new listener/emitter registration API to BrowserManager — there is no such +pub-sub surface in `src/main/browser` today and this single consumer does not +warrant introducing one. + +`getManagedBrowserGuestContext` must recognize an attached primary desktop +guest before renderer registration, returning a null page ID, while rejecting +popup descendants that merely inherited browser policies. It must also +recognize offscreen pages after `registerOffscreenGuest` and stop recognizing a +guest immediately when BrowserManager retires or unregisters it. The controller +re-checks this ownership on every certificate event even if it still has an +in-memory grant. + +An attached desktop webview can encounter a certificate error before its +document reaches `dom-ready`, which is when BrowserPane currently registers the +guest. Keep pending challenges keyed by WebContents ID until registration +supplies the page ID, then flush the failure to the renderer. + +BrowserPane should also register idempotently on the webview's `did-attach` +event, with `dom-ready` retained as a fallback. `registerGuest` should report +whether the main process accepted the registration so a rare attach-policy race +can retry at `dom-ready`; the renderer must not cache the WebContents ID as +registered until it receives `true`. + +Offscreen pages already register before `loadURL`, so their page ID is normally +available when the certificate event fires. Bring their main-frame navigation +and `did-fail-load` observation under the same BrowserManager lifecycle used by +desktop guests; the current offscreen loader only logs a rejected `loadURL`, so +it cannot by itself populate runtime failure snapshots. + +### Renderer state + +Certificate challenges are transient runtime state and must not be added to +the persisted `BrowserPage` schema. + +Add a transient map to the browser store: + +```ts +browserCertificateFailuresByPageId: Record +``` + +The workspace session serializer must omit this map. Clear an entry when: + +- the page completes a successful main-frame navigation; +- the user starts a different navigation; +- the page or workspace closes; +- the owning browser WebContents is replaced; or +- the main process reports that the challenge expired. + +The existing `BrowserLoadError` remains the persisted diagnostic source. Add a +pure Chromium-error classifier for Electron 43's certificate range (`-200` +through `-219`, excluding the unused values) so certificate failures still +render accurate copy after restore even when no live challenge exists. A live +certificate failure's error code takes presentation precedence over a +synthesized `-1` Chromium-error-page fallback. Independently, only a live, +matching challenge controls whether `Proceed Anyway (Unsafe)` is enabled. + +Use both the page ID and attempted origin when combining the live challenge +with `BrowserLoadError`. A stale challenge from a prior navigation must never +add a proceed action to a different failure. Main-process navigation-sequence +invalidation is the security boundary; renderer cleanup is defense in depth and +keeps the UI honest. + +### Local IPC + +Extend the browser preload API with: + +```ts +onCertificateFailureChanged( + callback: (event: { + browserPageId: string + failure: BrowserCertificateFailure | null + }) => void +): () => void + +proceedCertificate(args: { + browserPageId: string + challengeId: string +}): Promise< + | { ok: true } + | { + ok: false + reason: 'expired' | 'changed' | 'ineligible' | 'missing' | 'navigated' + } +> +``` + +Register the handler with the existing browser IPC group and reuse +`isTrustedBrowserRenderer`. The main process resolves the page to its current +WebContents and then consumes the exact pending challenge. Renderer-provided +origin, error code, or certificate identity are never trusted. + +On success, main consumes the challenge, records the grant, and calls +`loadURL` with the challenge's main-owned `navigationUrl`. It must not use +`webContents.getURL()`, which may be `chrome-error://chromewebdata/` or a +previously committed page. It also must not reload if the page's current +navigation sequence no longer matches the challenge. The renderer does not +assign `webview.src` as a second side effect. + +### Remote runtime RPC + +The remote browser owner exposes equivalent behavior through: + +```text +browser.certificate.proceed +``` + +with the runtime worktree selector, remote browser page ID, and challenge ID. +Advertise support through a new `browser.certificate-trust.v1` capability only +when the runtime has both a browser backend and the certificate controller. +Keep the method out of the mobile-scope RPC allowlist; it is for an +authenticated runtime-scope Orca client presenting browser chrome, not a guest +page or unauthenticated caller. + +Extend `RuntimeMobileSessionBrowserTab` compatibly with optional `loadError` +and `certificateFailure` fields. Both are emitted from live runtime state. +`loadError` keeps the existing `BrowserPage` last-known diagnostic semantics, +including workspace persistence; `certificateFailure` is transient and is +never written to workspace persistence. Controller changes, challenge expiry, +navigation, and offscreen `did-fail-load` must mark the runtime session snapshot +dirty so clients do not wait for unrelated state to change. Web clients and +desktop clients reconcile these fields through the existing remote page-handle +mapping. Each full snapshot replaces the prior transient certificate failure +for that page, including clearing it when the field is absent. An older runtime +without the capability always clears any previously mirrored proceed action. + +When the active page has a remote owner, BrowserPane routes approval to that +environment. It must not call local desktop IPC. If the runtime lacks the new +capability, the UI still classifies the certificate failure accurately but does +not render `Proceed Anyway (Unsafe)`. `Try HTTPS`, retry, and other navigation +actions continue through the existing owner-aware navigation path rather than +touching a local webview for a remote-owned page. + +### Scheme recovery + +Add pure functions in the shared browser URL domain: + +```ts +classifySchemeLessLocalDevAddress(rawInput: string): URL | null +isEligibleLocalCertificateHost(hostname: string): boolean +toHttpsRecoveryUrl(rawUrl: string): string | null +``` + +`classifySchemeLessLocalDevAddress` preserves the exact current HTTP-default +input set and is shared by address-bar normalization and tab-create entry. It +is deliberately broader than certificate eligibility. + +`isEligibleLocalCertificateHost` accepts a parsed/canonical hostname; normalize +case and an optional trailing dot and handle the brackets returned by +`URL.hostname` for IPv6. Validate IPv4 octets and DNS label boundaries instead +of using a substring or loose suffix check. + +`toHttpsRecoveryUrl` returns a value only for an HTTP URL with an eligible +loopback host. It changes `protocol` to `https:` on a parsed `URL` object and +preserves credentials, hostname, an explicit non-default port, path, query, and +fragment. When no port is present, including when the parsed HTTP URL normalized +`:80` away, the result uses HTTPS's default port 443. It never probes either +protocol. + +Both address-bar submission and tab-create entry use the shared local-address +classifier. Existing advertised workspace-port URLs remain authoritative and +require no new probing. + +## Security Model + +### Invariants + +- Default certificate verification remains strict. +- Only a trusted Orca renderer or authenticated runtime-scope Orca client can + request approval. +- The renderer cannot choose the origin, certificate digest, error, + WebContents, or eligibility result. +- A grant is exact-match only and never applies across tabs, ports, + certificates, or browser-owning processes. +- Session-cached accepted endpoints remain request-gated until process exit, + even after their original WebContents or bounded grant is removed. +- Certificate PEM/DER data and certificate digests do not enter workspace + state, history, telemetry, or ordinary logs. +- Subresource and iframe certificate failures cannot mint an approval action; + they can consume an already-approved exact-endpoint grant on the same + WebContents. +- The proceed affordance exists only in Orca chrome; guest content has no + direct approval channel. Main-process challenge validation remains the + security boundary and does not assume proof of a physical click. +- A remote client cannot approve a certificate in a different runtime + environment. +- Starting another main-frame navigation invalidates the pending approval in + the browser-owning process, not only in renderer state. + +### Threat cases + +| Threat | Required behavior | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Compromised web page sends forged IPC | Guest WebContents has no preload API; trusted-renderer check rejects it. | +| Compromised renderer invents a challenge | Main consumes only a server-created pending challenge tied to the current page and WebContents. | +| Certificate changes after approval | Leaf-digest mismatch rejects; a second untrusted leaf for that endpoint cannot be bypassed until restart. | +| Same host uses a different port | Secure-endpoint mismatch rejects. | +| Another tab visits the approved endpoint | WebContents mismatch rejects. | +| Approved tab later loads a foreign top document | Grant is invalidated when the main frame navigates to a different endpoint; the new document cannot reuse it. | +| Challenge is replayed | Challenge ID is single-use and expires. | +| User leaves the failed page before clicking | Navigation-sequence mismatch rejects without reloading the stale URL. | +| Approved page opens same-endpoint WSS/HMR | Existing endpoint grant permits it; WSS cannot create a grant by itself. | +| Public site presents an untrusted certificate | Host eligibility fails; no proceed action is available. | +| Remote page approval is sent locally | Ownership routing and remote page handles select the browser-owning runtime. | +| App restarts after approval | No persisted grant exists. | + +## Failure Handling + +- If a certificate event arrives before guest registration, retain it by + WebContents ID and flush after registration. +- If guest registration never completes, expire the pending challenge without + notifying unrelated pages. +- If `Proceed` arrives after expiry, navigation, or WebContents replacement, + return a typed failure and leave strict verification in place. +- If reload fails for a different reason, clear the certificate challenge and + present the new load error. +- If the same rejected challenge fires repeatedly in one navigation, coalesce + it without rotating the challenge ID or stacking announcements. +- If the certificate-error listener cannot parse the URL or leaf certificate, + reject normally and do not offer approval. +- If the remote connection drops during approval, keep the local UI stable and + surface the runtime error inline after the latency threshold. +- If an unexpected controller error occurs, call Electron's callback once with + `false`; never leave the request suspended. +- If an accepted main-frame page later opens a new same-endpoint TLS connection, + apply the exact grant to that HTTPS/WSS subrequest. Reject unmatched + subrequests silently without replacing the main-frame overlay. +- If an accepted endpoint presents a different untrusted leaf, reject it without + replacing the process-lifetime accepted identity or offering a second bypass. + +## Data and Privacy + +No migration is required. + +Add no new product analytics in v1. Do not record URLs, certificate digests, +challenge IDs, certificate subjects, or approval results. If product analytics +becomes a requirement later, define coarse actions in the existing typed +feature-interaction catalog as a separate reviewed change. + +Existing diagnostics may retain the Chromium numerical error code, as they do +for other browser load failures. Debug logging must contain at most the error +name, page ID, owner kind, and normalized loopback host class; omit query +strings, full URLs, and certificate digests. + +## Test Plan + +### Shared URL tests + +- Explicit HTTP and HTTPS local URLs remain unchanged. +- Scheme-less `localhost`, IPv4 loopback, and IPv6 loopback still normalize to + HTTP. +- Tab-create and address-bar local-address classification stay in parity for + the full legacy set, including wildcard and bracketed IPv6 inputs. +- The broader scheme-less classifier is not accidentally substituted for the + stricter certificate-eligibility predicate. +- `toHttpsRecoveryUrl` preserves host, an explicit non-default port, path, + query, and fragment; a URL with no parsed port changes from HTTP's default 80 + to HTTPS's default 443. +- `Try HTTPS` is unavailable for HTTPS, file URLs, public hosts, wildcard bind + hosts, private LAN hosts, and invalid URLs. +- Loopback eligibility covers `localhost`, trailing-dot localhost, + `.localhost` subdomains with and without a trailing dot, the full valid IPv4 + 127/8 range, and bracketed or unbracketed `::1` after URL parsing. +- Reject malformed IPv4, arbitrary bracketed IPv6, `0.0.0.0`, `::`, and names + that only contain the word `localhost`. + +### Main-process trust-controller tests + +- Unmanaged, retired, and popup WebContents are rejected without pending + challenges. +- A managed `-202` loopback failure creates one pending challenge and calls + `callback(false)`. +- Every event path calls its callback exactly once; only an accepted grant calls + `preventDefault()` and `callback(true)`. +- The digest is SHA-256 over DER leaf bytes and is stable across PEM formatting. +- An exact accepted grant covers same-endpoint HTTPS assets, WSS, and iframes on + the same WebContents without allowing those requests to mint a challenge. +- Wrong page, WebContents, origin, port, certificate digest, error type, and + challenge ID fail closed. +- A token is single-use and expires after five minutes. +- Repeated identical failures in one navigation coalesce; a new navigation + invalidates the token and rotates the challenge ID. +- Approving after navigation returns `navigated` and never reloads the old URL. +- Approval loads the main-owned challenged URL, not the current Chromium error + URL or a renderer-supplied URL. +- Certificate rotation fails closed and cannot replace the process-lifetime + accepted endpoint identity. +- WebContents destruction removes pending challenges and grants. +- Bounds evict the oldest pending challenge/grant. +- Registering a page after an early certificate event flushes the event to the + correct renderer. +- Concurrent tabs and session profiles remain isolated. + +### IPC and runtime tests + +- Browser certificate IPC rejects untrusted senders and malformed arguments. +- Renderer-supplied origin or certificate-identity fields are neither accepted + nor used. +- Local approval reloads only the currently mapped guest. +- Remote approval calls the owning environment's RPC with the remote page ID. +- Local IPC is not called for a remote-owned page. +- Mobile-scope runtime clients cannot call the approval method. +- Older runtimes without `browser.certificate-trust.v1` never show an enabled + proceed action. +- Runtime snapshots carry live load errors and certificate failure state, + publish immediately on changes, and do not add certificate challenges to + persisted session schemas. +- A snapshot that omits or clears a challenge removes the mirrored proceed + action rather than leaving a stale one behind. +- Two runtime environments with identical page IDs cannot approve each other's + challenge. + +### Renderer tests + +- `-202` renders certificate-specific copy and no server-running hint. +- A live certificate challenge still wins when Chromium's error-page fallback + produced diagnostic code `-1`. +- A live eligible challenge renders `Proceed Anyway (Unsafe)`. +- A restored `-202` load error without a live challenge remains accurate but + cannot proceed. +- Other certificate errors render accurate copy without v1 bypass. +- Local HTTP failures render `Try HTTPS`; HTTPS and non-loopback failures do not. +- Starting a new navigation clears stale certificate state. +- Success clears the overlay. +- Proceed disables immediately, delays the spinner, and preserves its width. +- Expired/changed responses show the inline recovery message. +- Keyboard focus and `aria-live` behavior do not regress the address bar. +- `Copy Address` remains available, interactive content is not blanket-dimmed, + and all new strings are present in the localization catalog. + +### Electron integration + +Run a local HTTPS server with a generated certificate whose SAN includes +`localhost` but whose authority is not trusted. + +1. `https://localhost:` fails with `-202` and shows certificate copy. +2. `Proceed Anyway (Unsafe)` reloads and renders the page. +3. HTTPS assets and a WSS echo/HMR endpoint on the same host and port load after + approval; a different-port asset remains blocked. +4. Reload in the same tab remains allowed without broadening the grant. +5. A second tab using the same session profile, origin, and certificate remains + blocked. This is a release-blocking assertion against the pinned Electron + binary, not a mocked controller test. +6. Navigating away before clicking makes the old challenge unusable and does + not pull the tab back to the failed URL. +7. Replacing the certificate keeps the original tab blocked and offers no + second bypass until the browser-owning process restarts. +8. Closing and recreating the browser surface removes the grant. +9. Restarting Orca removes the grant. +10. A system-trusted local certificate loads without an interstitial. +11. A public/non-loopback untrusted origin has no proceed action. +12. A hostname/SAN mismatch shows certificate copy but no proceed action. +13. `localhost:` first attempts HTTP and its failure offers `Try HTTPS`. + +Repeat the ownership-sensitive cases for: + +- a local desktop webview; +- an SSH local port forward opened in the desktop browser, using a certificate + whose SAN matches the browser-facing forwarded hostname; and +- a headless/offscreen browser page owned by `orca serve` under Linux/Xvfb. + +## Implementation Plan + +1. Add the shared scheme-less local classifier, strict certificate-host + predicate, secure-endpoint canonicalizer, and HTTPS-recovery function with + focused tests. +2. Consolidate address-bar and tab-create local classification without changing + current scheme defaults. +3. Add BrowserManager ownership/registration/retirement hooks, early + `did-attach` registration with a `dom-ready` fallback, and offscreen load + failure observation. +4. Add shared certificate failure/result types and the main-process trust + controller, including navigation-sequence invalidation and the app-level + listener. Its certificate-event handling resolves ownership through the + BrowserManager hooks from step 3, so those must land first. +5. Add local IPC, preload APIs, and transient renderer state. +6. Add the certificate-specific classification and copy as new exported + functions/branches in the existing `browser-notices.ts` (which already owns + load-failure copy via `formatLoadFailureDescription` / + `formatLoadFailureRecoveryHint`), and extract only the overlay JSX into a + focused `browser-load-failure-overlay.tsx` module instead of growing the + already grandfathered `BrowserPane.tsx`; do not add or extend a max-lines + disable. +7. Add certificate-specific and `Try HTTPS` UI, preserve existing recovery + actions, and update the localization catalog. +8. Add the offscreen/runtime capability, live snapshot fields and publication, + and approval RPC. +9. Add unit, IPC, renderer, runtime, and real-Electron integration coverage. +10. Run formatting, typecheck, lint, localization verification, targeted tests, + and cross-platform manual validation. + +## Rollout + +No feature flag is required for accurate error copy or `Try HTTPS` because both +paths preserve strict certificate verification. + +The proceed path should ship only after desktop and offscreen ownership tests +pass. If remote runtime support cannot land in the same release, gate the +button on `browser.certificate-trust.v1`; local desktop support may ship while +older/remote owners retain accurate error copy and trusted-CA guidance. + +Do not close issue #8454 until a packaged build has been validated against a +real self-signed local HTTPS server. Unit mocks alone do not establish Electron +certificate-event ordering. + +## UI Quality Bar + +- Follow `docs/STYLEGUIDE.md` and the adjacent browser failure overlay. +- Use only existing semantic tokens, shadcn buttons, and lucide icons. +- Keep the overlay quiet and monochrome; certificate failure must not introduce + an amber warning treatment. +- Copy must distinguish connection, DNS, and certificate failures without + claiming more than the Chromium error proves. +- Buttons must remain on one line at supported pane widths or wrap as one + deliberate group without overlap. +- Validate light/dark mode, macOS/Windows/Linux font metrics, and 200 ms remote + latency. +- Capture review evidence for local HTTP failure, eligible certificate failure, + ineligible certificate failure, accepted certificate, and the same browser + chrome after recovery. + +## Release Gates and Open Risks + +- The pinned Electron binary must prove that the session request gate blocks a + second tab, sibling HTTPS subresources/WSS, and a second port even when + Chromium does not re-emit `certificate-error`. If any isolation assertion + fails, omit the proceed path or redesign it around a dedicated ephemeral + partition; do not silently widen the grant. +- Same-endpoint WSS and fresh HTTPS subresource connections must work after + approval. A main document that renders while HMR/API traffic remains blocked + does not fix the local-development use case. +- A remote/headless certificate failure must reach the client as both accurate + load-error copy and a live challenge. Logging a rejected offscreen `loadURL` + is not sufficient. +- Stale approval must be rejected in the browser-owning process after any new + main-frame navigation, including when renderer cleanup or a remote snapshot + is delayed. +- SSH-forward validation must use a certificate whose SAN matches the + browser-facing hostname. Authority bypass must not mask a hostname mismatch. +- The app-level listener and real-certificate tests must pass on macOS, Windows, + and Linux/Xvfb. Platform trust-store differences do not justify enabling a + broader error allowlist. + +## Review Decisions and Follow-ups + +### From 2026-07-12 review + +- The integrated browser is part of the development workflow (including + automation, annotations, and remote ownership), so `Open Externally` and trusted + local-certificate guidance remain safer alternatives but do not replace an + explicit in-browser decision. +- V1 intentionally limits approval to `ERR_CERT_AUTHORITY_INVALID (-202)`. + Hostname/SAN, date, revoked, and malformed-certificate failures remain blocked; + broadening that set requires a separate security review and real-certificate + tests. +- Raw error details remain available to existing diagnostics rather than adding a + new disclosure affordance to the quiet recovery overlay. diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index f55f726f3..4629c844f 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -79,6 +79,8 @@ function mockBrowserManager( getWebContentsIdByTabId: () => tabs, getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId), getGuestWebContentsId: vi.fn(() => null), + getBrowserPageLoadError: vi.fn(() => null), + getBrowserPageCertificateFailure: vi.fn(() => null), unregisterGuest: vi.fn(), ensureWebviewVisible: vi.fn(async () => () => {}), acquireAutomationVisibility: vi.fn(async () => () => {}), @@ -531,6 +533,43 @@ describe('AgentBrowserBridge', () => { expect(result.tabs[0].url).toBe('https://a.com') }) + it('surfaces the browser-manager load error on each listed tab', () => { + const tabs = new Map([['tab-a', 1]]) + const wc1 = mockWebContents(1, 'chrome-error://chromewebdata/', '') + webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : null)) + const loadError = { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } + const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'tab-a', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + const b = new AgentBrowserBridge( + mockBrowserManager(tabs, new Map(), { + getBrowserPageLoadError: vi.fn((tabId: string) => (tabId === 'tab-a' ? loadError : null)), + getBrowserPageCertificateFailure: vi.fn((tabId: string) => + tabId === 'tab-a' ? certificateFailure : null + ) + }) + ) + + // Why: an agent driving the browser must see the structured cert failure, + // not just chrome-error:// from getURL(). + expect(b.tabList().tabs[0]).toMatchObject({ + url: 'https://localhost:3443/', + loadError, + certificateFailure + }) + }) + it('does not mutate active-tab routing when tab-list infers the first live tab', () => { const tabs = new Map([ ['tab-a', 1], diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 80817c81d..0bec7bf68 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -698,12 +698,18 @@ export class AgentBrowserBridge { if (firstLiveWcId === null) { firstLiveWcId = wcId } + const loadError = this.browserManager.getBrowserPageLoadError(tabId) + const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) result.push({ browserPageId: tabId, index: index++, - url: wc.getURL() ?? '', + // Why: failed WebContents report chrome-error://, which is neither + // actionable nor the address the user asked to load. + url: loadError?.validatedUrl ?? wc.getURL() ?? '', title: wc.getTitle() ?? '', - active: wcId === activeWcId + active: wcId === activeWcId, + loadError, + certificateFailure }) } // Why: if no tab has been explicitly activated yet, surface the first live diff --git a/src/main/browser/browser-certificate-challenge.ts b/src/main/browser/browser-certificate-challenge.ts new file mode 100644 index 000000000..2e49685b5 --- /dev/null +++ b/src/main/browser/browser-certificate-challenge.ts @@ -0,0 +1,77 @@ +import type { BrowserCertificateFailure } from '../../shared/types' +import { SUPPORTED_CERTIFICATE_ERROR_CODE } from './browser-certificate-identity' + +export const CERTIFICATE_CHALLENGE_TTL_MS = 5 * 60_000 +export const MAX_PENDING_CERTIFICATE_CHALLENGES = 32 +export const MAX_CERTIFICATE_GRANTS = 32 + +export type ManagedBrowserGuestContext = { + browserPageId: string | null + worktreeId: string | null + sessionProfileId: string | null + owner: 'desktop-webview' | 'offscreen' +} + +export type BrowserCertificateTrustControllerDependencies = { + resolveManagedGuestContext: (webContentsId: number) => ManagedBrowserGuestContext | null + resolveWebContentsIdForPage: (browserPageId: string) => number | null + resolveWebContents: (webContentsId: number) => Electron.WebContents | null + onFailureChanged: ( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ) => void + now?: () => number + createChallengeId?: () => string +} + +export type PendingCertificateChallenge = { + challengeId: string + guestWebContentsId: number + browserPageId: string | null + navigationSequence: number + navigationUrl: string + origin: string + displayHost: string + secureEndpoint: string + leafCertificateSha256: string + errorCode: number | null + error: string + observedAt: number + expiresAt: number +} + +export type CertificateTrustGrant = Pick< + PendingCertificateChallenge, + 'guestWebContentsId' | 'secureEndpoint' | 'leafCertificateSha256' | 'error' +> + +export function certificateChallengeIdentityMatches( + challenge: PendingCertificateChallenge, + candidate: Pick< + PendingCertificateChallenge, + 'navigationSequence' | 'secureEndpoint' | 'leafCertificateSha256' | 'error' + > +): boolean { + return ( + challenge.navigationSequence === candidate.navigationSequence && + challenge.secureEndpoint === candidate.secureEndpoint && + challenge.leafCertificateSha256 === candidate.leafCertificateSha256 && + challenge.error === candidate.error + ) +} + +export function toBrowserCertificateFailure( + challenge: PendingCertificateChallenge +): BrowserCertificateFailure { + return { + challengeId: challenge.challengeId, + browserPageId: challenge.browserPageId ?? '', + errorCode: challenge.errorCode, + error: challenge.error, + origin: challenge.origin, + displayHost: challenge.displayHost, + canProceed: challenge.errorCode === SUPPORTED_CERTIFICATE_ERROR_CODE, + observedAt: challenge.observedAt + } +} diff --git a/src/main/browser/browser-certificate-identity.ts b/src/main/browser/browser-certificate-identity.ts new file mode 100644 index 000000000..9170f8edd --- /dev/null +++ b/src/main/browser/browser-certificate-identity.ts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto' + +export const SUPPORTED_CERTIFICATE_ERROR = 'ERR_CERT_AUTHORITY_INVALID' +export const SUPPORTED_CERTIFICATE_ERROR_CODE = -202 + +export function normalizeCertificateError(error: string): string { + return error + .trim() + .replace(/^net::/i, '') + .toUpperCase() +} + +export function getSupportedCertificateErrorCode(error: string): number | null { + return normalizeCertificateError(error) === SUPPORTED_CERTIFICATE_ERROR + ? SUPPORTED_CERTIFICATE_ERROR_CODE + : null +} + +export function getLeafCertificateSha256(certificate: Electron.Certificate): string | null { + const match = certificate.data.match( + /-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/ + ) + if (!match) { + return null + } + try { + const der = Buffer.from(match[1].replace(/\s+/g, ''), 'base64') + return der.length > 0 ? createHash('sha256').update(der).digest('hex') : null + } catch { + return null + } +} diff --git a/src/main/browser/browser-certificate-request-guard.ts b/src/main/browser/browser-certificate-request-guard.ts new file mode 100644 index 000000000..0a4602804 --- /dev/null +++ b/src/main/browser/browser-certificate-request-guard.ts @@ -0,0 +1,203 @@ +import type { Session } from 'electron' + +import { toSecureCertificateEndpoint } from '../../shared/browser-url' +import { MAX_CERTIFICATE_GRANTS, type CertificateTrustGrant } from './browser-certificate-challenge' + +type CertificateIdentity = Pick< + CertificateTrustGrant, + 'secureEndpoint' | 'leafCertificateSha256' | 'error' +> + +type BlockedMainFrame = CertificateIdentity & { + webContentsId: number + navigationUrl: string + origin: string + displayHost: string +} + +type RequestGuardDependencies = { + onBlockedMainFrame: (blocked: BlockedMainFrame) => void +} + +function certificateIdentitiesMatch( + left: CertificateIdentity | undefined, + right: CertificateIdentity +): boolean { + return Boolean( + left && + left.secureEndpoint === right.secureEndpoint && + left.leafCertificateSha256 === right.leafCertificateSha256 && + left.error === right.error + ) +} + +export class BrowserCertificateRequestGuard { + private readonly grantsByGuestId = new Map() + private readonly grantSessionByGuestId = new Map() + private readonly guardedSessions = new Set() + private readonly acceptedIdentityBySession = new Map>() + + constructor(private readonly dependencies: RequestGuardDependencies) {} + + installSession(session: Session): void { + if (this.guardedSessions.has(session)) { + return + } + // Why: Chromium caches certificate continuations at session scope. This + // request gate restores the narrower per-WebContents approval boundary. + session.webRequest.onBeforeRequest((details, callback) => { + callback(this.shouldBlockRequest(session, details) ? { cancel: true } : {}) + }) + this.guardedSessions.add(session) + } + + removeSession(session: Session): void { + if (!this.guardedSessions.delete(session)) { + return + } + session.webRequest.onBeforeRequest(null) + this.acceptedIdentityBySession.delete(session) + for (const [webContentsId, grantSession] of this.grantSessionByGuestId) { + if (grantSession === session) { + this.revokeGuest(webContentsId) + } + } + } + + canOfferCertificate(session: Session, identity: CertificateIdentity): boolean { + if (!this.guardedSessions.has(session)) { + return false + } + const accepted = this.acceptedIdentityBySession.get(session)?.get(identity.secureEndpoint) + // Why: the request gate cannot inspect TLS after Chromium caches a decision. + // Never accept a second bad leaf for one endpoint in the same app process. + return !accepted || certificateIdentitiesMatch(accepted, identity) + } + + grant(session: Session, grant: CertificateTrustGrant): boolean { + if (!this.guardedSessions.has(session)) { + return false + } + let acceptedByEndpoint = this.acceptedIdentityBySession.get(session) + const accepted = acceptedByEndpoint?.get(grant.secureEndpoint) + // Why: once one leaf is accepted for an endpoint in this session, Chromium + // caches the TLS decision. Refuse a conflicting pending leaf so proceed() + // cannot report success when the next certificate-error will still fail. + if (accepted && !certificateIdentitiesMatch(accepted, grant)) { + return false + } + // Why: pin the identity at grant time, not only on the later certificate + // callback, so a concurrent sibling proceed cannot race in a second leaf. + if (!acceptedByEndpoint) { + acceptedByEndpoint = new Map() + this.acceptedIdentityBySession.set(session, acceptedByEndpoint) + } + acceptedByEndpoint.set(grant.secureEndpoint, { + secureEndpoint: grant.secureEndpoint, + leafCertificateSha256: grant.leafCertificateSha256, + error: grant.error + }) + this.grantsByGuestId.delete(grant.guestWebContentsId) + this.grantsByGuestId.set(grant.guestWebContentsId, grant) + this.grantSessionByGuestId.set(grant.guestWebContentsId, session) + this.enforceGrantBound() + return true + } + + shouldTrustCertificate( + session: Session, + webContentsId: number, + identity: CertificateIdentity + ): boolean { + if ( + !this.guardedSessions.has(session) || + this.grantSessionByGuestId.get(webContentsId) !== session || + !certificateIdentitiesMatch(this.grantsByGuestId.get(webContentsId), identity) + ) { + return false + } + let acceptedByEndpoint = this.acceptedIdentityBySession.get(session) + if (!acceptedByEndpoint) { + acceptedByEndpoint = new Map() + this.acceptedIdentityBySession.set(session, acceptedByEndpoint) + } + const accepted = acceptedByEndpoint.get(identity.secureEndpoint) + if (accepted && !certificateIdentitiesMatch(accepted, identity)) { + return false + } + acceptedByEndpoint.set(identity.secureEndpoint, identity) + return true + } + + revokeGuest(webContentsId: number): void { + this.grantsByGuestId.delete(webContentsId) + this.grantSessionByGuestId.delete(webContentsId) + } + + revokeForCommittedNavigation(webContentsId: number, url: string): void { + const grant = this.grantsByGuestId.get(webContentsId) + if (grant && toSecureCertificateEndpoint(url) !== grant.secureEndpoint) { + this.revokeGuest(webContentsId) + } + } + + private shouldBlockRequest( + session: Session, + details: Electron.OnBeforeRequestListenerDetails + ): boolean { + const secureEndpoint = toSecureCertificateEndpoint(details.url) + if (!secureEndpoint) { + return false + } + const accepted = this.acceptedIdentityBySession.get(session)?.get(secureEndpoint) + if (!accepted) { + return false + } + const webContentsId = details.webContentsId ?? details.webContents?.id + const grant = webContentsId === undefined ? undefined : this.grantsByGuestId.get(webContentsId) + if ( + webContentsId !== undefined && + grant && + this.grantSessionByGuestId.get(webContentsId) === session && + certificateIdentitiesMatch(grant, accepted) + ) { + return false + } + if (details.resourceType === 'mainFrame' && webContentsId !== undefined) { + this.reportBlockedMainFrame(webContentsId, details.url, accepted) + } + return true + } + + private reportBlockedMainFrame( + webContentsId: number, + navigationUrl: string, + identity: CertificateIdentity + ): void { + try { + const parsed = new URL(navigationUrl) + if (parsed.protocol !== 'https:') { + return + } + this.dependencies.onBlockedMainFrame({ + webContentsId, + navigationUrl, + origin: parsed.origin, + displayHost: parsed.host, + ...identity + }) + } catch { + // The request remains blocked even if Chromium supplies a malformed URL. + } + } + + private enforceGrantBound(): void { + while (this.grantsByGuestId.size > MAX_CERTIFICATE_GRANTS) { + const oldest = this.grantsByGuestId.keys().next().value as number | undefined + if (oldest === undefined) { + return + } + this.revokeGuest(oldest) + } + } +} diff --git a/src/main/browser/browser-certificate-trust-controller.test.ts b/src/main/browser/browser-certificate-trust-controller.test.ts new file mode 100644 index 000000000..85153aef5 --- /dev/null +++ b/src/main/browser/browser-certificate-trust-controller.test.ts @@ -0,0 +1,336 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { BrowserCertificateTrustController } from './browser-certificate-trust-controller' + +const FIRST_CERTIFICATE = certificate('first certificate') +const SECOND_CERTIFICATE = certificate('replacement certificate') +let beforeRequestListener: + | (( + details: Electron.OnBeforeRequestListenerDetails, + callback: (response: Electron.CallbackResponse) => void + ) => void) + | null = null +const browserSession = { + webRequest: { + onBeforeRequest: vi.fn( + ( + listener: + | (( + details: Electron.OnBeforeRequestListenerDetails, + callback: (response: Electron.CallbackResponse) => void + ) => void) + | null + ) => { + beforeRequestListener = listener + } + ) + } +} as unknown as Electron.Session + +function certificate(contents: string): Electron.Certificate { + return { + data: `-----BEGIN CERTIFICATE-----\n${Buffer.from(contents).toString('base64')}\n-----END CERTIFICATE-----` + } as Electron.Certificate +} + +function createGuest(id: number): Electron.WebContents { + return { + id, + session: browserSession, + isDestroyed: vi.fn(() => false), + loadURL: vi.fn(() => Promise.resolve()) + } as unknown as Electron.WebContents +} + +function beforeRequest(args: { + url?: string + webContentsId?: number + resourceType?: Electron.OnBeforeRequestListenerDetails['resourceType'] +}) { + const callback = vi.fn() + beforeRequestListener?.( + { + id: 1, + url: args.url ?? 'https://localhost:3443/app', + method: 'GET', + webContentsId: args.webContentsId, + resourceType: args.resourceType ?? 'mainFrame', + referrer: '', + timestamp: 1, + uploadData: [] + }, + callback + ) + return callback +} + +function certificateEvent(args: { + controller: BrowserCertificateTrustController + guest: Electron.WebContents + url?: string + error?: string + certificate?: Electron.Certificate + isMainFrame?: boolean +}) { + const preventDefault = vi.fn() + const callback = vi.fn() + args.controller.handleCertificateError({ + event: { preventDefault }, + webContents: args.guest, + url: args.url ?? 'https://localhost:3443/app', + error: args.error ?? 'net::ERR_CERT_AUTHORITY_INVALID', + certificate: args.certificate ?? FIRST_CERTIFICATE, + callback, + isMainFrame: args.isMainFrame ?? true + }) + return { preventDefault, callback } +} + +describe('BrowserCertificateTrustController', () => { + const guest = createGuest(7) + const otherGuest = createGuest(8) + const onFailureChanged = vi.fn() + let now = 1_000 + let challengeNumber = 0 + let pageByGuestId: Map + let guestByPageId: Map + let guestById: Map + let controller: BrowserCertificateTrustController + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(guest.isDestroyed).mockReturnValue(false) + vi.mocked(otherGuest.isDestroyed).mockReturnValue(false) + vi.mocked(guest.loadURL).mockResolvedValue(undefined) + vi.mocked(otherGuest.loadURL).mockResolvedValue(undefined) + now = 1_000 + challengeNumber = 0 + pageByGuestId = new Map([ + [guest.id, 'page-1'], + [otherGuest.id, 'page-2'] + ]) + guestByPageId = new Map([ + ['page-1', guest.id], + ['page-2', otherGuest.id] + ]) + guestById = new Map([ + [guest.id, guest], + [otherGuest.id, otherGuest] + ]) + controller = new BrowserCertificateTrustController({ + resolveManagedGuestContext: (webContentsId) => { + if (!pageByGuestId.has(webContentsId)) { + return null + } + return { + browserPageId: pageByGuestId.get(webContentsId) ?? null, + worktreeId: 'worktree-1', + sessionProfileId: 'profile-1', + owner: 'desktop-webview' + } + }, + resolveWebContentsIdForPage: (browserPageId) => guestByPageId.get(browserPageId) ?? null, + resolveWebContents: (webContentsId) => guestById.get(webContentsId) ?? null, + onFailureChanged, + now: () => now, + createChallengeId: () => `challenge-${++challengeNumber}` + }) + controller.installSessionRequestGuard(browserSession) + }) + + it('rejects first, then trusts only the approved guest, endpoint, certificate, and error', () => { + controller.onMainFrameNavigationStarted(guest.id) + const first = certificateEvent({ controller, guest }) + + expect(first.callback).toHaveBeenCalledOnce() + expect(first.callback).toHaveBeenCalledWith(false) + expect(first.preventDefault).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toMatchObject({ + challengeId: 'challenge-1', + browserPageId: 'page-1', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + errorCode: -202, + canProceed: true + }) + + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(guest.loadURL).toHaveBeenCalledWith('https://localhost:3443/app') + expect(onFailureChanged).toHaveBeenLastCalledWith(guest.id, null) + + const sameEndpoint = certificateEvent({ + controller, + guest, + url: 'wss://localhost:3443/socket' + }) + expect(sameEndpoint.preventDefault).toHaveBeenCalledOnce() + expect(sameEndpoint.callback).toHaveBeenCalledOnce() + expect(sameEndpoint.callback).toHaveBeenCalledWith(true) + + const otherTab = certificateEvent({ controller, guest: otherGuest }) + expect(otherTab.callback).toHaveBeenCalledWith(false) + expect(otherTab.preventDefault).not.toHaveBeenCalled() + + const otherPort = certificateEvent({ + controller, + guest, + url: 'https://localhost:3444/' + }) + expect(otherPort.callback).toHaveBeenCalledWith(false) + expect(otherPort.preventDefault).not.toHaveBeenCalled() + + const otherCertificate = certificateEvent({ + controller, + guest, + certificate: SECOND_CERTIFICATE + }) + expect(otherCertificate.callback).toHaveBeenCalledWith(false) + expect(otherCertificate.preventDefault).not.toHaveBeenCalled() + }) + + it('blocks a session-cached certificate for sibling requests until that guest approves it', () => { + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(certificateEvent({ controller, guest }).callback).toHaveBeenCalledWith(true) + + const siblingMainFrame = beforeRequest({ webContentsId: otherGuest.id }) + expect(siblingMainFrame).toHaveBeenCalledWith({ cancel: true }) + expect(controller.getFailure('page-2')).toMatchObject({ + challengeId: 'challenge-2', + browserPageId: 'page-2', + origin: 'https://localhost:3443', + canProceed: true + }) + + expect( + beforeRequest({ webContentsId: otherGuest.id, resourceType: 'webSocket' }) + ).toHaveBeenCalledWith({ cancel: true }) + expect(beforeRequest({ resourceType: 'webSocket' })).toHaveBeenCalledWith({ cancel: true }) + expect( + beforeRequest({ url: 'https://localhost:3444/app', webContentsId: otherGuest.id }) + ).toHaveBeenCalledWith({}) + expect(beforeRequest({ webContentsId: guest.id })).toHaveBeenCalledWith({}) + + expect(controller.proceed('page-2', 'challenge-2')).toEqual({ ok: true }) + expect(beforeRequest({ webContentsId: otherGuest.id })).toHaveBeenCalledWith({}) + }) + + it('fails closed when an accepted endpoint presents a replacement bad certificate', () => { + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(certificateEvent({ controller, guest }).callback).toHaveBeenCalledWith(true) + + controller.onMainFrameNavigationStarted(guest.id) + const replacement = certificateEvent({ controller, guest, certificate: SECOND_CERTIFICATE }) + + expect(replacement.callback).toHaveBeenCalledWith(false) + expect(replacement.preventDefault).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toBeNull() + }) + + it('rejects proceed for a second tab whose pending leaf conflicts with an accepted identity', () => { + // Both tabs observe different leaves before either is approved. + certificateEvent({ controller, guest }) + certificateEvent({ controller, guest: otherGuest, certificate: SECOND_CERTIFICATE }) + expect(controller.getFailure('page-1')?.challengeId).toBe('challenge-1') + expect(controller.getFailure('page-2')?.challengeId).toBe('challenge-2') + + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + // First proceed pins the session identity; the sibling's conflicting leaf + // must not report success (Chromium cannot honor a second bad leaf). + expect(controller.proceed('page-2', 'challenge-2')).toEqual({ + ok: false, + reason: 'ineligible' + }) + expect(otherGuest.loadURL).not.toHaveBeenCalled() + }) + + it('never offers approval for public hosts, non-authority failures, or subframes', () => { + for (const event of [ + certificateEvent({ controller, guest, url: 'https://example.com/' }), + certificateEvent({ controller, guest, error: 'ERR_CERT_DATE_INVALID' }), + certificateEvent({ controller, guest, isMainFrame: false }) + ]) { + expect(event.callback).toHaveBeenCalledOnce() + expect(event.callback).toHaveBeenCalledWith(false) + expect(event.preventDefault).not.toHaveBeenCalled() + } + expect(controller.getFailure('page-1')).toBeNull() + expect(onFailureChanged).not.toHaveBeenCalled() + }) + + it('queues an early challenge until the guest receives its browser page identity', () => { + pageByGuestId.set(guest.id, null) + certificateEvent({ controller, guest }) + + expect(onFailureChanged).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toBeNull() + + pageByGuestId.set(guest.id, 'page-1') + controller.onGuestRegistered(guest.id, 'page-1') + + expect(onFailureChanged).toHaveBeenCalledWith( + guest.id, + expect.objectContaining({ challengeId: 'challenge-1', browserPageId: 'page-1' }), + 'https://localhost:3443/app' + ) + }) + + it('rejects stale, expired, destroyed, and mismatched approval attempts', () => { + certificateEvent({ controller, guest }) + + expect(controller.proceed('page-1', 'wrong-challenge')).toEqual({ + ok: false, + reason: 'changed' + }) + now += 5 * 60_000 + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ + ok: false, + reason: 'expired' + }) + + certificateEvent({ controller, guest }) + vi.mocked(guest.isDestroyed).mockReturnValue(true) + expect(controller.proceed('page-1', 'challenge-2')).toEqual({ + ok: false, + reason: 'missing' + }) + expect(guest.loadURL).not.toHaveBeenCalled() + }) + + it('clears pending approval and grants when navigation or guest ownership changes', () => { + certificateEvent({ controller, guest }) + controller.onMainFrameNavigationStarted(guest.id) + expect(controller.getFailure('page-1')).toBeNull() + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ + ok: false, + reason: 'missing' + }) + + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-2')).toEqual({ ok: true }) + controller.onMainFrameNavigationCommitted(guest.id, 'https://example.com/') + const afterCommit = certificateEvent({ controller, guest }) + expect(afterCommit.callback).toHaveBeenCalledWith(false) + + controller.onGuestRetired(guest.id) + expect(controller.getFailure('page-1')).toBeNull() + }) + + it('answers malformed and unmanaged events exactly once without overriding Electron', () => { + pageByGuestId.delete(guest.id) + const unmanaged = certificateEvent({ controller, guest }) + const malformedUrl = certificateEvent({ controller, guest: otherGuest, url: 'not a URL' }) + const malformedCertificate = certificateEvent({ + controller, + guest: otherGuest, + certificate: { data: 'not a PEM certificate' } as Electron.Certificate + }) + + for (const event of [unmanaged, malformedUrl, malformedCertificate]) { + expect(event.callback).toHaveBeenCalledOnce() + expect(event.callback).toHaveBeenCalledWith(false) + expect(event.preventDefault).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/main/browser/browser-certificate-trust-controller.ts b/src/main/browser/browser-certificate-trust-controller.ts new file mode 100644 index 000000000..e69d7ad2c --- /dev/null +++ b/src/main/browser/browser-certificate-trust-controller.ts @@ -0,0 +1,300 @@ +import { randomUUID } from 'node:crypto' +import type { BrowserCertificateFailure, BrowserCertificateProceedResult } from '../../shared/types' +import { + isEligibleLocalCertificateHost, + toSecureCertificateEndpoint +} from '../../shared/browser-url' +import { + certificateChallengeIdentityMatches, + CERTIFICATE_CHALLENGE_TTL_MS, + MAX_PENDING_CERTIFICATE_CHALLENGES, + toBrowserCertificateFailure, + type BrowserCertificateTrustControllerDependencies, + type PendingCertificateChallenge +} from './browser-certificate-challenge' +import { + getLeafCertificateSha256, + getSupportedCertificateErrorCode, + normalizeCertificateError, + SUPPORTED_CERTIFICATE_ERROR, + SUPPORTED_CERTIFICATE_ERROR_CODE +} from './browser-certificate-identity' +import { BrowserCertificateRequestGuard } from './browser-certificate-request-guard' + +export type { ManagedBrowserGuestContext } from './browser-certificate-challenge' + +export class BrowserCertificateTrustController { + private readonly pendingByGuestId = new Map() + private readonly navigationSequenceByGuestId = new Map() + private readonly expiryTimerByGuestId = new Map>() + private readonly requestGuard: BrowserCertificateRequestGuard + + constructor(private readonly dependencies: BrowserCertificateTrustControllerDependencies) { + this.requestGuard = new BrowserCertificateRequestGuard({ + onBlockedMainFrame: (blocked) => { + const context = this.dependencies.resolveManagedGuestContext(blocked.webContentsId) + if (context) { + this.recordPendingChallenge({ + ...blocked, + browserPageId: context.browserPageId + }) + } + } + }) + } + + installSessionRequestGuard(session: Electron.Session): void { + this.requestGuard.installSession(session) + } + removeSessionRequestGuard(session: Electron.Session): void { + this.requestGuard.removeSession(session) + } + + handleCertificateError(args: { + event: Pick + webContents: Electron.WebContents + url: string + error: string + certificate: Electron.Certificate + callback: (isTrusted: boolean) => void + isMainFrame: boolean + }): void { + let answered = false + const answer = (trusted: boolean): void => { + if (!answered) { + answered = true + args.callback(trusted) + } + } + try { + const context = this.dependencies.resolveManagedGuestContext(args.webContents.id) + const parsed = new URL(args.url) + const endpoint = toSecureCertificateEndpoint(args.url) + const digest = getLeafCertificateSha256(args.certificate) + const error = normalizeCertificateError(args.error) + if (!context || !endpoint || !digest) { + answer(false) + return + } + const identity = { secureEndpoint: endpoint, leafCertificateSha256: digest, error } + if ( + this.requestGuard.shouldTrustCertificate( + args.webContents.session, + args.webContents.id, + identity + ) + ) { + args.event.preventDefault() + answer(true) + return + } + if ( + args.isMainFrame && + parsed.protocol === 'https:' && + error === SUPPORTED_CERTIFICATE_ERROR && + isEligibleLocalCertificateHost(parsed.hostname) && + this.requestGuard.canOfferCertificate(args.webContents.session, identity) + ) { + this.recordPendingChallenge({ + webContentsId: args.webContents.id, + browserPageId: context.browserPageId, + navigationUrl: args.url, + origin: parsed.origin, + displayHost: parsed.host, + secureEndpoint: endpoint, + leafCertificateSha256: digest, + error + }) + } + answer(false) + } catch { + answer(false) + } + } + + onGuestRegistered(webContentsId: number, browserPageId: string): void { + const pending = this.pendingByGuestId.get(webContentsId) + if (!pending) { + return + } + pending.browserPageId = browserPageId + this.emitFailure(pending) + } + + onGuestRetired(webContentsId: number): void { + this.clearPending(webContentsId, true) + this.requestGuard.revokeGuest(webContentsId) + this.navigationSequenceByGuestId.delete(webContentsId) + } + + onMainFrameNavigationStarted(webContentsId: number): void { + this.navigationSequenceByGuestId.set( + webContentsId, + (this.navigationSequenceByGuestId.get(webContentsId) ?? 0) + 1 + ) + this.clearPending(webContentsId, true) + } + + onMainFrameNavigationCommitted(webContentsId: number, url: string): void { + this.requestGuard.revokeForCommittedNavigation(webContentsId, url) + } + + getFailure(browserPageId: string): BrowserCertificateFailure | null { + this.pruneExpired() + for (const pending of this.pendingByGuestId.values()) { + if (pending.browserPageId === browserPageId) { + return toBrowserCertificateFailure(pending) + } + } + return null + } + + proceed(browserPageId: string, challengeId: string): BrowserCertificateProceedResult { + const webContentsId = this.dependencies.resolveWebContentsIdForPage(browserPageId) + if (webContentsId === null) { + return { ok: false, reason: 'missing' } + } + const pending = this.pendingByGuestId.get(webContentsId) + if (!pending) { + return { ok: false, reason: 'missing' } + } + if (pending.challengeId !== challengeId || pending.browserPageId !== browserPageId) { + return { ok: false, reason: 'changed' } + } + if (pending.expiresAt <= this.now()) { + this.clearPending(webContentsId, true) + return { ok: false, reason: 'expired' } + } + if (pending.errorCode !== SUPPORTED_CERTIFICATE_ERROR_CODE) { + return { ok: false, reason: 'ineligible' } + } + if (pending.navigationSequence !== (this.navigationSequenceByGuestId.get(webContentsId) ?? 0)) { + return { ok: false, reason: 'navigated' } + } + const context = this.dependencies.resolveManagedGuestContext(webContentsId) + const guest = this.dependencies.resolveWebContents(webContentsId) + if (!context || context.browserPageId !== browserPageId || !guest || guest.isDestroyed()) { + return { ok: false, reason: 'missing' } + } + const granted = this.requestGuard.grant(guest.session, { + guestWebContentsId: webContentsId, + secureEndpoint: pending.secureEndpoint, + leafCertificateSha256: pending.leafCertificateSha256, + error: pending.error + }) + if (!granted) { + return { ok: false, reason: 'ineligible' } + } + const navigationUrl = pending.navigationUrl + this.clearPending(webContentsId, true) + void guest.loadURL(navigationUrl).catch(() => {}) + return { ok: true } + } + + private recordPendingChallenge(args: { + webContentsId: number + browserPageId: string | null + navigationUrl: string + origin: string + displayHost: string + secureEndpoint: string + leafCertificateSha256: string + error: string + }): void { + const navigationSequence = this.navigationSequenceByGuestId.get(args.webContentsId) ?? 0 + const existing = this.pendingByGuestId.get(args.webContentsId) + if ( + existing && + certificateChallengeIdentityMatches(existing, { + navigationSequence, + secureEndpoint: args.secureEndpoint, + leafCertificateSha256: args.leafCertificateSha256, + error: args.error + }) + ) { + return + } + this.clearPending(args.webContentsId, true) + const observedAt = this.now() + const pending: PendingCertificateChallenge = { + challengeId: this.dependencies.createChallengeId?.() ?? randomUUID(), + guestWebContentsId: args.webContentsId, + browserPageId: args.browserPageId, + navigationSequence, + navigationUrl: args.navigationUrl, + origin: args.origin, + displayHost: args.displayHost, + secureEndpoint: args.secureEndpoint, + leafCertificateSha256: args.leafCertificateSha256, + errorCode: getSupportedCertificateErrorCode(args.error), + error: args.error, + observedAt, + expiresAt: observedAt + CERTIFICATE_CHALLENGE_TTL_MS + } + this.pendingByGuestId.set(args.webContentsId, pending) + this.scheduleExpiry(pending) + this.enforcePendingBound() + this.emitFailure(pending) + } + + private emitFailure(pending: PendingCertificateChallenge): void { + if (pending.browserPageId) { + this.dependencies.onFailureChanged( + pending.guestWebContentsId, + toBrowserCertificateFailure(pending), + pending.navigationUrl + ) + } + } + + private clearPending(webContentsId: number, notify: boolean): void { + const pending = this.pendingByGuestId.get(webContentsId) + this.pendingByGuestId.delete(webContentsId) + const timer = this.expiryTimerByGuestId.get(webContentsId) + if (timer) { + clearTimeout(timer) + this.expiryTimerByGuestId.delete(webContentsId) + } + if (notify && pending?.browserPageId) { + this.dependencies.onFailureChanged(webContentsId, null) + } + } + + private scheduleExpiry(pending: PendingCertificateChallenge): void { + const timer = setTimeout( + () => { + if ( + this.pendingByGuestId.get(pending.guestWebContentsId)?.challengeId === pending.challengeId + ) { + this.clearPending(pending.guestWebContentsId, true) + } + }, + Math.max(0, pending.expiresAt - this.now()) + ) + timer.unref?.() + this.expiryTimerByGuestId.set(pending.guestWebContentsId, timer) + } + + private pruneExpired(): void { + const now = this.now() + for (const pending of this.pendingByGuestId.values()) { + if (pending.expiresAt <= now) { + this.clearPending(pending.guestWebContentsId, true) + } + } + } + + private enforcePendingBound(): void { + while (this.pendingByGuestId.size > MAX_PENDING_CERTIFICATE_CHALLENGES) { + const oldest = this.pendingByGuestId.keys().next().value as number | undefined + if (oldest === undefined) { + return + } + this.clearPending(oldest, true) + } + } + + private now(): number { + return this.dependencies.now?.() ?? Date.now() + } +} diff --git a/src/main/browser/browser-manager-grab.test.ts b/src/main/browser/browser-manager-grab.test.ts index 0d10e915f..43652e6b9 100644 --- a/src/main/browser/browser-manager-grab.test.ts +++ b/src/main/browser/browser-manager-grab.test.ts @@ -781,7 +781,7 @@ describe('browserManager grab operations', () => { const promise = browserManager.awaitGrabSelection('tab-1', 'op-1', guest) // Find the did-start-navigation handler and trigger it with isMainFrame=true - const navHandler = guestOnMock.mock.calls.find( + const navHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'did-start-navigation' )?.[1] as ((...args: unknown[]) => void) | undefined @@ -798,7 +798,7 @@ describe('browserManager grab operations', () => { void browserManager.awaitGrabSelection('tab-1', 'op-1', guest) // Trigger did-start-navigation with isMainFrame=false (subframe) - const navHandler = guestOnMock.mock.calls.find( + const navHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'did-start-navigation' )?.[1] as ((...args: unknown[]) => void) | undefined diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 912923fc8..6a4d6400e 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -104,6 +104,7 @@ describe('browserManager', () => { webContentsFromIdMock.mockReset() openPopupWithOriginBarMock.mockReset() browserManager.unregisterAll() + browserManager.setBrowserGuestStateChangedListener(null) browserManager.setDictationShortcutForwardingPredicate(null) browserManager.setSettingsResolver(() => ({})) }) @@ -583,6 +584,45 @@ describe('browserManager', () => { expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') }) + it('tracks offscreen load failures for the owning worktree snapshot', () => { + const stateChanged = vi.fn() + const offscreenGuest = { + id: 605, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'window'), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn(), + on: vi.fn(), + off: vi.fn(), + getURL: vi.fn(() => 'https://localhost:3443/') + } + webContentsFromIdMock.mockReturnValue(offscreenGuest) + browserManager.setBrowserGuestStateChangedListener(stateChanged) + + browserManager.registerOffscreenGuest({ + browserPageId: 'offscreen-page', + worktreeId: 'remote-worktree', + webContentsId: offscreenGuest.id + }) + const didFailLoad = offscreenGuest.on.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + + expect(browserManager.getBrowserPageLoadError('offscreen-page')).toEqual({ + code: -202, + description: 'Certificate authority invalid', + validatedUrl: 'https://localhost:3443/' + }) + expect(stateChanged).toHaveBeenCalledWith('remote-worktree') + }) + it('falls back to opening popup URLs externally before a guest is registered', () => { const guest = { id: 105, @@ -1588,6 +1628,163 @@ describe('browserManager', () => { validatedUrl: 'http://localhost:3000/' } }) + expect(browserManager.getBrowserPageLoadError('browser-1')).toEqual({ + code: -105, + description: 'Name not resolved', + validatedUrl: 'http://localhost:3000/' + }) + + const didStartNavigationHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as + | ((event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void) + | undefined + didStartNavigationHandler?.(null, 'http://localhost:3000/retry', false, true) + expect(browserManager.getBrowserPageLoadError('browser-1')).toBeNull() + }) + + it('drops a queued failure when a replacement navigation starts before registration', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 407, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://example.com/') + } + webContentsFromIdMock.mockImplementation((id: number) => + id === guest.id + ? guest + : id === rendererWebContentsId + ? { isDestroyed: vi.fn(() => false), send: rendererSendMock } + : null + ) + + browserManager.attachGuestPolicies(guest as never) + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + didStartNavigation(null, 'https://example.com/', false, true) + expect( + browserManager.registerGuest({ + browserPageId: 'browser-late-registration', + webContentsId: guest.id, + rendererWebContentsId + }) + ).toBe(true) + + expect(rendererSendMock).not.toHaveBeenCalledWith( + 'browser:guest-load-failed', + expect.anything() + ) + expect(browserManager.getBrowserPageLoadError('browser-late-registration')).toBeNull() + }) + + it('keeps a certificate failure until a real main-frame navigation starts', () => { + const guest = { + id: 405, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + send: vi.fn(), + getURL: vi.fn(() => 'chrome-error://chromewebdata/') + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + + browserManager.registerGuest({ + browserPageId: 'browser-certificate-page', + webContentsId: guest.id, + rendererWebContentsId + }) + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + didStartNavigation(null, 'chrome-error://chromewebdata/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-certificate-page')?.code).toBe(-202) + + didStartNavigation(null, 'https://localhost:3443/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-certificate-page')).toBeNull() + }) + + it('restores a certificate failure when a retry navigation aborts before committing', () => { + const guest = { + id: 406, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + send: vi.fn(), + getURL: vi.fn(() => 'chrome-error://chromewebdata/') + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + + browserManager.registerGuest({ + browserPageId: 'browser-retry-abort-page', + webContentsId: guest.id, + rendererWebContentsId + }) + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + // Retry: the new navigation starts (overlay optimistically cleared)... + didStartNavigation(null, 'https://localhost:3443/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull() + // ...then aborts (ERR_ABORTED) before committing, so the error is restored. + didFailLoad(null, -3, 'Aborted', 'https://localhost:3443/', true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')?.code).toBe(-202) + + // A fresh navigation from a non-errored state drops the stash, so a later + // abort cannot resurrect the old error. + didStartNavigation(null, 'https://localhost:3443/', false, true) // stashes -202, clears active + didStartNavigation(null, 'https://example.com/', false, true) // active empty -> drops stash + didFailLoad(null, -3, 'Aborted', 'https://example.com/', true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull() }) it('queues permission denials and download requests until the guest registers', () => { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index b9232bcd8..60352732e 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -9,7 +9,8 @@ import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + toSecureCertificateEndpoint } from '../../shared/browser-url' import type { BrowserDownloadFinishedEvent, @@ -52,9 +53,18 @@ import { buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' import type { KeybindingOverrides } from '../../shared/keybindings' +import type { BrowserCertificateFailure, BrowserLoadError } from '../../shared/types' +import { + BrowserCertificateTrustController, + type ManagedBrowserGuestContext +} from './browser-certificate-trust-controller' const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 +function isChromiumInternalErrorUrl(url: string): boolean { + return url.startsWith('chrome-error://') +} + function resolveWithTimeout( promise: Promise, timeoutMs: number, @@ -238,23 +248,47 @@ export class BrowserManager { private readonly annotationViewportBridgeOpsByTabId = new Map>() private readonly worktreeIdByTabId = new Map() private readonly policyAttachedGuestIds = new Set() + private readonly offscreenGuestIds = new Set() private readonly policyCleanupByGuestId = new Map void>() private readonly clickedLinkFrameNameByGuestId = new Map() + private readonly loadErrorsByGuestId = new Map() + // Why: did-start-navigation optimistically hides the overlay, but an aborted + // nav never commits — stash the cleared error so did-fail-load(-3) can restore + // it instead of stranding the user on a blank surface. + private readonly clearedLoadErrorsByGuestId = new Map() + private browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null + private certificateTrustController: BrowserCertificateTrustController | null = null private shouldForwardDictationShortcut: (() => boolean) | null = null private readonly pendingLoadFailuresByGuestId = new Map< number, { code: number; description: string; validatedUrl: string } >() - - setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { - this.shouldForwardDictationShortcut = predicate - } private readonly pendingPermissionEventsByGuestId = new Map() private readonly pendingPopupEventsByGuestId = new Map() private readonly pendingDownloadIdsByGuestId = new Map() private readonly downloadsById = new Map() private readonly grabSessionController = new BrowserGrabSessionController() + setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { + this.shouldForwardDictationShortcut = predicate + } + + setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void { + this.browserGuestStateChangedListener = listener + } + + setCertificateTrustController(controller: BrowserCertificateTrustController): void { + this.certificateTrustController = controller + } + + installCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.installSessionRequestGuard(session) + } + + removeCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.removeSessionRequestGuard(session) + } + setSettingsResolver( resolver: () => { keybindings?: KeybindingOverrides @@ -827,18 +861,84 @@ export class BrowserManager { validatedURL: string, isMainFrame: boolean ): void => { - if (!isMainFrame || errorCode === -3) { + if (!isMainFrame) { return } - this.forwardOrQueueGuestLoadFailure(guest.id, { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + const certificateFailure = browserPageId + ? this.certificateTrustController?.getFailure(browserPageId) + : null + if ( + certificateFailure && + toSecureCertificateEndpoint(validatedURL || guest.getURL()) === + toSecureCertificateEndpoint(certificateFailure.origin) + ) { + // Why: a request-guard cancellation is the transport for the existing + // certificate warning; do not replace it with ERR_ABORTED/blocked copy. + return + } + if (errorCode === -3) { + // Why: an aborted main-frame nav never committed, so restore the error + // did-start-navigation optimistically cleared — otherwise a retry that + // aborts leaves the failed page with no overlay. + const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) + if (clearedError !== undefined) { + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.loadErrorsByGuestId.set(guest.id, clearedError) + this.forwardOrQueueGuestLoadFailure(guest.id, clearedError) + this.notifyBrowserGuestStateChanged(guest.id) + } + return + } + this.clearedLoadErrorsByGuestId.delete(guest.id) + const loadError = { code: errorCode, description: errorDescription || 'This site could not be reached.', - validatedUrl: validatedURL || guest.getURL() || 'about:blank' - }) + validatedUrl: redactKagiSessionToken(validatedURL || guest.getURL() || 'about:blank') + } + this.loadErrorsByGuestId.set(guest.id, loadError) + this.forwardOrQueueGuestLoadFailure(guest.id, loadError) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didStartNavigationHandler = ( + _event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) + // Why: a failure queued before renderer registration belongs only to the + // navigation that produced it. A replacement navigation must not replay + // that stale failure when its later dom-ready registers the guest. + this.pendingLoadFailuresByGuestId.delete(guest.id) + const activeError = this.loadErrorsByGuestId.get(guest.id) + if (activeError === undefined) { + // Why: no error to hide; drop any stale stash so a later abort cannot + // resurrect an error from a navigation that already succeeded. + this.clearedLoadErrorsByGuestId.delete(guest.id) + return + } + this.clearedLoadErrorsByGuestId.set(guest.id, activeError) + this.loadErrorsByGuestId.delete(guest.id) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didNavigateHandler = (_event: Electron.Event, url: string): void => { + // Why: a committed navigation means the optimistic stash from + // did-start-navigation is obsolete — drop it so a later ERR_ABORTED + // cannot restore a failure over the already-committed page. + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) } guest.on('will-navigate', navigationGuard) guest.on('will-redirect', navigationGuard) + guest.on('did-start-navigation', didStartNavigationHandler) + guest.on('did-navigate', didNavigateHandler) guest.on('did-fail-load', didFailLoadHandler) const handleDestroyed = (): void => { // Why: guests can be destroyed before renderer registration. Without @@ -874,6 +974,8 @@ export class BrowserManager { if (!guest.isDestroyed()) { guest.off('will-navigate', navigationGuard) guest.off('will-redirect', navigationGuard) + guest.off('did-start-navigation', didStartNavigationHandler) + guest.off('did-navigate', didNavigateHandler) guest.off('did-fail-load', didFailLoadHandler) } }) @@ -918,6 +1020,7 @@ export class BrowserManager { private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { const isPrimaryGuest = this.tabIdByWebContentsId.has(guestWebContentsId) + this.certificateTrustController?.onGuestRetired(guestWebContentsId) const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) if (policyCleanup) { policyCleanup() @@ -925,6 +1028,7 @@ export class BrowserManager { } this.policyAttachedGuestIds.delete(guestWebContentsId) this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) + this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization as soon as its primary // owner is retired, even if Chromium has not destroyed the child yet. @@ -936,6 +1040,8 @@ export class BrowserManager { } } this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.loadErrorsByGuestId.delete(guestWebContentsId) + this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) this.pendingPopupEventsByGuestId.delete(guestWebContentsId) this.cancelPendingDownloadsForGuest(guestWebContentsId) @@ -949,10 +1055,10 @@ export class BrowserManager { sessionProfileId, webContentsId, rendererWebContentsId - }: BrowserGuestRegistration): void { + }: BrowserGuestRegistration): boolean { const browserTabId = browserPageId ?? legacyBrowserTabId if (!browserTabId) { - return + return false } // Why: re-registering the same browser tab can happen when Chromium swaps // or recreates the underlying guest surface. Any active grab is bound to @@ -968,7 +1074,7 @@ export class BrowserManager { const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - return + return false } // Why: the renderer sends webContentsId, which we must not blindly trust. @@ -976,21 +1082,20 @@ export class BrowserManager { // causing us to overwrite its setWindowOpenHandler or attach unintended // context menus. Only accept genuine webview guest surfaces. if (guest.getType() !== 'webview') { - return + return false } if (!this.policyAttachedGuestIds.has(webContentsId)) { // Why: renderer registration is only the second half of the guest setup. // Main must only trust guests that already passed attach-time policy // installation; otherwise a trusted renderer could point us at some other // arbitrary webview and bypass the intended host-window attach boundary. - return + return false } const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { this.retireStaleGuestWebContents(previousWebContentsId) } - this.webContentsIdByTabId.set(browserTabId, webContentsId) this.tabIdByWebContentsId.set(webContentsId, browserTabId) if (workspaceId) { @@ -1001,6 +1106,7 @@ export class BrowserManager { if (worktreeId) { this.worktreeIdByTabId.set(browserTabId, worktreeId) } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) this.setupContextMenu(browserTabId, guest) this.setupGrabShortcut(browserTabId, guest) @@ -1010,6 +1116,7 @@ export class BrowserManager { this.flushPendingPermissionEvents(browserTabId, webContentsId) this.flushPendingPopupEvents(browserTabId, webContentsId) this.flushPendingDownloadRequests(browserTabId, webContentsId) + return true } unregisterGuest(browserTabId: string): void { @@ -1088,6 +1195,10 @@ export class BrowserManager { if (!guest || guest.isDestroyed()) { return } + // Why: offscreen pages have no renderer webview listeners, so main owns + // their load-failure lifecycle for remote browser chrome. + this.offscreenGuestIds.add(webContentsId) + this.attachGuestPolicies(guest) const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { this.retireStaleGuestWebContents(previousWebContentsId) @@ -1098,6 +1209,7 @@ export class BrowserManager { if (worktreeId) { this.worktreeIdByTabId.set(browserPageId, worktreeId) } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) } unregisterAll(): void { @@ -1111,6 +1223,7 @@ export class BrowserManager { this.unregisterGuest(browserTabId) } this.policyAttachedGuestIds.clear() + this.offscreenGuestIds.clear() // Why: unregisterGuest only cleans up guests that were registered (have an // entry in webContentsIdByTabId). Guests that went through // attachGuestPolicies but were never registered still have cleanup closures @@ -1125,6 +1238,8 @@ export class BrowserManager { this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() this.pendingLoadFailuresByGuestId.clear() + this.loadErrorsByGuestId.clear() + this.clearedLoadErrorsByGuestId.clear() this.pendingPermissionEventsByGuestId.clear() this.pendingPopupEventsByGuestId.clear() this.pendingDownloadIdsByGuestId.clear() @@ -1148,6 +1263,86 @@ export class BrowserManager { return this.sessionProfileIdByPageId.get(browserTabId) ?? null } + getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null { + const webContentsId = this.webContentsIdByTabId.get(browserPageId) + return webContentsId === undefined + ? null + : (this.loadErrorsByGuestId.get(webContentsId) ?? null) + } + + getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { + return this.certificateTrustController?.getFailure(browserPageId) ?? null + } + + getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null { + if (this.popupOwnerContextByGuestId.has(webContentsId)) { + return null + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null + const offscreen = this.offscreenGuestIds.has(webContentsId) + if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) { + return null + } + if (!offscreen) { + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') { + return null + } + } + return { + browserPageId, + worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null, + sessionProfileId: browserPageId + ? (this.sessionProfileIdByPageId.get(browserPageId) ?? null) + : null, + owner: offscreen ? 'offscreen' : 'desktop-webview' + } + } + + notifyCertificateFailureChanged( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ): void { + if (failure && navigationUrl) { + const loadError = { + code: failure.errorCode ?? -1, + description: failure.error, + validatedUrl: redactKagiSessionToken(navigationUrl) + } + this.loadErrorsByGuestId.set(webContentsId, loadError) + this.forwardOrQueueGuestLoadFailure(webContentsId, loadError) + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + if (!browserPageId) { + return + } + if (this.offscreenGuestIds.has(webContentsId)) { + this.notifyBrowserGuestStateChanged(webContentsId) + return + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + renderer?.send('browser:certificate-failure-changed', { browserPageId, failure }) + } + + private notifyBrowserGuestStateChanged(webContentsId: number): void { + if (!this.offscreenGuestIds.has(webContentsId)) { + return + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null + if (worktreeId) { + // Why: this runs inside an Electron guest event dispatch; the listener + // synchronously reconciles mobile-session tabs, and an escaping throw would + // become a fatal uncaught exception (no catch-all main-process guard). + try { + this.browserGuestStateChangedListener?.(worktreeId) + } catch (error) { + console.error('[browser-manager] browserGuestStateChanged listener failed', error) + } + } + } + notifyPermissionDenied(args: { guestWebContentsId: number permission: string @@ -2033,3 +2228,13 @@ export class BrowserManager { } export const browserManager = new BrowserManager() +export const browserCertificateTrustController = new BrowserCertificateTrustController({ + resolveManagedGuestContext: (webContentsId) => + browserManager.getManagedBrowserGuestContext(webContentsId), + resolveWebContentsIdForPage: (browserPageId) => + browserManager.getGuestWebContentsId(browserPageId), + resolveWebContents: (webContentsId) => webContents.fromId(webContentsId) ?? null, + onFailureChanged: (webContentsId, failure, navigationUrl) => + browserManager.notifyCertificateFailureChanged(webContentsId, failure, navigationUrl) +}) +browserManager.setCertificateTrustController(browserCertificateTrustController) diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 583afff08..174b5aa77 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -109,7 +109,9 @@ function installModuleMocks( vi.doMock('./browser-manager', () => ({ browserManager: { notifyPermissionDenied: browserManagerNotifyPermissionDeniedMock, - handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock + handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock, + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn() } })) vi.doMock('./browser-media-access', () => ({ diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 8d81590ae..f116e08c0 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -21,7 +21,9 @@ vi.mock('electron', () => ({ vi.mock('./browser-manager', () => ({ browserManager: { notifyPermissionDenied: vi.fn(), - handleGuestWillDownload: vi.fn() + handleGuestWillDownload: vi.fn(), + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn() } })) diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 3603de115..a70aff4e6 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -551,6 +551,7 @@ class BrowserSessionRegistry { } const sess = session.fromPartition(partition) + browserManager.installCertificateRequestGuard(sess) if (typeof sess.getUserAgent === 'function') { const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) sess.setUserAgent(cleanUA) @@ -622,6 +623,7 @@ class BrowserSessionRegistry { // Electron Session object survives; clear policy callbacks and listener // bookkeeping so removed profiles do not leave retained closures behind. this.configuredPartitions.delete(partition) + browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', this.handleWillDownload) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) diff --git a/src/main/index.ts b/src/main/index.ts index b120f62c8..cebff02a9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -163,7 +163,7 @@ import { } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { EmulatorBridge } from './emulator/emulator-bridge' -import { browserManager } from './browser/browser-manager' +import { browserCertificateTrustController, browserManager } from './browser/browser-manager' import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' import { setUnreadDockBadgeCount } from './dock/unread-badge' @@ -1710,6 +1710,22 @@ function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { app.whenReady().then(async () => { logStartupMilestone('app-ready') + // Why: certificate decisions must be installed before either desktop + // webviews or headless browser windows can issue their first TLS request. + app.on( + 'certificate-error', + (event, webContents, url, error, certificate, callback, isMainFrame) => { + browserCertificateTrustController.handleCertificateError({ + event, + webContents, + url, + error, + certificate, + callback, + isMainFrame + }) + } + ) electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) app.setName(devInstanceIdentity.name) @@ -1919,6 +1935,9 @@ app.whenReady().then(async () => { isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) runtime = runtimeService + browserManager.setBrowserGuestStateChangedListener((worktreeId) => { + runtimeService.notifyMobileSessionTabsChanged(worktreeId) + }) automations = new AutomationService(store, { claudeUsage, codexUsage, @@ -2401,6 +2420,7 @@ app.on('will-quit', (e) => { // Why: headless offscreen browser windows are main-process owned; tear them // down explicitly on quit alongside the other browser/session shutdowns. runtime?.getOffscreenBrowserBackend()?.destroyAll?.() + browserManager.setBrowserGuestStateChangedListener(null) const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() killAllPty() const watcherShutdown = shutdownWatchersOnce() diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index 042140cd3..7651d1306 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -11,6 +11,7 @@ const { openDevToolsMock, setAnnotationViewportBridgeMock, cancelDownloadMock, + proceedCertificateMock, browserWindowFromWebContentsMock, webContentsFromIdMock } = vi.hoisted(() => ({ @@ -24,6 +25,7 @@ const { openDevToolsMock: vi.fn().mockResolvedValue(true), setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true), cancelDownloadMock: vi.fn(), + proceedCertificateMock: vi.fn(), browserWindowFromWebContentsMock: vi.fn(), webContentsFromIdMock: vi.fn() })) @@ -42,6 +44,9 @@ vi.mock('electron', () => ({ })) vi.mock('../browser/browser-manager', () => ({ + browserCertificateTrustController: { + proceed: proceedCertificateMock + }, browserManager: { registerGuest: registerGuestMock, unregisterGuest: unregisterGuestMock, @@ -68,6 +73,7 @@ describe('registerBrowserHandlers', () => { removeHandlerMock.mockReset() handleMock.mockReset() registerGuestMock.mockReset() + registerGuestMock.mockReturnValue(true) unregisterGuestMock.mockReset() getGuestWebContentsIdMock.mockReset() getWebContentsIdByTabIdMock.mockReset() @@ -76,6 +82,8 @@ describe('registerBrowserHandlers', () => { openDevToolsMock.mockReset() setAnnotationViewportBridgeMock.mockReset() cancelDownloadMock.mockReset() + proceedCertificateMock.mockReset() + proceedCertificateMock.mockReturnValue({ ok: true }) browserWindowFromWebContentsMock.mockReset() webContentsFromIdMock.mockReset() webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) @@ -110,6 +118,41 @@ describe('registerBrowserHandlers', () => { expect(registerGuestMock).not.toHaveBeenCalled() }) + it('does not resolve registration waiters when BrowserManager rejects the guest', async () => { + vi.useFakeTimers() + try { + registerGuestMock.mockReturnValue(false) + const settled = Promise.allSettled([waitForTabRegistration('page-1', 1000)]) + registerBrowserHandlers() + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as (event: { sender: Electron.WebContents }, args: object) => boolean + + const result = registerHandler( + { + sender: { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + }, + { + browserPageId: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'worktree-1', + webContentsId: 123 + } + ) + + expect(result).toBe(false) + await vi.advanceTimersByTimeAsync(1001) + expect(await settled).toEqual([{ status: 'rejected', reason: expect.any(Error) }]) + } finally { + vi.useRealTimers() + } + }) + it('authorizes browser download cancellation through the owning renderer', () => { cancelDownloadMock.mockReturnValue(true) registerBrowserHandlers() @@ -157,6 +200,60 @@ describe('registerBrowserHandlers', () => { expect(cancelDownloadMock).not.toHaveBeenCalled() }) + it('allows only the trusted renderer to approve an exact certificate challenge', () => { + registerBrowserHandlers() + const proceedHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:proceedCertificate' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => unknown + const trustedSender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + expect( + proceedHandler( + { sender: trustedSender }, + { browserPageId: 'page-1', challengeId: 'challenge-1' } + ) + ).toEqual({ ok: true }) + expect(proceedCertificateMock).toHaveBeenCalledWith('page-1', 'challenge-1') + + proceedCertificateMock.mockClear() + const untrustedSender = { + id: 92, + isDestroyed: () => false, + getType: () => 'webview', + getURL: () => 'https://localhost:3443/' + } as Electron.WebContents + expect( + proceedHandler( + { sender: untrustedSender }, + { browserPageId: 'page-1', challengeId: 'challenge-1' } + ) + ).toEqual({ ok: false, reason: 'missing' }) + expect(proceedCertificateMock).not.toHaveBeenCalled() + }) + + it('rejects malformed certificate approval IPC arguments', () => { + registerBrowserHandlers() + const proceedHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:proceedCertificate' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => unknown + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + for (const args of [null, {}, { browserPageId: 1, challengeId: 'challenge-1' }]) { + expect(proceedHandler({ sender }, args)).toEqual({ ok: false, reason: 'missing' }) + } + expect(proceedCertificateMock).not.toHaveBeenCalled() + }) + it('updates the bridge active tab for the owning worktree', async () => { const onTabChangedMock = vi.fn() getGuestWebContentsIdMock.mockReturnValue(4242) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 1bc405c9d..f5c1c8fb2 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: browser IPC handlers must be registered together so the trust boundary (isTrustedBrowserRenderer) and handler teardown stay consistent. */ import { BrowserWindow, ipcMain, webContents } from 'electron' -import { browserManager } from '../browser/browser-manager' +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { browserSessionRegistry } from '../browser/browser-session-registry' import { @@ -24,6 +24,7 @@ import type { } from '../../shared/browser-grab-types' import type { BrowserCookieImportResult, + BrowserCertificateProceedResult, BrowserSessionProfile, BrowserSessionProfileScope, BrowserViewportOverride @@ -180,6 +181,7 @@ export function registerBrowserHandlers(): void { ipcMain.removeHandler('browser:captureSelectionScreenshot') ipcMain.removeHandler('browser:extractHoverPayload') ipcMain.removeHandler('browser:activeTabChanged') + ipcMain.removeHandler('browser:proceedCertificate') ipcMain.handle( 'browser:registerGuest', @@ -201,10 +203,13 @@ export function registerBrowserHandlers(): void { // with a new webContentsId. The bridge must destroy the old session's // proxy (its webContents is gone) and let the next command recreate it. const previousWcId = browserManager.getGuestWebContentsId(args.browserPageId) - browserManager.registerGuest({ + const registered = browserManager.registerGuest({ ...args, rendererWebContentsId: event.sender.id }) + if (!registered) { + return false + } if (agentBrowserBridgeRef && previousWcId !== null && previousWcId !== args.webContentsId) { agentBrowserBridgeRef.onProcessSwap(args.browserPageId, args.webContentsId, previousWcId) } @@ -235,6 +240,23 @@ export function registerBrowserHandlers(): void { return true }) + ipcMain.handle( + 'browser:proceedCertificate', + ( + event, + args: { browserPageId?: unknown; challengeId?: unknown } + ): BrowserCertificateProceedResult => { + if ( + !isTrustedBrowserRenderer(event.sender) || + typeof args?.browserPageId !== 'string' || + typeof args.challengeId !== 'string' + ) { + return { ok: false, reason: 'missing' } + } + return browserCertificateTrustController.proceed(args.browserPageId, args.challengeId) + } + ) + // Why: keeps the bridge's active tab in sync with the renderer's UI state. // Without this, a user switching tabs in the UI would leave the agent operating // on the previous tab, which is confusing. diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 053f61466..c7da17da9 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -49,9 +49,10 @@ import type { BrowserViewportResult, BrowserWaitResult } from '../../shared/runtime-types' +import type { BrowserCertificateProceedResult } from '../../shared/types' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' -import { browserManager } from '../browser/browser-manager' +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import { BrowserError } from '../browser/cdp-bridge' import { startBrowserScreencast, @@ -633,6 +634,16 @@ export class RuntimeBrowserCommands { } } + async browserProceedCertificate( + params: { challengeId: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + if (!target.browserPageId) { + return { ok: false, reason: 'missing' } + } + return browserCertificateTrustController.proceed(target.browserPageId, params.challengeId) + } + async browserTabShow(params: { page: string; worktree?: string }): Promise { const target = await this.resolveBrowserCommandTarget(params) return { tab: this.describeBrowserTab(params.page, target.worktreeId) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ba20f6820..055d1c2ac 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1538,6 +1538,123 @@ describe('OrcaRuntimeService', () => { expect(capabilities).toContain('browser.screencast.v1') // ...and the headless marker tells clients not to fall back to a local tab. expect(capabilities).toContain('browser.headless.v1') + expect(capabilities).toContain('browser.certificate-trust.v1') + }) + + it('surfaces live offscreen load failures in headless browser snapshots', () => { + const runtime = createRuntime() + runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) + runtime.setAgentBrowserBridge({ + tabList: vi.fn(() => ({ + tabs: [ + { + browserPageId: 'page-certificate-error', + index: 0, + url: 'https://localhost:3443/', + title: 'Local HTTPS', + active: true, + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + }, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'page-certificate-error', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + } + ] + })) + } as never) + const browserTabs = runtime['buildHeadlessMobileSessionBrowserTabs'](TEST_WORKTREE_ID) + expect(browserTabs).toContainEqual( + expect.objectContaining({ + type: 'browser', + browserPageId: 'page-certificate-error', + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + }, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'page-certificate-error', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + }) + ) + }) + + it('detects headless browser-tab changes by field, treating absent and null loadError alike', () => { + const runtime = createRuntime() + const base = { + type: 'browser' as const, + id: 'page-1', + title: 'Local', + browserWorkspaceId: 'page-1', + browserPageId: 'page-1', + url: 'https://localhost:3443/', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + const err = { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } + const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + const unchanged = (a: unknown[], b: unknown[]): boolean => + runtime['headlessBrowserTabsUnchanged'](a as never, b as never) + + // Absent vs explicit null loadError are equivalent (the JSON.stringify trap). + expect(unchanged([{ ...base }], [{ ...base, loadError: null }])).toBe(true) + expect(unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err } }])).toBe( + true + ) + // A load-error-only change (identical ids/order) must not be missed. + expect(unchanged([{ ...base }], [{ ...base, loadError: err }])).toBe(false) + expect( + unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err, code: -200 } }]) + ).toBe(false) + expect( + unchanged( + [{ ...base, certificateFailure }], + [{ ...base, certificateFailure: { ...certificateFailure } }] + ) + ).toBe(true) + expect(unchanged([{ ...base }], [{ ...base, certificateFailure }])).toBe(false) + expect( + unchanged( + [{ ...base, certificateFailure }], + [{ ...base, certificateFailure: { ...certificateFailure, challengeId: 'challenge-2' } }] + ) + ).toBe(false) + // Scalar and length changes are detected. + expect(unchanged([{ ...base }], [{ ...base, title: 'Changed' }])).toBe(false) + expect(unchanged([{ ...base }], [{ ...base, isActive: false }])).toBe(false) + expect(unchanged([{ ...base }], [{ ...base }, { ...base, id: 'page-2' }])).toBe(false) }) it('does not advertise headless browser capability when a renderer window exists', () => { @@ -1547,6 +1664,16 @@ describe('OrcaRuntimeService', () => { runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) expect(runtime.getStatus().capabilities).not.toContain('browser.headless.v1') + // Desktop webviews still host certificate trust, so the proceed capability + // remains advertised for remote clients controlling those pages. + expect(runtime.getStatus().capabilities).toContain('browser.certificate-trust.v1') + }) + + it('does not advertise certificate trust when no browser backend is available', () => { + const runtime = createRuntime() + + expect(runtime.getStatus().capabilities).not.toContain('browser.certificate-trust.v1') + expect(runtime.getStatus().capabilities).not.toContain('browser.screencast.v1') }) it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fdf3657e0..6e1bce48a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -264,6 +264,7 @@ import { } from '../../shared/worktree-ownership' import { BROWSER_HEADLESS_RUNTIME_CAPABILITY, + BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -3061,6 +3062,12 @@ export class OrcaRuntimeService { if (hasOffscreen) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) } + // Why: certificate proceed is owned by the browser-hosting process for both + // desktop webviews and offscreen pages. Advertise whenever either backend + // can host a page so remote clients can surface Proceed Anyway (Unsafe). + if (canBrowse) { + capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) + } return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, @@ -3430,13 +3437,16 @@ export class OrcaRuntimeService { allowAttachedWindow?: boolean onlyServeOwnedTerminals?: boolean } = {} - ): void { + ): Set { + // Why: report which worktrees were reconciled in place so callers don't + // reconcile them a second time (see notifyMobileSessionTabsChanged). + const reconciledWorktreeIds = new Set() if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { - return + return reconciledWorktreeIds } const session = this.store?.getWorkspaceSession?.() if (!session) { - return + return reconciledWorktreeIds } const entries = worktreeId !== undefined @@ -3455,6 +3465,7 @@ export class OrcaRuntimeService { // Reconcile just the browser tabs against the live bridge instead of // leaving a stale snapshot that omits a freshly-opened browser tab. this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, existing) + reconciledWorktreeIds.add(entryWorktreeId) continue } const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs( @@ -3554,6 +3565,7 @@ export class OrcaRuntimeService { tabs: mergedTabs }) } + return reconciledWorktreeIds } // Why: keep an existing snapshot's browser tabs in sync with the live bridge @@ -3568,13 +3580,11 @@ export class OrcaRuntimeService { } const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId) const liveIds = liveBrowserTabs.map((tab) => tab.id) - const existingBrowserIds = existing.tabs - .filter((tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser') - .map((tab) => tab.id) - const unchanged = - liveIds.length === existingBrowserIds.length && - liveIds.every((id, index) => existingBrowserIds[index] === id) - if (unchanged) { + const existingBrowserTabs = existing.tabs.filter( + (tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser' + ) + const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id) + if (this.headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) { return } const nonBrowserTabs = existing.tabs.filter((tab) => tab.type !== 'browser') @@ -4051,6 +4061,8 @@ export class OrcaRuntimeService { loading: false, canGoBack: false, canGoForward: false, + loadError: tab.loadError ?? undefined, + certificateFailure: tab.certificateFailure ?? undefined, ...(persistedProps ? { color: persistedProps.color } : {}), ...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}), isActive: tab.active === true @@ -4058,6 +4070,75 @@ export class OrcaRuntimeService { }) } + // Why: change detection for headless browser tabs. Compares the fields that + // actually vary (a JSON.stringify equality was order-sensitive and silently + // dropped `undefined` keys, so it only worked while both sides shared one + // construction path). + private headlessBrowserTabsUnchanged( + live: RuntimeMobileSessionBrowserTab[], + existing: RuntimeMobileSessionBrowserTab[] + ): boolean { + if (live.length !== existing.length) { + return false + } + return live.every((tab, index) => { + const prev = existing[index] + return ( + tab.id === prev.id && + tab.title === prev.title && + tab.url === prev.url && + tab.isActive === prev.isActive && + (tab.isPinned ?? false) === (prev.isPinned ?? false) && + (tab.color ?? null) === (prev.color ?? null) && + this.browserLoadErrorsEqual(tab.loadError, prev.loadError) && + this.browserCertificateFailuresEqual(tab.certificateFailure, prev.certificateFailure) + ) + }) + } + + private browserLoadErrorsEqual( + a: RuntimeMobileSessionBrowserTab['loadError'], + b: RuntimeMobileSessionBrowserTab['loadError'] + ): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.code === right.code && + left.description === right.description && + left.validatedUrl === right.validatedUrl + ) + } + + private browserCertificateFailuresEqual( + a: RuntimeMobileSessionBrowserTab['certificateFailure'], + b: RuntimeMobileSessionBrowserTab['certificateFailure'] + ): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.challengeId === right.challengeId && + left.browserPageId === right.browserPageId && + left.errorCode === right.errorCode && + left.error === right.error && + left.origin === right.origin && + left.displayHost === right.displayHost && + left.canProceed === right.canProceed && + left.observedAt === right.observedAt + ) + } + private getPersistedUnifiedSessionTabProps( worktreeId: string, tabId: string @@ -21565,6 +21646,17 @@ export class OrcaRuntimeService { this.notifyMobileSessionTabSnapshots() return } + if (this.offscreenBrowserBackend) { + const reconciled = this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + // Why: hydrate already reconciles an existing snapshot in place; only + // reconcile here when it didn't (fresh build or an early-returned hydrate). + if (!reconciled.has(worktreeId)) { + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + if (existing) { + this.reconcileHeadlessMobileSessionBrowserTabs(worktreeId, existing) + } + } + } // Why: structural changes (tab add/remove/activate) must propagate promptly, // so cancel any pending coalesced title/status notify — this immediate emit // already reflects the latest snapshot and supersedes it. @@ -25478,6 +25570,8 @@ export class OrcaRuntimeService { browserTabList: RuntimeBrowserCommands['browserTabList'] = this.browserCommands.browserTabList.bind(this.browserCommands) + browserProceedCertificate: RuntimeBrowserCommands['browserProceedCertificate'] = + this.browserCommands.browserProceedCertificate.bind(this.browserCommands) browserTabShow: RuntimeBrowserCommands['browserTabShow'] = this.browserCommands.browserTabShow.bind(this.browserCommands) diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index fc1b1277f..41484d207 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -1,5 +1,5 @@ import { defineMethod, type RpcMethod } from '../core' -import { BrowserTarget } from '../schemas' +import { BrowserTarget, requiredString } from '../schemas' import { Check, Drag, @@ -34,6 +34,10 @@ import { } from './browser-schemas' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' +const CertificateProceed = BrowserTarget.extend({ + challengeId: requiredString('Missing required challengeId') +}) + export const BROWSER_CORE_METHODS: RpcMethod[] = [ defineMethod({ name: 'browser.snapshot', @@ -50,6 +54,11 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: Goto, handler: async (params, { runtime }) => runtime.browserGoto(params) }), + defineMethod({ + name: 'browser.certificate.proceed', + params: CertificateProceed, + handler: async (params, { runtime }) => runtime.browserProceedCertificate(params) + }), ...BROWSER_TEXT_METHODS, defineMethod({ name: 'browser.select', diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 72a289344..9eff14566 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -1,7 +1,5 @@ -// Why: the browser method surface area is large enough that keeping every -// schema in the same file as its handler registration pushes the file past -// the 300-line lint cap. Grouping all browser schemas here keeps each -// handler file focused on dispatch wiring. +// Why: browser schemas stay separate from handler registration so both sides +// remain under the line cap and dispatch wiring stays scannable. import { z } from 'zod' import { BrowserTarget, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 03c8225b4..1b0a31dd2 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -1983,6 +1983,20 @@ describe('OrcaRuntimeRpcServer', () => { (response) => replies.push(JSON.parse(response) as Record), () => {} ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_browser_certificate_proceed', + method: 'browser.certificate.proceed', + deviceToken: mobile.token, + params: { + worktree: 'id:wt-1', + page: 'page-1', + challengeId: 'challenge-1' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_browser_dialog_accept', @@ -2123,6 +2137,13 @@ describe('OrcaRuntimeRpcServer', () => { expect(replies).toContainEqual( expect.objectContaining({ id: 'req_browser_viewport', ok: true }) ) + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_browser_certificate_proceed', + ok: false, + error: expect.objectContaining({ code: 'forbidden' }) + }) + ) expect(replies).toContainEqual( expect.objectContaining({ id: 'req_browser_dialog_accept', ok: true }) ) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a3db4bbbc..7c03b6f88 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -67,6 +67,8 @@ import type { BaseRefDefaultResult, BaseRefSearchResult, BrowserCookieImportResult, + BrowserCertificateFailure, + BrowserCertificateProceedResult, BrowserLoadError, BrowserSessionProfile, BrowserSessionProfileScope, @@ -479,7 +481,7 @@ export type BrowserApi = { worktreeId: string sessionProfileId?: string | null webContentsId: number - }) => Promise + }) => Promise unregisterGuest: (args: { browserPageId: string }) => Promise openDevTools: (args: { browserPageId: string }) => Promise setViewportOverride: (args: { @@ -490,6 +492,13 @@ export type BrowserApi = { onGuestLoadFailed: ( callback: (args: { browserPageId: string; loadError: BrowserLoadError }) => void ) => () => void + onCertificateFailureChanged: ( + callback: (event: { browserPageId: string; failure: BrowserCertificateFailure | null }) => void + ) => () => void + proceedCertificate: (args: { + browserPageId: string + challengeId: string + }) => Promise onPermissionDenied: (callback: (event: BrowserPermissionDeniedEvent) => void) => () => void onPopup: (callback: (event: BrowserPopupEvent) => void) => () => void onDownloadRequested: (callback: (event: BrowserDownloadRequestedEvent) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 4aa685d7b..9f0a30529 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2238,7 +2238,7 @@ const api = { worktreeId: string sessionProfileId?: string | null webContentsId: number - }): Promise => ipcRenderer.invoke('browser:registerGuest', args), + }): Promise => ipcRenderer.invoke('browser:registerGuest', args), unregisterGuest: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:unregisterGuest', args), @@ -2271,6 +2271,17 @@ const api = { return () => ipcRenderer.removeListener('browser:guest-load-failed', listener) }, + onCertificateFailureChanged: (callback): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: Parameters[0] + ): void => callback(data) + ipcRenderer.on('browser:certificate-failure-changed', listener) + return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener) + }, + + proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args), + onPermissionDenied: ( callback: (event: { browserPageId: string; permission: string; origin: string }) => void ): (() => void) => { diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 20cde69e5..50b4dc458 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -63,6 +63,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants' import { getOrcaProfileBrowserDefaultPartition } from '../../../../shared/orca-profiles' import type { + BrowserCertificateProceedResult, BrowserLoadError, BrowserPage as BrowserPageState, BrowserWorkspace as BrowserWorkspaceState @@ -70,7 +71,9 @@ import type { import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + resolveRemoteFailureExternalUrl, + toHttpsRecoveryUrl } from '../../../../shared/browser-url' import { keybindingMatchesAction } from '../../../../shared/keybindings' import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' @@ -165,13 +168,7 @@ import { type BrowserScreencastFrameMetadata } from '../../../../shared/browser-screencast-protocol' import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source' -import { - formatByteCount, - formatLoadFailureDescription, - formatLoadFailureRecoveryHint, - formatPermissionNotice, - formatPopupNotice -} from './browser-notices' +import { formatByteCount, formatPermissionNotice, formatPopupNotice } from './browser-notices' import { getDriverForBrowserPage, onBrowserDriverChange, @@ -186,6 +183,7 @@ import { useMarkupMode, type MarkupCaptureContext } from './markup/useMarkupMode import { MarkupOverlay } from './markup/MarkupOverlay' import { MarkupDrawButton } from './markup/MarkupDrawButton' import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery' +import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay' type BrowserTabPageState = Partial< Pick< @@ -700,28 +698,6 @@ function getRemoteBrowserDeviceScaleFactor(): number { return Math.min(2, Math.max(1, Number(scale.toFixed(2)))) } -function getLoadErrorMetadata(loadError: BrowserLoadError | null): { - displayUrl: string - host: string | null - isLocalhostLike: boolean -} { - const rawUrl = loadError?.validatedUrl ?? 'about:blank' - const displayUrl = toDisplayUrl(rawUrl) - try { - const parsed = new URL(rawUrl) - const host = parsed.host || null - const hostname = parsed.hostname - const isLocalhostLike = - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '0.0.0.0' || - hostname === '::1' - return { displayUrl, host, isLocalhostLike } - } catch { - return { displayUrl, host: null, isLocalhostLike: false } - } -} - function getOpenableExternalUrl( webview: Electron.WebviewTag | null, fallbackUrl: string @@ -960,6 +936,12 @@ function RemoteBrowserPagePane({ (token: RemoteBrowserOperationToken) => Promise >(async () => null) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) + const certificateFailure = useAppStore( + (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null + ) + const remotePageHandle = useAppStore( + (s) => s.remoteBrowserPageHandlesByPageId[browserTab.id] ?? null + ) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const closeBrowserPage = useAppStore((s) => s.closeBrowserPage) const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) @@ -1985,7 +1967,13 @@ function RemoteBrowserPagePane({ setRemoteError(message) onUpdatePageState(browserTab.id, { loading: false, - loadError: { code: 0, description: message, validatedUrl: url ?? browserTab.url } + // Why: validatedUrl crosses process/persistence boundaries, so redact a + // Kagi session token the same way the main-process failure path does. + loadError: { + code: 0, + description: message, + validatedUrl: redactKagiSessionToken(url ?? browserTab.url) + } }) } finally { if (isCurrentRemoteOperationToken(pageToken)) { @@ -2398,6 +2386,12 @@ function RemoteBrowserPagePane({ }, [frameUrl, handleRemoteScreenshotWheel]) const remoteFrameStyle = useMemo(() => getRemoteBrowserFrameStyle(frameMetadata), [frameMetadata]) + const remoteFailureUrl = browserTab.loadError?.validatedUrl ?? browserTab.url + const remoteFailureExternalUrl = resolveRemoteFailureExternalUrl(remoteFailureUrl) + const showRemoteFailureOverlay = + Boolean(browserTab.loadError) && + remoteFailureUrl !== 'about:blank' && + remoteFailureUrl !== ORCA_BROWSER_BLANK_URL // Why: markup works on remote panes by snapshotting the already-displayed // screencast — no in-page injection needed, so it is enabled here even @@ -2692,6 +2686,44 @@ function RemoteBrowserPagePane({ )} + {showRemoteFailureOverlay && browserTab.loadError ? ( + void runRemoteNavigation('browser.reload')} + onTryHttps={(url) => void runRemoteNavigation('browser.goto', url)} + onCopy={(url) => void window.api.ui.writeClipboardText(url)} + onOpenExternal={(url) => void window.api.shell.openUrl(url)} + certificateFailure={certificateFailure} + expectedBrowserPageId={ + remotePageHandle?.environmentId === activeRuntimeEnvironmentId + ? remotePageHandle.remotePageId + : null + } + onProceedCertificate={async (challengeId) => { + const target = runtimeTarget() + if ( + !target || + remotePageHandle?.environmentId !== target.environmentId || + remotePageHandle.remotePageId !== certificateFailure?.browserPageId + ) { + return { ok: false, reason: 'missing' } + } + return callRuntimeRpc( + target, + 'browser.certificate.proceed', + { + worktree: runtimeWorktree, + page: remotePageHandle.remotePageId, + challengeId + }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + }} + /> + ) : null} {remoteError ? (
{remoteError} @@ -2879,6 +2911,9 @@ function BrowserPagePane({ const browserAnnotations = useAppStore( (s) => s.browserAnnotationsByPageId[browserTab.id] ?? EMPTY_BROWSER_ANNOTATIONS ) + const certificateFailure = useAppStore( + (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null + ) const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[worktreeId]) const browserAnnotationsRef = useRef(browserAnnotations) browserAnnotationsRef.current = browserAnnotations @@ -3731,21 +3766,53 @@ function BrowserPagePane({ dismissAddressBarSuggestionsRef.current?.() } - const handleDomReady = (): void => { + let registrationInFlight: { webContentsId: number; promise: Promise } | null = null + const registerGuest = (): Promise => { const webContentsId = webview.getWebContentsId() - let queuedAnnotationViewportBridgeSync = false - if (registeredWebContentsIds.get(browserTab.id) !== webContentsId) { - registeredWebContentsIds.set(browserTab.id, webContentsId) - queuedAnnotationViewportBridgeSync = true - void window.api.browser - .registerGuest({ - browserPageId: browserTab.id, - workspaceId, - worktreeId, - sessionProfileId, - webContentsId - }) - .finally(() => syncBrowserAnnotationViewportBridge()) + if (registeredWebContentsIds.get(browserTab.id) === webContentsId) { + return Promise.resolve(true) + } + if (registrationInFlight?.webContentsId === webContentsId) { + return registrationInFlight.promise + } + const promise = window.api.browser + .registerGuest({ + browserPageId: browserTab.id, + workspaceId, + worktreeId, + sessionProfileId, + webContentsId + }) + .then((registered) => { + if (registered) { + registeredWebContentsIds.set(browserTab.id, webContentsId) + } + return registered + }) + // Why: normalize IPC rejection to false so the dom-ready fallback can + // retry attach-policy races without an unhandled promise rejection. + .catch(() => false) + .finally(() => { + if (registrationInFlight?.promise === promise) { + registrationInFlight = null + } + }) + registrationInFlight = { webContentsId, promise } + return promise + } + + const handleDidAttach = (): void => { + // Why: certificate failures can happen before dom-ready. Register at + // attach so main can map the initial failure to this page; dom-ready below + // remains an idempotent fallback for attach-policy races. + void registerGuest().finally(() => syncBrowserAnnotationViewportBridge()) + } + + const handleDomReady = (): void => { + const queuedAnnotationViewportBridgeSync = + registeredWebContentsIds.get(browserTab.id) !== webview.getWebContentsId() + if (queuedAnnotationViewportBridgeSync) { + void registerGuest().finally(() => syncBrowserAnnotationViewportBridge()) } syncNavigationState(webview) if (keepAddressBarFocusRef.current) { @@ -3964,6 +4031,7 @@ function BrowserPagePane({ } } + webview.addEventListener('did-attach', handleDidAttach) webview.addEventListener('dom-ready', handleDomReady) webview.addEventListener('focus', dismissAddressBarSuggestions) webview.addEventListener('did-start-loading', handleDidStartLoading) @@ -3997,6 +4065,7 @@ function BrowserPagePane({ } return () => { + webview.removeEventListener('did-attach', handleDidAttach) webview.removeEventListener('dom-ready', handleDomReady) webview.removeEventListener('focus', dismissAddressBarSuggestions) webview.removeEventListener('did-start-loading', handleDidStartLoading) @@ -4672,8 +4741,8 @@ function BrowserPagePane({ const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url) const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url) - const loadErrorMeta = getLoadErrorMetadata(browserTab.loadError) - const loadErrorHint = formatLoadFailureRecoveryHint(loadErrorMeta) + const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? currentBrowserUrl + const failureExternalUrl = normalizeExternalBrowserUrl(failedNavigationUrl) const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab const visibleDownloads = (() => { const active = downloadStates.filter((download) => download.status === 'downloading') @@ -5476,114 +5545,40 @@ function BrowserPagePane({ onClose={() => setFindOpen(false)} webviewRef={webviewRef} /> - {showFailureOverlay ? ( -
-
-
- -
-

- {loadErrorMeta.host - ? translate( - 'auto.components.browser.pane.BrowserPane.db325a7eeb', - "Can't reach {{value0}}", - { value0: loadErrorMeta.host } - ) - : translate( - 'auto.components.browser.pane.BrowserPane.b2856516e2', - "Can't load this page" - )} -

-

- {formatLoadFailureDescription(browserTab.loadError, loadErrorMeta)} -

- {loadErrorHint ? ( -

{loadErrorHint}

- ) : null} -
- - - {externalUrl ? ( - - ) : null} -
-
-
+ {showFailureOverlay && browserTab.loadError ? ( + { + const webview = webviewRef.current + if (!webview) { + return + } + onUpdatePageStateRef.current(browserTab.id, { loading: true }) + retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current) + }} + onTryHttps={navigateToUrl} + onCopy={(url) => { + void window.api.ui.writeClipboardText(url) + setResourceNotice( + translate( + 'browser.loadFailure.addressCopied', + 'Copied the current page address.' + ) + ) + }} + onOpenExternal={(url) => void window.api.shell.openUrl(url)} + certificateFailure={certificateFailure} + expectedBrowserPageId={browserTab.id} + onProceedCertificate={(challengeId) => + window.api.browser.proceedCertificate({ + browserPageId: browserTab.id, + challengeId + }) + } + /> ) : null} {isBlankTab ? (
diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx new file mode 100644 index 000000000..d49e8ccdc --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx @@ -0,0 +1,319 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay' + +const callbacks = { + onRetry: vi.fn(), + onTryHttps: vi.fn(), + onCopy: vi.fn(), + onOpenExternal: vi.fn(), + onProceedCertificate: vi.fn() +} + +const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.clearAllMocks() +}) + +describe('BrowserLoadFailureOverlay', () => { + it('uses certificate-specific copy while keeping strict verification', () => { + render( + + ) + + expect(screen.getByText("Connection isn't secure")).toBeInTheDocument() + expect( + screen.getByText( + "Orca doesn't trust the authority that issued the certificate for localhost:3443." + ) + ).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.queryByText(/make sure the server is running/i)).not.toBeInTheDocument() + }) + + it('keeps restored certificate errors accurate without allowing approval', () => { + render( + + ) + + expect(screen.getByText("The certificate doesn't match localhost:3443.")).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Copy Address' })).toBeEnabled() + }) + + it('offers HTTPS recovery only for an eligible failed HTTP address', () => { + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Try HTTPS' })) + expect(callbacks.onTryHttps).toHaveBeenCalledWith('https://localhost:3000/app') + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Try HTTPS' })).toBeNull() + }) + + it('hides Open Externally and works without an external handler', () => { + render( + + ) + + // externalUrl / onOpenExternal omitted (e.g. a remote loopback failure): + // the action is absent and the overlay still renders its other recovery UI. + expect(screen.queryByRole('button', { name: 'Open Externally' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Copy Address' })).toBeEnabled() + }) + + it('does not show local-certificate guidance for a public-host failure', () => { + render( + + ) + + expect(screen.queryByText(/use a trusted local certificate/i)).not.toBeInTheDocument() + }) + + it('offers approval only for a matching live challenge', () => { + const { rerender } = render( + + ) + + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + + rerender( + + ) + expect(screen.getByText("Connection isn't secure")).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + }) + + it('disables every action immediately and delays connecting feedback', async () => { + vi.useFakeTimers() + let resolveProceed: ((result: { ok: true }) => void) | null = null + callbacks.onProceedCertificate.mockReturnValue( + new Promise((resolve) => { + resolveProceed = resolve + }) + ) + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + expect(callbacks.onProceedCertificate).toHaveBeenCalledWith('challenge-1') + for (const name of ['Proceed Anyway (Unsafe)', 'Open Externally', 'Retry', 'Copy Address']) { + expect(screen.getByRole('button', { name })).toBeDisabled() + } + expect(screen.queryByText('Connecting…')).toBeNull() + + act(() => vi.advanceTimersByTime(199)) + expect(screen.queryByText('Connecting…')).toBeNull() + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByRole('button', { name: 'Connecting…' })).toBeDisabled() + + await act(async () => resolveProceed?.({ ok: true })) + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + }) + + it('recovers from typed approval failures and resets for a new challenge', async () => { + callbacks.onProceedCertificate.mockResolvedValue({ ok: false, reason: 'expired' }) + const props = { + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/app' + }, + externalUrl: null, + currentUrl: 'https://localhost:3443/app', + httpsRecoveryUrl: null, + expectedBrowserPageId: 'page-1', + ...callbacks + } + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/certificate approval expired/i) + ) + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + + callbacks.onProceedCertificate.mockReturnValue(new Promise(() => {})) + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled() + rerender( + + ) + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx new file mode 100644 index 000000000..06fa72491 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx @@ -0,0 +1,340 @@ +import { useEffect, useRef, useState } from 'react' +import { Copy, ExternalLink, Globe, Loader2, RefreshCw, ShieldAlert } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { + BrowserCertificateFailure, + BrowserCertificateProceedFailureReason, + BrowserCertificateProceedResult, + BrowserLoadError +} from '../../../../shared/types' +import { isEligibleLocalCertificateHost } from '../../../../shared/browser-url' +import { + formatLoadFailureDescription, + formatLoadFailureRecoveryHint, + isCertificateLoadError, + type LoadFailureMeta +} from './browser-notices' + +type BrowserLoadFailureOverlayProps = { + loadError: BrowserLoadError + externalUrl?: string | null + currentUrl: string + httpsRecoveryUrl: string | null + onRetry: () => void + onTryHttps: (url: string) => void + onCopy: (url: string) => void + onOpenExternal?: (url: string) => void + certificateFailure?: BrowserCertificateFailure | null + expectedBrowserPageId?: string | null + onProceedCertificate?: (challengeId: string) => Promise +} + +type CertificateProceedAttempt = { + challengeId: string + state: 'submitting' | 'failed' + showConnecting: boolean + reason?: BrowserCertificateProceedFailureReason | 'request-failed' +} + +function getLoadErrorMetadata(loadError: BrowserLoadError): LoadFailureMeta { + try { + const parsed = new URL(loadError.validatedUrl) + return { + host: parsed.host || null, + isLocalhostLike: + parsed.hostname === '0.0.0.0' || isEligibleLocalCertificateHost(parsed.hostname) + } + } catch { + return { host: null, isLocalhostLike: false } + } +} + +function normalizeCertificateError(error: string): string { + return error + .trim() + .replace(/^net::/i, '') + .toUpperCase() +} + +function getMatchingCertificateFailure(args: { + loadError: BrowserLoadError + certificateFailure?: BrowserCertificateFailure | null + expectedBrowserPageId?: string | null + canProceed: boolean +}): BrowserCertificateFailure | null { + const { loadError, certificateFailure, expectedBrowserPageId, canProceed } = args + const errorMatchesChallenge = Boolean( + certificateFailure && + (loadError.code === -1 || + (loadError.code === certificateFailure.errorCode && + normalizeCertificateError(certificateFailure.error) === + normalizeCertificateError(loadError.description))) + ) + if ( + !canProceed || + !certificateFailure?.canProceed || + !expectedBrowserPageId || + certificateFailure.browserPageId !== expectedBrowserPageId || + certificateFailure.errorCode !== -202 || + !errorMatchesChallenge + ) { + return null + } + try { + return new URL(loadError.validatedUrl).origin === certificateFailure.origin + ? certificateFailure + : null + } catch { + return null + } +} + +function formatCertificateProceedFailure( + reason: BrowserCertificateProceedFailureReason | 'request-failed' +): string { + if (reason === 'expired') { + return translate( + 'browser.loadFailure.certificateChallengeExpired', + 'This certificate approval expired. Retry the page to request a new one.' + ) + } + if (reason === 'changed' || reason === 'navigated') { + return translate( + 'browser.loadFailure.certificateChallengeChanged', + 'The certificate request changed. Retry the page and review the new warning.' + ) + } + if (reason === 'request-failed') { + return translate( + 'browser.loadFailure.certificateProceedFailed', + 'Orca could not approve this certificate request. Retry the page and try again.' + ) + } + return translate( + 'browser.loadFailure.certificateChallengeUnavailable', + 'This certificate request is no longer available. Retry the page to request a new one.' + ) +} + +export function BrowserLoadFailureOverlay({ + loadError, + externalUrl, + currentUrl, + httpsRecoveryUrl, + onRetry, + onTryHttps, + onCopy, + onOpenExternal, + certificateFailure, + expectedBrowserPageId, + onProceedCertificate +}: BrowserLoadFailureOverlayProps): React.JSX.Element { + const connectingTimerRef = useRef<{ + challengeId: string + timer: ReturnType + } | null>(null) + const [proceedAttempt, setProceedAttempt] = useState(null) + const matchingCertificateFailure = getMatchingCertificateFailure({ + loadError, + certificateFailure, + expectedBrowserPageId, + canProceed: Boolean(onProceedCertificate) + }) + // Why: Chromium's error-page fallback can replace the diagnostic code with + // -1 after main has already captured an exact live certificate challenge. + const presentationLoadError = + matchingCertificateFailure && loadError.code === -1 + ? { + ...loadError, + code: matchingCertificateFailure.errorCode ?? loadError.code, + description: matchingCertificateFailure.error + } + : loadError + const meta = getLoadErrorMetadata(presentationLoadError) + const certificateError = isCertificateLoadError(presentationLoadError) + const recoveryHint = formatLoadFailureRecoveryHint(meta, presentationLoadError) + const activeProceedAttempt = + proceedAttempt?.challengeId === matchingCertificateFailure?.challengeId ? proceedAttempt : null + const actionsDisabled = activeProceedAttempt?.state === 'submitting' + + useEffect(() => { + return () => { + if (connectingTimerRef.current) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + } + }, [matchingCertificateFailure?.challengeId]) + + const proceedCertificate = (): void => { + if (!matchingCertificateFailure || !onProceedCertificate || actionsDisabled) { + return + } + const challengeId = matchingCertificateFailure.challengeId + setProceedAttempt({ challengeId, state: 'submitting', showConnecting: false }) + connectingTimerRef.current = { + challengeId, + timer: setTimeout(() => { + setProceedAttempt((current) => + current?.challengeId === challengeId && current.state === 'submitting' + ? { ...current, showConnecting: true } + : current + ) + }, 200) + } + void onProceedCertificate(challengeId) + .then((result) => { + if (connectingTimerRef.current?.challengeId === challengeId) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + if (!result.ok) { + setProceedAttempt((current) => + current?.challengeId === challengeId + ? { + challengeId, + state: 'failed', + showConnecting: false, + reason: result.reason + } + : current + ) + } + }) + .catch(() => { + if (connectingTimerRef.current?.challengeId === challengeId) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + setProceedAttempt((current) => + current?.challengeId === challengeId + ? { + challengeId, + state: 'failed', + showConnecting: false, + reason: 'request-failed' + } + : current + ) + }) + } + const retryButton = ( + + ) + const copyButton = ( + + ) + const externalButton = + externalUrl && onOpenExternal ? ( + + ) : null + + return ( +
+
+
+ {certificateError ? : } +
+

+ {certificateError + ? translate('browser.loadFailure.connectionNotSecure', "Connection isn't secure") + : meta.host + ? translate('browser.loadFailure.cantReachHost', "Can't reach {{value0}}", { + value0: meta.host + }) + : translate('browser.loadFailure.cantLoadPage', "Can't load this page")} +

+

+ {formatLoadFailureDescription(presentationLoadError, meta)} +

+ {certificateError && meta.isLocalhostLike ? ( +

+ {translate( + 'browser.loadFailure.trustedCertificateGuidance', + 'For local development, use a trusted local certificate when possible.' + )} +

+ ) : null} + {recoveryHint ?

{recoveryHint}

: null} + {activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? ( +

+ {formatCertificateProceedFailure(activeProceedAttempt.reason)} +

+ ) : null} +
+ {certificateError ? ( + <> + {externalButton} + {retryButton} + {copyButton} + {matchingCertificateFailure ? ( + + ) : null} + + ) : ( + <> + {httpsRecoveryUrl ? ( + + ) : null} + {retryButton} + {copyButton} + {externalButton} + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/browser-notices.test.ts b/src/renderer/src/components/browser-pane/browser-notices.test.ts index b08bca7ba..24f31d386 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.test.ts +++ b/src/renderer/src/components/browser-pane/browser-notices.test.ts @@ -5,7 +5,8 @@ import { formatLoadFailureDescription, formatLoadFailureRecoveryHint, formatPermissionNotice, - formatPopupNotice + formatPopupNotice, + isCertificateLoadError } from './browser-notices' describe('browser notice formatting', () => { @@ -114,4 +115,29 @@ describe('browser notice formatting', () => { }) ).toBeNull() }) + + it('formats certificate failures without local-server recovery advice', () => { + const meta = { host: 'localhost:3443', isLocalhostLike: true } + const loadError = (code: number) => ({ + code, + description: 'certificate error', + validatedUrl: 'https://localhost:3443/' + }) + + expect(formatLoadFailureDescription(loadError(-200), meta)).toBe( + "The certificate doesn't match localhost:3443." + ) + expect(formatLoadFailureDescription(loadError(-201), meta)).toBe( + "The certificate for localhost:3443 isn't valid at the current date and time." + ) + expect(formatLoadFailureDescription(loadError(-202), meta)).toBe( + "Orca doesn't trust the authority that issued the certificate for localhost:3443." + ) + expect(formatLoadFailureDescription(loadError(-208), meta)).toBe( + "Orca couldn't verify the certificate for localhost:3443." + ) + expect(isCertificateLoadError(loadError(-219))).toBe(true) + expect(isCertificateLoadError(loadError(-215))).toBe(false) + expect(formatLoadFailureRecoveryHint(meta, loadError(-202))).toBeNull() + }) }) diff --git a/src/renderer/src/components/browser-pane/browser-notices.ts b/src/renderer/src/components/browser-pane/browser-notices.ts index 81e31c8d8..8c7b22800 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.ts +++ b/src/renderer/src/components/browser-pane/browser-notices.ts @@ -4,8 +4,10 @@ import type { BrowserPopupEvent } from '../../../../shared/browser-guest-events' import type { BrowserLoadError } from '../../../../shared/types' +import { isChromiumCertificateErrorCode } from '../../../../shared/browser-certificate-errors' +import { translate } from '@/i18n/i18n' -type LoadFailureMeta = { +export type LoadFailureMeta = { host: string | null isLocalhostLike: boolean } @@ -73,6 +75,35 @@ export function formatLoadFailureDescription( if (!loadError) { return 'The page did not respond.' } + if (isChromiumCertificateErrorCode(loadError.code)) { + const host = meta.host ?? 'this address' + if (loadError.code === -200) { + return translate( + 'browser.loadFailure.certificateNameMismatch', + "The certificate doesn't match {{value0}}.", + { value0: host } + ) + } + if (loadError.code === -201) { + return translate( + 'browser.loadFailure.certificateDateInvalid', + "The certificate for {{value0}} isn't valid at the current date and time.", + { value0: host } + ) + } + if (loadError.code === -202) { + return translate( + 'browser.loadFailure.certificateAuthorityInvalid', + "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + { value0: host } + ) + } + return translate( + 'browser.loadFailure.certificateVerificationFailed', + "Orca couldn't verify the certificate for {{value0}}.", + { value0: host } + ) + } if (meta.isLocalhostLike) { return "We couldn't connect to your local server." } @@ -82,9 +113,16 @@ export function formatLoadFailureDescription( return "We couldn't connect to this page." } -export function formatLoadFailureRecoveryHint(meta: LoadFailureMeta): string | null { - if (!meta.isLocalhostLike) { +export function formatLoadFailureRecoveryHint( + meta: LoadFailureMeta, + loadError?: BrowserLoadErrorLike +): string | null { + if (!meta.isLocalhostLike || (loadError && isChromiumCertificateErrorCode(loadError.code))) { return null } return 'If this should be a local app, make sure the server is running and listening on the expected port.' } + +export function isCertificateLoadError(loadError: BrowserLoadErrorLike): boolean { + return Boolean(loadError && isChromiumCertificateErrorCode(loadError.code)) +} diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts index 3dae3be68..f46d958ae 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts @@ -50,6 +50,15 @@ describe('tab create entry classification', () => { }) }) + it('keeps the shared legacy local-dev forms in parity with address-bar normalization', () => { + for (const input of ['0.0.0.0:3000', '[::1]:3000', '[2001:db8::1]:3000/path']) { + expect(classifyTabEntryQuery(input, readyFiles([]))).toMatchObject({ + kind: 'host-url', + url: expect.stringMatching(/^http:/) + }) + } + }) + it('does not classify invalid numeric hosts as URLs', () => { expect(classifyTabEntryQuery('999.999.999.999', readyFiles([]))).toEqual({ kind: 'new-file', diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts index 2f1ddfecc..375e5d717 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts @@ -1,4 +1,5 @@ import { translate } from '@/i18n/i18n' +import { classifySchemeLessLocalDevAddress } from '../../../../shared/browser-url' const HOST_FILE_EXTENSIONS = new Set([ 'css', @@ -15,9 +16,6 @@ const HOST_FILE_EXTENSIONS = new Set([ 'yml' ]) -const LOCAL_ADDRESS_PATTERN = - /^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[[0-9a-f:]+\])(?::\d+)?(?:[/?#].*)?$/i - const LOCALHOST_WITH_PORT_PATTERN = /^localhost(?::\d{1,5})?$/i const IPV4_WITH_PORT_PATTERN = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?::\d{1,5})?$/ @@ -33,7 +31,7 @@ export type HostUrlClassification = { kind: 'host-url'; url: string } export function classifyExplicitUrl(query: string): ExplicitUrlClassification | null { // Local dev inputs are handled by host-url classification so users can // enter localhost/IP addresses without an explicit scheme. - if (LOCAL_ADDRESS_PATTERN.test(query)) { + if (classifySchemeLessLocalDevAddress(query)) { return null } let url: URL @@ -55,15 +53,8 @@ export function classifyExplicitUrl(query: string): ExplicitUrlClassification | } function classifyLocalDevUrl(query: string): HostUrlClassification | null { - if (!LOCAL_ADDRESS_PATTERN.test(query)) { - return null - } - try { - const url = new URL(`http://${query}`) - return url.hostname ? { kind: 'host-url', url: url.href } : null - } catch { - return null - } + const url = classifySchemeLessLocalDevAddress(query) + return url?.hostname ? { kind: 'host-url', url: url.href } : null } function classifyHostLikeUrl(query: string): HostUrlClassification | null { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 838cf5072..2a0fe6601 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2108,6 +2108,18 @@ export function useIpcEvents(): void { }) ) + const unsubscribeCertificateFailure = window.api.browser.onCertificateFailureChanged?.( + ({ browserPageId, failure }) => { + if (isRuntimeEnvironmentActive()) { + return + } + useAppStore.getState().setBrowserPageCertificateFailure(browserPageId, failure) + } + ) + if (unsubscribeCertificateFailure) { + unsubs.push(unsubscribeCertificateFailure) + } + // Why: agent-browser drives navigation via CDP, bypassing Electron's webview // event system. The renderer's did-navigate listener never fires for those // navigations, so the Zustand store (address bar, tab title) stays stale. diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 45f8b3fb8..38d542e76 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7,6 +7,29 @@ "webDescription": "Retry the web client or reconnect to the paired runtime." } }, + "browser": { + "loadFailure": { + "connectionNotSecure": "Connection isn't secure", + "cantReachHost": "Can't reach {{value0}}", + "cantLoadPage": "Can't load this page", + "certificateNameMismatch": "The certificate doesn't match {{value0}}.", + "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", + "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", + "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", + "retry": "Retry", + "copyAddress": "Copy Address", + "addressCopied": "Copied the current page address.", + "openExternally": "Open Externally", + "tryHttps": "Try HTTPS", + "proceedUnsafe": "Proceed Anyway (Unsafe)", + "connecting": "Connecting…", + "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", + "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", + "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", + "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + } + }, "settings": { "appearance": { "language": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 48b2a4b52..d8d0681e1 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13284,5 +13284,28 @@ } } } + }, + "browser": { + "loadFailure": { + "connectionNotSecure": "Connection isn't secure", + "cantReachHost": "Can't reach {{value0}}", + "cantLoadPage": "Can't load this page", + "certificateNameMismatch": "The certificate doesn't match {{value0}}.", + "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", + "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", + "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", + "retry": "Retry", + "copyAddress": "Copy Address", + "addressCopied": "Copied the current page address.", + "openExternally": "Open Externally", + "tryHttps": "Try HTTPS", + "proceedUnsafe": "Proceed Anyway (Unsafe)", + "connecting": "Connecting…", + "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", + "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", + "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", + "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 538b1ef1b..8b38bf610 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13284,5 +13284,28 @@ } } } + }, + "browser": { + "loadFailure": { + "connectionNotSecure": "Connection isn't secure", + "cantReachHost": "Can't reach {{value0}}", + "cantLoadPage": "Can't load this page", + "certificateNameMismatch": "The certificate doesn't match {{value0}}.", + "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", + "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", + "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", + "retry": "Retry", + "copyAddress": "Copy Address", + "addressCopied": "Copied the current page address.", + "openExternally": "Open Externally", + "tryHttps": "Try HTTPS", + "proceedUnsafe": "Proceed Anyway (Unsafe)", + "connecting": "Connecting…", + "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", + "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", + "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", + "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ccb2d8335..90745d323 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13284,5 +13284,28 @@ } } } + }, + "browser": { + "loadFailure": { + "connectionNotSecure": "Connection isn't secure", + "cantReachHost": "Can't reach {{value0}}", + "cantLoadPage": "Can't load this page", + "certificateNameMismatch": "The certificate doesn't match {{value0}}.", + "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", + "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", + "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", + "retry": "Retry", + "copyAddress": "Copy Address", + "addressCopied": "Copied the current page address.", + "openExternally": "Open Externally", + "tryHttps": "Try HTTPS", + "proceedUnsafe": "Proceed Anyway (Unsafe)", + "connecting": "Connecting…", + "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", + "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", + "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", + "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 867696505..d6cd49a02 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13284,5 +13284,28 @@ } } } + }, + "browser": { + "loadFailure": { + "connectionNotSecure": "Connection isn't secure", + "cantReachHost": "Can't reach {{value0}}", + "cantLoadPage": "Can't load this page", + "certificateNameMismatch": "The certificate doesn't match {{value0}}.", + "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", + "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", + "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", + "retry": "Retry", + "copyAddress": "Copy Address", + "addressCopied": "Copied the current page address.", + "openExternally": "Open Externally", + "tryHttps": "Try HTTPS", + "proceedUnsafe": "Proceed Anyway (Unsafe)", + "connecting": "Connecting…", + "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", + "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", + "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", + "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + } } } diff --git a/src/renderer/src/runtime/remote-server-parity.test.ts b/src/renderer/src/runtime/remote-server-parity.test.ts index 6122838dc..dceb8ad7a 100644 --- a/src/renderer/src/runtime/remote-server-parity.test.ts +++ b/src/renderer/src/runtime/remote-server-parity.test.ts @@ -48,6 +48,7 @@ function makeState(overrides: Partial = {}): WebSession activeWorktreeId: WT, agentStatusByPaneKey: {}, agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, browserPagesByWorkspace: {}, browserTabsByWorktree: {}, groupsByWorktree: {}, diff --git a/src/renderer/src/runtime/sync-runtime-graph-browser.test.ts b/src/renderer/src/runtime/sync-runtime-graph-browser.test.ts index c57ceee92..4042e70c5 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-browser.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-browser.test.ts @@ -20,6 +20,7 @@ function makeState(overrides: Partial = {}): AppState { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, activeTabId: null, @@ -103,7 +104,19 @@ describe('browser mobile session sync', () => { createdAt: 1 } ] - } as unknown as AppState['browserPagesByWorkspace'] + } as unknown as AppState['browserPagesByWorkspace'], + browserCertificateFailuresByPageId: { + 'page-1': { + challengeId: 'challenge-1', + browserPageId: 'page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + } }) expect(buildMobileSessionTabSnapshots(state)[0]?.tabs).toMatchObject([ @@ -115,11 +128,40 @@ describe('browser mobile session sync', () => { title: 'Example Page', url: 'https://example.com/path', canGoBack: true, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'page-1' + }, isActive: true } ]) }) + it('does not resurrect a stale workspace failure after the active page clears it', () => { + const staleError = { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } + const workspace = { ...makeBrowserWorkspace(), loadError: staleError } + const activePage = { + ...workspace, + id: 'page-1', + workspaceId: workspace.id, + loadError: null + } + const state = makeState({ + activeBrowserTabIdByWorktree: { 'wt-1': workspace.id }, + browserTabsByWorktree: { 'wt-1': [workspace] }, + browserPagesByWorkspace: { [workspace.id]: [activePage] } + }) + + expect(buildMobileSessionTabSnapshots(state)[0]?.tabs[0]).toMatchObject({ + type: 'browser', + loadError: null + }) + }) + it('publishes fallback browser tabs by workspace id when no unified tab exists', () => { const state = makeState({ activeBrowserTabIdByWorktree: { 'wt-1': 'browser-1' }, diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index b684e3fba..e735e1300 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1492,6 +1492,12 @@ function buildMobileBrowserTab( loading: activePage?.loading ?? workspace.loading, canGoBack: activePage?.canGoBack ?? workspace.canGoBack, canGoForward: activePage?.canGoForward ?? workspace.canGoForward, + // Why: null means the active page successfully cleared its failure. Falling + // back through ?? would resurrect a stale workspace-level error. + loadError: activePage ? activePage.loadError : workspace.loadError, + certificateFailure: activePage + ? (state.browserCertificateFailuresByPageId[activePage.id] ?? null) + : null, color: unifiedTab?.color ?? null, isPinned: unifiedTab?.isPinned === true, isActive: unifiedTabId diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index d5eef6225..9d5f20f92 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -64,6 +64,7 @@ function makeState(overrides: Partial = {}): WebSession activeWorktreeId: WT, agentStatusByPaneKey: {}, agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, browserPagesByWorkspace: {}, browserTabsByWorktree: {}, groupsByWorktree: {}, @@ -2359,6 +2360,21 @@ describe('applyWebSessionTabsSnapshot', () => { loading: false, canGoBack: true, canGoForward: false, + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + }, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'host-browser-page', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + }, color: '#3b82f6', isPinned: true, isActive: true @@ -2390,13 +2406,28 @@ describe('applyWebSessionTabsSnapshot', () => { worktreeId: WT, url: 'https://example.com/', title: 'Example Domain', - loading: false + loading: false, + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } } ]) expect(patch.remoteBrowserPageHandlesByPageId?.['host-browser-page']).toEqual({ environmentId: ENV, remotePageId: 'host-browser-page' }) + expect(patch.browserCertificateFailuresByPageId?.['host-browser-page']).toEqual({ + challengeId: 'challenge-1', + browserPageId: 'host-browser-page', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + }) expect(patch.unifiedTabsByWorktree?.[WT]).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -2888,6 +2919,18 @@ describe('applyWebSessionTabsSnapshot', () => { remoteBrowserPageHandlesByPageId: { [page.id]: { environmentId: ENV, remotePageId: 'host-browser-page' } }, + browserCertificateFailuresByPageId: { + [page.id]: { + challengeId: 'stale-challenge', + browserPageId: 'host-browser-page', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 100 + } + }, openFiles: [file], unifiedTabsByWorktree: { [WT]: existingTabs } }), @@ -2945,6 +2988,9 @@ describe('applyWebSessionTabsSnapshot', () => { }) ]) ) + // Why: older runtimes omit this transient field. Omission must clear an + // earlier challenge instead of leaving an unsafe action wired to stale RPC input. + expect(patch.browserCertificateFailuresByPageId).toEqual({}) }) it('uses local markdown preview file ids while preserving the host unified tab id', () => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 54a949657..41fbb2c8f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -19,6 +19,7 @@ import type { RuntimeMobileSessionTerminalClientTab } from '../../../shared/runtime-types' import type { + BrowserCertificateFailure, BrowserPage, BrowserWorkspace, Tab, @@ -104,6 +105,7 @@ type MirroredTerminalTab = { type MirroredBrowserTab = { workspace: BrowserWorkspace page: BrowserPage + certificateFailure: BrowserCertificateFailure | null remotePageId: string unifiedTab: Tab hostTabId: string @@ -130,6 +132,7 @@ export type WebSessionTabsSyncState = Pick< | 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'browserPagesByWorkspace' + | 'browserCertificateFailuresByPageId' | 'browserTabsByWorktree' | 'groupsByWorktree' | 'layoutByWorktree' @@ -951,7 +954,7 @@ function buildMirroredBrowserTabs( faviconUrl: existing?.page.faviconUrl ?? null, canGoBack: tab.canGoBack, canGoForward: tab.canGoForward, - loadError: null, + loadError: tab.loadError ?? null, createdAt, browserRuntimeEnvironmentId: environmentId, viewportPresetId: existing?.page.viewportPresetId ?? null @@ -975,6 +978,7 @@ function buildMirroredBrowserTabs( return { workspace, page, + certificateFailure: tab.certificateFailure ?? null, remotePageId: tab.browserPageId, unifiedTab: buildBrowserUnifiedTab(workspace, tab, existing?.unifiedTab ?? null, groupId), hostTabId: tab.id @@ -1478,6 +1482,29 @@ function browserPageEqual(a: BrowserPage, b: BrowserPage): boolean { ) } +function browserCertificateFailureEqual( + a: BrowserCertificateFailure | null | undefined, + b: BrowserCertificateFailure | null | undefined +): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + return Boolean( + left && + right && + left.challengeId === right.challengeId && + left.browserPageId === right.browserPageId && + left.errorCode === right.errorCode && + left.error === right.error && + left.origin === right.origin && + left.displayHost === right.displayHost && + left.canProceed === right.canProceed && + left.observedAt === right.observedAt + ) +} + function sameBrowserPages( a: readonly BrowserPage[] | undefined, b: readonly BrowserPage[] | null @@ -2135,6 +2162,7 @@ export function applyWebSessionTabsSnapshot( let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId + let nextBrowserCertificateFailuresByPageId = state.browserCertificateFailuresByPageId for (const removedWorkspaceId of removedBrowserWorkspaceIds) { const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? [] if (nextBrowserPagesByWorkspace[removedWorkspaceId]) { @@ -2145,6 +2173,13 @@ export function applyWebSessionTabsSnapshot( delete nextBrowserPagesByWorkspace[removedWorkspaceId] } for (const page of pages) { + if (nextBrowserCertificateFailuresByPageId[page.id]) { + nextBrowserCertificateFailuresByPageId = + nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId + ? { ...state.browserCertificateFailuresByPageId } + : nextBrowserCertificateFailuresByPageId + delete nextBrowserCertificateFailuresByPageId[page.id] + } if (nextRemoteBrowserPageHandlesByPageId[page.id]) { nextRemoteBrowserPageHandlesByPageId = nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId @@ -2154,7 +2189,7 @@ export function applyWebSessionTabsSnapshot( } } } - for (const { page, remotePageId } of mirroredBrowserTabs) { + for (const { page, certificateFailure, remotePageId } of mirroredBrowserTabs) { const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? [] if (!sameBrowserPages(current, [page])) { nextBrowserPagesByWorkspace = @@ -2177,6 +2212,22 @@ export function applyWebSessionTabsSnapshot( remotePageId } } + if ( + !browserCertificateFailureEqual( + nextBrowserCertificateFailuresByPageId[page.id], + certificateFailure + ) + ) { + nextBrowserCertificateFailuresByPageId = + nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId + ? { ...state.browserCertificateFailuresByPageId } + : nextBrowserCertificateFailuresByPageId + if (certificateFailure) { + nextBrowserCertificateFailuresByPageId[page.id] = certificateFailure + } else { + delete nextBrowserCertificateFailuresByPageId[page.id] + } + } } const nextTabsByWorktree = withWorktreeEntry( @@ -2383,6 +2434,9 @@ export function applyWebSessionTabsSnapshot( ...(nextRemoteBrowserPageHandlesByPageId !== state.remoteBrowserPageHandlesByPageId ? { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } : {}), + ...(nextBrowserCertificateFailuresByPageId !== state.browserCertificateFailuresByPageId + ? { browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId } + : {}), ...(nextActiveTabIdByWorktree !== state.activeTabIdByWorktree ? { activeTabIdByWorktree: nextActiveTabIdByWorktree } : {}), diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index 61a534c61..c6b441fbb 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -180,6 +180,39 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() }) + it('keeps certificate challenges transient across navigation, success, and close', () => { + const store = createTestStore() + const tab = store.getState().createBrowserTab('wt-1', 'https://localhost:3443/') + const pageId = tab.activePageId + if (!pageId) { + throw new Error('Expected a new browser page') + } + const failure = { + challengeId: 'challenge-1', + browserPageId: pageId, + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + + store.getState().setBrowserPageCertificateFailure(pageId, failure) + expect(store.getState().browserCertificateFailuresByPageId[pageId]).toEqual(failure) + + store.getState().setBrowserPageUrl(pageId, 'https://localhost:3443/next') + expect(store.getState().browserCertificateFailuresByPageId[pageId]).toBeUndefined() + + store.getState().setBrowserPageCertificateFailure(pageId, failure) + store.getState().updateBrowserPageState(pageId, { loadError: null }) + expect(store.getState().browserCertificateFailuresByPageId[pageId]).toBeUndefined() + + store.getState().setBrowserPageCertificateFailure(pageId, failure) + store.getState().closeBrowserTab(tab.id) + expect(store.getState().browserCertificateFailuresByPageId[pageId]).toBeUndefined() + }) + it('creates inactive browser unified tabs without stealing the visible tab', () => { const store = createTestStore() diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 10f955275..ea4d2e0eb 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -4,6 +4,7 @@ import type { AppState } from '../types' import type { BrowserCookieImportResult, BrowserCookieImportSummary, + BrowserCertificateFailure, BrowserHistoryEntry, BrowserLoadError, BrowserPage, @@ -117,6 +118,7 @@ export type RemoteBrowserPageHandle = { export type BrowserSlice = { browserTabsByWorktree: Record browserPagesByWorkspace: Record + browserCertificateFailuresByPageId: Record browserAnnotationsByPageId: Record remoteBrowserPageHandlesByPageId: Record activeBrowserTabId: string | null @@ -159,6 +161,10 @@ export type BrowserSlice = { consumeAddressBarFocusRequest: (pageId: string) => boolean updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void + setBrowserPageCertificateFailure: ( + pageId: string, + failure: BrowserCertificateFailure | null + ) => void setBrowserTabUrl: (pageId: string, url: string) => void setBrowserPageUrl: (pageId: string, url: string) => void setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void @@ -473,6 +479,7 @@ function findPage( export const createBrowserSlice: StateCreator = (set, get) => ({ browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, browserAnnotationsByPageId: {}, remoteBrowserPageHandlesByPageId: {}, activeBrowserTabId: null, @@ -675,8 +682,12 @@ export const createBrowserSlice: StateCreator = const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } delete nextBrowserPagesByWorkspace[tabId] const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } for (const page of closedPages) { delete nextBrowserAnnotationsByPageId[page.id] + delete nextBrowserCertificateFailuresByPageId[page.id] } remotePagesToClose = closedPages.flatMap((page) => { const handle = s.remoteBrowserPageHandlesByPageId[page.id] @@ -766,6 +777,7 @@ export const createBrowserSlice: StateCreator = recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } }) @@ -1040,6 +1052,10 @@ export const createBrowserSlice: StateCreator = delete nextRemoteBrowserPageHandlesByPageId[pageId] const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } delete nextBrowserAnnotationsByPageId[pageId] + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } + delete nextBrowserCertificateFailuresByPageId[pageId] return { browserPagesByWorkspace: { @@ -1072,6 +1088,7 @@ export const createBrowserSlice: StateCreator = ) ), remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } }) @@ -1367,6 +1384,32 @@ export const createBrowserSlice: StateCreator = } return nextState }) + if (updates.loadError === null) { + get().setBrowserPageCertificateFailure(pageId, null) + } + }, + + setBrowserPageCertificateFailure: (pageId, failure) => { + set((s) => { + const current = s.browserCertificateFailuresByPageId[pageId] + if (failure === null) { + if (!current) { + return s + } + const nextFailures = { ...s.browserCertificateFailuresByPageId } + delete nextFailures[pageId] + return { browserCertificateFailuresByPageId: nextFailures } + } + if (!findPage(s.browserPagesByWorkspace, pageId) || current === failure) { + return s + } + return { + browserCertificateFailuresByPageId: { + ...s.browserCertificateFailuresByPageId, + [pageId]: failure + } + } + }) }, setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url), @@ -1425,6 +1468,7 @@ export const createBrowserSlice: StateCreator = : {}) } }) + get().setBrowserPageCertificateFailure(pageId, null) }, setRemoteBrowserPageHandle: (pageId, handle) => { @@ -1688,6 +1732,7 @@ export const createBrowserSlice: StateCreator = activeTabTypeByWorktree: nextActiveTabTypeByWorktree, activeTabType, remoteBrowserPageHandlesByPageId: {}, + browserCertificateFailuresByPageId: {}, browserAnnotationsByPageId: {}, browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []) } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d6f6d6e58..29268cee1 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1993,12 +1993,14 @@ function createGitApi(): NonNullable['git']> { function createBrowserApi(): NonNullable['browser']> { return { - registerGuest: () => Promise.resolve(), + registerGuest: () => Promise.resolve(false), unregisterGuest: () => Promise.resolve(), openDevTools: () => Promise.resolve(false), setViewportOverride: () => Promise.resolve(false), setAnnotationViewportBridge: () => Promise.resolve(false), onGuestLoadFailed: () => noopUnsubscribe, + onCertificateFailureChanged: () => noopUnsubscribe, + proceedCertificate: () => Promise.resolve({ ok: false, reason: 'missing' }), onPermissionDenied: () => noopUnsubscribe, onPopup: () => noopUnsubscribe, onDownloadRequested: () => noopUnsubscribe, diff --git a/src/shared/browser-certificate-errors.ts b/src/shared/browser-certificate-errors.ts new file mode 100644 index 000000000..9f1eba87d --- /dev/null +++ b/src/shared/browser-certificate-errors.ts @@ -0,0 +1,9 @@ +// Why: use Chromium's exact certificate codes so unrelated network failures +// never receive certificate-specific recovery copy. +const CHROMIUM_CERTIFICATE_ERROR_CODES = new Set([ + -200, -201, -202, -203, -204, -205, -206, -207, -208, -210, -211, -212, -213, -214, -217, -219 +]) + +export function isChromiumCertificateErrorCode(code: number): boolean { + return CHROMIUM_CERTIFICATE_ERROR_CODES.has(code) +} diff --git a/src/shared/browser-url.test.ts b/src/shared/browser-url.test.ts index ce5487be0..4a246355f 100644 --- a/src/shared/browser-url.test.ts +++ b/src/shared/browser-url.test.ts @@ -2,10 +2,15 @@ import { describe, expect, it } from 'vitest' import { ORCA_BROWSER_BLANK_URL } from './constants' import { buildSearchUrl, + classifySchemeLessLocalDevAddress, + isEligibleLocalCertificateHost, normalizeKagiSessionLink, normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + resolveRemoteFailureExternalUrl, + toSecureCertificateEndpoint, + toHttpsRecoveryUrl } from './browser-url' describe('browser-url helpers', () => { @@ -20,6 +25,98 @@ describe('browser-url helpers', () => { ) }) + it('keeps the legacy scheme-less local-dev classifier broader than certificate eligibility', () => { + for (const input of [ + 'localhost:3000/path', + '127.0.0.1:5173', + '0.0.0.0:8080', + '[::1]:3000', + '[2001:db8::1]:3000' + ]) { + expect(classifySchemeLessLocalDevAddress(input), input).not.toBeNull() + } + expect(classifySchemeLessLocalDevAddress('app.localhost:3000')).toBeNull() + expect(isEligibleLocalCertificateHost('0.0.0.0')).toBe(false) + expect(isEligibleLocalCertificateHost('[2001:db8::1]')).toBe(false) + }) + + it('recognizes only canonical loopback certificate hosts', () => { + for (const hostname of [ + 'localhost', + 'LOCALHOST.', + 'app.localhost', + 'deep.app.localhost.', + '127.0.0.1', + '127.255.255.255', + '::1', + '[::1]' + ]) { + expect(isEligibleLocalCertificateHost(hostname), hostname).toBe(true) + } + for (const hostname of [ + '0.0.0.0', + '::', + '[2001:db8::1]', + '192.168.1.1', + 'localhost.example.com', + 'notlocalhost', + '.localhost', + '-bad.localhost', + 'bad-.localhost', + '127.0.0.999', + '127.00.0.1' + ]) { + expect(isEligibleLocalCertificateHost(hostname), hostname).toBe(false) + } + }) + + it('constructs HTTPS recovery URLs without changing the rest of the address', () => { + expect(toHttpsRecoveryUrl('http://localhost:3000/path?q=1#preview')).toBe( + 'https://localhost:3000/path?q=1#preview' + ) + expect(toHttpsRecoveryUrl('http://user:pass@127.0.0.2:8080/')).toBe( + 'https://user:pass@127.0.0.2:8080/' + ) + expect(toHttpsRecoveryUrl('http://localhost:80/path')).toBe('https://localhost/path') + expect(toHttpsRecoveryUrl('https://localhost:3000/')).toBeNull() + expect(toHttpsRecoveryUrl('http://0.0.0.0:3000/')).toBeNull() + expect(toHttpsRecoveryUrl('http://example.com/')).toBeNull() + expect(toHttpsRecoveryUrl('not a url')).toBeNull() + }) + + it('canonicalizes secure certificate endpoints without path or credential data', () => { + expect(toSecureCertificateEndpoint('https://User:secret@LOCALHOST.:443/path?q=1')).toBe( + 'https://localhost:443' + ) + expect(toSecureCertificateEndpoint('wss://localhost:3000/socket')).toBe( + 'https://localhost:3000' + ) + expect(toSecureCertificateEndpoint('https://[::1]/')).toBe('https://[::1]:443') + expect(toSecureCertificateEndpoint('http://localhost:3000/')).toBeNull() + expect(toSecureCertificateEndpoint('not a url')).toBeNull() + }) + + it('offers Open Externally for a remote failure only when the URL is desktop-reachable', () => { + // Loopback / wildcard hosts are unreachable from the desktop system browser. + expect(resolveRemoteFailureExternalUrl('https://localhost:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('https://127.0.0.1:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('https://127.0.0.9:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('https://[::1]:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('https://app.localhost:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('http://0.0.0.0:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('http://[::]:3000/')).toBeNull() + // Public hosts are reachable, so the action is offered. + expect(resolveRemoteFailureExternalUrl('https://example.com/app')).toBe( + 'https://example.com/app' + ) + expect(resolveRemoteFailureExternalUrl('http://example.com:8080/x')).toBe( + 'http://example.com:8080/x' + ) + // Non-web schemes and garbage never become an external target. + expect(resolveRemoteFailureExternalUrl('file:///etc/passwd')).toBeNull() + expect(resolveRemoteFailureExternalUrl('not a url')).toBeNull() + }) + it('keeps normal web URLs and blank tabs in the allowed set', () => { expect(normalizeBrowserNavigationUrl('https://example.com')).toBe('https://example.com/') expect(normalizeBrowserNavigationUrl('')).toBe(ORCA_BROWSER_BLANK_URL) diff --git a/src/shared/browser-url.ts b/src/shared/browser-url.ts index 71e980db2..7eb2ca6eb 100644 --- a/src/shared/browser-url.ts +++ b/src/shared/browser-url.ts @@ -34,6 +34,111 @@ const SEARCH_ENGINE_URLS: Record = { export const DEFAULT_SEARCH_ENGINE: SearchEngine = 'google' +export function classifySchemeLessLocalDevAddress(rawInput: string): URL | null { + const trimmed = rawInput.trim() + if (!LOCAL_ADDRESS_PATTERN.test(trimmed)) { + return null + } + try { + return new URL(`http://${trimmed}`) + } catch { + return null + } +} + +function normalizeCertificateHostname(hostname: string): string { + const lower = hostname.trim().toLowerCase() + const unbracketed = lower.startsWith('[') && lower.endsWith(']') ? lower.slice(1, -1) : lower + return unbracketed.endsWith('.') ? unbracketed.slice(0, -1) : unbracketed +} + +function isValidDnsName(name: string): boolean { + if (name.length === 0 || name.length > 253) { + return false + } + return name + .split('.') + .every( + (label) => + label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label) + ) +} + +function isIpv4Loopback(hostname: string): boolean { + const octets = hostname.split('.') + if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) { + return false + } + const values = octets.map(Number) + return ( + values[0] === 127 && + values.every((value, index) => value >= 0 && value <= 255 && octets[index] === String(value)) + ) +} + +export function isEligibleLocalCertificateHost(hostname: string): boolean { + const normalized = normalizeCertificateHostname(hostname) + if (normalized === '::1' || isIpv4Loopback(normalized)) { + return true + } + if (!isValidDnsName(normalized)) { + return false + } + return normalized === 'localhost' || normalized.endsWith('.localhost') +} + +function isWildcardBindHost(hostname: string): boolean { + const normalized = normalizeCertificateHostname(hostname) + return normalized === '0.0.0.0' || normalized === '::' +} + +export function toHttpsRecoveryUrl(rawUrl: string): string | null { + try { + const parsed = new URL(rawUrl) + if (parsed.protocol !== 'http:' || !isEligibleLocalCertificateHost(parsed.hostname)) { + return null + } + parsed.protocol = 'https:' + return parsed.toString() + } catch { + return null + } +} + +export function toSecureCertificateEndpoint(rawUrl: string): string | null { + try { + const parsed = new URL(rawUrl) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'wss:') { + return null + } + const normalizedHostname = normalizeCertificateHostname(parsed.hostname) + if (!normalizedHostname) { + return null + } + const endpointHost = normalizedHostname.includes(':') + ? `[${normalizedHostname}]` + : normalizedHostname + return `https://${endpointHost}:${parsed.port || '443'}` + } catch { + return null + } +} + +// Why: a remote-owned browser page's loopback URL is unreachable from the +// desktop system browser, so Open Externally is offered only for publicly +// reachable (non-loopback) failure URLs. +export function resolveRemoteFailureExternalUrl(rawUrl: string): string | null { + try { + const parsed = new URL(rawUrl) + if (isWildcardBindHost(parsed.hostname) || isEligibleLocalCertificateHost(parsed.hostname)) { + return null + } + } catch { + return null + } + return normalizeExternalBrowserUrl(rawUrl) +} + export function normalizeKagiSessionLink(rawLink: string): string | null { const trimmed = rawLink.trim() if (!trimmed) { @@ -163,12 +268,9 @@ export function normalizeBrowserNavigationUrl( return ORCA_BROWSER_BLANK_URL } - if (LOCAL_ADDRESS_PATTERN.test(trimmed)) { - try { - return new URL(`http://${trimmed}`).toString() - } catch { - return null - } + const localDevAddress = classifySchemeLessLocalDevAddress(trimmed) + if (localDevAddress) { + return localDevAddress.toString() } if (WINDOWS_UNC_PATH_PATTERN.test(trimmed)) { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 89e430301..f6242c93e 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -38,6 +38,7 @@ export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const // offscreen backend). Advertised only when that backend is actually available, so // clients never fall back to a local desktop browser tab for a remote-owned page. export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const +export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const // Why: hosts without this strip terminal.send's inputKind (zod object drops // unknown keys), so a mobile xterm query reply would land as ordinary // floor-taking input. Mobile must not forward replies unless advertised. diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 774114b6d..953dae579 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -8,6 +8,8 @@ import type { import type { BaseRefSearchResult, BrowserCookieImportResult, + BrowserCertificateFailure, + BrowserLoadError, BrowserSessionProfile, BrowserSessionProfileSource, CreateWorktreeResult, @@ -206,6 +208,8 @@ export type RuntimeMobileSessionBrowserTab = { loading: boolean canGoBack: boolean canGoForward: boolean + loadError?: BrowserLoadError | null + certificateFailure?: BrowserCertificateFailure | null color?: string | null isPinned?: boolean isActive: boolean @@ -818,6 +822,11 @@ export type BrowserTabInfo = { url: string title: string active: boolean + // Why: a failed load leaves getURL() at chrome-error://; surface the structured + // error so an agent driving the browser can tell a bypassable certificate + // failure from an ordinary network error the way the UI can. + loadError?: BrowserLoadError | null + certificateFailure?: BrowserCertificateFailure | null worktreeId?: string | null profileId?: string | null profileLabel?: string | null diff --git a/src/shared/types.ts b/src/shared/types.ts index 4d4301a0b..ff46520c4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -889,6 +889,28 @@ export type BrowserLoadError = { validatedUrl: string } +export type BrowserCertificateFailure = { + challengeId: string + browserPageId: string + errorCode: number | null + error: string + origin: string + displayHost: string + canProceed: boolean + observedAt: number +} + +export type BrowserCertificateProceedFailureReason = + | 'expired' + | 'changed' + | 'ineligible' + | 'missing' + | 'navigated' + +export type BrowserCertificateProceedResult = + | { ok: true } + | { ok: false; reason: BrowserCertificateProceedFailureReason } + // Why: BrowserPage persists the active viewport preset so CDP emulation can be // reapplied on reload/navigation without the user re-picking from the toolbar. export type BrowserViewportPresetId = diff --git a/tests/e2e/browser-local-https-certificate-trust.spec.ts b/tests/e2e/browser-local-https-certificate-trust.spec.ts new file mode 100644 index 000000000..c82ec75f9 --- /dev/null +++ b/tests/e2e/browser-local-https-certificate-trust.spec.ts @@ -0,0 +1,199 @@ +import type { Page } from '@stablyai/playwright-test' + +import { expect, test } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveWorktreeId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { startLocalHttpProbeServer, startLocalHttpsServer } from './helpers/local-https-test-server' + +type CreatedBrowserTab = { + id: string + pageId: string +} + +async function createBrowserTab( + page: Page, + worktreeId: string, + url: string +): Promise { + const tab = await page.evaluate( + ({ targetWorktreeId, targetUrl }) => { + const state = window.__store?.getState() + if (!state) { + return null + } + const browserTab = state.createBrowserTab(targetWorktreeId, targetUrl, { + title: 'Local TLS', + activate: true + }) + return { id: browserTab.id, pageId: browserTab.activePageId ?? null } + }, + { targetWorktreeId: worktreeId, targetUrl: url } + ) + if (!tab?.pageId) { + throw new Error('Failed to create local TLS browser page') + } + return { id: tab.id, pageId: tab.pageId } +} + +async function switchToBrowserTab(page: Page, worktreeId: string, browserTabId: string) { + await page.evaluate( + ({ targetWorktreeId, targetBrowserTabId }) => { + const state = window.__store?.getState() + if (!state) { + return + } + if ( + !(state.browserTabsByWorktree[targetWorktreeId] ?? []).some( + (tab) => tab.id === targetBrowserTabId + ) + ) { + return + } + state.setActiveBrowserTab(targetBrowserTabId) + state.setActiveTabType('browser') + }, + { targetWorktreeId: worktreeId, targetBrowserTabId: browserTabId } + ) +} + +function browserSlot(page: Page, pageId: string) { + return page.locator(`[data-browser-overlay-tab-id="${pageId}"]`) +} + +async function readBrowserHeading(page: Page, browserTabId: string): Promise { + return page.evaluate(async (targetBrowserTabId) => { + const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find( + (candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + return null + } + try { + return await webview.executeJavaScript('document.querySelector("h1")?.textContent ?? null') + } catch { + return null + } + }, browserTabId) +} + +async function readBrowserState( + page: Page, + browserTabId: string, + stateName: '__localTlsState' | '__siblingTlsProbe' +): Promise | null> { + return page.evaluate( + async ({ targetBrowserTabId, targetStateName }) => { + const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find( + (candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + return null + } + try { + return await webview.executeJavaScript(`window.${targetStateName} ?? null`) + } catch { + return null + } + }, + { targetBrowserTabId: browserTabId, targetStateName: stateName } + ) +} + +async function reloadBrowserGuest(page: Page, browserTabId: string): Promise { + await page.evaluate((targetBrowserTabId) => { + const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find( + (candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + throw new Error(`Missing webview for browser tab ${targetBrowserTabId}`) + } + webview.reload() + }, browserTabId) +} + +test.describe('local HTTPS certificate trust', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + }) + + test('approves one exact local certificate endpoint without trusting sibling tabs or ports', async ({ + orcaPage + }) => { + const firstServer = await startLocalHttpsServer() + const secondPortServer = await startLocalHttpsServer() + const siblingProbeServer = await startLocalHttpProbeServer(firstServer) + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const firstTab = await createBrowserTab(orcaPage, worktreeId, firstServer.schemeLessUrl) + const firstSlot = browserSlot(orcaPage, firstTab.id) + + await expect(firstSlot.getByRole('button', { name: 'Try HTTPS' })).toBeVisible() + await firstSlot.getByRole('button', { name: 'Try HTTPS' }).click() + await expect( + firstSlot.getByRole('heading', { name: "Connection isn't secure" }) + ).toBeVisible() + // The certificate-failure branch keeps its safe recovery actions and the + // certificate-specific hint, but never the local-server connectivity hint. + await expect(firstSlot.getByRole('button', { name: 'Copy Address' })).toBeVisible() + await expect(firstSlot.getByRole('button', { name: 'Retry' })).toBeVisible() + await expect(firstSlot.getByText(/use a trusted local certificate/i)).toBeVisible() + await expect(firstSlot.getByText(/make sure the server is running/i)).toHaveCount(0) + await firstSlot.getByRole('button', { name: 'Proceed Anyway (Unsafe)' }).click() + await expect + .poll(() => readBrowserHeading(orcaPage, firstTab.id), { timeout: 10_000 }) + .toBe('Local HTTPS request 1') + await expect + .poll(() => readBrowserState(orcaPage, firstTab.id, '__localTlsState')) + .toEqual({ asset: true, webSocket: true }) + expect(firstServer.assetRequestCount()).toBe(1) + expect(firstServer.webSocketConnectionCount()).toBe(1) + + const secondTab = await createBrowserTab(orcaPage, worktreeId, firstServer.secureUrl) + const secondSlot = browserSlot(orcaPage, secondTab.id) + await expect( + secondSlot.getByRole('heading', { name: "Connection isn't secure" }) + ).toBeVisible() + // Why: approval is scoped to the first guest WebContents, not its shared + // profile partition, so this sibling still requires an explicit decision. + await expect( + secondSlot.getByRole('button', { name: 'Proceed Anyway (Unsafe)' }) + ).toBeVisible() + + const probeTab = await createBrowserTab(orcaPage, worktreeId, siblingProbeServer.url) + await expect + .poll(() => readBrowserState(orcaPage, probeTab.id, '__siblingTlsProbe')) + .toEqual({ asset: 'blocked', webSocket: 'blocked' }) + expect(firstServer.assetRequestCount()).toBe(1) + expect(firstServer.webSocketConnectionCount()).toBe(1) + + await switchToBrowserTab(orcaPage, worktreeId, firstTab.id) + await reloadBrowserGuest(orcaPage, firstTab.id) + await expect.poll(firstServer.documentRequestCount, { timeout: 10_000 }).toBe(2) + await expect + .poll(() => readBrowserHeading(orcaPage, firstTab.id), { timeout: 10_000 }) + .toBe('Local HTTPS request 2') + await expect.poll(firstServer.assetRequestCount).toBe(2) + await expect.poll(firstServer.webSocketConnectionCount).toBe(2) + + const firstAddressBar = firstSlot.locator('[data-orca-browser-address-bar="true"]') + await firstAddressBar.fill(secondPortServer.secureUrl) + await firstAddressBar.press('Enter') + await expect( + firstSlot.getByRole('heading', { name: "Connection isn't secure" }) + ).toBeVisible() + await expect(firstSlot.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeVisible() + await expect.poll(secondPortServer.documentRequestCount).toBe(0) + } finally { + await Promise.all([firstServer.close(), secondPortServer.close(), siblingProbeServer.close()]) + } + }) +}) diff --git a/tests/e2e/helpers/local-https-test-certificate.ts b/tests/e2e/helpers/local-https-test-certificate.ts new file mode 100644 index 000000000..3fafc61bc --- /dev/null +++ b/tests/e2e/helpers/local-https-test-certificate.ts @@ -0,0 +1,50 @@ +// Static test-only identity with SANs for localhost and 127.0.0.1. Keeping the +// fixture in-repo avoids an OpenSSL dependency on Windows E2E runners. +export const LOCAL_HTTPS_TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCjkYboq/BDMxGh +0jYeeAEnhH466vJO2NTIsDAaqhVDxXSWqgcqPHhAXS3I4CCzBuTBKMCfZnFLjOf6 +/+8mGhMsWUmgkchZ23fo5kszbgslx+bu0NAT14OAuD0E5rNEJwGA2W7i3a3k1ULX +TSUpE9W1dxQLQzLSmXIKYST1Sadi3/qtuikcQW1E9Lyb1guf3gvm+jHSzUiM33ge +JX6zNkd3fX+uc2oNkm+tFo7Ol52A++cSIqxB8MCqrfUNhISyzu4Ozne9bsCoWdoW +4aLX+XVVRw9Y8nU9cIvt88H1a9oChrG+5oXU166pAdKZvAUV65fi/y3w3ldgKu5I +Xk+RJ+1xAgMBAAECggEAExg15DNPOd8MNcR7FCE8/EKhtEnRZd427+MuiGxWzWmv +d7erXLVAsf3WrpKoipG5UmnKG8mbG/9b5O+r+LoWRyj4uQWPupqt47q/qGY2J6/P +kA1BHzHbXINVfz0ZzBDUInkvkk0f49z3/7eWKRaTTgrzxHFQrWhjmV3YEUjrARYW +DtlAJiCuBW2Mrnl866kLwzH3KHGFs4bPYIyMr3LYDYVDlHEdGuseI1OclGXbDcyF +z0Es9Z1u+W6ttnamvanhS7MskWCMOddtXKpaiuDQqo1r3xbhSHCA+7cs7J0cQ1FX +cq5VZ2/EMyI++U0/0sW0aWnUNKmZcGNwaDschejI6QKBgQDPfsyZ4o8urLEwPciB +PnfHxWtB/9IhqKwUHoUg5rPtYsyQBoWqAFBfS68FP91yv5KH0Y1sQ3SbN11+1d2U +uk0gQaV9TqLa51/8Za6C8Sig7UdwArEmuEmIJhj0Jux6QxF6th9Dpnrd4Bxtm42T +lnEMSvSB3vbyDk1Q+WJKblpJWQKBgQDJzgBJDGuj7LJrMaGqLjRshOoCyYbBjVsO +3GX24Vm1eO9SSOEpucoqf7hswnKx+/h1GPCxTife9/qobgGV64L7CMSrQviIC39l +GlUGyh1onGEZB/0xLjswuocRBU9qX88zRwpanqctxMtxD/36k2IP6ru4acvrryKD +lWla01Sp2QKBgGkuKH7VFqmdRpBisTG6vbMZgt5I1HbVbq0gL3HXIFv0Gifj9nuP +fy5fShAKKLITJC8O7XZ01zYbIZy6woCy04fHXyEe7HS0lrZ1wLmFj4fL38uKwcwT +3MpULZAN7w+m0cR3b2+2g0/XW/G/yUuIFjQaBsmSgXGACHdEgyuhtsi5AoGASLKu +RaJ00Gu/ZoBNpdnZRtKm3nQs2GMMz5C0JrjNsWMsi673dimY27CBBqUR3m5P9hcS +9jyafmdE5BIk/hYGbFqfRrbsg03pCcnvoW+EIqBbFkJbgrEN36MCby5DiqWTJfzM +jRKkVQeU5lkFfJRFekhscaWjMXc47sAPYQnKcRkCgYEAsHhU/aUtLUnSDgqrmRoC +VBxeCBa3/pMlBuiiRRMKvjMp+h0eGmgP0sP549d+PGBjkAu7Bp1szWUuB19OcULy +c33XAFdeZScLL1E3eMRVX4LwfJ5ZlUBrd3fv5R2X9D0TrAcHaGEVVHhkwRlhi69W +AcJ+zYua3ZDxmpZtCgGomOA= +-----END PRIVATE KEY-----` + +export const LOCAL_HTTPS_TEST_CERTIFICATE = `-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUIw41Eu7j9W1oTBKtoStG2UbRdUIwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDcxMzA2MzAxN1oXDTM2MDcx +MDA2MzAxN1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAo5GG6KvwQzMRodI2HngBJ4R+OuryTtjUyLAwGqoVQ8V0 +lqoHKjx4QF0tyOAgswbkwSjAn2ZxS4zn+v/vJhoTLFlJoJHIWdt36OZLM24LJcfm +7tDQE9eDgLg9BOazRCcBgNlu4t2t5NVC100lKRPVtXcUC0My0plyCmEk9UmnYt/6 +rbopHEFtRPS8m9YLn94L5vox0s1IjN94HiV+szZHd31/rnNqDZJvrRaOzpedgPvn +EiKsQfDAqq31DYSEss7uDs53vW7AqFnaFuGi1/l1VUcPWPJ1PXCL7fPB9WvaAoax +vuaF1NeuqQHSmbwFFeuX4v8t8N5XYCruSF5PkSftcQIDAQABo28wbTAdBgNVHQ4E +FgQUCmY8mF92kNqEbWlJ15z7Y1qq8wcwHwYDVR0jBBgwFoAUCmY8mF92kNqEbWlJ +15z7Y1qq8wcwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBACidMu/gBoJozW/HwWzU924fHndhnZRV +nhzR1JWB1/7DaeC1ePJYs6eRKe+A7mV1qxPXDMpMqzv3KAo4WirDW8y4rsM/sp5w +C2qTGbo+nF6u4tX6p9RQnN4HPeJfGXOtOfkyXK7M7C7O+Rzo3RdJmZZk8GBWe0+q +vYIIvbLmC4zRFLL2Mp8fPlW7VeQWl8u+2nXQrC/0oOGN7cXjtsj8Dcu6939srMW/ +6KiypTji5Idkn7HK2qq4Obbzbet8euWabhMZ3N5+tAcWHvJoNy6wGQlBZmZaPbQD +6LnMrP0J7dmEcShy5MXhNp6BFQ9jkhGVkIxk3fRtHFO9vDPYbdo0Pho= +-----END CERTIFICATE-----` diff --git a/tests/e2e/helpers/local-https-test-server.ts b/tests/e2e/helpers/local-https-test-server.ts new file mode 100644 index 000000000..b2c99fa4e --- /dev/null +++ b/tests/e2e/helpers/local-https-test-server.ts @@ -0,0 +1,162 @@ +import { createServer as createHttpServer, type Server } from 'node:http' +import { createServer as createHttpsServer } from 'node:https' +import type { AddressInfo } from 'node:net' +import { WebSocketServer } from 'ws' + +import { + LOCAL_HTTPS_TEST_CERTIFICATE, + LOCAL_HTTPS_TEST_PRIVATE_KEY +} from './local-https-test-certificate' + +export type LocalHttpsServer = { + secureUrl: string + schemeLessUrl: string + documentRequestCount: () => number + assetRequestCount: () => number + webSocketConnectionCount: () => number + close: () => Promise +} + +export type LocalHttpProbeServer = { + url: string + close: () => Promise +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + ) +} + +export async function startLocalHttpsServer(): Promise { + let documentRequestCount = 0 + let assetRequestCount = 0 + let webSocketConnectionCount = 0 + let secureOrigin = '' + const server = createHttpsServer( + { key: LOCAL_HTTPS_TEST_PRIVATE_KEY, cert: LOCAL_HTTPS_TEST_CERTIFICATE }, + (request, response) => { + const requestUrl = new URL(request.url ?? '/', secureOrigin) + if (requestUrl.pathname === '/asset.svg') { + assetRequestCount += 1 + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'image/svg+xml; charset=utf-8' + }) + response.end('') + return + } + // Why: count only the root document. Favicon/probes would inflate the + // document counter and flake exact E2E request-count assertions. + if (requestUrl.pathname !== '/') { + response.writeHead(404) + response.end() + return + } + documentRequestCount += 1 + const socketUrl = `${secureOrigin.replace('https:', 'wss:')}/socket` + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/html; charset=utf-8' + }) + response.end(` + + + Untrusted TLS page + +

Local HTTPS request ${documentRequestCount}

+ TLS asset + + + + `) + } + ) + const webSocketServer = new WebSocketServer({ noServer: true }) + server.on('upgrade', (request, socket, head) => { + if (request.url !== '/socket') { + socket.destroy() + return + } + webSocketServer.handleUpgrade(request, socket, head, (client) => { + webSocketServer.emit('connection', client, request) + }) + }) + webSocketServer.on('connection', (client) => { + webSocketConnectionCount += 1 + client.send('ready') + }) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + secureOrigin = `https://127.0.0.1:${port}` + + return { + secureUrl: `${secureOrigin}/`, + schemeLessUrl: `127.0.0.1:${port}/`, + documentRequestCount: () => documentRequestCount, + assetRequestCount: () => assetRequestCount, + webSocketConnectionCount: () => webSocketConnectionCount, + close: async () => { + for (const client of webSocketServer.clients) { + client.terminate() + } + await new Promise((resolve) => webSocketServer.close(() => resolve())) + await closeServer(server) + } + } +} + +export async function startLocalHttpProbeServer( + target: LocalHttpsServer +): Promise { + const assetUrl = new URL('/asset.svg', target.secureUrl).toString() + const socketUrl = new URL('/socket', target.secureUrl).toString().replace('https:', 'wss:') + const server = createHttpServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + response.end(` + + + Sibling TLS probe + +

Sibling TLS probe

+ Sibling TLS asset + + + + `) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { url: `http://127.0.0.1:${port}/`, close: () => closeServer(server) } +}