Skip to content

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

Merged
nwparker merged 1 commit into
mainfrom
nwparker/fix-8454-wave2
Jul 17, 2026
Merged

fix(browser): local HTTPS Try HTTPS + cert proceed (#8454)#9104
nwparker merged 1 commit into
mainfrom
nwparker/fix-8454-wave2

Conversation

@nwparker

Copy link
Copy Markdown
Contributor

Description

Fixes #8454 — the built-in browser could not reach HTTPS-only local dev servers (e.g. next dev --experimental-https).

Two independent failures:

  1. Scheme-less loopback still defaults to http:// (no silent flip to HTTPS — that would break normal HTTP dev servers). After a local HTTP failure, the load overlay offers Try HTTPS.
  2. Self-signed / untrusted loopback certs had no proceed path. Adds certificate-specific failure copy and Proceed Anyway (Unsafe) for ERR_CERT_AUTHORITY_INVALID on eligible loopback hosts only (localhost / *.localhost / 127/8 / ::1).

Approval is temporary and narrow: one WebContents × secure endpoint × leaf SHA-256 × error, in-memory only. A session webRequest gate contains Chromium’s certificate-cache so trust cannot leak to sibling tabs/assets/WSS. Desktop webviews and SSH/headless offscreen pages both supported (browser.certificate.proceed + browser.certificate-trust.v1).

Credit: Design + core implementation from @AmethystLiang in #9070. This PR rebases that work onto current main, resolves conflicts (markup mode, clicked-link frame routing, removed serveSimStateWatcher), and adds adversarial hardening (below). Supersedes #9070.

Evidence (of fix — tests/commands before→after)

Before (main / this worktree pre-fix):

$ pnpm exec vitest run --config config/vitest.config.ts src/shared/browser-url.test.ts -t "normalizes manual local-dev"
✓ normalizes manual local-dev inputs to http
  localhost:3000 → http://localhost:3000/   # documents forced-http contract
# no certificate-error handler, no Try HTTPS, no Proceed Anyway

After:

$ pnpm exec vitest run --config config/vitest.config.ts \
  src/shared/browser-url.test.ts \
  src/main/browser/browser-certificate-trust-controller.test.ts \
  src/main/browser/browser-manager.test.ts \
  src/main/ipc/browser.test.ts \
  src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx \
  src/renderer/src/components/browser-pane/browser-notices.test.ts \
  src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts \
  src/main/runtime/orca-runtime.test.ts \
  src/main/runtime/runtime-rpc.test.ts \
  src/renderer/src/store/slices/browser.test.ts \
  src/renderer/src/runtime/sync-runtime-graph-browser.test.ts

Test Files  11 passed (11)
     Tests  923 passed (923)

Still: scheme-less localhost:3000http://… (intentional). New coverage for eligibility, HTTPS recovery URLs, grant isolation, conflicting-leaf proceed rejection, and overlay actions. Real-Electron e2e suite added at tests/e2e/browser-local-https-certificate-trust.spec.ts (not run in this agent pass; unit mocks alone do not replace packaged validation).

ELI5

Typing localhost:3000 still goes to plain HTTP first (most dev servers are HTTP). If that fails, a button lets you try HTTPS. If the HTTPS cert is self-signed and local, Orca shows a clear warning and a one-time “I understand, open it” button for that tab only — not a global “ignore all certs” switch.

User-regression-tradeoffs

Ideally zero for normal workflows:

Area Tradeoff
HTTP localhost Unchanged default (http://)
Explicit https:// Still preserved
file:// / UNC / absolute paths Unchanged
Public HTTPS untrusted certs Still blocked (no proceed) — intentional
Non-authority cert errors (date, name, etc.) No proceed — intentional
Cross-tab trust Blocked by request gate after grant
Persisted exceptions None (cleared on tab close / process exit)

Adversarial review findings fixed (vs #9070)

  • Pin accepted cert identity at grant time so a sibling tab with a different leaf cannot proceed() successfully after the first approval
  • Drop stashed load errors on navigation commit so later ERR_ABORTED cannot restore an old overlay over a good page
  • registerGuest IPC rejection → false so did-attach/dom-ready retry is clean
  • Advertise browser.certificate-trust.v1 whenever a browser backend can host pages (desktop or headless), not only offscreen
  • E2E test server counts only / as document requests (favicon/probes 404)

)

Bare localhost still defaults to http:// (no silent scheme flip). After a
local HTTP failure, offer Try HTTPS. For untrusted loopback HTTPS certs
(ERR_CERT_AUTHORITY_INVALID), show certificate-specific copy and an
explicit Proceed Anyway (Unsafe) path bound to one WebContents, secure
endpoint, leaf SHA-256, and error — in-memory only, with a session request
gate so Chromium's cert-cache cannot leak trust across tabs.

Also covers desktop webviews and SSH/headless offscreen pages via runtime
RPC + browser.certificate-trust.v1.

Based on design and implementation from @AmethystLiang in #9070, rebased
onto current main with conflict resolution and adversarial hardening:
- pin accepted identity at grant time (reject conflicting sibling proceeds)
- clear stashed load errors on navigation commit
- normalize registerGuest IPC rejection for did-attach/dom-ready retry
- advertise certificate-trust whenever a browser backend can host pages
- harden e2e local HTTPS server document counting

Closes #8454

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds scoped local HTTPS certificate approval for eligible loopback endpoints, including certificate identity hashing, pending challenges, session request gating, navigation invalidation, and lifecycle cleanup. Exposes structured load and certificate failures through browser APIs, IPC, runtime RPC, and synchronized tab state. Replaces browser failure rendering with a shared recovery overlay supporting HTTPS retry and unsafe certificate proceeding. Adds localized messages, URL helpers, controller and renderer tests, runtime synchronization coverage, and a real local HTTPS end-to-end test.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the main fix, evidence, and risk notes, but it omits required template sections like Screenshots, Testing, AI Review Report, Security Audit, and Notes. Add the required template sections with screenshots or 'No visual change', testing checklist/results, AI review report, security audit, and notes.
Docstring Coverage ⚠️ Warning Docstring coverage is 2.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: local HTTPS support with Try HTTPS and certificate proceed handling.
Linked Issues check ✅ Passed The PR addresses #8454 by preserving scheme-less HTTP, adding Try HTTPS, and enabling a loopback-only cert proceed flow with scoped trust.
Out of Scope Changes check ✅ Passed The added UI, runtime, test, and translation changes all support the HTTPS local-dev trust flow and do not appear unrelated.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
src/main/ipc/browser.ts (1)

243-259: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use optional chaining for challengeId for consistency.

While this code won't crash when args is undefined due to the || short-circuiting on args?.browserPageId, using optional chaining for challengeId makes the code more resilient to future refactoring.

💡 Proposed consistency improvement
       if (
         !isTrustedBrowserRenderer(event.sender) ||
         typeof args?.browserPageId !== 'string' ||
-        typeof args.challengeId !== 'string'
+        typeof args?.challengeId !== 'string'
       ) {

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6dd588e6-5c71-4462-80ef-e6efd17facbc

📥 Commits

Reviewing files that changed from the base of the PR and between 1cdc8e8 and d16e287.

📒 Files selected for processing (55)
  • docs/reference/browser-local-https-certificate-trust.md
  • src/main/browser/agent-browser-bridge.test.ts
  • src/main/browser/agent-browser-bridge.ts
  • src/main/browser/browser-certificate-challenge.ts
  • src/main/browser/browser-certificate-identity.ts
  • src/main/browser/browser-certificate-request-guard.ts
  • src/main/browser/browser-certificate-trust-controller.test.ts
  • src/main/browser/browser-certificate-trust-controller.ts
  • src/main/browser/browser-manager-grab.test.ts
  • src/main/browser/browser-manager.test.ts
  • src/main/browser/browser-manager.ts
  • src/main/browser/browser-session-registry.persistence.test.ts
  • src/main/browser/browser-session-registry.test.ts
  • src/main/browser/browser-session-registry.ts
  • src/main/index.ts
  • src/main/ipc/browser.test.ts
  • src/main/ipc/browser.ts
  • src/main/runtime/orca-runtime-browser.ts
  • src/main/runtime/orca-runtime.test.ts
  • src/main/runtime/orca-runtime.ts
  • src/main/runtime/rpc/methods/browser-core.ts
  • src/main/runtime/rpc/methods/browser-schemas.ts
  • src/main/runtime/runtime-rpc.test.ts
  • src/preload/api-types.ts
  • src/preload/index.ts
  • src/renderer/src/components/browser-pane/BrowserPane.tsx
  • src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx
  • src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx
  • src/renderer/src/components/browser-pane/browser-notices.test.ts
  • src/renderer/src/components/browser-pane/browser-notices.ts
  • src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts
  • src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts
  • src/renderer/src/hooks/useIpcEvents.ts
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/zh.json
  • src/renderer/src/runtime/remote-server-parity.test.ts
  • src/renderer/src/runtime/sync-runtime-graph-browser.test.ts
  • src/renderer/src/runtime/sync-runtime-graph.ts
  • src/renderer/src/runtime/web-session-tabs-sync.test.ts
  • src/renderer/src/runtime/web-session-tabs-sync.ts
  • src/renderer/src/store/slices/browser.test.ts
  • src/renderer/src/store/slices/browser.ts
  • src/renderer/src/web/web-preload-api.ts
  • src/shared/browser-certificate-errors.ts
  • src/shared/browser-url.test.ts
  • src/shared/browser-url.ts
  • src/shared/protocol-version.ts
  • src/shared/runtime-types.ts
  • src/shared/types.ts
  • tests/e2e/browser-local-https-certificate-trust.spec.ts
  • tests/e2e/helpers/local-https-test-certificate.ts
  • tests/e2e/helpers/local-https-test-server.ts

Comment on lines +166 to +168
if (details.resourceType === 'mainFrame' && webContentsId !== undefined) {
this.reportBlockedMainFrame(webContentsId, details.url, accepted)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Suppress synthetic challenges after certificate rotation.

After a conflicting leaf is rejected and the grant is revoked, the next retry reaches this branch and recreates Proceed Anyway from the old accepted identity. Track the endpoint as rotated/blocked and suppress further challenges until process restart.

Add coverage for: replacement certificate → retry through request gate → no new failure challenge.

Comment on lines +637 to +646
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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fallback to the active browser tab when --page is omitted.

When a CLI or Agent caller uses a command with BrowserCommandTargetParams and omits the --page parameter, resolveBrowserCommandTarget resolves target.browserPageId to undefined.

Other commands in this file (like browserTabSetProfile and browserTabProfileClone) manually fall back to the bridge's active page ID. Without this fallback, browserProceedCertificate will unexpectedly fail with reason: 'missing' when run without an explicit --page target.

🐛 Proposed fix to enable implicit active-tab routing
   async browserProceedCertificate(
     params: { challengeId: string } & BrowserCommandTargetParams
   ): Promise<BrowserCertificateProceedResult> {
     const target = await this.resolveBrowserCommandTarget(params)
-    if (!target.browserPageId) {
+    const browserPageId = target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId)
+    if (!browserPageId) {
       return { ok: false, reason: 'missing' }
     }
-    return browserCertificateTrustController.proceed(target.browserPageId, params.challengeId)
+    return browserCertificateTrustController.proceed(browserPageId, params.challengeId)
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 browserProceedCertificate(
params: { challengeId: string } & BrowserCommandTargetParams
): Promise<BrowserCertificateProceedResult> {
const target = await this.resolveBrowserCommandTarget(params)
const browserPageId = target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId)
if (!browserPageId) {
return { ok: false, reason: 'missing' }
}
return browserCertificateTrustController.proceed(browserPageId, params.challengeId)
}

Comment on lines +53 to +60
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:/)
})
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the complete normalized URL.

Checking only /^http:/ would miss regressions that corrupt the IPv6 brackets, port, host, or path despite retaining the scheme. Use an input/expected table and assert the exact url.

Proposed test strengthening
-    for (const input of ['0.0.0.0:3000', '[::1]:3000', '[2001:db8::1]:3000/path']) {
+    for (const [input, url] of [
+      ['0.0.0.0:3000', 'http://0.0.0.0:3000'],
+      ['[::1]:3000', 'http://[::1]:3000'],
+      ['[2001:db8::1]:3000/path', 'http://[2001:db8::1]:3000/path']
+    ]) {
       expect(classifyTabEntryQuery(input, readyFiles([]))).toMatchObject({
         kind: 'host-url',
-        url: expect.stringMatching(/^http:/)
+        url
       })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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('keeps the shared legacy local-dev forms in parity with address-bar normalization', () => {
for (const [input, url] of [
['0.0.0.0:3000', 'http://0.0.0.0:3000'],
['[::1]:3000', 'http://[::1]:3000'],
['[2001:db8::1]:3000/path', 'http://[2001:db8::1]:3000/path']
]) {
expect(classifyTabEntryQuery(input, readyFiles([]))).toMatchObject({
kind: 'host-url',
url
})
}
})

Comment on lines +131 to +134
certificateFailure: {
challengeId: 'challenge-1',
browserPageId: 'page-1'
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the complete synchronized certificate failure.

This nested subset still passes if errorCode, error, origin, displayHost, canProceed, or observedAt are dropped. Assert the full fixture so transport-contract regressions are detected.

Proposed assertion
         certificateFailure: {
           challengeId: 'challenge-1',
-          browserPageId: 'page-1'
+          browserPageId: 'page-1',
+          errorCode: -202,
+          error: 'ERR_CERT_AUTHORITY_INVALID',
+          origin: 'https://localhost:3443',
+          displayHost: 'localhost:3443',
+          canProceed: true,
+          observedAt: 123
         },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
certificateFailure: {
challengeId: 'challenge-1',
browserPageId: 'page-1'
},
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
},

Comment on lines +136 to +161
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use the browser page ID for viewport and webview lookups.

These helpers query a viewport keyed by BrowserPage.id, but the calls pass the workspace-tab id. Use .pageId; retain .id only for switchToBrowserTab.

Proposed fix
-      const firstSlot = browserSlot(orcaPage, firstTab.id)
+      const firstSlot = browserSlot(orcaPage, firstTab.pageId)

-        .poll(() => readBrowserHeading(orcaPage, firstTab.id), { timeout: 10_000 })
+        .poll(() => readBrowserHeading(orcaPage, firstTab.pageId), { timeout: 10_000 })

-        .poll(() => readBrowserState(orcaPage, firstTab.id, '__localTlsState'))
+        .poll(() => readBrowserState(orcaPage, firstTab.pageId, '__localTlsState'))

-      const secondSlot = browserSlot(orcaPage, secondTab.id)
+      const secondSlot = browserSlot(orcaPage, secondTab.pageId)

-        .poll(() => readBrowserState(orcaPage, probeTab.id, '__siblingTlsProbe'))
+        .poll(() => readBrowserState(orcaPage, probeTab.pageId, '__siblingTlsProbe'))

       await switchToBrowserTab(orcaPage, worktreeId, firstTab.id)
-      await reloadBrowserGuest(orcaPage, firstTab.id)
+      await reloadBrowserGuest(orcaPage, firstTab.pageId)

-        .poll(() => readBrowserHeading(orcaPage, firstTab.id), { timeout: 10_000 })
+        .poll(() => readBrowserHeading(orcaPage, firstTab.pageId), { timeout: 10_000 })

Also applies to: 171-185

@nwparker
nwparker merged commit 6e91ca6 into main Jul 17, 2026
4 of 5 checks passed
@nwparker
nwparker deleted the nwparker/fix-8454-wave2 branch July 17, 2026 02:11
nwparker added a commit that referenced this pull request Jul 17, 2026
Main's #9104 reads browserCertificateFailuresByPageId in buildMobileBrowserTab
but left partial AppState test helpers without the field, breaking PR Checks
merge commits. Default the map in fixtures and use optional chaining so partial
state cannot throw.
nwparker added a commit that referenced this pull request Jul 17, 2026
* fix(agents): recognize OpenCode native OC | tab titles

OpenCode's native OSC titles use `OC | <task>` without an `opencode`
token, so title classifiers left tabs as Claude/unknown. Map the native
marker (optional mux prefix) to OpenCode identity in both title
classifiers, exclude it from isClaudeAgent, and cover lookalikes plus
stale Claude launch reclaim.

Builds on and supersedes #8590 (credit @gatsby74). Fixes #8478.

* fix(agents): drop renderer import from #8478 shared repro

tsconfig.node includes src/shared tests; importing agent-status pulled
renderer modules outside the node project and failed typecheck. Assert
opencode identity via shared title classifiers only (OpenCode TUI sets
"OpenCode" and `OC | ${title}`).

* fix(runtime): fill browser cert failure map in mobile snapshot fixtures

Main's #9104 reads browserCertificateFailuresByPageId in buildMobileBrowserTab
but left partial AppState test helpers without the field, breaking PR Checks
merge commits. Default the map in fixtures and use optional chaining so partial
state cannot throw.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Built-in browser can't reach an HTTPS local dev server (localhost forced to http://, self-signed certs rejected)

1 participant