Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
parent
3b227d06e8
commit
6e91ca6c0e
|
|
@ -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.
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -1538,6 +1538,123 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(capabilities).toContain('browser.screencast.v1')
|
||||
// ...and the headless marker tells clients not to fall back to a local tab.
|
||||
expect(capabilities).toContain('browser.headless.v1')
|
||||
expect(capabilities).toContain('browser.certificate-trust.v1')
|
||||
})
|
||||
|
||||
it('surfaces live offscreen load failures in headless browser snapshots', () => {
|
||||
const runtime = createRuntime()
|
||||
runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() })
|
||||
runtime.setAgentBrowserBridge({
|
||||
tabList: vi.fn(() => ({
|
||||
tabs: [
|
||||
{
|
||||
browserPageId: 'page-certificate-error',
|
||||
index: 0,
|
||||
url: 'https://localhost:3443/',
|
||||
title: 'Local HTTPS',
|
||||
active: true,
|
||||
loadError: {
|
||||
code: -202,
|
||||
description: 'ERR_CERT_AUTHORITY_INVALID',
|
||||
validatedUrl: 'https://localhost:3443/'
|
||||
},
|
||||
certificateFailure: {
|
||||
challengeId: 'challenge-1',
|
||||
browserPageId: 'page-certificate-error',
|
||||
errorCode: -202,
|
||||
error: 'ERR_CERT_AUTHORITY_INVALID',
|
||||
origin: 'https://localhost:3443',
|
||||
displayHost: 'localhost:3443',
|
||||
canProceed: true,
|
||||
observedAt: 123
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
} as never)
|
||||
const browserTabs = runtime['buildHeadlessMobileSessionBrowserTabs'](TEST_WORKTREE_ID)
|
||||
expect(browserTabs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'browser',
|
||||
browserPageId: 'page-certificate-error',
|
||||
loadError: {
|
||||
code: -202,
|
||||
description: 'ERR_CERT_AUTHORITY_INVALID',
|
||||
validatedUrl: 'https://localhost:3443/'
|
||||
},
|
||||
certificateFailure: {
|
||||
challengeId: 'challenge-1',
|
||||
browserPageId: 'page-certificate-error',
|
||||
errorCode: -202,
|
||||
error: 'ERR_CERT_AUTHORITY_INVALID',
|
||||
origin: 'https://localhost:3443',
|
||||
displayHost: 'localhost:3443',
|
||||
canProceed: true,
|
||||
observedAt: 123
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('detects headless browser-tab changes by field, treating absent and null loadError alike', () => {
|
||||
const runtime = createRuntime()
|
||||
const base = {
|
||||
type: 'browser' as const,
|
||||
id: 'page-1',
|
||||
title: 'Local',
|
||||
browserWorkspaceId: 'page-1',
|
||||
browserPageId: 'page-1',
|
||||
url: 'https://localhost:3443/',
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isActive: true
|
||||
}
|
||||
const err = {
|
||||
code: -202,
|
||||
description: 'ERR_CERT_AUTHORITY_INVALID',
|
||||
validatedUrl: 'https://localhost:3443/'
|
||||
}
|
||||
const certificateFailure = {
|
||||
challengeId: 'challenge-1',
|
||||
browserPageId: 'page-1',
|
||||
errorCode: -202,
|
||||
error: 'ERR_CERT_AUTHORITY_INVALID',
|
||||
origin: 'https://localhost:3443',
|
||||
displayHost: 'localhost:3443',
|
||||
canProceed: true,
|
||||
observedAt: 123
|
||||
}
|
||||
const unchanged = (a: unknown[], b: unknown[]): boolean =>
|
||||
runtime['headlessBrowserTabsUnchanged'](a as never, b as never)
|
||||
|
||||
// Absent vs explicit null loadError are equivalent (the JSON.stringify trap).
|
||||
expect(unchanged([{ ...base }], [{ ...base, loadError: null }])).toBe(true)
|
||||
expect(unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err } }])).toBe(
|
||||
true
|
||||
)
|
||||
// A load-error-only change (identical ids/order) must not be missed.
|
||||
expect(unchanged([{ ...base }], [{ ...base, loadError: err }])).toBe(false)
|
||||
expect(
|
||||
unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err, code: -200 } }])
|
||||
).toBe(false)
|
||||
expect(
|
||||
unchanged(
|
||||
[{ ...base, certificateFailure }],
|
||||
[{ ...base, certificateFailure: { ...certificateFailure } }]
|
||||
)
|
||||
).toBe(true)
|
||||
expect(unchanged([{ ...base }], [{ ...base, certificateFailure }])).toBe(false)
|
||||
expect(
|
||||
unchanged(
|
||||
[{ ...base, certificateFailure }],
|
||||
[{ ...base, certificateFailure: { ...certificateFailure, challengeId: 'challenge-2' } }]
|
||||
)
|
||||
).toBe(false)
|
||||
// Scalar and length changes are detected.
|
||||
expect(unchanged([{ ...base }], [{ ...base, title: 'Changed' }])).toBe(false)
|
||||
expect(unchanged([{ ...base }], [{ ...base, isActive: false }])).toBe(false)
|
||||
expect(unchanged([{ ...base }], [{ ...base }, { ...base, id: 'page-2' }])).toBe(false)
|
||||
})
|
||||
|
||||
it('does not advertise headless browser capability when a renderer window exists', () => {
|
||||
|
|
@ -1547,6 +1664,16 @@ describe('OrcaRuntimeService', () => {
|
|||
runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() })
|
||||
|
||||
expect(runtime.getStatus().capabilities).not.toContain('browser.headless.v1')
|
||||
// Desktop webviews still host certificate trust, so the proceed capability
|
||||
// remains advertised for remote clients controlling those pages.
|
||||
expect(runtime.getStatus().capabilities).toContain('browser.certificate-trust.v1')
|
||||
})
|
||||
|
||||
it('does not advertise certificate trust when no browser backend is available', () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
expect(runtime.getStatus().capabilities).not.toContain('browser.certificate-trust.v1')
|
||||
expect(runtime.getStatus().capabilities).not.toContain('browser.screencast.v1')
|
||||
})
|
||||
|
||||
it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ function makeState(overrides: Partial<WebSessionTabsSyncState> = {}): WebSession
|
|||
activeWorktreeId: WT,
|
||||
agentStatusByPaneKey: {},
|
||||
agentStatusEpoch: 0,
|
||||
browserCertificateFailuresByPageId: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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()])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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-----`
|
||||
|
|
@ -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) }
|
||||
}
|
||||
Loading…
Reference in New Issue