Generalize browser automation hosting beyond the desktop app (#1878)
* feat(browser): generalize browser automation hosts Rename the browser automation client capability to a host-neutral payload, advertise supported commands, and route tab-scoped commands by browserId affinity so future browser hosts can register without another protocol shape change. * fix(browser): harden browser host registration Reject unusable browser host capabilities, clean up pending requests when a host registration is replaced, and defer list_tabs affinity updates until every host in the aggregation succeeds. * fix(browser): preserve host tab affinity across reconnects Keep stranded tab ownership tied to the disconnected host so the same host can reclaim open tabs after reconnect, and tolerate future command names in browser_host capability payloads while retaining only known commands.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
BROWSER_AUTOMATION_COMMAND_NAMES,
|
||||
type BrowserAutomationCommandName,
|
||||
} from "./rpc-schemas.js";
|
||||
|
||||
const KNOWN_BROWSER_AUTOMATION_COMMAND_NAMES = new Set<string>(BROWSER_AUTOMATION_COMMAND_NAMES);
|
||||
|
||||
export const BrowserAutomationHostCapabilitySchema = z
|
||||
.object({
|
||||
supportedCommands: z.array(z.string().min(1)).transform((commands, context) => {
|
||||
const supportedCommands: BrowserAutomationCommandName[] = [];
|
||||
const seen = new Set<BrowserAutomationCommandName>();
|
||||
|
||||
for (const command of commands) {
|
||||
if (!isKnownBrowserAutomationCommandName(command) || seen.has(command)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(command);
|
||||
supportedCommands.push(command);
|
||||
}
|
||||
|
||||
if (supportedCommands.length === 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "supportedCommands must include at least one known browser automation command",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
return supportedCommands;
|
||||
}),
|
||||
hostKind: z.string().min(1).default("browser host"),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type BrowserAutomationHostCapability = z.infer<typeof BrowserAutomationHostCapabilitySchema>;
|
||||
|
||||
function isKnownBrowserAutomationCommandName(value: string): value is BrowserAutomationCommandName {
|
||||
return KNOWN_BROWSER_AUTOMATION_COMMAND_NAMES.has(value);
|
||||
}
|
||||
@@ -564,8 +564,8 @@ describe("browser automation execute RPC schemas", () => {
|
||||
requestId: "req-error",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_no_desktop",
|
||||
message: "No desktop browser automation client is connected.",
|
||||
code: "browser_no_host",
|
||||
message: "No browser automation host is connected.",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -574,8 +574,8 @@ describe("browser automation execute RPC schemas", () => {
|
||||
requestId: "req-error",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_no_desktop",
|
||||
message: "No desktop browser automation client is connected.",
|
||||
code: "browser_no_host",
|
||||
message: "No browser automation host is connected.",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
export const BrowserAutomationErrorCodeSchema = z.enum([
|
||||
"browser_disabled",
|
||||
"browser_no_desktop",
|
||||
"browser_no_host",
|
||||
"browser_tab_not_found",
|
||||
"browser_tab_closed",
|
||||
"browser_timeout",
|
||||
@@ -20,6 +20,29 @@ const BROWSER_AUTOMATION_BROWSER_ID_MESSAGE =
|
||||
const BROWSER_AUTOMATION_WAIT_CONDITION_MESSAGE =
|
||||
"browser_wait requires exactly one of text or url";
|
||||
|
||||
export const BROWSER_AUTOMATION_COMMAND_NAMES = [
|
||||
"list_tabs",
|
||||
"new_tab",
|
||||
"snapshot",
|
||||
"click",
|
||||
"fill",
|
||||
"wait",
|
||||
"type",
|
||||
"keypress",
|
||||
"navigate",
|
||||
"back",
|
||||
"forward",
|
||||
"reload",
|
||||
"screenshot",
|
||||
"upload",
|
||||
"select",
|
||||
"hover",
|
||||
"drag",
|
||||
"logs",
|
||||
] as const;
|
||||
|
||||
export const BrowserAutomationCommandNameSchema = z.enum(BROWSER_AUTOMATION_COMMAND_NAMES);
|
||||
|
||||
export const BrowserAutomationBrowserIdSchema = z
|
||||
.string({ error: () => BROWSER_AUTOMATION_BROWSER_ID_MESSAGE })
|
||||
.min(1, BROWSER_AUTOMATION_BROWSER_ID_MESSAGE)
|
||||
@@ -399,6 +422,7 @@ export const BrowserAutomationExecuteResponseSchema = z.object({
|
||||
});
|
||||
|
||||
export type BrowserAutomationErrorCode = z.infer<typeof BrowserAutomationErrorCodeSchema>;
|
||||
export type BrowserAutomationCommandName = z.infer<typeof BrowserAutomationCommandNameSchema>;
|
||||
export type BrowserAutomationCommand = z.infer<typeof BrowserAutomationCommandSchema>;
|
||||
export type BrowserAutomationResult = z.infer<typeof BrowserAutomationResultSchema>;
|
||||
export type BrowserAutomationConsoleLogEntry = z.infer<
|
||||
|
||||
@@ -11,7 +11,7 @@ export const CLIENT_CAPS = {
|
||||
// Old clients use a strict TerminalState schema and would reject the extra fields.
|
||||
// Drop the gate (always send the flags) when floor >= v0.1.88.
|
||||
terminalReflowableSnapshot: "terminal_reflowable_snapshot",
|
||||
desktopBrowserAutomation: "desktop_browser_automation",
|
||||
browserHost: "browser_host",
|
||||
} as const;
|
||||
|
||||
export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "./browser-automation/rpc-schemas.js";
|
||||
import { CLIENT_CAPS } from "./client-capabilities.js";
|
||||
import {
|
||||
MutableDaemonConfigPatchSchema,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
describe("browser automation protocol integration", () => {
|
||||
const browserId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
test("desktop automation capability parses in hello without narrowing old clients", () => {
|
||||
test("browser host capability parses supported commands in hello", () => {
|
||||
expect(
|
||||
WSHelloMessageSchema.parse({
|
||||
type: "hello",
|
||||
@@ -20,13 +21,86 @@ describe("browser automation protocol integration", () => {
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.desktopBrowserAutomation]: true,
|
||||
[CLIENT_CAPS.browserHost]: {
|
||||
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
|
||||
hostKind: "desktop app",
|
||||
},
|
||||
},
|
||||
}).capabilities,
|
||||
).toMatchObject({
|
||||
[CLIENT_CAPS.desktopBrowserAutomation]: true,
|
||||
[CLIENT_CAPS.browserHost]: {
|
||||
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
|
||||
hostKind: "desktop app",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("browser host capability requires at least one supported command", () => {
|
||||
expect(() =>
|
||||
WSHelloMessageSchema.parse({
|
||||
type: "hello",
|
||||
clientId: "client-1",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.browserHost]: {
|
||||
supportedCommands: [],
|
||||
hostKind: "desktop app",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
WSHelloMessageSchema.parse({
|
||||
type: "hello",
|
||||
clientId: "client-2",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.browserHost]: {},
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
WSHelloMessageSchema.parse({
|
||||
type: "hello",
|
||||
clientId: "client-3",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.browserHost]: {
|
||||
supportedCommands: ["future_command"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("browser host capability ignores unknown future commands when known commands remain", () => {
|
||||
expect(
|
||||
WSHelloMessageSchema.parse({
|
||||
type: "hello",
|
||||
clientId: "client-1",
|
||||
clientType: "mobile",
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.browserHost]: {
|
||||
supportedCommands: ["list_tabs", "future_command", "list_tabs"],
|
||||
hostKind: "desktop app",
|
||||
},
|
||||
},
|
||||
}).capabilities,
|
||||
).toMatchObject({
|
||||
[CLIENT_CAPS.browserHost]: {
|
||||
supportedCommands: ["list_tabs"],
|
||||
hostKind: "desktop app",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("hello remains valid when no browser host capability is advertised", () => {
|
||||
expect(
|
||||
WSHelloMessageSchema.parse({
|
||||
type: "hello",
|
||||
@@ -37,7 +111,7 @@ describe("browser automation protocol integration", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("daemon to desktop execute request is an outbound session message", () => {
|
||||
test("daemon to browser host execute request is an outbound session message", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "browser.automation.execute.request",
|
||||
requestId: "req-1",
|
||||
@@ -47,7 +121,7 @@ describe("browser automation protocol integration", () => {
|
||||
expect(parsed.type).toBe("browser.automation.execute.request");
|
||||
});
|
||||
|
||||
test("desktop to daemon execute response is an inbound session message", () => {
|
||||
test("browser host to daemon execute response is an inbound session message", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "browser.automation.execute.response",
|
||||
payload: {
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
BrowserAutomationExecuteRequestSchema,
|
||||
BrowserAutomationExecuteResponseSchema,
|
||||
} from "./browser-automation/rpc-schemas.js";
|
||||
import { BrowserAutomationHostCapabilitySchema } from "./browser-automation/capabilities.js";
|
||||
import {
|
||||
PaseoConfigRawSchema,
|
||||
PaseoLifecycleCommandRawSchema,
|
||||
@@ -4672,7 +4673,7 @@ export const WSHelloMessageSchema = z.object({
|
||||
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.desktopBrowserAutomation]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
|
||||
Reference in New Issue
Block a user