@alfe.ai/openclaw-remote 0.0.15 → 0.0.16

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,789 @@
1
+ import { createRequire } from "node:module";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { AgentApiClient, installToolErrorCapture } from "@alfe.ai/agent-api-client";
4
+ import { BrowserSurface } from "@alfe.ai/browser";
5
+ import { resolveConfig } from "@alfe.ai/config";
6
+ import { PublicToolError, defineTool, errResult, getActivationKey, guardedStart, publicToolError, resetActivation } from "@alfe.ai/openclaw-plugin-kit";
7
+ import { RemoteFrameType, RemoteServiceClient, decodeSessionOpenPayload } from "@alfe.ai/remote";
8
+ import { TerminalSurface } from "@alfe.ai/terminal";
9
+ //#region src/ssrf.ts
10
+ /** Hostnames blocked outright (case-insensitive, exact match). */
11
+ const BLOCKED_HOSTNAMES = new Set(["localhost"]);
12
+ /** Hostname suffixes blocked outright (internal service discovery names). */
13
+ const BLOCKED_HOST_SUFFIXES = [".internal", ".local"];
14
+ /** Parse a dotted-quad IPv4 literal into its four octets, or null. */
15
+ function parseIpv4(host) {
16
+ const parts = host.split(".");
17
+ if (parts.length !== 4) return null;
18
+ const octets = [];
19
+ for (const part of parts) {
20
+ if (!/^\d{1,3}$/.test(part)) return null;
21
+ const n = Number(part);
22
+ if (n > 255) return null;
23
+ octets.push(n);
24
+ }
25
+ return octets;
26
+ }
27
+ /** Is this IPv4 literal private, loopback, link-local, or otherwise reserved? */
28
+ function isPrivateOrReservedIpv4(host) {
29
+ const octets = parseIpv4(host);
30
+ if (!octets) return false;
31
+ const [a, b] = octets;
32
+ if (a === 0) return true;
33
+ if (a === 10) return true;
34
+ if (a === 127) return true;
35
+ if (a === 169 && b === 254) return true;
36
+ if (a === 172 && b >= 16 && b <= 31) return true;
37
+ if (a === 192 && b === 0 && octets[2] === 0) return true;
38
+ if (a === 192 && b === 0 && octets[2] === 2) return true;
39
+ if (a === 192 && b === 168) return true;
40
+ if (a === 192 && b === 88 && octets[2] === 99) return true;
41
+ if (a === 100 && b >= 64 && b <= 127) return true;
42
+ if (a === 198 && (b === 18 || b === 19)) return true;
43
+ if (a === 198 && b === 51 && octets[2] === 100) return true;
44
+ if (a === 203 && b === 0 && octets[2] === 113) return true;
45
+ if (a >= 224) return true;
46
+ return false;
47
+ }
48
+ /** Is this IPv6 literal (brackets already stripped) loopback/private/link-local? */
49
+ function isPrivateOrReservedIpv6(host) {
50
+ const h = host.toLowerCase();
51
+ if (h === "::1" || h === "::") return true;
52
+ const mapped = /^::ffff:(.+)$/.exec(h);
53
+ if (mapped) {
54
+ const rest = mapped[1];
55
+ if (rest.includes(".")) return isPrivateOrReservedIpv4(rest);
56
+ const groups = rest.split(":");
57
+ if (groups.length === 2 && groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) {
58
+ const g1 = parseInt(groups[0], 16);
59
+ const g2 = parseInt(groups[1], 16);
60
+ return isPrivateOrReservedIpv4([
61
+ g1 >> 8 & 255,
62
+ g1 & 255,
63
+ g2 >> 8 & 255,
64
+ g2 & 255
65
+ ].join("."));
66
+ }
67
+ }
68
+ if (/^f[cd][0-9a-f]*:/.test(h)) return true;
69
+ if (/^fe[89ab][0-9a-f]*:/.test(h)) return true;
70
+ if (/^ff[0-9a-f]{2}:/u.test(h)) return true;
71
+ if (h.startsWith("100::") || h.startsWith("100:0:0:0:")) return true;
72
+ if (h === "2001:db8::" || h.startsWith("2001:db8:")) return true;
73
+ return false;
74
+ }
75
+ /**
76
+ * Build the navigation predicate for a given SSRF policy. When
77
+ * `dangerouslyAllowPrivateNetwork` is true the predicate is allow-all
78
+ * otherwise it blocks the reserved ranges above and any non-http(s) scheme.
79
+ */
80
+ function buildIsNavigationAllowed(policy, log) {
81
+ const allowPrivate = policy?.dangerouslyAllowPrivateNetwork === true;
82
+ const block = () => {
83
+ log?.warn("SSRF policy blocked browser navigation");
84
+ return false;
85
+ };
86
+ return (rawUrl) => {
87
+ let url;
88
+ try {
89
+ url = new URL(rawUrl);
90
+ } catch {
91
+ return block();
92
+ }
93
+ if (url.protocol !== "http:" && url.protocol !== "https:") return block();
94
+ if (url.username !== "" || url.password !== "") return block();
95
+ if (allowPrivate) return true;
96
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
97
+ if (BLOCKED_HOSTNAMES.has(host)) return block();
98
+ if (BLOCKED_HOST_SUFFIXES.some((s) => host.endsWith(s))) return block();
99
+ if (parseIpv4(host)) {
100
+ if (isPrivateOrReservedIpv4(host)) return block();
101
+ } else if (host.includes(":")) {
102
+ if (isPrivateOrReservedIpv6(host)) return block();
103
+ }
104
+ return true;
105
+ };
106
+ }
107
+ //#endregion
108
+ //#region src/runtime.ts
109
+ /**
110
+ * @alfe.ai/openclaw-remote — testable runtime for the interactive remote-control
111
+ * relay. Owns one outbound WS to the relay and routes per-session frames to the
112
+ * browser co-browse surface (@alfe.ai/browser) or the web terminal surface
113
+ * (@alfe.ai/terminal). Registers the agent-callable browser tools, including
114
+ * `request_browser_takeover` ("help me complete these").
115
+ *
116
+ * Follows the established Alfe plugin shape (see @alfe.ai/openclaw-webhooks):
117
+ * tools register on every activate(); the long-lived connection is guarded and
118
+ * created inside registerService.start.
119
+ */
120
+ const pkg = createRequire(import.meta.url)("../package.json");
121
+ const REMOTE_ACTIVATION_KEY = getActivationKey("remote");
122
+ const DEFAULT_HANDOFF_TIMEOUT_MS = 600 * 1e3;
123
+ const DEFAULT_CHROME_PATH = "/usr/bin/google-chrome-stable";
124
+ const RUNTIME_STATE_KEY = "__alfeOpenClawRemoteRuntimeState";
125
+ const MAX_REMOTE_URL_CHARS = 2048;
126
+ const MAX_PATH_CHARS = 4096;
127
+ const MAX_AGENT_ID_CHARS = 256;
128
+ const MAX_SESSION_ID_CHARS = 256;
129
+ const MAX_SELECTOR_CHARS = 4096;
130
+ const MAX_TYPE_TEXT_CHARS = 32 * 1024;
131
+ const MAX_EXPRESSION_CHARS = 128 * 1024;
132
+ const MAX_NAVIGATION_URL_CHARS = 8192;
133
+ const MAX_INSTRUCTIONS_CHARS = 2e3;
134
+ const MAX_CONVERSATION_ID_CHARS = 200;
135
+ const MAX_SCREENSHOT_BYTES = 10 * 1024 * 1024;
136
+ const MAX_TERMINAL_RELAY_BUFFER_BYTES = 4 * 1024 * 1024;
137
+ const GENERIC_TOOL_ERROR = "Tool execution failed. Retry, and inspect agent diagnostics if the failure persists.";
138
+ const PLUGIN_VERSION = validatePackageVersion(pkg.version);
139
+ function createRemotePluginRuntimeState() {
140
+ return {
141
+ generation: 0,
142
+ remoteClient: null,
143
+ browserSurface: null,
144
+ terminalSurface: null,
145
+ apiClient: null,
146
+ handoffTimeoutMs: DEFAULT_HANDOFF_TIMEOUT_MS,
147
+ sessionSurfaces: /* @__PURE__ */ new Map(),
148
+ stopPromise: null
149
+ };
150
+ }
151
+ /**
152
+ * Derive the relay WebSocket URL from the agent's cloud apiUrl when the
153
+ * manifest doesn't provide one — mirrors @alfe.ai/console-client's
154
+ * `deriveConsoleWsUrl` so the URL is per-stage automatically:
155
+ * https://api.dev.alfe.ai → wss://remote.dev.alfe.ai/ws
156
+ * (matches config.flyDomains.remote per stage). This is why the
157
+ * headless-browser integration manifest carries no hardcoded, prod-pinned
158
+ * `remoteWsUrl`: the plugin resolves it from the agent's own endpoint.
159
+ */
160
+ function deriveRemoteWsUrl(apiUrl) {
161
+ try {
162
+ const url = new URL(apiUrl);
163
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || !url.hostname.startsWith("api.")) return void 0;
164
+ return `wss://remote.${url.hostname.slice(4)}/ws`;
165
+ } catch {
166
+ return;
167
+ }
168
+ }
169
+ /**
170
+ * Derive the dashboard (control-plane) base URL from the agent's cloud apiUrl,
171
+ * mirroring `deriveRemoteWsUrl` so it's per-stage automatically:
172
+ * https://api.dev.alfe.ai → https://app.dev.alfe.ai
173
+ * https://api.alfe.ai → https://app.alfe.ai (prod)
174
+ * The `api.` → `app.` swap matches config.*.ts `dashboardDomain` for every
175
+ * stage. Returns undefined for a non-`api.` host (e.g. localhost/dev override)
176
+ * so the caller can fall back to a session-only link.
177
+ */
178
+ function deriveDashboardBaseUrl(apiUrl) {
179
+ try {
180
+ const url = new URL(apiUrl);
181
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || !url.hostname.startsWith("api.")) return void 0;
182
+ return `https://app.${url.hostname.slice(4)}`;
183
+ } catch {
184
+ return;
185
+ }
186
+ }
187
+ /**
188
+ * Build the dashboard deep-link ("control URL") the human opens to take over.
189
+ * Prefers the full agent-scoped path; if the dashboard host or agentId is
190
+ * unavailable, degrades gracefully (session-only path, then null).
191
+ */
192
+ function buildControlUrl(dashboardBaseUrl, agentId, sessionId) {
193
+ if (!isDashboardBaseUrl(dashboardBaseUrl) || !isBoundedString(sessionId, MAX_SESSION_ID_CHARS)) return void 0;
194
+ const session = encodeURIComponent(sessionId);
195
+ if (agentId && isBoundedString(agentId, MAX_AGENT_ID_CHARS)) return `${dashboardBaseUrl}/agents/${encodeURIComponent(agentId)}?tab=browser&session=${session}`;
196
+ return `${dashboardBaseUrl}/agents?tab=browser&session=${session}`;
197
+ }
198
+ /** Route an inbound relay frame to the owning surface by session. */
199
+ function dispatchFrame(state, frame, log) {
200
+ if (frame.type === RemoteFrameType.SESSION_OPEN) {
201
+ const open = decodeSessionOpenPayload(frame.payload);
202
+ if (!open) return;
203
+ const existingSurface = state.sessionSurfaces.get(frame.sessionId);
204
+ if (existingSurface !== void 0 && existingSurface !== open.surface) {
205
+ log.warn("Remote session attempted to change surface type");
206
+ return;
207
+ }
208
+ const handler = open.surface === "terminal" ? state.terminalSurface : state.browserSurface;
209
+ if (!handler) {
210
+ log.warn("Remote session surface is unavailable");
211
+ return;
212
+ }
213
+ state.sessionSurfaces.set(frame.sessionId, open.surface);
214
+ Promise.resolve(handler.openSession(frame.sessionId, open)).catch(() => {
215
+ if (state.sessionSurfaces.get(frame.sessionId) === open.surface) state.sessionSurfaces.delete(frame.sessionId);
216
+ try {
217
+ handler.closeSession(frame.sessionId);
218
+ } catch {}
219
+ log.warn("Remote session could not be opened");
220
+ });
221
+ return;
222
+ }
223
+ const surface = state.sessionSurfaces.get(frame.sessionId);
224
+ const handler = surface === "terminal" ? state.terminalSurface : surface === "browser" ? state.browserSurface : null;
225
+ if (!handler) return;
226
+ if (frame.type === RemoteFrameType.SESSION_CLOSE) {
227
+ state.sessionSurfaces.delete(frame.sessionId);
228
+ try {
229
+ handler.closeSession(frame.sessionId);
230
+ } catch {
231
+ log.warn("Remote session close failed");
232
+ }
233
+ return;
234
+ }
235
+ try {
236
+ handler.handleFrame(frame);
237
+ } catch {
238
+ log.warn("Remote session frame handling failed");
239
+ }
240
+ }
241
+ function startService(pluginConfig, ssrfPolicy, workspaceDir, log, dependencies, state) {
242
+ guardedStart(REMOTE_ACTIVATION_KEY, log, () => {
243
+ if (pluginConfig === null) {
244
+ log.error("Remote plugin configuration is invalid");
245
+ resetActivation(REMOTE_ACTIVATION_KEY);
246
+ return;
247
+ }
248
+ return startServiceInner(pluginConfig, ssrfPolicy, workspaceDir, log, dependencies, state);
249
+ });
250
+ }
251
+ async function startServiceInner(pluginConfig, ssrfPolicy, workspaceDir, log, dependencies, state) {
252
+ if (state.remoteClient !== null || state.browserSurface !== null || state.terminalSurface !== null || state.apiClient !== null) {
253
+ log.warn("Remote runtime already owns active resources");
254
+ return;
255
+ }
256
+ let alfeConfig = null;
257
+ try {
258
+ alfeConfig = dependencies.resolveRuntimeConfig();
259
+ } catch {
260
+ log.info("Could not resolve Alfe config — remote plugin idle");
261
+ }
262
+ const apiKey = alfeConfig?.apiKey;
263
+ const apiUrl = alfeConfig?.apiUrl;
264
+ const wsUrl = pluginConfig.remoteWsUrl ?? (apiUrl ? deriveRemoteWsUrl(apiUrl) : void 0);
265
+ if (!wsUrl || !apiKey || !apiUrl) {
266
+ log.info("Remote relay URL or credentials not configured — plugin running without relay");
267
+ resetActivation(REMOTE_ACTIVATION_KEY);
268
+ return;
269
+ }
270
+ const generation = state.generation + 1;
271
+ const nextApiClient = dependencies.createApiClient({
272
+ apiKey,
273
+ apiUrl
274
+ });
275
+ const resolvedWorkspace = selectWorkspacePath(workspaceDir, alfeConfig?.workspacePath);
276
+ let nextBrowserSurface = null;
277
+ let nextTerminalSurface = null;
278
+ let nextRemoteClient = null;
279
+ const sendFrame = (frame) => {
280
+ if (state.generation === generation && state.remoteClient === nextRemoteClient) nextRemoteClient?.sendFrame(frame);
281
+ };
282
+ const sendTerminalFrame = (frame) => {
283
+ if (state.generation !== generation || state.remoteClient !== nextRemoteClient || nextRemoteClient === null) return;
284
+ if ((nextRemoteClient.bufferedAmount ?? 0) >= MAX_TERMINAL_RELAY_BUFFER_BYTES) throw new Error("Remote terminal relay buffer limit reached");
285
+ nextRemoteClient.sendFrame(frame);
286
+ };
287
+ try {
288
+ nextBrowserSurface = dependencies.createBrowserSurface({
289
+ executablePath: pluginConfig.browserExecutablePath ?? DEFAULT_CHROME_PATH,
290
+ headless: pluginConfig.browserHeadless ?? true,
291
+ noSandbox: pluginConfig.browserNoSandbox ?? true,
292
+ userDataDir: join(resolvedWorkspace, ".alfe-browser-profile"),
293
+ isNavigationAllowed: buildIsNavigationAllowed(ssrfPolicy, log),
294
+ logger: log
295
+ }, sendFrame);
296
+ nextTerminalSurface = dependencies.createTerminalSurface({
297
+ cwd: resolvedWorkspace,
298
+ logger: log
299
+ }, sendTerminalFrame);
300
+ nextRemoteClient = dependencies.createRemoteClient({
301
+ wsUrl,
302
+ apiKey,
303
+ onFrame: (frame) => {
304
+ if (state.generation === generation && state.remoteClient === nextRemoteClient) dispatchFrame(state, frame, log);
305
+ },
306
+ onConnectionChange: (connected) => {
307
+ if (state.generation !== generation || state.remoteClient !== nextRemoteClient) return;
308
+ log.info(`Remote relay connection: ${connected ? "connected" : "disconnected"}`);
309
+ if (!connected) closeAllSessions(state, log);
310
+ },
311
+ logger: log
312
+ });
313
+ } catch (error) {
314
+ nextRemoteClient?.stop();
315
+ nextTerminalSurface?.shutdown();
316
+ await nextBrowserSurface?.shutdown().catch(() => void 0);
317
+ throw error;
318
+ }
319
+ state.generation = generation;
320
+ state.handoffTimeoutMs = pluginConfig.handoffTimeoutMs ?? DEFAULT_HANDOFF_TIMEOUT_MS;
321
+ state.apiClient = nextApiClient;
322
+ state.browserSurface = nextBrowserSurface;
323
+ state.terminalSurface = nextTerminalSurface;
324
+ state.remoteClient = nextRemoteClient;
325
+ state.dashboardBaseUrl = deriveDashboardBaseUrl(apiUrl);
326
+ state.selfAgentId = void 0;
327
+ try {
328
+ nextRemoteClient.start();
329
+ } catch (error) {
330
+ await stopService(state, log);
331
+ throw error;
332
+ }
333
+ nextApiClient.whoami().then(({ agentId }) => {
334
+ if (state.generation === generation && state.apiClient === nextApiClient && isBoundedString(agentId, MAX_AGENT_ID_CHARS)) state.selfAgentId = agentId;
335
+ }).catch(() => {
336
+ log.debug("whoami failed; takeover links will omit agentId");
337
+ });
338
+ log.info("Remote plugin started");
339
+ }
340
+ function stopService(state, log) {
341
+ if (state.stopPromise !== null) return state.stopPromise;
342
+ const generation = state.generation + 1;
343
+ state.generation = generation;
344
+ const remoteClient = state.remoteClient;
345
+ const browserSurface = state.browserSurface;
346
+ const terminalSurface = state.terminalSurface;
347
+ const stopping = (async () => {
348
+ remoteClient?.stop();
349
+ closeAllSessions(state, log);
350
+ terminalSurface?.shutdown();
351
+ await browserSurface?.shutdown().catch(() => void 0);
352
+ if (state.generation === generation) {
353
+ state.remoteClient = null;
354
+ state.browserSurface = null;
355
+ state.terminalSurface = null;
356
+ state.apiClient = null;
357
+ state.dashboardBaseUrl = void 0;
358
+ state.selfAgentId = void 0;
359
+ state.handoffTimeoutMs = DEFAULT_HANDOFF_TIMEOUT_MS;
360
+ state.sessionSurfaces.clear();
361
+ }
362
+ })().finally(() => {
363
+ if (state.stopPromise === stopping) state.stopPromise = null;
364
+ resetActivation(REMOTE_ACTIVATION_KEY);
365
+ log.info("Remote plugin stopped");
366
+ });
367
+ state.stopPromise = stopping;
368
+ return stopping;
369
+ }
370
+ function closeAllSessions(state, log) {
371
+ for (const [sessionId, surface] of state.sessionSurfaces) {
372
+ const handler = surface === "terminal" ? state.terminalSurface : state.browserSurface;
373
+ try {
374
+ handler?.closeSession(sessionId);
375
+ } catch {
376
+ log.warn("Remote session cleanup failed");
377
+ }
378
+ }
379
+ state.sessionSurfaces.clear();
380
+ }
381
+ function registerTools(api, state) {
382
+ const needBrowser = () => {
383
+ if (!state.browserSurface) throw publicToolError("Browser surface is unavailable; retry after the remote service connects.");
384
+ return state.browserSurface;
385
+ };
386
+ api.registerTool(remoteTool({
387
+ name: "browser_navigate",
388
+ description: "Navigate the shared browser to a URL. Returns the final URL and page title.",
389
+ parameters: {
390
+ type: "object",
391
+ properties: { url: {
392
+ type: "string",
393
+ minLength: 1,
394
+ maxLength: MAX_NAVIGATION_URL_CHARS,
395
+ description: "Absolute credential-free HTTP(S) URL to open"
396
+ } },
397
+ required: ["url"],
398
+ additionalProperties: false
399
+ },
400
+ handler: async (params) => {
401
+ const url = requireHttpUrl(params.url, "url", MAX_NAVIGATION_URL_CHARS);
402
+ try {
403
+ return await needBrowser().automation.navigate(url);
404
+ } catch (error) {
405
+ if (error instanceof Error && error.message === "Navigation blocked by browser policy") throw publicToolError(error.message);
406
+ throw error;
407
+ }
408
+ }
409
+ }));
410
+ api.registerTool(remoteTool({
411
+ name: "browser_click",
412
+ description: "Click an element in the shared browser by CSS selector.",
413
+ parameters: {
414
+ type: "object",
415
+ properties: { selector: {
416
+ type: "string",
417
+ minLength: 1,
418
+ maxLength: MAX_SELECTOR_CHARS
419
+ } },
420
+ required: ["selector"],
421
+ additionalProperties: false
422
+ },
423
+ handler: async (params) => {
424
+ await needBrowser().automation.click(requireString(params.selector, "selector", MAX_SELECTOR_CHARS));
425
+ return { ok: true };
426
+ }
427
+ }));
428
+ api.registerTool(remoteTool({
429
+ name: "browser_type",
430
+ description: "Type text into an element in the shared browser by CSS selector.",
431
+ parameters: {
432
+ type: "object",
433
+ properties: {
434
+ selector: {
435
+ type: "string",
436
+ minLength: 1,
437
+ maxLength: MAX_SELECTOR_CHARS
438
+ },
439
+ text: {
440
+ type: "string",
441
+ maxLength: MAX_TYPE_TEXT_CHARS
442
+ }
443
+ },
444
+ required: ["selector", "text"],
445
+ additionalProperties: false
446
+ },
447
+ handler: async (params) => {
448
+ await needBrowser().automation.type(requireString(params.selector, "selector", MAX_SELECTOR_CHARS), requireString(params.text, "text", MAX_TYPE_TEXT_CHARS, true));
449
+ return { ok: true };
450
+ }
451
+ }));
452
+ api.registerTool(remoteTool({
453
+ name: "browser_wait_for",
454
+ description: "Wait for exactly one selector, URL substring, or fixed delay.",
455
+ parameters: {
456
+ type: "object",
457
+ properties: {
458
+ selector: {
459
+ type: "string",
460
+ minLength: 1,
461
+ maxLength: MAX_SELECTOR_CHARS
462
+ },
463
+ urlPattern: {
464
+ type: "string",
465
+ minLength: 1,
466
+ maxLength: MAX_NAVIGATION_URL_CHARS
467
+ },
468
+ ms: {
469
+ type: "integer",
470
+ minimum: 0,
471
+ maximum: 12e4
472
+ }
473
+ },
474
+ additionalProperties: false
475
+ },
476
+ handler: async (params) => {
477
+ const options = parseWaitOptions(params);
478
+ await needBrowser().automation.waitFor(options);
479
+ return { ok: true };
480
+ }
481
+ }));
482
+ api.registerTool({
483
+ name: "browser_screenshot",
484
+ label: "browser_screenshot",
485
+ description: "Capture a JPEG screenshot of the shared browser's current page.",
486
+ parameters: {
487
+ type: "object",
488
+ properties: {},
489
+ additionalProperties: false
490
+ },
491
+ execute: async (_toolCallId, params) => {
492
+ try {
493
+ if (!isRecord(params) || Object.keys(params).length > 0) throw publicToolError("browser_screenshot accepts no parameters");
494
+ const data = await needBrowser().automation.screenshot();
495
+ const bytes = decodedBase64Bytes(data);
496
+ if (bytes < 1 || bytes > MAX_SCREENSHOT_BYTES) throw new Error("Browser screenshot exceeded its media boundary");
497
+ return {
498
+ content: [{
499
+ type: "image",
500
+ data,
501
+ mimeType: "image/jpeg"
502
+ }],
503
+ details: {
504
+ mimeType: "image/jpeg",
505
+ bytes
506
+ }
507
+ };
508
+ } catch (error) {
509
+ return errResult(error instanceof PublicToolError ? error.message : GENERIC_TOOL_ERROR);
510
+ }
511
+ }
512
+ });
513
+ api.registerTool(remoteTool({
514
+ name: "browser_evaluate",
515
+ description: "Evaluate a JavaScript expression in the shared browser page and return bounded JSON.",
516
+ parameters: {
517
+ type: "object",
518
+ properties: { expression: {
519
+ type: "string",
520
+ minLength: 1,
521
+ maxLength: MAX_EXPRESSION_CHARS
522
+ } },
523
+ required: ["expression"],
524
+ additionalProperties: false
525
+ },
526
+ handler: async (params) => ({ result: await needBrowser().automation.evaluate(requireString(params.expression, "expression", MAX_EXPRESSION_CHARS)) })
527
+ }));
528
+ api.registerTool(remoteTool({
529
+ name: "request_browser_takeover",
530
+ description: "Ask a human to take over the live browser for a manual step. Share the returned control URL with the user, then wait for the hand-back.",
531
+ parameters: {
532
+ type: "object",
533
+ properties: {
534
+ instructions: {
535
+ type: "string",
536
+ minLength: 1,
537
+ maxLength: MAX_INSTRUCTIONS_CHARS,
538
+ description: "Exact task for the human to complete"
539
+ },
540
+ url: {
541
+ type: "string",
542
+ minLength: 1,
543
+ maxLength: 2048,
544
+ description: "Optional credential-free HTTP(S) page shown for context"
545
+ },
546
+ conversationId: {
547
+ type: "string",
548
+ minLength: 1,
549
+ maxLength: MAX_CONVERSATION_ID_CHARS
550
+ }
551
+ },
552
+ required: ["instructions"],
553
+ additionalProperties: false
554
+ },
555
+ handler: async (params) => {
556
+ const surface = needBrowser();
557
+ const client = state.apiClient;
558
+ if (!client) throw publicToolError("Remote service is unavailable; retry after it connects.");
559
+ const generation = state.generation;
560
+ const instructions = requireString(params.instructions, "instructions", MAX_INSTRUCTIONS_CHARS);
561
+ const url = params.url === void 0 ? void 0 : requireHttpUrl(params.url, "url", 2048);
562
+ const conversationId = optionalString(params.conversationId, "conversationId", MAX_CONVERSATION_ID_CHARS);
563
+ const sessionId = requireTrustedString((await client.requestBrowserTakeover({
564
+ instructions,
565
+ url,
566
+ conversationId
567
+ })).sessionId, "remote session ID", MAX_SESSION_ID_CHARS);
568
+ if (state.generation !== generation || state.browserSurface !== surface || state.apiClient !== client) {
569
+ await client.completeRemoteSession(sessionId).catch(() => void 0);
570
+ throw publicToolError("Remote browser restarted while creating the takeover; retry the request.");
571
+ }
572
+ const controlUrl = buildControlUrl(state.dashboardBaseUrl, state.selfAgentId, sessionId);
573
+ const message = controlUrl ? `Browser takeover requested (session ${sessionId}). Ask the user to open ${controlUrl}, complete the task, and hand control back.` : `Browser takeover requested (session ${sessionId}). Ask the user to open this agent's Browser tab in the Alfe dashboard, complete the task, and hand control back.`;
574
+ surface.addHold();
575
+ try {
576
+ return {
577
+ sessionId,
578
+ controlUrl,
579
+ message,
580
+ ...await surface.requestHandoff(state.handoffTimeoutMs)
581
+ };
582
+ } finally {
583
+ surface.removeHold();
584
+ await client.completeRemoteSession(sessionId).catch(() => void 0);
585
+ }
586
+ }
587
+ }));
588
+ }
589
+ function createRemotePlugin(dependencies = {}) {
590
+ const state = dependencies.runtimeState ?? getGlobalRuntimeState();
591
+ const runtimeDependencies = {
592
+ resolveRuntimeConfig: dependencies.resolveConfig ?? resolveConfig,
593
+ createApiClient: dependencies.createApiClient ?? ((config) => new AgentApiClient(config)),
594
+ createRemoteClient: dependencies.createRemoteClient ?? ((options) => new RemoteServiceClient(options)),
595
+ createBrowserSurface: dependencies.createBrowserSurface ?? ((options, sendFrame) => new BrowserSurface(options, sendFrame)),
596
+ createTerminalSurface: dependencies.createTerminalSurface ?? ((options, sendFrame) => new TerminalSurface(options, sendFrame))
597
+ };
598
+ const installErrorCapture = dependencies.installErrorCapture ?? installToolErrorCapture;
599
+ return {
600
+ id: "@alfe.ai/openclaw-remote",
601
+ name: "Remote",
602
+ description: "Interactive remote control — browser co-browse takeover and web terminal",
603
+ version: PLUGIN_VERSION,
604
+ activate(api) {
605
+ installErrorCapture(api, { plugin: "openclaw-remote" });
606
+ const log = api.logger;
607
+ let pluginConfig = null;
608
+ let ssrfPolicy;
609
+ try {
610
+ const parsedPluginConfig = parseRemotePluginConfig(api.config?.plugins?.entries?.["@alfe.ai/openclaw-remote"]?.config, api.config?.browser);
611
+ const parsedSsrfPolicy = parseSsrfPolicy(api.config?.browser?.ssrfPolicy);
612
+ pluginConfig = parsedPluginConfig;
613
+ ssrfPolicy = parsedSsrfPolicy;
614
+ } catch {
615
+ log.error("Remote plugin configuration is invalid");
616
+ }
617
+ registerTools(api, state);
618
+ api.registerService({
619
+ id: "alfe-remote",
620
+ start: (context) => {
621
+ startService(pluginConfig, ssrfPolicy, context.workspaceDir, log, runtimeDependencies, state);
622
+ },
623
+ stop: () => stopService(state, log)
624
+ });
625
+ log.info("Alfe Remote plugin activated");
626
+ },
627
+ async deactivate(api) {
628
+ await stopService(state, api.logger);
629
+ api.logger.info("Alfe Remote plugin deactivated");
630
+ }
631
+ };
632
+ }
633
+ function parseRemotePluginConfig(value, browserFallback) {
634
+ const config = value === void 0 ? {} : requireRecord(value, "remote plugin config");
635
+ const allowedKeys = new Set([
636
+ "remoteWsUrl",
637
+ "browserExecutablePath",
638
+ "browserHeadless",
639
+ "browserNoSandbox",
640
+ "handoffTimeoutMs"
641
+ ]);
642
+ if (Object.keys(config).some((key) => !allowedKeys.has(key))) throw new Error("Remote plugin config contains unsupported fields");
643
+ const browser = isRecord(browserFallback) ? browserFallback : {};
644
+ return {
645
+ remoteWsUrl: optionalRelayUrl(config.remoteWsUrl),
646
+ browserExecutablePath: optionalAbsolutePath(config.browserExecutablePath ?? browser.executablePath),
647
+ browserHeadless: optionalBoolean(config.browserHeadless ?? browser.headless, "browserHeadless"),
648
+ browserNoSandbox: optionalBoolean(config.browserNoSandbox ?? browser.noSandbox, "browserNoSandbox"),
649
+ handoffTimeoutMs: optionalInteger(config.handoffTimeoutMs, "handoffTimeoutMs", 1e3, 1800 * 1e3)
650
+ };
651
+ }
652
+ function parseSsrfPolicy(value) {
653
+ if (value === void 0) return void 0;
654
+ const policy = requireRecord(value, "browser SSRF policy");
655
+ if (Object.keys(policy).some((key) => key !== "dangerouslyAllowPrivateNetwork")) throw new Error("Browser SSRF policy contains unsupported fields");
656
+ return { dangerouslyAllowPrivateNetwork: optionalBoolean(policy.dangerouslyAllowPrivateNetwork, "dangerouslyAllowPrivateNetwork") };
657
+ }
658
+ function remoteTool(definition) {
659
+ return defineTool({
660
+ ...definition,
661
+ handler: (params) => Promise.resolve(definition.handler(params))
662
+ });
663
+ }
664
+ function requireString(value, label, maxChars, allowEmpty = false) {
665
+ if (typeof value !== "string" || !allowEmpty && value.length < 1 || value.length > maxChars || hasControlCharacter(value)) throw publicToolError(`${label} must contain ${allowEmpty ? "at most" : "1 to"} ${String(maxChars)} non-control characters`);
666
+ return value;
667
+ }
668
+ function optionalString(value, label, maxChars) {
669
+ return value === void 0 ? void 0 : requireString(value, label, maxChars);
670
+ }
671
+ function requireHttpUrl(value, label, maxChars) {
672
+ const raw = requireString(value, label, maxChars);
673
+ let url;
674
+ try {
675
+ url = new URL(raw);
676
+ } catch {
677
+ throw publicToolError(`${label} must be an absolute HTTP(S) URL`);
678
+ }
679
+ if (url.protocol !== "http:" && url.protocol !== "https:" || url.username !== "" || url.password !== "") throw publicToolError(`${label} must be an absolute HTTP(S) URL without credentials`);
680
+ return url.href;
681
+ }
682
+ function parseWaitOptions(params) {
683
+ if ([
684
+ params.selector,
685
+ params.urlPattern,
686
+ params.ms
687
+ ].filter((value) => value !== void 0).length !== 1) throw publicToolError("Provide exactly one of selector, urlPattern, or ms");
688
+ if (params.selector !== void 0) return { selector: requireString(params.selector, "selector", MAX_SELECTOR_CHARS) };
689
+ if (params.urlPattern !== void 0) return { urlPattern: requireString(params.urlPattern, "urlPattern", MAX_NAVIGATION_URL_CHARS) };
690
+ if (!Number.isInteger(params.ms) || params.ms < 0 || params.ms > 12e4) throw publicToolError("ms must be an integer from 0 to 120000");
691
+ return { ms: params.ms };
692
+ }
693
+ function requireTrustedString(value, label, maxChars) {
694
+ if (!isBoundedString(value, maxChars)) throw new Error(`${label} is invalid`);
695
+ return value;
696
+ }
697
+ function decodedBase64Bytes(value) {
698
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) throw new Error("Browser screenshot was not valid base64");
699
+ return Buffer.from(value, "base64").byteLength;
700
+ }
701
+ function selectWorkspacePath(contextPath, configuredPath) {
702
+ for (const candidate of [contextPath, configuredPath]) if (typeof candidate === "string" && candidate.length > 0 && candidate.length <= MAX_PATH_CHARS && !hasControlCharacter(candidate) && isAbsolute(candidate)) return candidate;
703
+ throw new Error("Remote workspace path is invalid");
704
+ }
705
+ function optionalRelayUrl(value) {
706
+ if (value === void 0) return void 0;
707
+ if (!isBoundedString(value, MAX_REMOTE_URL_CHARS)) throw new Error("remoteWsUrl is invalid");
708
+ let url;
709
+ try {
710
+ url = new URL(value);
711
+ } catch {
712
+ throw new Error("remoteWsUrl is invalid");
713
+ }
714
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/gu, "");
715
+ const loopback = host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/u.test(host);
716
+ if (url.protocol !== "wss:" && !(url.protocol === "ws:" && loopback) || url.username !== "" || url.password !== "" || url.hash !== "") throw new Error("remoteWsUrl is invalid");
717
+ return url.href;
718
+ }
719
+ function isDashboardBaseUrl(value) {
720
+ if (!isBoundedString(value, MAX_REMOTE_URL_CHARS)) return false;
721
+ try {
722
+ const url = new URL(value);
723
+ return url.protocol === "https:" && url.username === "" && url.password === "" && url.hostname.startsWith("app.") && url.pathname === "/" && url.search === "" && url.hash === "";
724
+ } catch {
725
+ return false;
726
+ }
727
+ }
728
+ function optionalAbsolutePath(value) {
729
+ if (value === void 0) return void 0;
730
+ if (!isBoundedString(value, MAX_PATH_CHARS) || !isAbsolute(value)) throw new Error("browserExecutablePath is invalid");
731
+ return value;
732
+ }
733
+ function optionalBoolean(value, label) {
734
+ if (value === void 0) return void 0;
735
+ if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
736
+ return value;
737
+ }
738
+ function optionalInteger(value, label, minimum, maximum) {
739
+ if (value === void 0) return void 0;
740
+ if (!Number.isInteger(value) || value < minimum || value > maximum) throw new Error(`${label} must be an integer from ${String(minimum)} to ${String(maximum)}`);
741
+ return value;
742
+ }
743
+ function getGlobalRuntimeState() {
744
+ const root = globalThis;
745
+ const existing = root[RUNTIME_STATE_KEY];
746
+ if (isRuntimeState(existing)) return existing;
747
+ const state = createRemotePluginRuntimeState();
748
+ root[RUNTIME_STATE_KEY] = state;
749
+ return state;
750
+ }
751
+ function isRuntimeState(value) {
752
+ if (!isRecord(value)) return false;
753
+ return typeof value.generation === "number" && Number.isSafeInteger(value.generation) && value.generation >= 0 && typeof value.handoffTimeoutMs === "number" && Number.isInteger(value.handoffTimeoutMs) && value.handoffTimeoutMs >= 1e3 && value.handoffTimeoutMs <= 1800 * 1e3 && value.sessionSurfaces instanceof Map && (value.stopPromise === null || value.stopPromise instanceof Promise) && (value.remoteClient === null || isRemoteClient(value.remoteClient)) && (value.browserSurface === null || isBrowserSurface(value.browserSurface)) && (value.terminalSurface === null || isTerminalSurface(value.terminalSurface)) && (value.apiClient === null || isRemoteApiClient(value.apiClient));
754
+ }
755
+ function isRemoteClient(value) {
756
+ return isRecord(value) && typeof value.start === "function" && typeof value.stop === "function" && typeof value.sendFrame === "function";
757
+ }
758
+ function isBrowserSurface(value) {
759
+ return isRecord(value) && isRecord(value.automation) && typeof value.automation.navigate === "function" && typeof value.automation.click === "function" && typeof value.automation.type === "function" && typeof value.automation.waitFor === "function" && typeof value.automation.screenshot === "function" && typeof value.automation.evaluate === "function" && typeof value.openSession === "function" && typeof value.closeSession === "function" && typeof value.handleFrame === "function" && typeof value.requestHandoff === "function" && typeof value.addHold === "function" && typeof value.removeHold === "function" && typeof value.shutdown === "function";
760
+ }
761
+ function isTerminalSurface(value) {
762
+ return isRecord(value) && typeof value.openSession === "function" && typeof value.closeSession === "function" && typeof value.handleFrame === "function" && typeof value.shutdown === "function";
763
+ }
764
+ function isRemoteApiClient(value) {
765
+ return isRecord(value) && typeof value.whoami === "function" && typeof value.requestBrowserTakeover === "function" && typeof value.completeRemoteSession === "function";
766
+ }
767
+ function requireRecord(value, label) {
768
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
769
+ return value;
770
+ }
771
+ function isRecord(value) {
772
+ return typeof value === "object" && value !== null && !Array.isArray(value);
773
+ }
774
+ function isBoundedString(value, maxChars) {
775
+ return typeof value === "string" && value.length > 0 && value.length <= maxChars && !hasControlCharacter(value);
776
+ }
777
+ function hasControlCharacter(value) {
778
+ for (let index = 0; index < value.length; index += 1) {
779
+ const code = value.charCodeAt(index);
780
+ if (code < 32 || code === 127) return true;
781
+ }
782
+ return false;
783
+ }
784
+ function validatePackageVersion(value) {
785
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(value)) throw new Error("openclaw-remote package version is invalid");
786
+ return value;
787
+ }
788
+ //#endregion
789
+ export { createRemotePluginRuntimeState as a, parseRemotePluginConfig as c, createRemotePlugin as i, buildIsNavigationAllowed as l, REMOTE_ACTIVATION_KEY as n, deriveDashboardBaseUrl as o, buildControlUrl as r, deriveRemoteWsUrl as s, PLUGIN_VERSION as t };