fix(browser): agent clicks keep their target when a page changes, by upgrading agent-browser to 0.39.4 (#36007)

* chore(browser): upgrade bundled agent-browser to 0.39.4

0.39.4 keeps refs stable across snapshots and refuses stale or hidden
click targets. Its replies now carry daemon lifecycle bookkeeping, which
is dropped where the JSON is parsed, and its tarball binaries lose the
executable bit pnpm's skipped postinstall used to restore, so the orcad
dev resolver now restores it like the desktop one.

* fix(browser): keep orcad's Chromium unbounded and read 0.39's stale-ref error

* fix(browser): import the shared stale-ref rule in orcad and keep the parser typed

* fix(browser): keep workers and frames running after the agent-browser helper leaves

agent-browser 0.39 asks auto-attached targets to wait for it before
starting. The proxy forwards that onto the guest's shared debugger,
which outlives the helper, so after the helper's idle exit nothing
resumed new workers or frames.
This commit is contained in:
Kelvin Amoaba
2026-10-29 21:15:04 +00:00
committed by GitHub
parent b2b704282c
commit 5834a3dcfd
11 changed files with 129 additions and 28 deletions
+1 -1
View File
@@ -227,7 +227,7 @@
"@parcel/watcher": "^2.5.6",
"@xterm/addon-serialize": "0.15.0-beta.301",
"@xterm/headless": "6.1.0-beta.303",
"agent-browser": "~0.27.3",
"agent-browser": "~0.39.4",
"electron-updater": "^6.8.10",
"fflate": "0.8.3",
"i18next": "^26.5.11",
+14 -11
View File
@@ -216,8 +216,8 @@ importers:
specifier: 6.1.0-beta.303
version: 6.1.0-beta.303
agent-browser:
specifier: ~0.27.3
version: 0.27.3
specifier: ~0.39.4
version: 0.39.4
electron-updater:
specifier: ^6.8.10
version: 6.8.10(electron@43.8.1(supports-color@7.2.0))(supports-color@7.2.0)
@@ -480,12 +480,12 @@ importers:
hast-util-from-html-isomorphic:
specifier: 2.0.0
version: 2.0.0
hast-util-to-text:
specifier: 4.0.2
version: 4.0.2
hast-util-to-jsx-runtime:
specifier: 2.3.6
version: 2.3.6(supports-color@7.2.0)
hast-util-to-text:
specifier: 4.0.2
version: 4.0.2
html-to-image:
specifier: ^1.11.13
version: 1.11.13
@@ -4423,8 +4423,8 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agent-browser@0.27.3:
resolution: {integrity: sha512-isQ1xWoYOnfR8BigYRlYmkFOrqw71tKEmUEMmFJGStQFOyCrLSsKoOAZEu8TjPZdmjD1nPQ/hLLYlxTwdHnA0Q==}
agent-browser@0.39.4:
resolution: {integrity: sha512-R0ue5CWlpkGtbcXeq3ZKM96zLrCXkNKEGyttgWhi0eSNKoNSvxglvv1/D9NhKxe+MI+djiCFepPd4PYOM0H2Rg==}
engines: {node: '>=24.0.0', pnpm: '>=11.0.0'}
hasBin: true
@@ -4432,9 +4432,12 @@ packages:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
'@types/ajv': '*'
ajv: ^8.0.0
peerDependenciesMeta:
'@types/ajv':
optional: true
ajv:
optional: true
ajv@6.15.0:
resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
@@ -8839,7 +8842,7 @@ snapshots:
dependencies:
'@hono/node-server': 2.1.0(hono@4.13.11)
ajv: 8.20.0
ajv-formats: 3.0.1
ajv-formats: 3.0.1(ajv@8.20.0)
content-type: 1.0.5
cors: 2.8.6
cross-spawn: 7.0.6
@@ -11137,10 +11140,10 @@ snapshots:
agent-base@7.1.4: {}
agent-browser@0.27.3: {}
agent-browser@0.39.4: {}
ajv-formats@3.0.1:
dependencies:
ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies:
ajv: 8.20.0
ajv@6.15.0:
@@ -14,8 +14,27 @@ export function parseAgentBrowserJsonOutput(stdout: string): AgentBrowserEnvelop
const parsed = JSON.parse(stdout)
// Orca batches one command; a longer array is a passthrough `exec batch` reply.
if (!Array.isArray(parsed) || parsed.length !== 1) {
return parsed
return withoutDaemonLifecycle(parsed)
}
const [entry] = parsed
return { success: entry?.success, data: entry?.result, error: entry?.error }
return withoutDaemonLifecycle({
success: entry?.success,
data: entry?.result,
error: entry?.error
})
}
// agent-browser 0.31+ adds its daemon's launch bookkeeping to every reply; it is not page data.
function withoutDaemonLifecycle(envelope: AgentBrowserEnvelope): AgentBrowserEnvelope {
const data = envelope?.data
if (!data || typeof data !== 'object' || Array.isArray(data) || !('lifecycle' in data)) {
return envelope
}
const { lifecycle: _lifecycle, ...rest } = data
return { ...envelope, data: rest }
}
/** "Unknown ref" for a ref never issued; 0.39+ also says "Stale ref for role=…" once its element is gone. */
export function isAgentBrowserStaleRefError(message: string): boolean {
return /unknown ref|stale ref|ref not found|element not found: @e/i.test(message)
}
@@ -202,6 +202,22 @@ describe('AgentBrowserBridge', () => {
await expect(bridge.snapshot()).rejects.toThrow('Command failed')
})
it('drops the daemon lifecycle agent-browser adds to every result', async () => {
// Real agent-browser 0.39.4 batch reply.
const reply =
'[{"command":["click","@e2"],"error":null,"result":{"clicked":"@e2","lifecycle":{"effectiveLaunch":{"browserLaunched":true,"engine":"chrome","launchHash":520248349195465926},"launched":false,"relaunchedBrowser":false,"restartedBackground":false,"restoreStatus":"not_configured","reused":true,"saveStatus":"not_attempted"}},"success":true}]'
answerAgentBrowserCommands(execFileMock, stdinWrites, (_command, cb) => cb(null, reply, ''))
await expect(bridge.click('@e2')).resolves.toEqual({ clicked: '@e2' })
})
it('reports a ref whose element is gone as stale', async () => {
// agent-browser 0.39.2+ wording when it cannot re-resolve a ref by role and name.
failWith(
'Stale ref for role=button name=Approve cannot be recovered: several elements now match. Run snapshot again and use a fresh ref.'
)
await expect(bridge.click('@e2')).rejects.toMatchObject({ code: 'browser_stale_ref' })
})
it('returns browser_error with truncated output for malformed JSON', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: ExecFileCallback) => {
@@ -1,11 +1,13 @@
import { app } from 'electron'
import { existsSync, accessSync, chmodSync, constants } from 'node:fs'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { platform, arch } from 'node:os'
import type { WebContents } from 'electron'
import { BrowserError } from './cdp-bridge'
import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep'
import { EMBEDDED_NAVIGATION_TIMEOUT_MS } from './agent-browser-bridge-types'
import { ensureDependencyBinaryExecutable } from './agent-browser-dependency-binary'
import { isAgentBrowserStaleRefError } from './agent-browser-batch-command'
export function agentBrowserNativeName(): string {
const ext = process.platform === 'win32' ? '.exe' : ''
@@ -33,13 +35,7 @@ export function resolveAgentBrowserBinary(): string {
agentBrowserNativeName()
)
if (existsSync(nmBin)) {
if (process.platform !== 'win32') {
try {
accessSync(nmBin, constants.X_OK)
} catch {
chmodSync(nmBin, 0o755)
}
}
ensureDependencyBinaryExecutable(nmBin)
return nmBin
}
@@ -91,7 +87,7 @@ export function stripAgentBrowserTargetArgs(args: string[]): string[] {
// Stale refs need a distinct code so agents can retry with a fresh snapshot.
export function classifyErrorCode(message: string): string {
if (/unknown ref|ref not found|element not found: @e/i.test(message)) {
if (isAgentBrowserStaleRefError(message)) {
return 'browser_stale_ref'
}
return 'browser_error'
@@ -0,0 +1,13 @@
import { accessSync, chmodSync, constants } from 'node:fs'
/** pnpm skips agent-browser's postinstall, which is what marks its tarball binaries executable. */
export function ensureDependencyBinaryExecutable(path: string): void {
if (process.platform === 'win32') {
return
}
try {
accessSync(path, constants.X_OK)
} catch {
chmodSync(path, 0o755)
}
}
+20
View File
@@ -340,6 +340,26 @@ describe('CdpWsProxy', () => {
client.close()
})
it('never leaves auto-attached workers and frames waiting on a client that may leave', async () => {
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({})
const client = await connect(endpoint)
// agent-browser 0.39 asks child targets to wait for it; the shared guest debugger outlives it.
await sendAndReceive(client, {
id: 1,
method: 'Target.setAutoAttach',
params: { autoAttach: true, waitForDebuggerOnStart: true, flatten: true },
sessionId: 'oopif-session-123'
})
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith(
'Target.setAutoAttach',
{ autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
'oopif-session-123'
)
client.close()
})
it('forwards CDP events from debugger to client', async () => {
const client = await connect(endpoint)
+6
View File
@@ -297,6 +297,12 @@ export class CdpWsProxy {
void this.navigation.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
return
}
// The guest debugger outlives this client, so nothing would resume workers or frames left waiting.
if (msg.method === 'Target.setAutoAttach') {
const params = { ...msg.params, waitForDebuggerOnStart: false }
this.debuggerChannel.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
return
}
this.debuggerChannel.forwardCommand(
client,
clientId,
@@ -93,10 +93,30 @@ describe('orcad external-chromium agent-browser environment', () => {
}
)
it('reports a ref whose element is gone as stale', async () => {
runProcessMock.mockResolvedValue({
code: 1,
signal: null,
stdout: JSON.stringify({
success: false,
error:
'Stale ref for role=button name=Approve cannot be recovered: several elements now match.'
}),
stderr: '',
timedOut: false
})
const session = new ExternalChromiumBrowserSession(
'/opt/orca/agent-browser',
{ executablePath: BASE.executablePath, provider: 'chromium' },
'/state'
)
await expect(session.run(['click', '@e2'])).rejects.toMatchObject({ code: 'browser_stale_ref' })
})
it('never bounds the daemon that owns the Chromium tree', () => {
const env = externalChromiumAgentBrowserEnvironment({ inheritedEnv: {}, ...BASE })
expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBeUndefined()
expect(env.AGENT_BROWSER_IDLE_TIMEOUT_MS).toBe('0')
expect(env.AGENT_BROWSER_EXECUTABLE_PATH).toBe(BASE.executablePath)
expect(env.AGENT_BROWSER_SESSION).toBe(BASE.sessionName)
})
@@ -6,6 +6,7 @@ import { BrowserError } from '../browser/browser-error'
import {
AGENT_BROWSER_BATCH_ARGS,
agentBrowserBatchInput,
isAgentBrowserStaleRefError,
parseAgentBrowserJsonOutput
} from '../browser/agent-browser-batch-command'
import { BROWSER_UNAVAILABLE_ERROR_CODE } from '../../shared/runtime-types'
@@ -38,7 +39,7 @@ const AgentBrowserEnvelope = z.object({
export type AgentBrowserTab = z.infer<typeof AgentBrowserTab>
function classifyAgentBrowserError(message: string): string {
if (/unknown ref|ref not found|element not found: @e/i.test(message)) {
if (isAgentBrowserStaleRefError(message)) {
return 'browser_stale_ref'
}
if (/evaluation error/i.test(message)) {
@@ -57,6 +58,7 @@ function classifyAgentBrowserError(message: string): string {
}
// No idle timeout: this daemon owns the user’s remote Chromium and all its live tabs.
// agent-browser 0.33+ reads an unset timeout as one hour, so only "0" keeps it unbounded.
export function externalChromiumAgentBrowserEnvironment(options: {
inheritedEnv: NodeJS.ProcessEnv
executablePath: string
@@ -66,6 +68,8 @@ export function externalChromiumAgentBrowserEnvironment(options: {
}): NodeJS.ProcessEnv {
return {
...options.inheritedEnv,
AGENT_BROWSER_IDLE_TIMEOUT_MS:
options.inheritedEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS?.trim() || '0',
AGENT_BROWSER_EXECUTABLE_PATH: options.executablePath,
AGENT_BROWSER_PROFILE: options.profilePath,
AGENT_BROWSER_SESSION: options.sessionName,
+7 -3
View File
@@ -2,6 +2,7 @@ import { accessSync, constants, existsSync } from 'node:fs'
import { arch, platform } from 'node:os'
import { dirname, join } from 'node:path'
import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name'
import { ensureDependencyBinaryExecutable } from '../browser/agent-browser-dependency-binary'
/** A slot carries only its own target's driver, so trying both libc names needs no libc probe. */
function hostDriverNames(): string[] {
@@ -14,14 +15,17 @@ function hostDriverNames(): string[] {
export function resolveOrcadAgentBrowserBinary(): string | null {
const candidates = hostDriverNames().flatMap((name) => [
join(dirname(process.argv[1] ?? __filename), name),
join(process.cwd(), 'node_modules', 'agent-browser', 'bin', name)
{ path: join(dirname(process.argv[1] ?? __filename), name), dependency: false },
{ path: join(process.cwd(), 'node_modules', 'agent-browser', 'bin', name), dependency: true }
])
for (const candidate of candidates) {
for (const { path: candidate, dependency } of candidates) {
if (!existsSync(candidate)) {
continue
}
try {
if (dependency) {
ensureDependencyBinaryExecutable(candidate)
}
accessSync(candidate, process.platform === 'win32' ? constants.F_OK : constants.X_OK)
return candidate
} catch {