mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
@@ -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 })
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user