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

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Neil
2026-07-16 19:11:34 -07:00
committed by GitHub
co-authored by Jinjing
parent 3b227d06e8
commit 6e91ca6c0e
55 changed files with 4697 additions and 226 deletions
+12 -1
View File
@@ -49,9 +49,10 @@ import type {
BrowserViewportResult,
BrowserWaitResult
} from '../../shared/runtime-types'
import type { BrowserCertificateProceedResult } from '../../shared/types'
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
import type { BrowserBackend } from '../browser/browser-backend'
import { browserManager } from '../browser/browser-manager'
import { browserCertificateTrustController, browserManager } from '../browser/browser-manager'
import { BrowserError } from '../browser/cdp-bridge'
import {
startBrowserScreencast,
@@ -633,6 +634,16 @@ export class RuntimeBrowserCommands {
}
}
async browserProceedCertificate(
params: { challengeId: string } & BrowserCommandTargetParams
): Promise<BrowserCertificateProceedResult> {
const target = await this.resolveBrowserCommandTarget(params)
if (!target.browserPageId) {
return { ok: false, reason: 'missing' }
}
return browserCertificateTrustController.proceed(target.browserPageId, params.challengeId)
}
async browserTabShow(params: { page: string; worktree?: string }): Promise<BrowserTabShowResult> {
const target = await this.resolveBrowserCommandTarget(params)
return { tab: this.describeBrowserTab(params.page, target.worktreeId) }
+127
View File
@@ -1538,6 +1538,123 @@ describe('OrcaRuntimeService', () => {
expect(capabilities).toContain('browser.screencast.v1')
// ...and the headless marker tells clients not to fall back to a local tab.
expect(capabilities).toContain('browser.headless.v1')
expect(capabilities).toContain('browser.certificate-trust.v1')
})
it('surfaces live offscreen load failures in headless browser snapshots', () => {
const runtime = createRuntime()
runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() })
runtime.setAgentBrowserBridge({
tabList: vi.fn(() => ({
tabs: [
{
browserPageId: 'page-certificate-error',
index: 0,
url: 'https://localhost:3443/',
title: 'Local HTTPS',
active: true,
loadError: {
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3443/'
},
certificateFailure: {
challengeId: 'challenge-1',
browserPageId: 'page-certificate-error',
errorCode: -202,
error: 'ERR_CERT_AUTHORITY_INVALID',
origin: 'https://localhost:3443',
displayHost: 'localhost:3443',
canProceed: true,
observedAt: 123
}
}
]
}))
} as never)
const browserTabs = runtime['buildHeadlessMobileSessionBrowserTabs'](TEST_WORKTREE_ID)
expect(browserTabs).toContainEqual(
expect.objectContaining({
type: 'browser',
browserPageId: 'page-certificate-error',
loadError: {
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3443/'
},
certificateFailure: {
challengeId: 'challenge-1',
browserPageId: 'page-certificate-error',
errorCode: -202,
error: 'ERR_CERT_AUTHORITY_INVALID',
origin: 'https://localhost:3443',
displayHost: 'localhost:3443',
canProceed: true,
observedAt: 123
}
})
)
})
it('detects headless browser-tab changes by field, treating absent and null loadError alike', () => {
const runtime = createRuntime()
const base = {
type: 'browser' as const,
id: 'page-1',
title: 'Local',
browserWorkspaceId: 'page-1',
browserPageId: 'page-1',
url: 'https://localhost:3443/',
loading: false,
canGoBack: false,
canGoForward: false,
isActive: true
}
const err = {
code: -202,
description: 'ERR_CERT_AUTHORITY_INVALID',
validatedUrl: 'https://localhost:3443/'
}
const certificateFailure = {
challengeId: 'challenge-1',
browserPageId: 'page-1',
errorCode: -202,
error: 'ERR_CERT_AUTHORITY_INVALID',
origin: 'https://localhost:3443',
displayHost: 'localhost:3443',
canProceed: true,
observedAt: 123
}
const unchanged = (a: unknown[], b: unknown[]): boolean =>
runtime['headlessBrowserTabsUnchanged'](a as never, b as never)
// Absent vs explicit null loadError are equivalent (the JSON.stringify trap).
expect(unchanged([{ ...base }], [{ ...base, loadError: null }])).toBe(true)
expect(unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err } }])).toBe(
true
)
// A load-error-only change (identical ids/order) must not be missed.
expect(unchanged([{ ...base }], [{ ...base, loadError: err }])).toBe(false)
expect(
unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err, code: -200 } }])
).toBe(false)
expect(
unchanged(
[{ ...base, certificateFailure }],
[{ ...base, certificateFailure: { ...certificateFailure } }]
)
).toBe(true)
expect(unchanged([{ ...base }], [{ ...base, certificateFailure }])).toBe(false)
expect(
unchanged(
[{ ...base, certificateFailure }],
[{ ...base, certificateFailure: { ...certificateFailure, challengeId: 'challenge-2' } }]
)
).toBe(false)
// Scalar and length changes are detected.
expect(unchanged([{ ...base }], [{ ...base, title: 'Changed' }])).toBe(false)
expect(unchanged([{ ...base }], [{ ...base, isActive: false }])).toBe(false)
expect(unchanged([{ ...base }], [{ ...base }, { ...base, id: 'page-2' }])).toBe(false)
})
it('does not advertise headless browser capability when a renderer window exists', () => {
@@ -1547,6 +1664,16 @@ describe('OrcaRuntimeService', () => {
runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() })
expect(runtime.getStatus().capabilities).not.toContain('browser.headless.v1')
// Desktop webviews still host certificate trust, so the proceed capability
// remains advertised for remote clients controlling those pages.
expect(runtime.getStatus().capabilities).toContain('browser.certificate-trust.v1')
})
it('does not advertise certificate trust when no browser backend is available', () => {
const runtime = createRuntime()
expect(runtime.getStatus().capabilities).not.toContain('browser.certificate-trust.v1')
expect(runtime.getStatus().capabilities).not.toContain('browser.screencast.v1')
})
it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => {
+104 -10
View File
@@ -264,6 +264,7 @@ import {
} from '../../shared/worktree-ownership'
import {
BROWSER_HEADLESS_RUNTIME_CAPABILITY,
BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY,
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
RUNTIME_CAPABILITIES,
RUNTIME_PROTOCOL_VERSION,
@@ -3061,6 +3062,12 @@ export class OrcaRuntimeService {
if (hasOffscreen) {
capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY)
}
// Why: certificate proceed is owned by the browser-hosting process for both
// desktop webviews and offscreen pages. Advertise whenever either backend
// can host a page so remote clients can surface Proceed Anyway (Unsafe).
if (canBrowse) {
capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY)
}
return {
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
@@ -3430,13 +3437,16 @@ export class OrcaRuntimeService {
allowAttachedWindow?: boolean
onlyServeOwnedTerminals?: boolean
} = {}
): void {
): Set<string> {
// Why: report which worktrees were reconciled in place so callers don't
// reconcile them a second time (see notifyMobileSessionTabsChanged).
const reconciledWorktreeIds = new Set<string>()
if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) {
return
return reconciledWorktreeIds
}
const session = this.store?.getWorkspaceSession?.()
if (!session) {
return
return reconciledWorktreeIds
}
const entries =
worktreeId !== undefined
@@ -3455,6 +3465,7 @@ export class OrcaRuntimeService {
// Reconcile just the browser tabs against the live bridge instead of
// leaving a stale snapshot that omits a freshly-opened browser tab.
this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, existing)
reconciledWorktreeIds.add(entryWorktreeId)
continue
}
const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs(
@@ -3554,6 +3565,7 @@ export class OrcaRuntimeService {
tabs: mergedTabs
})
}
return reconciledWorktreeIds
}
// Why: keep an existing snapshot's browser tabs in sync with the live bridge
@@ -3568,13 +3580,11 @@ export class OrcaRuntimeService {
}
const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId)
const liveIds = liveBrowserTabs.map((tab) => tab.id)
const existingBrowserIds = existing.tabs
.filter((tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser')
.map((tab) => tab.id)
const unchanged =
liveIds.length === existingBrowserIds.length &&
liveIds.every((id, index) => existingBrowserIds[index] === id)
if (unchanged) {
const existingBrowserTabs = existing.tabs.filter(
(tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser'
)
const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id)
if (this.headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) {
return
}
const nonBrowserTabs = existing.tabs.filter((tab) => tab.type !== 'browser')
@@ -4051,6 +4061,8 @@ export class OrcaRuntimeService {
loading: false,
canGoBack: false,
canGoForward: false,
loadError: tab.loadError ?? undefined,
certificateFailure: tab.certificateFailure ?? undefined,
...(persistedProps ? { color: persistedProps.color } : {}),
...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}),
isActive: tab.active === true
@@ -4058,6 +4070,75 @@ export class OrcaRuntimeService {
})
}
// Why: change detection for headless browser tabs. Compares the fields that
// actually vary (a JSON.stringify equality was order-sensitive and silently
// dropped `undefined` keys, so it only worked while both sides shared one
// construction path).
private headlessBrowserTabsUnchanged(
live: RuntimeMobileSessionBrowserTab[],
existing: RuntimeMobileSessionBrowserTab[]
): boolean {
if (live.length !== existing.length) {
return false
}
return live.every((tab, index) => {
const prev = existing[index]
return (
tab.id === prev.id &&
tab.title === prev.title &&
tab.url === prev.url &&
tab.isActive === prev.isActive &&
(tab.isPinned ?? false) === (prev.isPinned ?? false) &&
(tab.color ?? null) === (prev.color ?? null) &&
this.browserLoadErrorsEqual(tab.loadError, prev.loadError) &&
this.browserCertificateFailuresEqual(tab.certificateFailure, prev.certificateFailure)
)
})
}
private browserLoadErrorsEqual(
a: RuntimeMobileSessionBrowserTab['loadError'],
b: RuntimeMobileSessionBrowserTab['loadError']
): boolean {
const left = a ?? null
const right = b ?? null
if (left === right) {
return true
}
if (!left || !right) {
return false
}
return (
left.code === right.code &&
left.description === right.description &&
left.validatedUrl === right.validatedUrl
)
}
private browserCertificateFailuresEqual(
a: RuntimeMobileSessionBrowserTab['certificateFailure'],
b: RuntimeMobileSessionBrowserTab['certificateFailure']
): boolean {
const left = a ?? null
const right = b ?? null
if (left === right) {
return true
}
if (!left || !right) {
return false
}
return (
left.challengeId === right.challengeId &&
left.browserPageId === right.browserPageId &&
left.errorCode === right.errorCode &&
left.error === right.error &&
left.origin === right.origin &&
left.displayHost === right.displayHost &&
left.canProceed === right.canProceed &&
left.observedAt === right.observedAt
)
}
private getPersistedUnifiedSessionTabProps(
worktreeId: string,
tabId: string
@@ -21565,6 +21646,17 @@ export class OrcaRuntimeService {
this.notifyMobileSessionTabSnapshots()
return
}
if (this.offscreenBrowserBackend) {
const reconciled = this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId)
// Why: hydrate already reconciles an existing snapshot in place; only
// reconcile here when it didn't (fresh build or an early-returned hydrate).
if (!reconciled.has(worktreeId)) {
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
if (existing) {
this.reconcileHeadlessMobileSessionBrowserTabs(worktreeId, existing)
}
}
}
// Why: structural changes (tab add/remove/activate) must propagate promptly,
// so cancel any pending coalesced title/status notify — this immediate emit
// already reflects the latest snapshot and supersedes it.
@@ -25478,6 +25570,8 @@ export class OrcaRuntimeService {
browserTabList: RuntimeBrowserCommands['browserTabList'] =
this.browserCommands.browserTabList.bind(this.browserCommands)
browserProceedCertificate: RuntimeBrowserCommands['browserProceedCertificate'] =
this.browserCommands.browserProceedCertificate.bind(this.browserCommands)
browserTabShow: RuntimeBrowserCommands['browserTabShow'] =
this.browserCommands.browserTabShow.bind(this.browserCommands)
+10 -1
View File
@@ -1,5 +1,5 @@
import { defineMethod, type RpcMethod } from '../core'
import { BrowserTarget } from '../schemas'
import { BrowserTarget, requiredString } from '../schemas'
import {
Check,
Drag,
@@ -34,6 +34,10 @@ import {
} from './browser-schemas'
import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods'
const CertificateProceed = BrowserTarget.extend({
challengeId: requiredString('Missing required challengeId')
})
export const BROWSER_CORE_METHODS: RpcMethod[] = [
defineMethod({
name: 'browser.snapshot',
@@ -50,6 +54,11 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [
params: Goto,
handler: async (params, { runtime }) => runtime.browserGoto(params)
}),
defineMethod({
name: 'browser.certificate.proceed',
params: CertificateProceed,
handler: async (params, { runtime }) => runtime.browserProceedCertificate(params)
}),
...BROWSER_TEXT_METHODS,
defineMethod({
name: 'browser.select',
@@ -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,
+21
View File
@@ -1983,6 +1983,20 @@ describe('OrcaRuntimeRpcServer', () => {
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_browser_certificate_proceed',
method: 'browser.certificate.proceed',
deviceToken: mobile.token,
params: {
worktree: 'id:wt-1',
page: 'page-1',
challengeId: 'challenge-1'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_browser_dialog_accept',
@@ -2123,6 +2137,13 @@ describe('OrcaRuntimeRpcServer', () => {
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_browser_viewport', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({
id: 'req_browser_certificate_proceed',
ok: false,
error: expect.objectContaining({ code: 'forbidden' })
})
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_browser_dialog_accept', ok: true })
)