@otterware/openclaw-otter 0.2.0 → 0.2.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.
- package/control.js +413 -42
- package/index.js +63 -13
- package/package.json +1 -1
package/control.js
CHANGED
|
@@ -1,12 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
1
6
|
/** A held tab is released this long after Otter last sent input or a heartbeat (it quit, say). */
|
|
2
7
|
export const CONTROL_TTL_MS = 30_000;
|
|
3
8
|
/** How long a batch waits for one sent before it, before giving up on that one. */
|
|
4
9
|
const GAP_MS = 150;
|
|
10
|
+
/** A tab nobody watches or holds for this long loses its DevTools connection. */
|
|
11
|
+
const IDLE_MS = 60_000;
|
|
12
|
+
const EVENTS_KEPT = 200;
|
|
13
|
+
const CALL_TIMEOUT_MS = 5_000;
|
|
14
|
+
/** Files travel base64 in one gateway message (16 MB at most). */
|
|
15
|
+
export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
16
|
+
const UPLOADS_KEPT_MS = 10 * 60_000;
|
|
17
|
+
|
|
18
|
+
/** What the agent's browser tool may still do while you hold a tab: read pages, not act on them. */
|
|
19
|
+
export const READ_ACTIONS = new Set([
|
|
20
|
+
"doctor",
|
|
21
|
+
"status",
|
|
22
|
+
"tabs",
|
|
23
|
+
"snapshot",
|
|
24
|
+
"screenshot",
|
|
25
|
+
"requests",
|
|
26
|
+
"errors",
|
|
27
|
+
"text",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const WORLD = "otter-watch";
|
|
31
|
+
const BINDING = "__otterSaw";
|
|
32
|
+
/**
|
|
33
|
+
* Runs in a world of its own, where the page can't see or stop it: where clicks land, the field
|
|
34
|
+
* being typed in, and what you copy (Chromium's clipboard isn't yours, so the copy is read here).
|
|
35
|
+
*/
|
|
36
|
+
const WATCH_SCRIPT = `(() => {
|
|
37
|
+
if (globalThis.${BINDING}Ready || window !== top) return;
|
|
38
|
+
globalThis.${BINDING}Ready = true;
|
|
39
|
+
const say = (event) => { try { ${BINDING}(JSON.stringify(event)); } catch {} };
|
|
40
|
+
let move = null;
|
|
41
|
+
addEventListener("pointermove", (event) => {
|
|
42
|
+
if (!event.isTrusted) return;
|
|
43
|
+
if (!move) setTimeout(() => { say(move); move = null; }, 33);
|
|
44
|
+
move = { type: "pointer", phase: "move", x: event.clientX, y: event.clientY };
|
|
45
|
+
}, true);
|
|
46
|
+
addEventListener("pointerdown", (event) => {
|
|
47
|
+
if (event.isTrusted) say({ type: "pointer", phase: "click", x: event.clientX, y: event.clientY });
|
|
48
|
+
}, true);
|
|
49
|
+
const field = () => {
|
|
50
|
+
let found = document.activeElement;
|
|
51
|
+
while (found?.shadowRoot?.activeElement) found = found.shadowRoot.activeElement;
|
|
52
|
+
return found;
|
|
53
|
+
};
|
|
54
|
+
let copying = false;
|
|
55
|
+
addEventListener("keydown", (event) => {
|
|
56
|
+
if (!event.isTrusted) return;
|
|
57
|
+
const key = event.key.toLowerCase();
|
|
58
|
+
if ((event.ctrlKey || event.metaKey) && !event.altKey && (key === "c" || key === "x")) {
|
|
59
|
+
copying = true;
|
|
60
|
+
setTimeout(() => { copying = false; });
|
|
61
|
+
}
|
|
62
|
+
const typed = field();
|
|
63
|
+
if (!typed || typed === document.body || typed === document.documentElement) return;
|
|
64
|
+
const box = typed.getBoundingClientRect();
|
|
65
|
+
say({ type: "typing", x: box.x, y: box.y, width: box.width, height: box.height });
|
|
66
|
+
}, true);
|
|
67
|
+
const selection = () => {
|
|
68
|
+
const typed = field();
|
|
69
|
+
if ((typed instanceof HTMLInputElement || typed instanceof HTMLTextAreaElement) && typed.selectionStart !== null)
|
|
70
|
+
return typed.value.slice(typed.selectionStart, typed.selectionEnd ?? typed.selectionStart);
|
|
71
|
+
return String(document.getSelection() ?? "");
|
|
72
|
+
};
|
|
73
|
+
for (const type of ["copy", "cut"]) {
|
|
74
|
+
addEventListener(type, (event) => {
|
|
75
|
+
if (copying && event.isTrusted) say({ type: "clipboard", text: selection().slice(0, 1048576) });
|
|
76
|
+
}, true);
|
|
77
|
+
addEventListener(type, (event) => {
|
|
78
|
+
if (!copying || !event.isTrusted || !event.defaultPrevented) return;
|
|
79
|
+
const text = event.clipboardData?.getData("text/plain");
|
|
80
|
+
if (text) say({ type: "clipboard", text: text.slice(0, 1048576) });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
})();`;
|
|
5
84
|
|
|
6
85
|
const MOUSE_TYPES = { move: "mouseMoved", down: "mousePressed", up: "mouseReleased" };
|
|
7
86
|
const BUTTONS = new Set(["none", "left", "middle", "right"]);
|
|
8
87
|
const num = (value) => (Number.isFinite(value) ? value : 0);
|
|
9
88
|
|
|
89
|
+
/** Headless Linux Chromium only copies, cuts (and with ⌘, selects all or undoes) when told to. */
|
|
90
|
+
export function editingCommand(key, modifiers) {
|
|
91
|
+
const modifier = modifiers & 0b0111;
|
|
92
|
+
if (modifier !== 2 && modifier !== 4) return null;
|
|
93
|
+
const lower = key.toLowerCase();
|
|
94
|
+
const command =
|
|
95
|
+
lower === "c"
|
|
96
|
+
? "copy"
|
|
97
|
+
: lower === "x"
|
|
98
|
+
? "cut"
|
|
99
|
+
: modifier !== 4
|
|
100
|
+
? null
|
|
101
|
+
: lower === "a"
|
|
102
|
+
? "selectAll"
|
|
103
|
+
: lower === "z"
|
|
104
|
+
? modifiers & 8
|
|
105
|
+
? "redo"
|
|
106
|
+
: "undo"
|
|
107
|
+
: null;
|
|
108
|
+
return command ? { commands: [command] } : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
10
111
|
/** The DevTools call that replays one of Otter's input events, as a person's input. */
|
|
11
112
|
export function cdpInput(event) {
|
|
12
113
|
const modifiers = num(event?.modifiers);
|
|
@@ -39,16 +140,22 @@ export function cdpInput(event) {
|
|
|
39
140
|
];
|
|
40
141
|
case "key": {
|
|
41
142
|
if (typeof event.key !== "string" || typeof event.code !== "string") return null;
|
|
143
|
+
if (event.action === "up")
|
|
144
|
+
return [
|
|
145
|
+
"Input.dispatchKeyEvent",
|
|
146
|
+
{ type: "keyUp", key: event.key, code: event.code, modifiers },
|
|
147
|
+
];
|
|
42
148
|
const text = typeof event.text === "string" ? event.text : undefined;
|
|
43
149
|
return [
|
|
44
150
|
"Input.dispatchKeyEvent",
|
|
45
151
|
{
|
|
46
|
-
type:
|
|
152
|
+
type: text ? "keyDown" : "rawKeyDown",
|
|
47
153
|
key: event.key,
|
|
48
154
|
code: event.code,
|
|
49
155
|
windowsVirtualKeyCode: num(event.keyCode),
|
|
50
156
|
nativeVirtualKeyCode: num(event.keyCode),
|
|
51
157
|
...(text ? { text, unmodifiedText: text } : {}),
|
|
158
|
+
...editingCommand(event.key, modifiers),
|
|
52
159
|
modifiers,
|
|
53
160
|
},
|
|
54
161
|
];
|
|
@@ -57,12 +164,26 @@ export function cdpInput(event) {
|
|
|
57
164
|
return typeof event.text === "string"
|
|
58
165
|
? ["Input.insertText", { text: event.text.slice(0, 10_000) }]
|
|
59
166
|
: null;
|
|
167
|
+
case "dialog":
|
|
168
|
+
return [
|
|
169
|
+
"Page.handleJavaScriptDialog",
|
|
170
|
+
{
|
|
171
|
+
accept: event.accept === true,
|
|
172
|
+
...(typeof event.promptText === "string" ? { promptText: event.promptText } : {}),
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
case "navigate":
|
|
176
|
+
return typeof event.url === "string" && /^https?:\/\//i.test(event.url)
|
|
177
|
+
? ["Page.navigate", { url: event.url }]
|
|
178
|
+
: null;
|
|
179
|
+
case "reload":
|
|
180
|
+
return ["Page.reload", {}];
|
|
60
181
|
default:
|
|
61
182
|
return null;
|
|
62
183
|
}
|
|
63
184
|
}
|
|
64
185
|
|
|
65
|
-
/** A page's own DevTools connection
|
|
186
|
+
/** A page's own DevTools connection. Input goes without waiting for Chromium's replies. */
|
|
66
187
|
export async function connectTarget(cdpUrl, targetId) {
|
|
67
188
|
const list = await fetch(new URL("/json/list", cdpUrl), { signal: AbortSignal.timeout(5000) })
|
|
68
189
|
.then((response) => response.json())
|
|
@@ -83,79 +204,329 @@ export async function connectTarget(cdpUrl, targetId) {
|
|
|
83
204
|
);
|
|
84
205
|
});
|
|
85
206
|
let id = 0;
|
|
207
|
+
const replies = new Map();
|
|
208
|
+
const listeners = new Set();
|
|
209
|
+
socket.addEventListener("message", ({ data }) => {
|
|
210
|
+
const message = JSON.parse(String(data));
|
|
211
|
+
if (message.id === undefined) {
|
|
212
|
+
for (const listener of listeners) listener(message.method, message.params ?? {});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const reply = replies.get(message.id);
|
|
216
|
+
if (!reply) return;
|
|
217
|
+
replies.delete(message.id);
|
|
218
|
+
if (message.error) reply.reject(new Error(message.error.message));
|
|
219
|
+
else reply.resolve(message.result ?? {});
|
|
220
|
+
});
|
|
221
|
+
socket.addEventListener("close", () => {
|
|
222
|
+
for (const reply of replies.values()) reply.reject(new Error("That tab has closed."));
|
|
223
|
+
replies.clear();
|
|
224
|
+
});
|
|
86
225
|
return {
|
|
87
226
|
open: () => socket.readyState === WebSocket.OPEN,
|
|
88
227
|
send: (method, params) => socket.send(JSON.stringify({ id: ++id, method, params })),
|
|
228
|
+
// Bounded: a page showing a pop-up holds back what runs script in it, and a request left
|
|
229
|
+
// waiting keeps the plugin from being updated.
|
|
230
|
+
call: (method, params = {}) =>
|
|
231
|
+
new Promise((resolve, reject) => {
|
|
232
|
+
const n = ++id;
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
replies.delete(n);
|
|
235
|
+
reject(new Error("The agent's browser didn't answer."));
|
|
236
|
+
}, CALL_TIMEOUT_MS);
|
|
237
|
+
replies.set(n, {
|
|
238
|
+
resolve: (value) => (clearTimeout(timer), resolve(value)),
|
|
239
|
+
reject: (error) => (clearTimeout(timer), reject(error)),
|
|
240
|
+
});
|
|
241
|
+
socket.send(JSON.stringify({ id: n, method, params }));
|
|
242
|
+
}),
|
|
243
|
+
on: (listener) => listeners.add(listener),
|
|
89
244
|
close: () => socket.close(),
|
|
90
245
|
};
|
|
91
246
|
}
|
|
92
247
|
|
|
93
248
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
249
|
+
* The agent's tabs as Otter sees them: who holds which (and their input, replayed in the order
|
|
250
|
+
* Otter numbered it however the gateway runs requests), and what happens on them (where the
|
|
251
|
+
* pointer lands, what's typed in, pop-ups, file pickers, copies) for whoever watches.
|
|
96
252
|
*/
|
|
97
|
-
export function createBrowserControl({
|
|
98
|
-
|
|
253
|
+
export function createBrowserControl({
|
|
254
|
+
cdpUrlFor,
|
|
255
|
+
answerDialog,
|
|
256
|
+
openDialog,
|
|
257
|
+
connect = connectTarget,
|
|
258
|
+
now = Date.now,
|
|
259
|
+
uploads = path.join(tmpdir(), "otter-uploads"),
|
|
260
|
+
}) {
|
|
261
|
+
const tabs = new Map();
|
|
262
|
+
// Event numbers start over when the plugin does (an update, a gateway restart): say which run.
|
|
263
|
+
const run = randomUUID();
|
|
99
264
|
|
|
265
|
+
let sweeper;
|
|
266
|
+
const tabOf = (targetId) => {
|
|
267
|
+
let tab = tabs.get(targetId);
|
|
268
|
+
if (!tab) {
|
|
269
|
+
tab = {
|
|
270
|
+
targetId,
|
|
271
|
+
lock: null,
|
|
272
|
+
events: [],
|
|
273
|
+
seq: 0,
|
|
274
|
+
waiters: new Set(),
|
|
275
|
+
seen: now(),
|
|
276
|
+
chain: Promise.resolve(),
|
|
277
|
+
};
|
|
278
|
+
tabs.set(targetId, tab);
|
|
279
|
+
sweeper ??= setInterval(() => sweep(), IDLE_MS);
|
|
280
|
+
sweeper.unref?.();
|
|
281
|
+
}
|
|
282
|
+
return tab;
|
|
283
|
+
};
|
|
100
284
|
const holding = (targetId) => {
|
|
101
|
-
const tab =
|
|
102
|
-
if (tab && tab.until > now()) return tab;
|
|
103
|
-
if (tab)
|
|
285
|
+
const tab = tabs.get(targetId);
|
|
286
|
+
if (tab?.lock && tab.lock.until > now()) return tab;
|
|
287
|
+
if (tab?.lock) unlock(tab);
|
|
104
288
|
return undefined;
|
|
105
289
|
};
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
290
|
+
const unlock = (tab) => {
|
|
291
|
+
clearTimeout(tab.lock?.gap);
|
|
292
|
+
for (const batch of tab.lock?.waiting.values() ?? []) batch.done();
|
|
293
|
+
tab.lock = null;
|
|
294
|
+
tab.chooser = null;
|
|
295
|
+
void tab.page
|
|
296
|
+
?.then(
|
|
297
|
+
(page) =>
|
|
298
|
+
page.open() && page.call("Page.setInterceptFileChooserDialog", { enabled: false }),
|
|
299
|
+
)
|
|
300
|
+
.catch(() => {});
|
|
301
|
+
};
|
|
302
|
+
/** Lets go of tabs nobody has held or watched for a while, and their DevTools connections. */
|
|
303
|
+
const sweep = (all = false) => {
|
|
304
|
+
for (const [targetId, tab] of tabs) {
|
|
305
|
+
if (!all && (holding(targetId) || now() - tab.seen < IDLE_MS)) continue;
|
|
306
|
+
if (tab.lock) unlock(tab);
|
|
307
|
+
for (const wake of tab.waiters) wake();
|
|
308
|
+
tabs.delete(targetId);
|
|
309
|
+
void tab.page?.then((page) => page.close()).catch(() => {});
|
|
310
|
+
}
|
|
311
|
+
if (tabs.size === 0 && sweeper) {
|
|
312
|
+
clearInterval(sweeper);
|
|
313
|
+
sweeper = undefined;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
const tell = (tab, event) => {
|
|
317
|
+
tab.seq += 1;
|
|
318
|
+
tab.events.push({ ...event, seq: tab.seq });
|
|
319
|
+
if (tab.events.length > EVENTS_KEPT) tab.events.shift();
|
|
320
|
+
for (const wake of tab.waiters) wake();
|
|
321
|
+
};
|
|
322
|
+
const watch = (tab, page) => {
|
|
323
|
+
page.on((method, params) => {
|
|
324
|
+
if (method === "Runtime.bindingCalled" && params.name === BINDING) {
|
|
325
|
+
try {
|
|
326
|
+
tell(tab, JSON.parse(params.payload));
|
|
327
|
+
} catch {}
|
|
328
|
+
} else if (method === "Page.javascriptDialogOpening") {
|
|
329
|
+
tab.dialog = {
|
|
330
|
+
dialogType: params.type,
|
|
331
|
+
message: String(params.message ?? "").slice(0, 4000),
|
|
332
|
+
defaultPrompt: String(params.defaultPrompt ?? "").slice(0, 4000),
|
|
333
|
+
};
|
|
334
|
+
tell(tab, { type: "dialog", ...tab.dialog });
|
|
335
|
+
} else if (method === "Page.javascriptDialogClosed") {
|
|
336
|
+
tab.dialog = null;
|
|
337
|
+
tell(tab, { type: "dialogClosed" });
|
|
338
|
+
} else if (method === "Page.fileChooserOpened" && tab.lock) {
|
|
339
|
+
tab.chooser = { backendNodeId: params.backendNodeId };
|
|
340
|
+
tell(tab, { type: "fileChooser", multiple: params.mode === "selectMultiple" });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
// Not waited for: a page showing a pop-up holds every command until it closes, and runs them
|
|
344
|
+
// in order then. Binding calls only arrive with Runtime on (its other events are ignored).
|
|
345
|
+
for (const [method, params] of [
|
|
346
|
+
["Runtime.enable", {}],
|
|
347
|
+
["Runtime.addBinding", { name: BINDING, executionContextName: WORLD }],
|
|
348
|
+
["Page.enable", {}],
|
|
349
|
+
[
|
|
350
|
+
"Page.addScriptToEvaluateOnNewDocument",
|
|
351
|
+
{ source: WATCH_SCRIPT, worldName: WORLD, runImmediately: true },
|
|
352
|
+
],
|
|
353
|
+
])
|
|
354
|
+
void page.call(method, params).catch(() => {});
|
|
355
|
+
};
|
|
356
|
+
const open = async (tab, targetId, profile) => {
|
|
357
|
+
const page = await connect(await cdpUrlFor(profile), targetId);
|
|
358
|
+
watch(tab, page);
|
|
359
|
+
// A pop-up already open before we connected isn't announced again.
|
|
360
|
+
const already = await openDialog?.(targetId, profile).catch(() => null);
|
|
361
|
+
if (already && !tab.dialog) {
|
|
362
|
+
tab.dialog = already;
|
|
363
|
+
tell(tab, { type: "dialog", ...already });
|
|
364
|
+
}
|
|
365
|
+
return page;
|
|
111
366
|
};
|
|
112
|
-
const open = (targetId, profile) => cdpUrlFor(profile).then((url) => connect(url, targetId));
|
|
113
367
|
const pageOf = async (tab, targetId, profile) => {
|
|
114
|
-
tab.
|
|
368
|
+
tab.profile = profile;
|
|
369
|
+
tab.page ??= open(tab, targetId, profile);
|
|
115
370
|
const page = await tab.page.catch(() => null);
|
|
116
371
|
if (page?.open()) return page;
|
|
117
|
-
tab.page = open(targetId, profile);
|
|
372
|
+
tab.page = open(tab, targetId, profile);
|
|
118
373
|
return tab.page;
|
|
119
374
|
};
|
|
375
|
+
|
|
376
|
+
const replay = async (tab, page, event) => {
|
|
377
|
+
// Answered through OpenClaw when it can, so it knows the pop-up is gone and the agent can go on.
|
|
378
|
+
if (event?.type === "dialog" && answerDialog) {
|
|
379
|
+
const answer = { accept: event.accept === true, promptText: event.promptText };
|
|
380
|
+
if (
|
|
381
|
+
await answerDialog(tab.targetId, tab.profile, answer).then(
|
|
382
|
+
() => true,
|
|
383
|
+
() => false,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (event?.type === "history") {
|
|
389
|
+
const { currentIndex, entries } = await page.call("Page.getNavigationHistory");
|
|
390
|
+
const entry = entries?.[currentIndex + (event.delta === -1 ? -1 : 1)];
|
|
391
|
+
if (entry) page.send("Page.navigateToHistoryEntry", { entryId: entry.id });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const call = cdpInput(event);
|
|
395
|
+
if (call) page.send(...call);
|
|
396
|
+
};
|
|
397
|
+
/**
|
|
398
|
+
* Replays the batches now in order, one after another for the tab: a pop-up's answer or a
|
|
399
|
+
* history move waits on a reply, and what follows must not overtake it.
|
|
400
|
+
*/
|
|
120
401
|
const flush = (tab, page) => {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
tab.
|
|
128
|
-
|
|
402
|
+
const lock = tab.lock;
|
|
403
|
+
const missing = lock.next;
|
|
404
|
+
while (lock.waiting.has(lock.next)) {
|
|
405
|
+
const batch = lock.waiting.get(lock.next);
|
|
406
|
+
lock.waiting.delete(lock.next);
|
|
407
|
+
lock.next += 1;
|
|
408
|
+
tab.chain = tab.chain
|
|
409
|
+
.then(async () => {
|
|
410
|
+
// Handed back meanwhile: what's still queued is dropped, not sent over the agent.
|
|
411
|
+
for (const event of batch.events) {
|
|
412
|
+
if (tab.lock !== lock) return;
|
|
413
|
+
await replay(tab, page, event).catch(() => {});
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
.finally(batch.done);
|
|
129
417
|
}
|
|
130
418
|
// A batch is missing: let it arrive, then go on without it. Batches arriving behind it
|
|
131
419
|
// don't push that back.
|
|
132
|
-
if (
|
|
133
|
-
clearTimeout(
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
tab.
|
|
420
|
+
if (lock.gap && lock.next === missing && lock.waiting.size > 0) return;
|
|
421
|
+
clearTimeout(lock.gap);
|
|
422
|
+
lock.gap = undefined;
|
|
423
|
+
if (lock.waiting.size === 0) return;
|
|
424
|
+
lock.gap = setTimeout(() => {
|
|
425
|
+
lock.gap = undefined;
|
|
426
|
+
if (tab.lock !== lock) return;
|
|
427
|
+
lock.next = Math.min(...lock.waiting.keys());
|
|
139
428
|
flush(tab, page);
|
|
140
429
|
}, GAP_MS);
|
|
141
430
|
};
|
|
142
431
|
|
|
143
432
|
return {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
433
|
+
/** The plugin is being replaced: let go of everything. */
|
|
434
|
+
dispose: () => sweep(true),
|
|
435
|
+
/** Whether the agent's browser tool may run this call now: reads always, actions unless held. */
|
|
436
|
+
allows: (params) =>
|
|
437
|
+
READ_ACTIONS.has(params?.action) || ![...tabs.keys()].some((id) => holding(id)),
|
|
438
|
+
async take(targetId, profile) {
|
|
439
|
+
sweep();
|
|
440
|
+
const held = holding(targetId);
|
|
441
|
+
if (held) {
|
|
442
|
+
held.lock.until = now() + CONTROL_TTL_MS;
|
|
443
|
+
held.seen = now();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const tab = tabOf(targetId);
|
|
447
|
+
tab.seen = now();
|
|
448
|
+
tab.lock = { until: now() + CONTROL_TTL_MS, next: 1, waiting: new Map() };
|
|
449
|
+
// The page's own file picker is offered to you while you hold it.
|
|
450
|
+
const page = await pageOf(tab, targetId, profile);
|
|
451
|
+
void page.call("Page.setInterceptFileChooserDialog", { enabled: true }).catch(() => {});
|
|
452
|
+
},
|
|
453
|
+
release(targetId) {
|
|
454
|
+
const tab = tabs.get(targetId);
|
|
455
|
+
if (tab?.lock) unlock(tab);
|
|
149
456
|
},
|
|
150
|
-
release: drop,
|
|
151
457
|
async input(targetId, profile, seq, events) {
|
|
152
458
|
const tab = holding(targetId);
|
|
153
459
|
if (!tab) throw new Error("Take control of this tab first.");
|
|
154
|
-
tab.until = now() + CONTROL_TTL_MS;
|
|
155
|
-
|
|
156
|
-
|
|
460
|
+
tab.lock.until = now() + CONTROL_TTL_MS;
|
|
461
|
+
tab.seen = now();
|
|
462
|
+
if (!Number.isInteger(seq) || seq < tab.lock.next || !Array.isArray(events)) return;
|
|
463
|
+
const lock = tab.lock;
|
|
464
|
+
// The request stays open until its batch is replayed: answering a pop-up goes through
|
|
465
|
+
// OpenClaw, which drops the call once the request that made it returns.
|
|
466
|
+
const replayed = new Promise((done) =>
|
|
467
|
+
lock.waiting.set(seq, { events: events.slice(0, 200), done }),
|
|
468
|
+
);
|
|
469
|
+
const page = await pageOf(tab, targetId, profile);
|
|
470
|
+
if (tab.lock === lock) flush(tab, page);
|
|
471
|
+
await replayed;
|
|
472
|
+
},
|
|
473
|
+
/**
|
|
474
|
+
* What happened on the tab since `since`, waiting up to `waitMs` for something to. Without
|
|
475
|
+
* `since`: no events, just where they're up to and any open pop-up.
|
|
476
|
+
*/
|
|
477
|
+
async events(targetId, profile, since, waitMs, from) {
|
|
478
|
+
if (from !== run) since = undefined;
|
|
479
|
+
sweep();
|
|
480
|
+
const tab = tabOf(targetId);
|
|
481
|
+
tab.seen = now();
|
|
482
|
+
await pageOf(tab, targetId, profile);
|
|
483
|
+
const state = () => ({
|
|
484
|
+
run,
|
|
485
|
+
seq: tab.seq,
|
|
486
|
+
dialog: tab.dialog ?? null,
|
|
487
|
+
events: Number.isInteger(since) ? tab.events.filter((e) => e.seq > since) : [],
|
|
488
|
+
});
|
|
489
|
+
if (!Number.isInteger(since) || since < tab.seq || !(waitMs > 0)) return state();
|
|
490
|
+
await new Promise((resolve) => {
|
|
491
|
+
const done = () => {
|
|
492
|
+
clearTimeout(timer);
|
|
493
|
+
tab.waiters.delete(done);
|
|
494
|
+
resolve();
|
|
495
|
+
};
|
|
496
|
+
const timer = setTimeout(done, Math.min(waitMs, 25_000));
|
|
497
|
+
tab.waiters.add(done);
|
|
498
|
+
});
|
|
499
|
+
tab.seen = now();
|
|
500
|
+
return state();
|
|
501
|
+
},
|
|
502
|
+
/** The files you picked for the page's own picker (none cancels it). */
|
|
503
|
+
async upload(targetId, profile, files) {
|
|
504
|
+
const tab = holding(targetId);
|
|
505
|
+
if (!tab) throw new Error("Take control of this tab first.");
|
|
506
|
+
const chooser = tab.chooser;
|
|
507
|
+
if (!chooser) throw new Error("The page isn't asking for a file.");
|
|
508
|
+
tab.chooser = null;
|
|
509
|
+
const picked = Array.isArray(files) ? files : [];
|
|
510
|
+
const total = picked.reduce((sum, f) => sum + String(f?.data ?? "").length * 0.75, 0);
|
|
511
|
+
if (total > MAX_UPLOAD_BYTES) throw new Error("Those files are too large (10 MB at most).");
|
|
512
|
+
const dir = path.join(uploads, randomUUID());
|
|
513
|
+
const paths = [];
|
|
514
|
+
if (picked.length > 0) {
|
|
515
|
+
for (const [i, file] of picked.entries()) {
|
|
516
|
+
// A folder each, so two files of the same name both reach the page as named.
|
|
517
|
+
const at = path.join(dir, String(i));
|
|
518
|
+
await mkdir(at, { recursive: true });
|
|
519
|
+
const name = path.basename(String(file?.name ?? "")) || `file-${i + 1}`;
|
|
520
|
+
await writeFile(path.join(at, name), Buffer.from(String(file?.data ?? ""), "base64"));
|
|
521
|
+
paths.push(path.join(at, name));
|
|
522
|
+
}
|
|
523
|
+
setTimeout(() => void rm(dir, { recursive: true, force: true }), UPLOADS_KEPT_MS).unref?.();
|
|
524
|
+
}
|
|
157
525
|
const page = await pageOf(tab, targetId, profile);
|
|
158
|
-
|
|
526
|
+
await page.call("DOM.setFileInputFiles", {
|
|
527
|
+
files: paths,
|
|
528
|
+
backendNodeId: chooser.backendNodeId,
|
|
529
|
+
});
|
|
159
530
|
},
|
|
160
531
|
};
|
|
161
532
|
}
|
package/index.js
CHANGED
|
@@ -195,15 +195,43 @@ export default defineChannelPluginEntry({
|
|
|
195
195
|
|
|
196
196
|
// Take control of the agent's browser from Otter: your input goes to the tab as a person's
|
|
197
197
|
// would, and the agent's browser waits until you hand it back.
|
|
198
|
+
const browser = async (method, path, query, body) => {
|
|
199
|
+
const result = await dispatchGatewayMethod("browser.request", {
|
|
200
|
+
method,
|
|
201
|
+
path,
|
|
202
|
+
...(query ? { query } : {}),
|
|
203
|
+
...(body ? { body } : {}),
|
|
204
|
+
});
|
|
205
|
+
if (!result?.ok) throw new Error(result?.error?.message ?? "The browser didn't answer.");
|
|
206
|
+
return result.payload;
|
|
207
|
+
};
|
|
208
|
+
const pendingDialog = async (targetId, profile) => {
|
|
209
|
+
const query = { ...(profile ? { profile } : {}), targetId };
|
|
210
|
+
return (await browser("GET", "/dialogs", query))?.browserState?.dialogs?.pending?.[0];
|
|
211
|
+
};
|
|
198
212
|
const control = createBrowserControl({
|
|
199
|
-
async
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
213
|
+
async openDialog(targetId, profile) {
|
|
214
|
+
const open = await pendingDialog(targetId, profile);
|
|
215
|
+
return open
|
|
216
|
+
? {
|
|
217
|
+
dialogType: String(open.type),
|
|
218
|
+
message: String(open.message ?? ""),
|
|
219
|
+
defaultPrompt: String(open.defaultValue ?? ""),
|
|
220
|
+
}
|
|
221
|
+
: null;
|
|
222
|
+
},
|
|
223
|
+
async answerDialog(targetId, profile, { accept, promptText }) {
|
|
224
|
+
const pending = await pendingDialog(targetId, profile);
|
|
225
|
+
if (!pending) throw new Error("No pop-up is open.");
|
|
226
|
+
await browser("POST", "/hooks/dialog", profile ? { profile } : undefined, {
|
|
227
|
+
targetId,
|
|
228
|
+
dialogId: pending.id,
|
|
229
|
+
accept,
|
|
230
|
+
...(typeof promptText === "string" ? { promptText } : {}),
|
|
203
231
|
});
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const profiles =
|
|
232
|
+
},
|
|
233
|
+
async cdpUrlFor(profile) {
|
|
234
|
+
const profiles = (await browser("GET", "/profiles"))?.profiles ?? [];
|
|
207
235
|
const found = profiles.find((p) => (profile ? p.name === profile : p.isDefault));
|
|
208
236
|
const url = typeof found?.cdpUrl === "string" ? URL.parse(found.cdpUrl) : null;
|
|
209
237
|
if (!url || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
|
|
@@ -211,15 +239,18 @@ export default defineChannelPluginEntry({
|
|
|
211
239
|
return url.href;
|
|
212
240
|
},
|
|
213
241
|
});
|
|
242
|
+
api.lifecycle?.onDispose?.(() => control.dispose());
|
|
214
243
|
const targetOf = (params) => {
|
|
215
244
|
if (typeof params?.targetId !== "string" || !params.targetId)
|
|
216
245
|
throw new Error("targetId is required");
|
|
217
246
|
return params.targetId;
|
|
218
247
|
};
|
|
248
|
+
const profileOf = (params) =>
|
|
249
|
+
typeof params?.profile === "string" ? params.profile : undefined;
|
|
219
250
|
api.registerGatewayMethod(
|
|
220
251
|
"otter.browser.control",
|
|
221
|
-
({ params }) => {
|
|
222
|
-
if (params?.take === true) control.take(targetOf(params));
|
|
252
|
+
async ({ params }) => {
|
|
253
|
+
if (params?.take === true) await control.take(targetOf(params), profileOf(params));
|
|
223
254
|
else control.release(targetOf(params));
|
|
224
255
|
return { controlled: params?.take === true };
|
|
225
256
|
},
|
|
@@ -228,18 +259,37 @@ export default defineChannelPluginEntry({
|
|
|
228
259
|
api.registerGatewayMethod(
|
|
229
260
|
"otter.browser.input",
|
|
230
261
|
async ({ params }) => {
|
|
231
|
-
|
|
232
|
-
|
|
262
|
+
await control.input(targetOf(params), profileOf(params), params?.seq, params?.events);
|
|
263
|
+
return {};
|
|
264
|
+
},
|
|
265
|
+
{ scope: "operator.admin" },
|
|
266
|
+
);
|
|
267
|
+
api.registerGatewayMethod(
|
|
268
|
+
"otter.browser.events",
|
|
269
|
+
({ params }) =>
|
|
270
|
+
control.events(
|
|
271
|
+
targetOf(params),
|
|
272
|
+
profileOf(params),
|
|
273
|
+
params?.since,
|
|
274
|
+
params?.waitMs,
|
|
275
|
+
params?.run,
|
|
276
|
+
),
|
|
277
|
+
{ scope: "operator.admin" },
|
|
278
|
+
);
|
|
279
|
+
api.registerGatewayMethod(
|
|
280
|
+
"otter.browser.upload",
|
|
281
|
+
async ({ params }) => {
|
|
282
|
+
await control.upload(targetOf(params), profileOf(params), params?.files);
|
|
233
283
|
return {};
|
|
234
284
|
},
|
|
235
285
|
{ scope: "operator.admin" },
|
|
236
286
|
);
|
|
237
287
|
api.on("before_tool_call", (event) =>
|
|
238
|
-
/(^|__)browser$/.test(event.toolName) && control.
|
|
288
|
+
/(^|__)browser$/.test(event.toolName) && !control.allows(event.params)
|
|
239
289
|
? {
|
|
240
290
|
block: true,
|
|
241
291
|
blockReason:
|
|
242
|
-
"The user has taken control of the browser.
|
|
292
|
+
"The user has taken control of the browser. Reading pages still works; wait until they hand it back before acting on one.",
|
|
243
293
|
}
|
|
244
294
|
: undefined,
|
|
245
295
|
);
|
package/package.json
CHANGED