@omercnet/paseo-shared-browser 0.3.1-next.72.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import type { JsonValue } from "./runtime-protocol";
7
+ import { AgentSupervisorClient } from "./supervisor-client";
8
+ import { resolveSupervisorPaths } from "./supervisor";
9
+
10
+ const TICKET_ENV = "PASEO_SHARED_BROWSER_TICKET";
11
+ const MIN_VIEWPORT = { width: 320, height: 480 } as const;
12
+ const MAX_VIEWPORT = { width: 1600, height: 1200 } as const;
13
+
14
+ const pointSchema = z.object({
15
+ x: z.number().finite().nonnegative(),
16
+ y: z.number().finite().nonnegative(),
17
+ width: z.number().finite().positive().max(16_384),
18
+ height: z.number().finite().positive().max(16_384),
19
+ });
20
+
21
+ const inputEventSchema = z.discriminatedUnion("kind", [
22
+ z.object({
23
+ kind: z.literal("click"),
24
+ point: pointSchema,
25
+ button: z.enum(["left", "right", "middle"]).default("left"),
26
+ clickCount: z.union([z.literal(1), z.literal(2)]).default(1),
27
+ }),
28
+ z.object({ kind: z.literal("move"), point: pointSchema }),
29
+ z.object({
30
+ kind: z.literal("drag"),
31
+ start: pointSchema,
32
+ end: pointSchema,
33
+ button: z.enum(["left", "right", "middle"]).default("left"),
34
+ }),
35
+ z.object({
36
+ kind: z.literal("scroll"),
37
+ point: pointSchema,
38
+ deltaX: z.number().finite().min(-4_000).max(4_000),
39
+ deltaY: z.number().finite().min(-4_000).max(4_000),
40
+ }),
41
+ z.object({ kind: z.literal("type"), text: z.string().min(1).max(4_000) }),
42
+ z.object({
43
+ kind: z.literal("key"),
44
+ key: z.enum([
45
+ "Enter",
46
+ "Tab",
47
+ "Backspace",
48
+ "Delete",
49
+ "Escape",
50
+ "ArrowUp",
51
+ "ArrowDown",
52
+ "ArrowLeft",
53
+ "ArrowRight",
54
+ "Home",
55
+ "End",
56
+ "PageUp",
57
+ "PageDown",
58
+ "Space",
59
+ ]),
60
+ }),
61
+ ]);
62
+
63
+ function paseoHome(): string {
64
+ return process.env.PASEO_HOME ?? join(homedir(), ".paseo");
65
+ }
66
+
67
+ function asObject(value: JsonValue): Record<string, JsonValue> {
68
+ if (!value || typeof value !== "object" || Array.isArray(value))
69
+ throw new Error("Shared Browser supervisor returned an invalid response");
70
+ return value;
71
+ }
72
+
73
+ function textResult(value: JsonValue) {
74
+ return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
75
+ }
76
+
77
+ async function main(): Promise<void> {
78
+ const ticket = process.env[TICKET_ENV];
79
+ if (!ticket) throw new Error(`${TICKET_ENV} is required`);
80
+ const client = new AgentSupervisorClient({
81
+ ticket,
82
+ paths: resolveSupervisorPaths(paseoHome()),
83
+ });
84
+ await client.open();
85
+
86
+ const server = new McpServer({ name: "paseo-shared-browser", version: "0.2.2" });
87
+
88
+ server.registerTool(
89
+ "shared_browser_status",
90
+ {
91
+ description: "Read the current shared browser state for this agent's workspace.",
92
+ inputSchema: z.object({}),
93
+ annotations: { readOnlyHint: true, idempotentHint: true },
94
+ },
95
+ async () => textResult(await client.request("status", {})),
96
+ );
97
+
98
+ server.registerTool(
99
+ "shared_browser_capture",
100
+ {
101
+ description:
102
+ "Capture the current shared browser frame and state. Capture before sending input.",
103
+ inputSchema: z.object({ quality: z.enum(["low", "medium", "high"]).default("medium") }),
104
+ annotations: { readOnlyHint: true, idempotentHint: true },
105
+ },
106
+ async ({ quality }) => {
107
+ const result = await client.request("capture", { quality });
108
+ const data = asObject(result);
109
+ const frame = data.frame;
110
+ const content: Array<
111
+ { type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
112
+ > = [{ type: "text", text: JSON.stringify(data.state, null, 2) }];
113
+ if (frame && typeof frame === "object" && !Array.isArray(frame)) {
114
+ const image = frame as Record<string, JsonValue>;
115
+ if (typeof image.dataBase64 === "string" && typeof image.mimeType === "string")
116
+ content.push({ type: "image", data: image.dataBase64, mimeType: image.mimeType });
117
+ }
118
+ return { content };
119
+ },
120
+ );
121
+
122
+ server.registerTool(
123
+ "shared_browser_acquire_control",
124
+ {
125
+ description:
126
+ "Acquire browser control if no human or other viewer currently holds it. Forced takeover is unavailable.",
127
+ inputSchema: z.object({}),
128
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
129
+ },
130
+ async () => textResult(await client.request("acquire-control", {})),
131
+ );
132
+
133
+ server.registerTool(
134
+ "shared_browser_release_control",
135
+ {
136
+ description: "Release this agent's browser control lease.",
137
+ inputSchema: z.object({}),
138
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
139
+ },
140
+ async () => textResult(await client.request("release-control", {})),
141
+ );
142
+ server.registerTool(
143
+ "shared_browser_navigate",
144
+ {
145
+ description: "Navigate the shared browser using the agent's current observed state.",
146
+ inputSchema: z.object({
147
+ action: z.discriminatedUnion("kind", [
148
+ z.object({ kind: z.literal("goto"), url: z.string().trim().min(1).max(8_192) }),
149
+ z.object({ kind: z.literal("back") }),
150
+ z.object({ kind: z.literal("forward") }),
151
+ z.object({ kind: z.literal("reload") }),
152
+ ]),
153
+ }),
154
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
155
+ },
156
+ async ({ action }) =>
157
+ textResult(await client.request("navigate", { action: action as unknown as JsonValue })),
158
+ );
159
+
160
+ server.registerTool(
161
+ "shared_browser_input",
162
+ {
163
+ description:
164
+ "Send one input event using the exact frame returned by the latest capture. Stale frames are rejected.",
165
+ inputSchema: z.object({ event: inputEventSchema }),
166
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
167
+ },
168
+ async ({ event }) =>
169
+ textResult(await client.request("input", { event: event as unknown as JsonValue })),
170
+ );
171
+
172
+ server.registerTool(
173
+ "shared_browser_viewport",
174
+ {
175
+ description: "Resize the shared browser viewport using the agent's current observed state.",
176
+ inputSchema: z.object({
177
+ width: z.number().int().min(MIN_VIEWPORT.width).max(MAX_VIEWPORT.width),
178
+ height: z.number().int().min(MIN_VIEWPORT.height).max(MAX_VIEWPORT.height),
179
+ }),
180
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
181
+ },
182
+ async (viewport) =>
183
+ textResult(await client.request("viewport", { viewport: viewport as unknown as JsonValue })),
184
+ );
185
+
186
+ const transport = new StdioServerTransport();
187
+ process.once("exit", () => client.disconnect());
188
+ await server.connect(transport);
189
+ }
190
+
191
+ void main().catch((error: unknown) => {
192
+ console.error(error instanceof Error ? error.message : String(error));
193
+ process.exitCode = 1;
194
+ });
@@ -0,0 +1,122 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { AgentBrowserRuntime, type BrowserViewport } from "./agent-browser-runtime";
5
+ import type { JsonValue } from "./runtime-protocol";
6
+ import type { RuntimeOwner } from "./supervisor";
7
+
8
+ const DEFAULT_BROWSER_URL = "https://example.com/";
9
+
10
+ interface OwnedRuntime {
11
+ runtimeId: string;
12
+ runtime: AgentBrowserRuntime;
13
+ }
14
+
15
+ function paseoHome(): string {
16
+ return process.env.PASEO_HOME ?? join(homedir(), ".paseo");
17
+ }
18
+
19
+ export async function createRuntimeOwner(): Promise<RuntimeOwner<OwnedRuntime>> {
20
+ const root = join(paseoHome(), "plugin-data", "shared-browser");
21
+ const binaryPath =
22
+ process.env.PASEO_SHARED_BROWSER_AGENT_BROWSER_BINARY ??
23
+ join(root, "runtime", "node_modules", ".bin", "agent-browser");
24
+ const executablePath =
25
+ process.env.PASEO_SHARED_BROWSER_CHROMIUM_EXECUTABLE ??
26
+ join(root, "runtime", "chromium", "chrome");
27
+ return {
28
+ async create(workspaceId) {
29
+ const hash = createHash("sha256").update(workspaceId).digest("hex");
30
+ const runtime = new AgentBrowserRuntime({
31
+ binaryPath,
32
+ executablePath,
33
+ profilePath: join(root, "profiles", hash),
34
+ ipcDirectory: join(root, "ipc"),
35
+ session: `ws-${hash.slice(0, 16)}`,
36
+ initialUrl: DEFAULT_BROWSER_URL,
37
+ });
38
+ await runtime.launch();
39
+ return { runtimeId: randomUUID(), runtime };
40
+ },
41
+ async request(owned, operation, input) {
42
+ const data =
43
+ input && typeof input === "object" && !Array.isArray(input)
44
+ ? (input as Record<string, JsonValue>)
45
+ : {};
46
+ switch (operation) {
47
+ case "identity":
48
+ return owned.runtime.identity() as unknown as JsonValue;
49
+ case "state":
50
+ return owned.runtime.state() as unknown as JsonValue;
51
+ case "navigate":
52
+ await owned.runtime.navigate(String(data.url));
53
+ return null;
54
+ case "back":
55
+ await owned.runtime.back();
56
+ return null;
57
+ case "forward":
58
+ await owned.runtime.forward();
59
+ return null;
60
+ case "reload":
61
+ await owned.runtime.reload();
62
+ return null;
63
+ case "emulate":
64
+ await owned.runtime.emulate(data as unknown as BrowserViewport);
65
+ return null;
66
+ case "screencast.start":
67
+ await owned.runtime.startScreencast(Number(data.quality));
68
+ return null;
69
+ case "screencast.stop":
70
+ await owned.runtime.stopScreencast();
71
+ return null;
72
+ case "frame":
73
+ return (await owned.runtime.frame(
74
+ Number(data.maxBytes),
75
+ Number(data.quality),
76
+ Number(data.waitMs),
77
+ )) as unknown as JsonValue;
78
+ case "mouse.move":
79
+ await owned.runtime.mouseMove(Number(data.x), Number(data.y));
80
+ return null;
81
+ case "mouse.down":
82
+ await owned.runtime.mouseDown(
83
+ Number(data.x),
84
+ Number(data.y),
85
+ String(data.button) as "left" | "middle" | "right",
86
+ Number(data.clickCount),
87
+ );
88
+ return null;
89
+ case "mouse.up":
90
+ await owned.runtime.mouseUp(
91
+ Number(data.x),
92
+ Number(data.y),
93
+ String(data.button) as "left" | "middle" | "right",
94
+ Number(data.clickCount),
95
+ );
96
+ return null;
97
+ case "mouse.wheel":
98
+ await owned.runtime.wheel(
99
+ Number(data.x),
100
+ Number(data.y),
101
+ Number(data.deltaX),
102
+ Number(data.deltaY),
103
+ );
104
+ return null;
105
+ case "text.insert":
106
+ await owned.runtime.insertText(String(data.text));
107
+ return null;
108
+ case "key.down":
109
+ await owned.runtime.keyDown(String(data.key));
110
+ return null;
111
+ case "key.up":
112
+ await owned.runtime.keyUp(String(data.key));
113
+ return null;
114
+ default:
115
+ throw new Error(`Unknown browser runtime operation: ${operation}`);
116
+ }
117
+ },
118
+ async stop(owned) {
119
+ await owned.runtime.shutdown();
120
+ },
121
+ };
122
+ }
@@ -0,0 +1,260 @@
1
+ export const RUNTIME_PROTOCOL_VERSION = 2 as const;
2
+ export const DEFAULT_ORPHAN_GRACE_MS = 120_000;
3
+ export const DEFAULT_BRIDGE_HEARTBEAT_MS = 10_000;
4
+ export const DEFAULT_BRIDGE_TIMEOUT_MS = 30_000;
5
+
6
+ export type JsonPrimitive = string | number | boolean | null;
7
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
8
+
9
+ export interface RuntimeDescriptor {
10
+ workspaceId: string;
11
+ runtimeId: string;
12
+ createdAt: number;
13
+ }
14
+
15
+ export interface BridgeLease {
16
+ bridgeId: string;
17
+ epoch: number;
18
+ heartbeatIntervalMs: number;
19
+ expiresAt: number;
20
+ }
21
+
22
+ interface RuntimeRequestBase {
23
+ id: string;
24
+ version: typeof RUNTIME_PROTOCOL_VERSION;
25
+ }
26
+
27
+ interface AdminRequestBase extends RuntimeRequestBase {
28
+ token: string;
29
+ bridgeId: string;
30
+ }
31
+
32
+ export type AgentBrowserOperation =
33
+ | "status"
34
+ | "capture"
35
+ | "acquire-control"
36
+ | "release-control"
37
+ | "navigate"
38
+ | "input"
39
+ | "viewport";
40
+
41
+ export type RuntimeRequest =
42
+ | (AdminRequestBase & { method: "bridge.claim"; takeover?: boolean })
43
+ | (AdminRequestBase & { method: "bridge.heartbeat"; epoch: number })
44
+ | (AdminRequestBase & { method: "workspace.ensure"; epoch: number; workspaceId: string })
45
+ | (AdminRequestBase & {
46
+ method: "workspace.request";
47
+ epoch: number;
48
+ workspaceId: string;
49
+ operation: string;
50
+ input: JsonValue;
51
+ })
52
+ | (AdminRequestBase & { method: "workspace.archive"; epoch: number; workspaceId: string })
53
+ | (AdminRequestBase & {
54
+ method: "browser.request";
55
+ epoch: number;
56
+ operation: string;
57
+ input: JsonValue;
58
+ })
59
+ | (AdminRequestBase & { method: "ticket.issue"; epoch: number; ticket: string })
60
+ | (AdminRequestBase & {
61
+ method: "ticket.bind";
62
+ epoch: number;
63
+ ticket: string;
64
+ agentId: string;
65
+ workspaceId: string;
66
+ })
67
+ | (AdminRequestBase & { method: "agent.revoke"; epoch: number; agentId: string })
68
+ | (RuntimeRequestBase & {
69
+ method: "agent.request";
70
+ ticket: string;
71
+ operation: AgentBrowserOperation;
72
+ input: JsonValue;
73
+ });
74
+
75
+ export type RuntimeResult = BridgeLease | RuntimeDescriptor | JsonValue | { archived: true };
76
+
77
+ export type RuntimeResponse =
78
+ | { id: string; ok: true; result: RuntimeResult }
79
+ | { id: string; ok: false; error: { code: RuntimeErrorCode; message: string } };
80
+
81
+ export type RuntimeErrorCode =
82
+ | "AUTHENTICATION_FAILED"
83
+ | "BRIDGE_FENCED"
84
+ | "INVALID_REQUEST"
85
+ | "PROTOCOL_MISMATCH"
86
+ | "WORKSPACE_ARCHIVED"
87
+ | "WORKSPACE_NOT_FOUND"
88
+ | "RUNTIME_BUSY"
89
+ | "UNKNOWN_OUTCOME"
90
+ | "RUNTIME_FAILURE";
91
+
92
+ export class RuntimeProtocolError extends Error {
93
+ readonly code: RuntimeErrorCode;
94
+
95
+ constructor(code: RuntimeErrorCode, message: string) {
96
+ super(message);
97
+ this.name = "RuntimeProtocolError";
98
+ this.code = code;
99
+ }
100
+ }
101
+
102
+ export function isRuntimeResponse(value: unknown): value is RuntimeResponse {
103
+ if (!isRecord(value) || typeof value.id !== "string" || typeof value.ok !== "boolean")
104
+ return false;
105
+ if (value.ok) return "result" in value;
106
+ return (
107
+ isRecord(value.error) &&
108
+ typeof value.error.code === "string" &&
109
+ typeof value.error.message === "string"
110
+ );
111
+ }
112
+
113
+ export function parseRuntimeRequest(value: unknown): RuntimeRequest {
114
+ if (!isRecord(value))
115
+ throw new RuntimeProtocolError("INVALID_REQUEST", "Request must be an object");
116
+ const id = requireString(value, "id");
117
+ const method = requireString(value, "method");
118
+ if (value.version !== RUNTIME_PROTOCOL_VERSION) {
119
+ throw new RuntimeProtocolError(
120
+ "PROTOCOL_MISMATCH",
121
+ `Expected protocol version ${RUNTIME_PROTOCOL_VERSION}`,
122
+ );
123
+ }
124
+ if (method === "agent.request") {
125
+ return {
126
+ id,
127
+ version: RUNTIME_PROTOCOL_VERSION,
128
+ method,
129
+ ticket: requireString(value, "ticket"),
130
+ operation: requireAgentOperation(value.operation),
131
+ input: requireJsonValue(value, "input"),
132
+ };
133
+ }
134
+
135
+ const token = requireString(value, "token");
136
+ const bridgeId = requireString(value, "bridgeId");
137
+ if (method === "bridge.claim")
138
+ return {
139
+ id,
140
+ version: RUNTIME_PROTOCOL_VERSION,
141
+ method,
142
+ token,
143
+ bridgeId,
144
+ takeover: value.takeover === true,
145
+ };
146
+ const epoch = requireInteger(value, "epoch");
147
+ if (method === "bridge.heartbeat")
148
+ return { id, version: RUNTIME_PROTOCOL_VERSION, method, token, bridgeId, epoch };
149
+ if (method === "workspace.ensure" || method === "workspace.archive")
150
+ return {
151
+ id,
152
+ version: RUNTIME_PROTOCOL_VERSION,
153
+ method,
154
+ token,
155
+ bridgeId,
156
+ epoch,
157
+ workspaceId: requireString(value, "workspaceId"),
158
+ };
159
+ if (method === "workspace.request")
160
+ return {
161
+ id,
162
+ version: RUNTIME_PROTOCOL_VERSION,
163
+ method,
164
+ token,
165
+ bridgeId,
166
+ epoch,
167
+ workspaceId: requireString(value, "workspaceId"),
168
+ operation: requireString(value, "operation"),
169
+ input: requireJsonValue(value, "input"),
170
+ };
171
+ if (method === "browser.request")
172
+ return {
173
+ id,
174
+ version: RUNTIME_PROTOCOL_VERSION,
175
+ method,
176
+ token,
177
+ bridgeId,
178
+ epoch,
179
+ operation: requireString(value, "operation"),
180
+ input: requireJsonValue(value, "input"),
181
+ };
182
+ if (method === "ticket.issue")
183
+ return {
184
+ id,
185
+ version: RUNTIME_PROTOCOL_VERSION,
186
+ method,
187
+ token,
188
+ bridgeId,
189
+ epoch,
190
+ ticket: requireString(value, "ticket"),
191
+ };
192
+ if (method === "ticket.bind")
193
+ return {
194
+ id,
195
+ version: RUNTIME_PROTOCOL_VERSION,
196
+ method,
197
+ token,
198
+ bridgeId,
199
+ epoch,
200
+ ticket: requireString(value, "ticket"),
201
+ agentId: requireString(value, "agentId"),
202
+ workspaceId: requireString(value, "workspaceId"),
203
+ };
204
+ if (method === "agent.revoke")
205
+ return {
206
+ id,
207
+ version: RUNTIME_PROTOCOL_VERSION,
208
+ method,
209
+ token,
210
+ bridgeId,
211
+ epoch,
212
+ agentId: requireString(value, "agentId"),
213
+ };
214
+ throw new RuntimeProtocolError("INVALID_REQUEST", `Unknown method: ${method}`);
215
+ }
216
+
217
+ function isRecord(value: unknown): value is Record<string, unknown> {
218
+ return typeof value === "object" && value !== null && !Array.isArray(value);
219
+ }
220
+
221
+ function requireString(value: Record<string, unknown>, key: string): string {
222
+ const result = value[key];
223
+ if (typeof result !== "string" || result.length === 0)
224
+ throw new RuntimeProtocolError("INVALID_REQUEST", `${key} must be a non-empty string`);
225
+ return result;
226
+ }
227
+
228
+ function requireInteger(value: Record<string, unknown>, key: string): number {
229
+ const result = value[key];
230
+ if (typeof result !== "number" || !Number.isSafeInteger(result))
231
+ throw new RuntimeProtocolError("INVALID_REQUEST", `${key} must be a safe integer`);
232
+ return result;
233
+ }
234
+
235
+ function requireAgentOperation(value: unknown): AgentBrowserOperation {
236
+ if (
237
+ value === "status" ||
238
+ value === "capture" ||
239
+ value === "acquire-control" ||
240
+ value === "release-control" ||
241
+ value === "navigate" ||
242
+ value === "input" ||
243
+ value === "viewport"
244
+ )
245
+ return value;
246
+ throw new RuntimeProtocolError("INVALID_REQUEST", "Unknown agent browser operation");
247
+ }
248
+
249
+ function requireJsonValue(value: Record<string, unknown>, key: string): JsonValue {
250
+ if (!(key in value) || !isJsonValue(value[key]))
251
+ throw new RuntimeProtocolError("INVALID_REQUEST", `${key} must be valid JSON`);
252
+ return value[key];
253
+ }
254
+
255
+ function isJsonValue(value: unknown): value is JsonValue {
256
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
257
+ if (typeof value === "number") return Number.isFinite(value);
258
+ if (Array.isArray(value)) return value.every(isJsonValue);
259
+ return isRecord(value) && Object.values(value).every(isJsonValue);
260
+ }