@alfe.ai/openclaw-remote 0.0.19 → 0.1.0

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.
package/README.md CHANGED
@@ -40,6 +40,27 @@ Validation failures deliberately safe for the model are returned as structured
40
40
  tool errors. Unexpected browser, relay, and API diagnostics are redacted.
41
41
  Screenshots use OpenClaw image content and keep base64 out of JSON details.
42
42
 
43
+ ## Trusted local adapters
44
+
45
+ The root library exports two helpers for plugins in the same OpenClaw process:
46
+
47
+ - `withRemoteBrowserOperation(callback, { signal? })` invokes the callback with
48
+ `{ browserWSEndpoint, targetId, signal }` under the existing automation queue.
49
+ Connect to that exact target; reuse its profile and authenticated page.
50
+ Disconnect the attachment and await child exit before the callback settles,
51
+ including when aborted. Never close the owned Chrome or expose its endpoint.
52
+ - `requestRemoteBrowserTakeover({ instructions, conversationId?, timeout?, signal? })`
53
+ uses the existing takeover session and waits for hand-back. `timeout` is in
54
+ milliseconds. Tell the user to open the Browser tab before invoking; provide
55
+ `conversationId` to deliver the chat card during the wait. Its result includes
56
+ `sessionId`, optional `controlUrl`, `released`, `timedOut`, `url`, and `title`.
57
+
58
+ Finish an operation before requesting takeover, then acquire a fresh operation
59
+ after hand-back. Nested operations/handoffs deadlock on the shared queue.
60
+ These helpers never launch a second runtime; an absent or outdated owner needs
61
+ the remote plugin started or updated/restarted. A CDP adapter is trusted code,
62
+ not a target-isolated sandbox. Its consuming tools must redact raw failures.
63
+
43
64
  ## Development
44
65
 
45
66
  ```bash
package/dist/index.cjs CHANGED
@@ -9,3 +9,5 @@ exports.createRemotePluginRuntimeState = require_runtime.createRemotePluginRunti
9
9
  exports.deriveDashboardBaseUrl = require_runtime.deriveDashboardBaseUrl;
10
10
  exports.deriveRemoteWsUrl = require_runtime.deriveRemoteWsUrl;
11
11
  exports.parseRemotePluginConfig = require_runtime.parseRemotePluginConfig;
12
+ exports.requestRemoteBrowserTakeover = require_runtime.requestRemoteBrowserTakeover;
13
+ exports.withRemoteBrowserOperation = require_runtime.withRemoteBrowserOperation;
package/dist/index.d.cts CHANGED
@@ -89,6 +89,18 @@ interface Logger$2 {
89
89
  //#endregion
90
90
  //#endregion
91
91
  //#region src/types.d.ts
92
+ /** Private local capability for a trusted adapter, never a tool result. */
93
+ interface BrowserOperationContext {
94
+ /** Loopback Chrome endpoint. Do not log, persist, or expose to a viewer/model. */
95
+ browserWSEndpoint: string;
96
+ /** The exact active page's CDP target; never choose the first context/page. */
97
+ targetId: string;
98
+ /** On abort, stop and await all external work before returning. */
99
+ signal: AbortSignal;
100
+ }
101
+ interface BrowserOperationOptions {
102
+ signal?: AbortSignal;
103
+ }
92
104
  interface BrowserSessionOptions {
93
105
  /** Path to the Chrome/Chromium binary (from the headless-browser integration). */
94
106
  executablePath: string;
@@ -284,11 +296,13 @@ interface BrowserSurfaceLike {
284
296
  }): Promise<void>;
285
297
  screenshot(): Promise<string>;
286
298
  evaluate(expression: string): Promise<unknown>;
299
+ /** Optional only to recognize an older process-global owner during upgrade. */
300
+ withCdpOperation?<T>(operation: (context: BrowserOperationContext) => Promise<T>, options?: BrowserOperationOptions): Promise<T>;
287
301
  };
288
302
  openSession(sessionId: number, open: SessionOpenPayload): void | Promise<void>;
289
303
  closeSession(sessionId: number): void;
290
304
  handleFrame(frame: RemoteFrame): void;
291
- requestHandoff(timeoutMs: number): Promise<HandoffResult>;
305
+ requestHandoff(timeoutMs: number, signal?: AbortSignal): Promise<HandoffResult>;
292
306
  addHold(): void;
293
307
  removeHold(): void;
294
308
  shutdown(): Promise<void>;
@@ -330,6 +344,26 @@ interface RemotePluginDependencies {
330
344
  runtimeState?: RemotePluginRuntimeState;
331
345
  }
332
346
  declare function createRemotePluginRuntimeState(): RemotePluginRuntimeState;
347
+ interface RemoteBrowserTakeoverOptions {
348
+ instructions: string;
349
+ conversationId?: string;
350
+ /** Milliseconds, 1000..1800000; defaults to the remote runtime's timeout. */
351
+ timeout?: number;
352
+ signal?: AbortSignal;
353
+ }
354
+ interface RemoteBrowserTakeoverResult extends HandoffResult {
355
+ sessionId: string;
356
+ controlUrl?: string;
357
+ }
358
+ /** Trusted in-process adapter bridge, not a model-facing endpoint-discovery tool.
359
+ * The callback owns its CDP attachment/children and MUST await their cleanup
360
+ * before settling, including on signal abort. Never close Chrome, retain the
361
+ * endpoint, or call nested browser operations/takeover from this callback. */
362
+ declare function withRemoteBrowserOperation<T>(operation: (context: BrowserOperationContext) => Promise<T>, options?: BrowserOperationOptions): Promise<T>;
363
+ /** Create the existing takeover request and await human hand-back. Tell the
364
+ * user to open the agent Browser tab before invoking; the URL returns afterward.
365
+ * Call between operations, never while holding a withRemoteBrowserOperation. */
366
+ declare function requestRemoteBrowserTakeover(options: RemoteBrowserTakeoverOptions): Promise<RemoteBrowserTakeoverResult>;
333
367
  /**
334
368
  * Derive the relay WebSocket URL from the agent's cloud apiUrl when the
335
369
  * manifest doesn't provide one — mirrors @alfe.ai/console-client's
@@ -397,4 +431,4 @@ declare function buildIsNavigationAllowed(policy: SsrfPolicy | undefined, log?:
397
431
  warn(msg: string): void;
398
432
  }): (url: string) => boolean;
399
433
  //#endregion
400
- export { type BrowserSurfaceLike, PLUGIN_VERSION, REMOTE_ACTIVATION_KEY, type RemoteApiClient, type RemoteClientLike, type RemotePluginConfig, type RemotePluginDependencies, type RemotePluginRuntimeState, type SsrfPolicy, type TerminalSurfaceLike, buildControlUrl, buildIsNavigationAllowed, createRemotePlugin, createRemotePluginRuntimeState, deriveDashboardBaseUrl, deriveRemoteWsUrl, parseRemotePluginConfig };
434
+ export { type BrowserOperationContext, type BrowserOperationOptions, type BrowserSurfaceLike, PLUGIN_VERSION, REMOTE_ACTIVATION_KEY, type RemoteApiClient, type RemoteBrowserTakeoverOptions, type RemoteBrowserTakeoverResult, type RemoteClientLike, type RemotePluginConfig, type RemotePluginDependencies, type RemotePluginRuntimeState, type SsrfPolicy, type TerminalSurfaceLike, buildControlUrl, buildIsNavigationAllowed, createRemotePlugin, createRemotePluginRuntimeState, deriveDashboardBaseUrl, deriveRemoteWsUrl, parseRemotePluginConfig, requestRemoteBrowserTakeover, withRemoteBrowserOperation };
package/dist/index.d.ts CHANGED
@@ -89,6 +89,18 @@ interface Logger$2 {
89
89
  //#endregion
90
90
  //#endregion
91
91
  //#region src/types.d.ts
92
+ /** Private local capability for a trusted adapter, never a tool result. */
93
+ interface BrowserOperationContext {
94
+ /** Loopback Chrome endpoint. Do not log, persist, or expose to a viewer/model. */
95
+ browserWSEndpoint: string;
96
+ /** The exact active page's CDP target; never choose the first context/page. */
97
+ targetId: string;
98
+ /** On abort, stop and await all external work before returning. */
99
+ signal: AbortSignal;
100
+ }
101
+ interface BrowserOperationOptions {
102
+ signal?: AbortSignal;
103
+ }
92
104
  interface BrowserSessionOptions {
93
105
  /** Path to the Chrome/Chromium binary (from the headless-browser integration). */
94
106
  executablePath: string;
@@ -284,11 +296,13 @@ interface BrowserSurfaceLike {
284
296
  }): Promise<void>;
285
297
  screenshot(): Promise<string>;
286
298
  evaluate(expression: string): Promise<unknown>;
299
+ /** Optional only to recognize an older process-global owner during upgrade. */
300
+ withCdpOperation?<T>(operation: (context: BrowserOperationContext) => Promise<T>, options?: BrowserOperationOptions): Promise<T>;
287
301
  };
288
302
  openSession(sessionId: number, open: SessionOpenPayload): void | Promise<void>;
289
303
  closeSession(sessionId: number): void;
290
304
  handleFrame(frame: RemoteFrame): void;
291
- requestHandoff(timeoutMs: number): Promise<HandoffResult>;
305
+ requestHandoff(timeoutMs: number, signal?: AbortSignal): Promise<HandoffResult>;
292
306
  addHold(): void;
293
307
  removeHold(): void;
294
308
  shutdown(): Promise<void>;
@@ -330,6 +344,26 @@ interface RemotePluginDependencies {
330
344
  runtimeState?: RemotePluginRuntimeState;
331
345
  }
332
346
  declare function createRemotePluginRuntimeState(): RemotePluginRuntimeState;
347
+ interface RemoteBrowserTakeoverOptions {
348
+ instructions: string;
349
+ conversationId?: string;
350
+ /** Milliseconds, 1000..1800000; defaults to the remote runtime's timeout. */
351
+ timeout?: number;
352
+ signal?: AbortSignal;
353
+ }
354
+ interface RemoteBrowserTakeoverResult extends HandoffResult {
355
+ sessionId: string;
356
+ controlUrl?: string;
357
+ }
358
+ /** Trusted in-process adapter bridge, not a model-facing endpoint-discovery tool.
359
+ * The callback owns its CDP attachment/children and MUST await their cleanup
360
+ * before settling, including on signal abort. Never close Chrome, retain the
361
+ * endpoint, or call nested browser operations/takeover from this callback. */
362
+ declare function withRemoteBrowserOperation<T>(operation: (context: BrowserOperationContext) => Promise<T>, options?: BrowserOperationOptions): Promise<T>;
363
+ /** Create the existing takeover request and await human hand-back. Tell the
364
+ * user to open the agent Browser tab before invoking; the URL returns afterward.
365
+ * Call between operations, never while holding a withRemoteBrowserOperation. */
366
+ declare function requestRemoteBrowserTakeover(options: RemoteBrowserTakeoverOptions): Promise<RemoteBrowserTakeoverResult>;
333
367
  /**
334
368
  * Derive the relay WebSocket URL from the agent's cloud apiUrl when the
335
369
  * manifest doesn't provide one — mirrors @alfe.ai/console-client's
@@ -397,4 +431,4 @@ declare function buildIsNavigationAllowed(policy: SsrfPolicy | undefined, log?:
397
431
  warn(msg: string): void;
398
432
  }): (url: string) => boolean;
399
433
  //#endregion
400
- export { type BrowserSurfaceLike, PLUGIN_VERSION, REMOTE_ACTIVATION_KEY, type RemoteApiClient, type RemoteClientLike, type RemotePluginConfig, type RemotePluginDependencies, type RemotePluginRuntimeState, type SsrfPolicy, type TerminalSurfaceLike, buildControlUrl, buildIsNavigationAllowed, createRemotePlugin, createRemotePluginRuntimeState, deriveDashboardBaseUrl, deriveRemoteWsUrl, parseRemotePluginConfig };
434
+ export { type BrowserOperationContext, type BrowserOperationOptions, type BrowserSurfaceLike, PLUGIN_VERSION, REMOTE_ACTIVATION_KEY, type RemoteApiClient, type RemoteBrowserTakeoverOptions, type RemoteBrowserTakeoverResult, type RemoteClientLike, type RemotePluginConfig, type RemotePluginDependencies, type RemotePluginRuntimeState, type SsrfPolicy, type TerminalSurfaceLike, buildControlUrl, buildIsNavigationAllowed, createRemotePlugin, createRemotePluginRuntimeState, deriveDashboardBaseUrl, deriveRemoteWsUrl, parseRemotePluginConfig, requestRemoteBrowserTakeover, withRemoteBrowserOperation };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as createRemotePluginRuntimeState, c as parseRemotePluginConfig, i as createRemotePlugin, l as buildIsNavigationAllowed, n as REMOTE_ACTIVATION_KEY, o as deriveDashboardBaseUrl, r as buildControlUrl, s as deriveRemoteWsUrl, t as PLUGIN_VERSION } from "./runtime.js";
2
- export { PLUGIN_VERSION, REMOTE_ACTIVATION_KEY, buildControlUrl, buildIsNavigationAllowed, createRemotePlugin, createRemotePluginRuntimeState, deriveDashboardBaseUrl, deriveRemoteWsUrl, parseRemotePluginConfig };
1
+ import { a as createRemotePluginRuntimeState, c as parseRemotePluginConfig, d as buildIsNavigationAllowed, i as createRemotePlugin, l as requestRemoteBrowserTakeover, n as REMOTE_ACTIVATION_KEY, o as deriveDashboardBaseUrl, r as buildControlUrl, s as deriveRemoteWsUrl, t as PLUGIN_VERSION, u as withRemoteBrowserOperation } from "./runtime.js";
2
+ export { PLUGIN_VERSION, REMOTE_ACTIVATION_KEY, buildControlUrl, buildIsNavigationAllowed, createRemotePlugin, createRemotePluginRuntimeState, deriveDashboardBaseUrl, deriveRemoteWsUrl, parseRemotePluginConfig, requestRemoteBrowserTakeover, withRemoteBrowserOperation };
package/dist/runtime.cjs CHANGED
@@ -148,6 +148,70 @@ function createRemotePluginRuntimeState() {
148
148
  stopPromise: null
149
149
  };
150
150
  }
151
+ /** Trusted in-process adapter bridge, not a model-facing endpoint-discovery tool.
152
+ * The callback owns its CDP attachment/children and MUST await their cleanup
153
+ * before settling, including on signal abort. Never close Chrome, retain the
154
+ * endpoint, or call nested browser operations/takeover from this callback. */
155
+ async function withRemoteBrowserOperation(operation, options = {}) {
156
+ const state = requireGlobalRuntimeState();
157
+ const surface = requireBrowserSurface(state);
158
+ const generation = state.generation;
159
+ if (!surface.automation.withCdpOperation) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("Shared browser adapter support is unavailable; update and restart the remote plugin.");
160
+ const assertCurrent = () => {
161
+ if (state.generation !== generation || state.browserSurface !== surface || state.stopPromise !== null) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("Remote browser restarted during the operation; retry after it reconnects.");
162
+ };
163
+ const result = await surface.automation.withCdpOperation(async (context) => {
164
+ assertCurrent();
165
+ const value = await operation(context);
166
+ assertCurrent();
167
+ return value;
168
+ }, options);
169
+ assertCurrent();
170
+ return result;
171
+ }
172
+ /** Create the existing takeover request and await human hand-back. Tell the
173
+ * user to open the agent Browser tab before invoking; the URL returns afterward.
174
+ * Call between operations, never while holding a withRemoteBrowserOperation. */
175
+ async function requestRemoteBrowserTakeover(options) {
176
+ return requestBrowserTakeover(requireGlobalRuntimeState(), options);
177
+ }
178
+ function requireBrowserSurface(state) {
179
+ if (!state.browserSurface || state.stopPromise !== null) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("Browser surface is unavailable; retry after the remote service connects.");
180
+ return state.browserSurface;
181
+ }
182
+ async function requestBrowserTakeover(state, options) {
183
+ const surface = requireBrowserSurface(state);
184
+ const client = state.apiClient;
185
+ if (!client) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("Remote service is unavailable; retry after it connects.");
186
+ if (options.signal && !surface.automation.withCdpOperation) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("Cancellable browser takeover is unavailable; update and restart the remote plugin.");
187
+ options.signal?.throwIfAborted();
188
+ const generation = state.generation;
189
+ const instructions = requireString(options.instructions, "instructions", MAX_INSTRUCTIONS_CHARS);
190
+ const conversationId = optionalString(options.conversationId, "conversationId", MAX_CONVERSATION_ID_CHARS);
191
+ const url = options.url === void 0 ? void 0 : requireHttpUrl(options.url, "url", 2048);
192
+ const timeout = optionalInteger(options.timeout, "timeout", 1e3, 1800 * 1e3) ?? state.handoffTimeoutMs;
193
+ let sessionId;
194
+ surface.addHold();
195
+ try {
196
+ sessionId = requireTrustedString((await client.requestBrowserTakeover({
197
+ instructions,
198
+ url,
199
+ conversationId
200
+ })).sessionId, "remote session ID", MAX_SESSION_ID_CHARS);
201
+ options.signal?.throwIfAborted();
202
+ if (state.generation !== generation || state.browserSurface !== surface || state.apiClient !== client || state.stopPromise !== null) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("Remote browser restarted while creating the takeover; retry the request.");
203
+ const controlUrl = buildControlUrl(state.dashboardBaseUrl, state.selfAgentId, sessionId);
204
+ const result = options.signal ? await surface.requestHandoff(timeout, options.signal) : await surface.requestHandoff(timeout);
205
+ return {
206
+ sessionId,
207
+ controlUrl,
208
+ ...result
209
+ };
210
+ } finally {
211
+ surface.removeHold();
212
+ if (sessionId) await client.completeRemoteSession(sessionId).catch(() => void 0);
213
+ }
214
+ }
151
215
  /**
152
216
  * Derive the relay WebSocket URL from the agent's cloud apiUrl when the
153
217
  * manifest doesn't provide one — mirrors @alfe.ai/console-client's
@@ -208,16 +272,28 @@ function dispatchFrame(state, frame, log) {
208
272
  const handler = open.surface === "terminal" ? state.terminalSurface : state.browserSurface;
209
273
  if (!handler) {
210
274
  log.warn("Remote session surface is unavailable");
275
+ closeUnavailableSession(state.remoteClient, frame.sessionId, log);
211
276
  return;
212
277
  }
278
+ const client = state.remoteClient;
279
+ const generation = state.generation;
280
+ const sessions = state.sessionSurfaces;
213
281
  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 {}
282
+ const failOpen = () => {
283
+ if (state.generation === generation && state.remoteClient === client && state.sessionSurfaces === sessions && state.sessionSurfaces.get(frame.sessionId) === open.surface) {
284
+ state.sessionSurfaces.delete(frame.sessionId);
285
+ try {
286
+ handler.closeSession(frame.sessionId);
287
+ } catch {}
288
+ closeUnavailableSession(client, frame.sessionId, log);
289
+ }
219
290
  log.warn("Remote session could not be opened");
220
- });
291
+ };
292
+ try {
293
+ Promise.resolve(handler.openSession(frame.sessionId, open)).catch(failOpen);
294
+ } catch {
295
+ failOpen();
296
+ }
221
297
  return;
222
298
  }
223
299
  const surface = state.sessionSurfaces.get(frame.sessionId);
@@ -238,6 +314,13 @@ function dispatchFrame(state, frame, log) {
238
314
  log.warn("Remote session frame handling failed");
239
315
  }
240
316
  }
317
+ function closeUnavailableSession(client, sessionId, log) {
318
+ try {
319
+ client?.sendFrame((0, _alfe_ai_remote.encodeJsonFrame)(_alfe_ai_remote.RemoteFrameType.SESSION_CLOSE, sessionId, { reason: "surface_unavailable" }));
320
+ } catch {
321
+ log.warn("Could not close unavailable remote session");
322
+ }
323
+ }
241
324
  function startService(pluginConfig, ssrfPolicy, workspaceDir, log, dependencies, state) {
242
325
  (0, _alfe_ai_openclaw_plugin_kit.guardedStart)(REMOTE_ACTIVATION_KEY, log, () => {
243
326
  if (pluginConfig === null) {
@@ -377,12 +460,10 @@ function closeAllSessions(state, log) {
377
460
  }
378
461
  }
379
462
  state.sessionSurfaces.clear();
463
+ state.sessionSurfaces = /* @__PURE__ */ new Map();
380
464
  }
381
465
  function registerTools(api, state) {
382
- const needBrowser = () => {
383
- if (!state.browserSurface) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("Browser surface is unavailable; retry after the remote service connects.");
384
- return state.browserSurface;
385
- };
466
+ const needBrowser = () => requireBrowserSurface(state);
386
467
  api.registerTool(remoteTool({
387
468
  name: "browser_navigate",
388
469
  description: "Navigate the shared browser to a URL. Returns the final URL and page title.",
@@ -527,7 +608,7 @@ function registerTools(api, state) {
527
608
  }));
528
609
  api.registerTool(remoteTool({
529
610
  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.",
611
+ description: "Ask a human to take over the live shared browser for a manual step. Tell the user to open this agent's Browser tab before calling: this tool waits until hand-back or timeout, so its returned URL arrives afterward. Supply the current conversationId when available to show the takeover card in chat. The optional URL is context only; navigate the browser before requesting takeover.",
531
612
  parameters: {
532
613
  type: "object",
533
614
  properties: {
@@ -553,36 +634,16 @@ function registerTools(api, state) {
553
634
  additionalProperties: false
554
635
  },
555
636
  handler: async (params) => {
556
- const surface = needBrowser();
557
- const client = state.apiClient;
558
- if (!client) throw (0, _alfe_ai_openclaw_plugin_kit.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 (0, _alfe_ai_openclaw_plugin_kit.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
- }
637
+ const result = await requestBrowserTakeover(state, {
638
+ instructions: requireString(params.instructions, "instructions", MAX_INSTRUCTIONS_CHARS),
639
+ url: params.url === void 0 ? void 0 : requireHttpUrl(params.url, "url", 2048),
640
+ conversationId: optionalString(params.conversationId, "conversationId", MAX_CONVERSATION_ID_CHARS)
641
+ });
642
+ const message = result.timedOut ? "Browser takeover timed out. Request a new takeover if the user still needs to complete the task." : "Browser control returned to the agent. Continue on the existing page and check whether the user completed the task.";
643
+ return {
644
+ ...result,
645
+ message
646
+ };
586
647
  }
587
648
  }));
588
649
  }
@@ -748,6 +809,11 @@ function getGlobalRuntimeState() {
748
809
  root[RUNTIME_STATE_KEY] = state;
749
810
  return state;
750
811
  }
812
+ function requireGlobalRuntimeState() {
813
+ const existing = globalThis[RUNTIME_STATE_KEY];
814
+ if (!isRuntimeState(existing)) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("Shared browser runtime is unavailable; start or update and restart the remote plugin.");
815
+ return existing;
816
+ }
751
817
  function isRuntimeState(value) {
752
818
  if (!isRecord(value)) return false;
753
819
  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));
@@ -840,3 +906,15 @@ Object.defineProperty(exports, "parseRemotePluginConfig", {
840
906
  return parseRemotePluginConfig;
841
907
  }
842
908
  });
909
+ Object.defineProperty(exports, "requestRemoteBrowserTakeover", {
910
+ enumerable: true,
911
+ get: function() {
912
+ return requestRemoteBrowserTakeover;
913
+ }
914
+ });
915
+ Object.defineProperty(exports, "withRemoteBrowserOperation", {
916
+ enumerable: true,
917
+ get: function() {
918
+ return withRemoteBrowserOperation;
919
+ }
920
+ });
package/dist/runtime.js CHANGED
@@ -4,7 +4,7 @@ import { AgentApiClient, installToolErrorCapture } from "@alfe.ai/agent-api-clie
4
4
  import { BrowserSurface } from "@alfe.ai/browser";
5
5
  import { resolveConfig } from "@alfe.ai/config";
6
6
  import { PublicToolError, defineTool, errResult, getActivationKey, guardedStart, publicToolError, resetActivation } from "@alfe.ai/openclaw-plugin-kit";
7
- import { RemoteFrameType, RemoteServiceClient, decodeSessionOpenPayload } from "@alfe.ai/remote";
7
+ import { RemoteFrameType, RemoteServiceClient, decodeSessionOpenPayload, encodeJsonFrame } from "@alfe.ai/remote";
8
8
  import { TerminalSurface } from "@alfe.ai/terminal";
9
9
  //#region src/ssrf.ts
10
10
  /** Hostnames blocked outright (case-insensitive, exact match). */
@@ -148,6 +148,70 @@ function createRemotePluginRuntimeState() {
148
148
  stopPromise: null
149
149
  };
150
150
  }
151
+ /** Trusted in-process adapter bridge, not a model-facing endpoint-discovery tool.
152
+ * The callback owns its CDP attachment/children and MUST await their cleanup
153
+ * before settling, including on signal abort. Never close Chrome, retain the
154
+ * endpoint, or call nested browser operations/takeover from this callback. */
155
+ async function withRemoteBrowserOperation(operation, options = {}) {
156
+ const state = requireGlobalRuntimeState();
157
+ const surface = requireBrowserSurface(state);
158
+ const generation = state.generation;
159
+ if (!surface.automation.withCdpOperation) throw publicToolError("Shared browser adapter support is unavailable; update and restart the remote plugin.");
160
+ const assertCurrent = () => {
161
+ if (state.generation !== generation || state.browserSurface !== surface || state.stopPromise !== null) throw publicToolError("Remote browser restarted during the operation; retry after it reconnects.");
162
+ };
163
+ const result = await surface.automation.withCdpOperation(async (context) => {
164
+ assertCurrent();
165
+ const value = await operation(context);
166
+ assertCurrent();
167
+ return value;
168
+ }, options);
169
+ assertCurrent();
170
+ return result;
171
+ }
172
+ /** Create the existing takeover request and await human hand-back. Tell the
173
+ * user to open the agent Browser tab before invoking; the URL returns afterward.
174
+ * Call between operations, never while holding a withRemoteBrowserOperation. */
175
+ async function requestRemoteBrowserTakeover(options) {
176
+ return requestBrowserTakeover(requireGlobalRuntimeState(), options);
177
+ }
178
+ function requireBrowserSurface(state) {
179
+ if (!state.browserSurface || state.stopPromise !== null) throw publicToolError("Browser surface is unavailable; retry after the remote service connects.");
180
+ return state.browserSurface;
181
+ }
182
+ async function requestBrowserTakeover(state, options) {
183
+ const surface = requireBrowserSurface(state);
184
+ const client = state.apiClient;
185
+ if (!client) throw publicToolError("Remote service is unavailable; retry after it connects.");
186
+ if (options.signal && !surface.automation.withCdpOperation) throw publicToolError("Cancellable browser takeover is unavailable; update and restart the remote plugin.");
187
+ options.signal?.throwIfAborted();
188
+ const generation = state.generation;
189
+ const instructions = requireString(options.instructions, "instructions", MAX_INSTRUCTIONS_CHARS);
190
+ const conversationId = optionalString(options.conversationId, "conversationId", MAX_CONVERSATION_ID_CHARS);
191
+ const url = options.url === void 0 ? void 0 : requireHttpUrl(options.url, "url", 2048);
192
+ const timeout = optionalInteger(options.timeout, "timeout", 1e3, 1800 * 1e3) ?? state.handoffTimeoutMs;
193
+ let sessionId;
194
+ surface.addHold();
195
+ try {
196
+ sessionId = requireTrustedString((await client.requestBrowserTakeover({
197
+ instructions,
198
+ url,
199
+ conversationId
200
+ })).sessionId, "remote session ID", MAX_SESSION_ID_CHARS);
201
+ options.signal?.throwIfAborted();
202
+ if (state.generation !== generation || state.browserSurface !== surface || state.apiClient !== client || state.stopPromise !== null) throw publicToolError("Remote browser restarted while creating the takeover; retry the request.");
203
+ const controlUrl = buildControlUrl(state.dashboardBaseUrl, state.selfAgentId, sessionId);
204
+ const result = options.signal ? await surface.requestHandoff(timeout, options.signal) : await surface.requestHandoff(timeout);
205
+ return {
206
+ sessionId,
207
+ controlUrl,
208
+ ...result
209
+ };
210
+ } finally {
211
+ surface.removeHold();
212
+ if (sessionId) await client.completeRemoteSession(sessionId).catch(() => void 0);
213
+ }
214
+ }
151
215
  /**
152
216
  * Derive the relay WebSocket URL from the agent's cloud apiUrl when the
153
217
  * manifest doesn't provide one — mirrors @alfe.ai/console-client's
@@ -208,16 +272,28 @@ function dispatchFrame(state, frame, log) {
208
272
  const handler = open.surface === "terminal" ? state.terminalSurface : state.browserSurface;
209
273
  if (!handler) {
210
274
  log.warn("Remote session surface is unavailable");
275
+ closeUnavailableSession(state.remoteClient, frame.sessionId, log);
211
276
  return;
212
277
  }
278
+ const client = state.remoteClient;
279
+ const generation = state.generation;
280
+ const sessions = state.sessionSurfaces;
213
281
  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 {}
282
+ const failOpen = () => {
283
+ if (state.generation === generation && state.remoteClient === client && state.sessionSurfaces === sessions && state.sessionSurfaces.get(frame.sessionId) === open.surface) {
284
+ state.sessionSurfaces.delete(frame.sessionId);
285
+ try {
286
+ handler.closeSession(frame.sessionId);
287
+ } catch {}
288
+ closeUnavailableSession(client, frame.sessionId, log);
289
+ }
219
290
  log.warn("Remote session could not be opened");
220
- });
291
+ };
292
+ try {
293
+ Promise.resolve(handler.openSession(frame.sessionId, open)).catch(failOpen);
294
+ } catch {
295
+ failOpen();
296
+ }
221
297
  return;
222
298
  }
223
299
  const surface = state.sessionSurfaces.get(frame.sessionId);
@@ -238,6 +314,13 @@ function dispatchFrame(state, frame, log) {
238
314
  log.warn("Remote session frame handling failed");
239
315
  }
240
316
  }
317
+ function closeUnavailableSession(client, sessionId, log) {
318
+ try {
319
+ client?.sendFrame(encodeJsonFrame(RemoteFrameType.SESSION_CLOSE, sessionId, { reason: "surface_unavailable" }));
320
+ } catch {
321
+ log.warn("Could not close unavailable remote session");
322
+ }
323
+ }
241
324
  function startService(pluginConfig, ssrfPolicy, workspaceDir, log, dependencies, state) {
242
325
  guardedStart(REMOTE_ACTIVATION_KEY, log, () => {
243
326
  if (pluginConfig === null) {
@@ -377,12 +460,10 @@ function closeAllSessions(state, log) {
377
460
  }
378
461
  }
379
462
  state.sessionSurfaces.clear();
463
+ state.sessionSurfaces = /* @__PURE__ */ new Map();
380
464
  }
381
465
  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
- };
466
+ const needBrowser = () => requireBrowserSurface(state);
386
467
  api.registerTool(remoteTool({
387
468
  name: "browser_navigate",
388
469
  description: "Navigate the shared browser to a URL. Returns the final URL and page title.",
@@ -527,7 +608,7 @@ function registerTools(api, state) {
527
608
  }));
528
609
  api.registerTool(remoteTool({
529
610
  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.",
611
+ description: "Ask a human to take over the live shared browser for a manual step. Tell the user to open this agent's Browser tab before calling: this tool waits until hand-back or timeout, so its returned URL arrives afterward. Supply the current conversationId when available to show the takeover card in chat. The optional URL is context only; navigate the browser before requesting takeover.",
531
612
  parameters: {
532
613
  type: "object",
533
614
  properties: {
@@ -553,36 +634,16 @@ function registerTools(api, state) {
553
634
  additionalProperties: false
554
635
  },
555
636
  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
- }
637
+ const result = await requestBrowserTakeover(state, {
638
+ instructions: requireString(params.instructions, "instructions", MAX_INSTRUCTIONS_CHARS),
639
+ url: params.url === void 0 ? void 0 : requireHttpUrl(params.url, "url", 2048),
640
+ conversationId: optionalString(params.conversationId, "conversationId", MAX_CONVERSATION_ID_CHARS)
641
+ });
642
+ const message = result.timedOut ? "Browser takeover timed out. Request a new takeover if the user still needs to complete the task." : "Browser control returned to the agent. Continue on the existing page and check whether the user completed the task.";
643
+ return {
644
+ ...result,
645
+ message
646
+ };
586
647
  }
587
648
  }));
588
649
  }
@@ -748,6 +809,11 @@ function getGlobalRuntimeState() {
748
809
  root[RUNTIME_STATE_KEY] = state;
749
810
  return state;
750
811
  }
812
+ function requireGlobalRuntimeState() {
813
+ const existing = globalThis[RUNTIME_STATE_KEY];
814
+ if (!isRuntimeState(existing)) throw publicToolError("Shared browser runtime is unavailable; start or update and restart the remote plugin.");
815
+ return existing;
816
+ }
751
817
  function isRuntimeState(value) {
752
818
  if (!isRecord(value)) return false;
753
819
  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));
@@ -786,4 +852,4 @@ function validatePackageVersion(value) {
786
852
  return value;
787
853
  }
788
854
  //#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 };
855
+ export { createRemotePluginRuntimeState as a, parseRemotePluginConfig as c, buildIsNavigationAllowed as d, createRemotePlugin as i, requestRemoteBrowserTakeover as l, REMOTE_ACTIVATION_KEY as n, deriveDashboardBaseUrl as o, buildControlUrl as r, deriveRemoteWsUrl as s, PLUGIN_VERSION as t, withRemoteBrowserOperation as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-remote",
3
- "version": "0.0.19",
3
+ "version": "0.1.0",
4
4
  "description": "OpenClaw plugin for Alfe's interactive remote-control relay — browser co-browse + web terminal surfaces over one outbound WS",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",
@@ -28,12 +28,12 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@alfe.ai/agent-api-client": "^0.18.0",
31
+ "@alfe.ai/agent-api-client": "^0.19.0",
32
32
  "@alfe.ai/openclaw-plugin-kit": "0.2.0",
33
- "@alfe.ai/browser": "^0.2.1",
33
+ "@alfe.ai/browser": "^0.3.0",
34
34
  "@alfe.ai/config": "0.4.1",
35
- "@alfe.ai/remote": "^0.1.1",
36
- "@alfe.ai/terminal": "^0.1.3"
35
+ "@alfe.ai/remote": "^0.2.0",
36
+ "@alfe.ai/terminal": "^0.1.4"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "openclaw": ">=2026.3.0"