fix(browser): local HTTPS Try HTTPS + cert proceed (#8454) (#9104)

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Neil 2026-07-16 19:11:34 -07:00 committed by GitHub
parent 3b227d06e8
commit 6e91ca6c0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 4697 additions and 226 deletions

View File

@ -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 `<webview>` 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<string, BrowserCertificateFailure>
```
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:<port>` 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:<port>` 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.

View File

@ -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],

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<number, CertificateTrustGrant>()
private readonly grantSessionByGuestId = new Map<number, Session>()
private readonly guardedSessions = new Set<Session>()
private readonly acceptedIdentityBySession = new Map<Session, Map<string, CertificateIdentity>>()
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)
}
}
}

View File

@ -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<number, string | null>
let guestByPageId: Map<string, number>
let guestById: Map<number, Electron.WebContents>
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()
}
})
})

View File

@ -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<number, PendingCertificateChallenge>()
private readonly navigationSequenceByGuestId = new Map<number, number>()
private readonly expiryTimerByGuestId = new Map<number, ReturnType<typeof setTimeout>>()
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<Electron.Event, 'preventDefault'>
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()
}
}

View File

@ -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

View File

@ -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', () => {

View File

@ -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<T>(
promise: Promise<T>,
timeoutMs: number,
@ -238,23 +248,47 @@ export class BrowserManager {
private readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
private readonly worktreeIdByTabId = new Map<string, string>()
private readonly policyAttachedGuestIds = new Set<number>()
private readonly offscreenGuestIds = new Set<number>()
private readonly policyCleanupByGuestId = new Map<number, () => void>()
private readonly clickedLinkFrameNameByGuestId = new Map<number, string>()
private readonly loadErrorsByGuestId = new Map<number, BrowserLoadError>()
// 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<number, BrowserLoadError>()
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<number, PendingPermissionEvent[]>()
private readonly pendingPopupEventsByGuestId = new Map<number, PendingPopupEvent[]>()
private readonly pendingDownloadIdsByGuestId = new Map<number, string[]>()
private readonly downloadsById = new Map<string, ActiveDownload>()
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)

View File

@ -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', () => ({

View File

@ -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()
}
}))

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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.

View File

@ -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<BrowserCertificateProceedResult> {
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<BrowserTabShowResult> {
const target = await this.resolveBrowserCommandTarget(params)
return { tab: this.describeBrowserTab(params.page, target.worktreeId) }

View File

@ -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 worktrees offscreen browser pages when its metadata is removed (leak fix)', () => {

View File

@ -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<string> {
// Why: report which worktrees were reconciled in place so callers don't
// reconcile them a second time (see notifyMobileSessionTabsChanged).
const reconciledWorktreeIds = new Set<string>()
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)

View File

@ -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',

View File

@ -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,

View File

@ -1983,6 +1983,20 @@ describe('OrcaRuntimeRpcServer', () => {
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
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<string, unknown>),
() => {}
)
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 })
)

View File

@ -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<void>
}) => Promise<boolean>
unregisterGuest: (args: { browserPageId: string }) => Promise<void>
openDevTools: (args: { browserPageId: string }) => Promise<boolean>
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<BrowserCertificateProceedResult>
onPermissionDenied: (callback: (event: BrowserPermissionDeniedEvent) => void) => () => void
onPopup: (callback: (event: BrowserPopupEvent) => void) => () => void
onDownloadRequested: (callback: (event: BrowserDownloadRequestedEvent) => void) => () => void

View File

@ -2238,7 +2238,7 @@ const api = {
worktreeId: string
sessionProfileId?: string | null
webContentsId: number
}): Promise<void> => ipcRenderer.invoke('browser:registerGuest', args),
}): Promise<boolean> => ipcRenderer.invoke('browser:registerGuest', args),
unregisterGuest: (args: { browserPageId: string }): Promise<void> =>
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<typeof callback>[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) => {

View File

@ -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<BrowserTabInfo | null>
>(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 <img> — no in-page injection needed, so it is enabled here even
@ -2692,6 +2686,44 @@ function RemoteBrowserPagePane({
</div>
</div>
)}
{showRemoteFailureOverlay && browserTab.loadError ? (
<BrowserLoadFailureOverlay
loadError={browserTab.loadError}
externalUrl={remoteFailureExternalUrl}
currentUrl={toDisplayUrl(remoteFailureUrl)}
httpsRecoveryUrl={toHttpsRecoveryUrl(remoteFailureUrl)}
onRetry={() => 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<BrowserCertificateProceedResult>(
target,
'browser.certificate.proceed',
{
worktree: runtimeWorktree,
page: remotePageHandle.remotePageId,
challengeId
},
{ timeoutMs: 15_000, suppressFeatureInteraction: true }
)
}}
/>
) : null}
{remoteError ? (
<div className="absolute bottom-4 left-1/2 max-w-md -translate-x-1/2 rounded-md border border-border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md">
{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<boolean> } | null = null
const registerGuest = (): Promise<boolean> => {
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 ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.02),transparent_58%)] px-6">
<div className="flex max-w-sm flex-col items-center px-8 py-8 text-center opacity-70">
<div className="mb-4 rounded-full border border-border/70 bg-muted/30 p-3">
<Globe className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-semibold text-foreground/85">
{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"
)}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{formatLoadFailureDescription(browserTab.loadError, loadErrorMeta)}
</p>
{loadErrorHint ? (
<p className="mt-2 text-xs text-muted-foreground/80">{loadErrorHint}</p>
) : null}
<div className="mt-5 flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-9 gap-2 px-3"
title={translate(
'auto.components.browser.pane.BrowserPane.781d6459ad',
'Retry'
)}
onClick={() => {
const webview = webviewRef.current
if (!webview) {
return
}
onUpdatePageStateRef.current(browserTab.id, {
loading: true
})
retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current)
}}
>
<RefreshCw className="size-4" />
<span>
{translate(
'auto.components.browser.pane.BrowserPane.c6be71329e',
'Refresh'
)}
</span>
</Button>
<Button
size="sm"
variant="ghost"
className="h-9 gap-2 px-3"
title={translate(
'auto.components.browser.pane.BrowserPane.3c085f638d',
'Copy failed page URL'
)}
onClick={() => {
// Why: failed guests often leave users stranded on a blank
// error surface. Put the current URL on the clipboard from
// the recovery UI itself so they can retry elsewhere
// without having to discover the toolbar overflow first.
void window.api.ui.writeClipboardText(currentBrowserUrl)
setResourceNotice('Copied the current page URL.')
}}
>
<Copy className="size-4" />
<span>
{translate(
'auto.components.browser.pane.BrowserPane.93be92f8d1',
'Copy Address'
)}
</span>
</Button>
{externalUrl ? (
<Button
size="sm"
variant="ghost"
className="h-9 gap-2 px-3"
title={translate(
'auto.components.browser.pane.BrowserPane.da68d35f7b',
'Open failed page in default browser'
)}
onClick={() => {
// Why: page failures inside Orca can still be recoverable
// in the system browser, especially for OAuth, captive
// portals, or enterprise auth flows that rely on a full
// browser profile. Keep this action in the failed-state
// overlay so recovery does not depend on toolbar affordance
// discovery while the guest itself is unusable.
void window.api.shell.openUrl(externalUrl)
}}
>
<ExternalLink className="size-4" />
<span>
{translate(
'auto.components.browser.pane.BrowserPane.1c78adc73d',
'Open Externally'
)}
</span>
</Button>
) : null}
</div>
</div>
</div>
{showFailureOverlay && browserTab.loadError ? (
<BrowserLoadFailureOverlay
loadError={browserTab.loadError}
externalUrl={failureExternalUrl}
currentUrl={toDisplayUrl(failedNavigationUrl)}
httpsRecoveryUrl={toHttpsRecoveryUrl(failedNavigationUrl)}
onRetry={() => {
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 ? (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.02),transparent_58%)] px-6">

View File

@ -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(
<BrowserLoadFailureOverlay
loadError={{
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3443/app'
}}
externalUrl="https://localhost:3443/app"
currentUrl="https://localhost:3443/app"
httpsRecoveryUrl={null}
{...callbacks}
/>
)
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(
<BrowserLoadFailureOverlay
loadError={{
code: -200,
description: 'ERR_CERT_COMMON_NAME_INVALID',
validatedUrl: 'https://localhost:3443/'
}}
externalUrl={null}
currentUrl="https://localhost:3443/"
httpsRecoveryUrl={null}
{...callbacks}
/>
)
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(
<BrowserLoadFailureOverlay
loadError={{
code: -102,
description: 'ERR_CONNECTION_REFUSED',
validatedUrl: 'http://localhost:3000/app'
}}
externalUrl="http://localhost:3000/app"
currentUrl="http://localhost:3000/app"
httpsRecoveryUrl="https://localhost:3000/app"
{...callbacks}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Try HTTPS' }))
expect(callbacks.onTryHttps).toHaveBeenCalledWith('https://localhost:3000/app')
rerender(
<BrowserLoadFailureOverlay
loadError={{
code: -102,
description: 'ERR_CONNECTION_REFUSED',
validatedUrl: 'https://localhost:3000/app'
}}
externalUrl="https://localhost:3000/app"
currentUrl="https://localhost:3000/app"
httpsRecoveryUrl={null}
{...callbacks}
/>
)
expect(screen.queryByRole('button', { name: 'Try HTTPS' })).toBeNull()
})
it('hides Open Externally and works without an external handler', () => {
render(
<BrowserLoadFailureOverlay
loadError={{
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3443/'
}}
currentUrl="https://localhost:3443/"
httpsRecoveryUrl={null}
onRetry={callbacks.onRetry}
onTryHttps={callbacks.onTryHttps}
onCopy={callbacks.onCopy}
/>
)
// 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(
<BrowserLoadFailureOverlay
loadError={{
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://example.com/'
}}
externalUrl="https://example.com/"
currentUrl="https://example.com/"
httpsRecoveryUrl={null}
{...callbacks}
/>
)
expect(screen.queryByText(/use a trusted local certificate/i)).not.toBeInTheDocument()
})
it('offers approval only for a matching live challenge', () => {
const { rerender } = render(
<BrowserLoadFailureOverlay
loadError={{
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3443/app'
}}
externalUrl={null}
currentUrl="https://localhost:3443/app"
httpsRecoveryUrl={null}
certificateFailure={certificateFailure}
expectedBrowserPageId="page-1"
{...callbacks}
/>
)
expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled()
rerender(
<BrowserLoadFailureOverlay
loadError={{
code: -1,
description: 'ERR_FAILED',
validatedUrl: 'https://localhost:3443/app'
}}
externalUrl={null}
currentUrl="https://localhost:3443/app"
httpsRecoveryUrl={null}
certificateFailure={certificateFailure}
expectedBrowserPageId="page-1"
{...callbacks}
/>
)
expect(screen.getByText("Connection isn't secure")).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled()
rerender(
<BrowserLoadFailureOverlay
loadError={{
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3444/app'
}}
externalUrl={null}
currentUrl="https://localhost:3444/app"
httpsRecoveryUrl={null}
certificateFailure={certificateFailure}
expectedBrowserPageId="page-1"
{...callbacks}
/>
)
expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull()
rerender(
<BrowserLoadFailureOverlay
loadError={{
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3443/app'
}}
externalUrl={null}
currentUrl="https://localhost:3443/app"
httpsRecoveryUrl={null}
certificateFailure={certificateFailure}
expectedBrowserPageId="other-page"
{...callbacks}
/>
)
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(
<BrowserLoadFailureOverlay
loadError={{
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3443/app'
}}
externalUrl="https://localhost:3443/app"
currentUrl="https://localhost:3443/app"
httpsRecoveryUrl={null}
certificateFailure={certificateFailure}
expectedBrowserPageId="page-1"
{...callbacks}
/>
)
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(
<BrowserLoadFailureOverlay
loadError={{
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3443/app'
}}
externalUrl="https://localhost:3443/app"
currentUrl="https://localhost:3443/app"
httpsRecoveryUrl={null}
certificateFailure={null}
expectedBrowserPageId="page-1"
{...callbacks}
/>
)
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(
<BrowserLoadFailureOverlay {...props} certificateFailure={certificateFailure} />
)
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(
<BrowserLoadFailureOverlay
{...props}
certificateFailure={{ ...certificateFailure, challengeId: 'challenge-2' }}
/>
)
expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled()
expect(screen.queryByRole('alert')).toBeNull()
})
})

View File

@ -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<BrowserCertificateProceedResult>
}
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<typeof setTimeout>
} | null>(null)
const [proceedAttempt, setProceedAttempt] = useState<CertificateProceedAttempt | null>(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 = (
<Button
size="sm"
variant={certificateError && !externalUrl ? 'default' : 'outline'}
className="h-9 gap-2 px-3"
disabled={actionsDisabled}
onClick={onRetry}
>
<RefreshCw className="size-4" />
{translate('browser.loadFailure.retry', 'Retry')}
</Button>
)
const copyButton = (
<Button
size="sm"
variant="ghost"
className="h-9 gap-2 px-3"
disabled={actionsDisabled}
onClick={() => onCopy(currentUrl)}
>
<Copy className="size-4" />
{translate('browser.loadFailure.copyAddress', 'Copy Address')}
</Button>
)
const externalButton =
externalUrl && onOpenExternal ? (
<Button
size="sm"
variant={certificateError ? 'default' : 'ghost'}
className="h-9 gap-2 px-3"
disabled={actionsDisabled}
onClick={() => onOpenExternal(externalUrl)}
>
<ExternalLink className="size-4" />
{translate('browser.loadFailure.openExternally', 'Open Externally')}
</Button>
) : null
return (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background px-6">
<div aria-live="polite" className="flex max-w-lg flex-col items-center px-8 py-8 text-center">
<div className="mb-4 rounded-full border border-border bg-muted p-3 text-muted-foreground">
{certificateError ? <ShieldAlert className="size-5" /> : <Globe className="size-5" />}
</div>
<h2 className="text-base font-semibold text-foreground">
{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")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{formatLoadFailureDescription(presentationLoadError, meta)}
</p>
{certificateError && meta.isLocalhostLike ? (
<p className="mt-2 text-xs text-muted-foreground">
{translate(
'browser.loadFailure.trustedCertificateGuidance',
'For local development, use a trusted local certificate when possible.'
)}
</p>
) : null}
{recoveryHint ? <p className="mt-2 text-xs text-muted-foreground">{recoveryHint}</p> : null}
{activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? (
<p role="alert" className="mt-3 text-xs text-destructive">
{formatCertificateProceedFailure(activeProceedAttempt.reason)}
</p>
) : null}
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
{certificateError ? (
<>
{externalButton}
{retryButton}
{copyButton}
{matchingCertificateFailure ? (
<Button
size="sm"
variant="outline"
className="h-9 w-48 gap-2 px-3"
disabled={actionsDisabled}
onClick={proceedCertificate}
>
{activeProceedAttempt?.state === 'submitting' &&
activeProceedAttempt.showConnecting ? (
<>
<Loader2 className="size-4 animate-spin" />
{translate('browser.loadFailure.connecting', 'Connecting…')}
</>
) : (
translate('browser.loadFailure.proceedUnsafe', 'Proceed Anyway (Unsafe)')
)}
</Button>
) : null}
</>
) : (
<>
{httpsRecoveryUrl ? (
<Button
size="sm"
className="h-9 gap-2 px-3"
disabled={actionsDisabled}
onClick={() => onTryHttps(httpsRecoveryUrl)}
>
{translate('browser.loadFailure.tryHttps', 'Try HTTPS')}
</Button>
) : null}
{retryButton}
{copyButton}
{externalButton}
</>
)}
</div>
</div>
</div>
)
}

View File

@ -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()
})
})

View File

@ -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))
}

View File

@ -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',

View File

@ -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 {

View File

@ -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.

View File

@ -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": {

View File

@ -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."
}
}
}

View File

@ -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."
}
}
}

View File

@ -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."
}
}
}

View File

@ -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."
}
}
}

View File

@ -48,6 +48,7 @@ function makeState(overrides: Partial<WebSessionTabsSyncState> = {}): WebSession
activeWorktreeId: WT,
agentStatusByPaneKey: {},
agentStatusEpoch: 0,
browserCertificateFailuresByPageId: {},
browserPagesByWorkspace: {},
browserTabsByWorktree: {},
groupsByWorktree: {},

View File

@ -20,6 +20,7 @@ function makeState(overrides: Partial<AppState> = {}): 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' },

View File

@ -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

View File

@ -64,6 +64,7 @@ function makeState(overrides: Partial<WebSessionTabsSyncState> = {}): 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', () => {

View File

@ -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 }
: {}),

View File

@ -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()

View File

@ -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<string, BrowserWorkspace[]>
browserPagesByWorkspace: Record<string, BrowserPage[]>
browserCertificateFailuresByPageId: Record<string, BrowserCertificateFailure>
browserAnnotationsByPageId: Record<string, BrowserPageAnnotation[]>
remoteBrowserPageHandlesByPageId: Record<string, RemoteBrowserPageHandle>
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<AppState, [], [], BrowserSlice> = (set, get) => ({
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
browserCertificateFailuresByPageId: {},
browserAnnotationsByPageId: {},
remoteBrowserPageHandlesByPageId: {},
activeBrowserTabId: null,
@ -675,8 +682,12 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree,
recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace,
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId
}
})
@ -1040,6 +1052,10 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
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<AppState, [], [], BrowserSlice> =
)
),
remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId,
browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId,
browserAnnotationsByPageId: nextBrowserAnnotationsByPageId
}
})
@ -1367,6 +1384,32 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
}
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<AppState, [], [], BrowserSlice> =
: {})
}
})
get().setBrowserPageCertificateFailure(pageId, null)
},
setRemoteBrowserPageHandle: (pageId, handle) => {
@ -1688,6 +1732,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
activeTabType,
remoteBrowserPageHandlesByPageId: {},
browserCertificateFailuresByPageId: {},
browserAnnotationsByPageId: {},
browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? [])
}

View File

@ -1993,12 +1993,14 @@ function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
function createBrowserApi(): NonNullable<Partial<PreloadApi>['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,

View File

@ -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)
}

View File

@ -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)

View File

@ -34,6 +34,111 @@ const SEARCH_ENGINE_URLS: Record<SearchEngine, string> = {
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)) {

View File

@ -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.

View File

@ -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

View File

@ -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 =

View File

@ -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<CreatedBrowserTab> {
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<string | null> {
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<Record<string, boolean | string> | 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<void> {
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()])
}
})
})

View File

@ -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-----`

View File

@ -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<void>
}
export type LocalHttpProbeServer = {
url: string
close: () => Promise<void>
}
function closeServer(server: Server): Promise<void> {
return new Promise<void>((resolve, reject) =>
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
)
}
export async function startLocalHttpsServer(): Promise<LocalHttpsServer> {
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('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>')
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(`
<!doctype html>
<html>
<head><title>Untrusted TLS page</title></head>
<body>
<h1>Local HTTPS request ${documentRequestCount}</h1>
<img src="/asset.svg" alt="TLS asset">
<script>
window.__localTlsState = { asset: false, webSocket: false }
document.querySelector('img').addEventListener('load', () => {
window.__localTlsState.asset = true
})
const socket = new WebSocket(${JSON.stringify(socketUrl)})
socket.addEventListener('message', () => {
window.__localTlsState.webSocket = true
socket.close()
})
</script>
</body>
</html>
`)
}
)
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<void>((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<void>((resolve) => webSocketServer.close(() => resolve()))
await closeServer(server)
}
}
}
export async function startLocalHttpProbeServer(
target: LocalHttpsServer
): Promise<LocalHttpProbeServer> {
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(`
<!doctype html>
<html>
<head><title>Sibling TLS probe</title></head>
<body>
<h1>Sibling TLS probe</h1>
<img alt="Sibling TLS asset">
<script>
window.__siblingTlsProbe = { asset: 'pending', webSocket: 'pending' }
const image = document.querySelector('img')
image.addEventListener('load', () => { window.__siblingTlsProbe.asset = 'allowed' })
image.addEventListener('error', () => { window.__siblingTlsProbe.asset = 'blocked' })
image.src = ${JSON.stringify(assetUrl)}
const socket = new WebSocket(${JSON.stringify(socketUrl)})
socket.addEventListener('open', () => {
window.__siblingTlsProbe.webSocket = 'allowed'
socket.close()
})
socket.addEventListener('error', () => {
window.__siblingTlsProbe.webSocket = 'blocked'
})
</script>
</body>
</html>
`)
})
await new Promise<void>((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) }
}