@inetafrica/open-claudia 3.5.23 → 3.5.25
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/CHANGELOG.md +22 -0
- package/core/agent-browser-cli.js +98 -0
- package/core/browser-address.js +171 -0
- package/core/browser-control.js +258 -39
- package/core/browser-drive.js +589 -0
- package/core/browser-lock.js +33 -2
- package/core/browser-teach.js +59 -44
- package/core/browser-view.js +154 -8
- package/core/cards.js +18 -0
- package/core/loopback.js +4 -1
- package/core/providers/env.js +17 -0
- package/core/system-prompt.js +14 -1
- package/core/tool-guard.js +3 -0
- package/package.json +2 -2
- package/test-provider-env-isolation.js +24 -0
- package/test-provider-language.js +11 -1
- package/web.js +7 -2
package/core/browser-control.js
CHANGED
|
@@ -7,10 +7,21 @@
|
|
|
7
7
|
// the browser. It lasts 120 s; the page's heartbeat and every accepted input extend it.
|
|
8
8
|
// - one input WebSocket per agent-browser session (ws://127.0.0.1:<port>/?maxFps=2): events
|
|
9
9
|
// are checked strictly, field by field, and forwarded in order. The same socket keeps the
|
|
10
|
-
// latest frame, which Teach me saves as a click's picture.
|
|
10
|
+
// latest frame, which Teach me saves as a click's (or a navigation's) picture.
|
|
11
|
+
// - the address bar: go to an address, back, forward, reload. The stream socket only takes
|
|
12
|
+
// input, so these are agent-browser commands on the same session, run in the session's queue
|
|
13
|
+
// after the input before them, with the bot's own agent-browser environment and never a
|
|
14
|
+
// launch flag (core/agent-browser-cli.js: other launch settings would restart the browser).
|
|
15
|
+
// Only public internet addresses (core/browser-address.js).
|
|
16
|
+
// - the phone and web view (core/browser-drive.js): a take or a "fit" with the driver's screen
|
|
17
|
+
// makes the bot's tab take its size, phone layout and touch, and then the driver's input goes
|
|
18
|
+
// straight to Chrome through that view (typed text too: input_text). Only with Chrome in its
|
|
19
|
+
// own container; elsewhere the browser keeps its size and input goes through agent-browser.
|
|
11
20
|
// - hand back (release, a closed panel, or a lock found expired by the 30 s sweeper): the lock
|
|
12
|
-
// goes, the socket
|
|
13
|
-
//
|
|
21
|
+
// goes, the view and the socket close, any recording stops, and the bot is woken in the
|
|
22
|
+
// person's own chat (C6), but only when it waits for them: it sent them an Open Browser card,
|
|
23
|
+
// or one of its browser commands was refused while they drove. A recording stopped at the
|
|
24
|
+
// same time folds into one wake-up. Otherwise the hand back is only logged.
|
|
14
25
|
//
|
|
15
26
|
// Every route answers { status, body }; web.js writes it. Agent Space reaches these routes with
|
|
16
27
|
// the control token only.
|
|
@@ -19,6 +30,9 @@ const WebSocket = require("ws");
|
|
|
19
30
|
const lockFile = require("./browser-lock");
|
|
20
31
|
const view = require("./browser-view");
|
|
21
32
|
const teach = require("./browser-teach");
|
|
33
|
+
const address = require("./browser-address");
|
|
34
|
+
const drive = require("./browser-drive");
|
|
35
|
+
const { runAgentBrowser } = require("./agent-browser-cli");
|
|
22
36
|
|
|
23
37
|
const LOCK_TTL_MS = lockFile.LOCK_TTL_MS;
|
|
24
38
|
// Inputs extend the lock; the file (fsynced) is rewritten at most this often, so the lock ends
|
|
@@ -31,19 +45,32 @@ const MAX_EVENTS = 50;
|
|
|
31
45
|
const MAX_PENDING_EVENTS = 1000;
|
|
32
46
|
const COORD_MAX = 10000;
|
|
33
47
|
const ID_RE = /^[A-Za-z0-9._:-]{1,100}$/;
|
|
48
|
+
// An address-bar command still running after this is killed, and the person told it failed.
|
|
49
|
+
const NAVIGATE_TIMEOUT_MS = 20 * 1000;
|
|
50
|
+
const NAVIGATIONS = new Set(["open", "back", "forward", "reload"]);
|
|
51
|
+
|
|
52
|
+
// An Open Browser card counts as a request for a person this long.
|
|
53
|
+
const REQUEST_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
34
54
|
|
|
35
55
|
const HAND_BACK_REASONS = {
|
|
36
56
|
closed: " (they closed the Browser)",
|
|
37
57
|
expired: " (their Browser went quiet for 2 minutes, so control came back on its own)",
|
|
38
58
|
};
|
|
39
59
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
60
|
+
function defaultDeps() {
|
|
61
|
+
return {
|
|
62
|
+
now: () => Date.now(),
|
|
63
|
+
listSessions: () => view.listSessions(),
|
|
64
|
+
connect: (url) => new WebSocket(url),
|
|
65
|
+
scheduler: null, // core/scheduler by default (through core/cards.js wakeOrigin)
|
|
66
|
+
runFor: null, // the chat's provider, settings and live session; activeRun() by default
|
|
67
|
+
execFile: null, // child_process.execFile by default (core/agent-browser-cli.js)
|
|
68
|
+
lookupHost: null, // dns.promises.lookup by default (core/browser-address.js)
|
|
69
|
+
navigateTimeoutMs: NAVIGATE_TIMEOUT_MS,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let deps = defaultDeps();
|
|
47
74
|
|
|
48
75
|
function reply(status, body) {
|
|
49
76
|
return { status, body };
|
|
@@ -70,6 +97,19 @@ function sameUser(lock, who) {
|
|
|
70
97
|
return !!lock && !!who && String(lock.userId) === String(who.userId);
|
|
71
98
|
}
|
|
72
99
|
|
|
100
|
+
// The live lock this person holds on this session, extended (every accepted input or navigation
|
|
101
|
+
// extends it; the file is rewritten at most every LOCK_REWRITE_MS), or null.
|
|
102
|
+
function extendDriverLock(session, who) {
|
|
103
|
+
const now = deps.now();
|
|
104
|
+
const lock = lockFile.liveLock(now);
|
|
105
|
+
if (!lock || lock.session !== session || !sameUser(lock, who)) return null;
|
|
106
|
+
if (lockFile.lockExpiresAt(lock) - now < LOCK_TTL_MS - LOCK_REWRITE_MS) {
|
|
107
|
+
lock.expiresAt = iso(now + LOCK_TTL_MS);
|
|
108
|
+
lockFile.writeLock(lock);
|
|
109
|
+
}
|
|
110
|
+
return lock;
|
|
111
|
+
}
|
|
112
|
+
|
|
73
113
|
// ---------------------------------------------------------------------------
|
|
74
114
|
// C3: input events, checked field by field. Only the listed fields are forwarded.
|
|
75
115
|
|
|
@@ -77,6 +117,7 @@ const MOUSE_EVENTS = new Set(["mousePressed", "mouseReleased", "mouseMoved", "mo
|
|
|
77
117
|
const MOUSE_BUTTONS = new Set(["none", "left", "middle", "right"]);
|
|
78
118
|
const KEY_EVENTS = new Set(["keyDown", "keyUp", "char"]);
|
|
79
119
|
const TOUCH_EVENTS = new Set(["touchStart", "touchMove", "touchEnd", "touchCancel"]);
|
|
120
|
+
const TEXT_MAX = drive.TEXT_MAX;
|
|
80
121
|
|
|
81
122
|
const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
82
123
|
const inRange = (v, lo, hi) => isNumber(v) && v >= lo && v <= hi;
|
|
@@ -133,11 +174,34 @@ function checkEvent(ev) {
|
|
|
133
174
|
}
|
|
134
175
|
const out = { type: "input_touch", eventType: ev.eventType, touchPoints };
|
|
135
176
|
if (!optional(out, "modifiers", modifiers)) return null;
|
|
177
|
+
// When the finger moved, on the driver's device (ms): keeps a swipe's speed (core/browser-drive.js).
|
|
178
|
+
if (!optional(out, "t", (v) => inRange(v, 0, 1e13))) return null;
|
|
136
179
|
return out;
|
|
137
180
|
}
|
|
181
|
+
// Text the driver typed (their phone keyboard, a paste), inserted where the page's caret is.
|
|
182
|
+
if (ev.type === "input_text") {
|
|
183
|
+
if (typeof ev.text !== "string" || !ev.text || Array.from(ev.text).length > TEXT_MAX) return null;
|
|
184
|
+
return { type: "input_text", text: ev.text };
|
|
185
|
+
}
|
|
138
186
|
return null;
|
|
139
187
|
}
|
|
140
188
|
|
|
189
|
+
// input_text as key presses, for a bot without the driver's view (agent-browser's socket takes
|
|
190
|
+
// no text) and for Teach me, which counts typing from key presses.
|
|
191
|
+
function textAsKeys(text) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const ch of Array.from(text)) {
|
|
194
|
+
if (ch === "\n" || ch === "\r") {
|
|
195
|
+
out.push({ type: "input_keyboard", eventType: "keyDown", key: "Enter", code: "Enter", text: "\r", windowsVirtualKeyCode: 13 });
|
|
196
|
+
out.push({ type: "input_keyboard", eventType: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 });
|
|
197
|
+
} else {
|
|
198
|
+
out.push({ type: "input_keyboard", eventType: "keyDown", key: ch, text: ch });
|
|
199
|
+
out.push({ type: "input_keyboard", eventType: "keyUp", key: ch });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
141
205
|
// ---------------------------------------------------------------------------
|
|
142
206
|
// Input sockets: one per agent-browser session, opened on take (or on the first input after a
|
|
143
207
|
// restart), closed on hand back.
|
|
@@ -295,7 +359,23 @@ function wakeChat({ chatId, userId }, prompt, label) {
|
|
|
295
359
|
|
|
296
360
|
function handBackLine(name, reason) {
|
|
297
361
|
const who = teach.cleanName(name) || "Someone";
|
|
298
|
-
return `[Browser] ${who} handed the browser back${HAND_BACK_REASONS[reason] || ""}.
|
|
362
|
+
return `[Browser] ${who} handed the browser back${HAND_BACK_REASONS[reason] || ""}. Check the page first (it may have changed), then carry on with what you were waiting on them for.`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Was the bot waiting for this person when they handed back? It asked for them with an Open
|
|
366
|
+
// Browser card in their chat (since the day before they took over), or one of its own browser
|
|
367
|
+
// commands was refused while they drove.
|
|
368
|
+
function botWasWaiting(lock) {
|
|
369
|
+
let asked = [];
|
|
370
|
+
try {
|
|
371
|
+
const since = Date.parse(lock.since);
|
|
372
|
+
asked = require("./cards").takeBrowserRequests(lock.chatId, (Number.isFinite(since) ? since : deps.now()) - REQUEST_MAX_AGE_MS, deps.now());
|
|
373
|
+
} catch (e) {
|
|
374
|
+
console.error(`[browser] reading the bot's Open Browser cards failed: ${e.message}`);
|
|
375
|
+
}
|
|
376
|
+
const refused = lockFile.waitedDuring(lock);
|
|
377
|
+
lockFile.clearWaiting();
|
|
378
|
+
return asked.length > 0 || refused;
|
|
299
379
|
}
|
|
300
380
|
|
|
301
381
|
// ---------------------------------------------------------------------------
|
|
@@ -305,7 +385,39 @@ function lockBody(lock) {
|
|
|
305
385
|
return { driving: true, driver: { name: lock.name } };
|
|
306
386
|
}
|
|
307
387
|
|
|
308
|
-
|
|
388
|
+
// The driver's view on this lock's session: start it (or move it to a new screen) and remember it
|
|
389
|
+
// in the lock, so a restarted bot can bring it back. -> the fit, or null (the browser keeps its size).
|
|
390
|
+
async function startView(lock, viewport) {
|
|
391
|
+
if (!viewport || !drive.supported()) return null;
|
|
392
|
+
const info = sessionInfo(lock.session);
|
|
393
|
+
if (!info) return null;
|
|
394
|
+
const r = drive.active(lock.session)
|
|
395
|
+
? await drive.fit(viewport)
|
|
396
|
+
: await drive.start({ session: info, viewport, savedBounds: lock.bounds || null });
|
|
397
|
+
const now = lockFile.readLock();
|
|
398
|
+
// The lock changed hands (or was handed back) while the view started: undo it.
|
|
399
|
+
if (!now || now.since !== lock.since || now.userId !== lock.userId) {
|
|
400
|
+
if (r.ok && drive.active(lock.session)) await drive.stop();
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
if (!r.ok) return null;
|
|
404
|
+
now.fit = r.fit;
|
|
405
|
+
if (r.bounds) now.bounds = r.bounds;
|
|
406
|
+
lockFile.writeLock(now);
|
|
407
|
+
return r.fit;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// After a bot restart the lock remembers a view that is no longer live: bring it back. Once at a
|
|
411
|
+
// time, in the background.
|
|
412
|
+
let reviving = null;
|
|
413
|
+
function reviveView(lock) {
|
|
414
|
+
if (!lock || !lock.fit || reviving || drive.active(lock.session) || !drive.supported()) return;
|
|
415
|
+
reviving = startView(lock, lock.fit)
|
|
416
|
+
.catch((e) => console.error(`[browser] bringing the driver's view back failed: ${e.message}`))
|
|
417
|
+
.finally(() => { reviving = null; });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function take({ session, who, viewport } = {}) {
|
|
309
421
|
if (!validSession(session) || !validWho(who, { needChat: true })) return reply(400, { error: "bad-request" });
|
|
310
422
|
// A lock that ran out but has not been swept yet is handed back first, the sweeper's way: its
|
|
311
423
|
// driver's chat is woken and their recording stopped, whoever takes over now.
|
|
@@ -324,9 +436,25 @@ async function take({ session, who } = {}) {
|
|
|
324
436
|
since: current && current.session === session ? current.since : iso(now),
|
|
325
437
|
expiresAt: iso(now + LOCK_TTL_MS),
|
|
326
438
|
};
|
|
439
|
+
// The same person taking again (a reloaded page) keeps the window's size from before their view.
|
|
440
|
+
if (current && current.session === session && sameUser(current, who) && current.bounds) lock.bounds = current.bounds;
|
|
327
441
|
lockFile.writeLock(lock);
|
|
328
442
|
openInputSocket(session);
|
|
329
|
-
return reply(200, lockBody(lock));
|
|
443
|
+
if (!viewport) return reply(200, lockBody(lock));
|
|
444
|
+
// fit: the driver's view now live, or null (this bot's browser keeps its own size).
|
|
445
|
+
const fitted = await startView(lock, viewport);
|
|
446
|
+
return reply(200, { ...lockBody(lock), fit: fitted });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// The driver's screen changed (a turned phone, a resized panel): { session, who, viewport }.
|
|
450
|
+
async function fitView({ session, who, viewport } = {}) {
|
|
451
|
+
if (!validSession(session) || !validWho(who)) return reply(400, { error: "bad-request" });
|
|
452
|
+
if (!drive.checkViewport(viewport)) return reply(400, { error: "bad-viewport" });
|
|
453
|
+
const lock = extendDriverLock(session, who);
|
|
454
|
+
if (!lock) return reply(409, { driving: false, error: "not-driving" });
|
|
455
|
+
if (!drive.supported()) return reply(200, { ...lockBody(lock), fit: null });
|
|
456
|
+
const fitted = await startView(lock, viewport);
|
|
457
|
+
return reply(200, { ...lockBody(lock), fit: fitted });
|
|
330
458
|
}
|
|
331
459
|
|
|
332
460
|
function renew({ session, who } = {}) {
|
|
@@ -339,7 +467,8 @@ function renew({ session, who } = {}) {
|
|
|
339
467
|
current.expiresAt = iso(now + LOCK_TTL_MS);
|
|
340
468
|
lockFile.writeLock(current);
|
|
341
469
|
openInputSocket(current.session); // after a restart the socket comes back here
|
|
342
|
-
|
|
470
|
+
reviveView(current); // and the driver's view
|
|
471
|
+
return reply(200, current.fit ? { ...lockBody(current), fit: drive.active(current.session) ? current.fit : null } : lockBody(current));
|
|
343
472
|
}
|
|
344
473
|
|
|
345
474
|
// Hand the browser back: claim the lock (synchronously, so a release and the sweeper never both
|
|
@@ -353,6 +482,13 @@ async function handBack(expected, { reason = null } = {}) {
|
|
|
353
482
|
let stopped = null;
|
|
354
483
|
await runQueued(current.session, 0, async () => {
|
|
355
484
|
closeInputSocket(current.session);
|
|
485
|
+
// The bot's tab goes back to its own size before the bot carries on.
|
|
486
|
+
try {
|
|
487
|
+
if (drive.active(current.session)) await drive.stop();
|
|
488
|
+
else if (current.bounds) await drive.restoreWindow(info, current.bounds);
|
|
489
|
+
} catch (e) {
|
|
490
|
+
console.error(`[browser] ending the driver's view failed: ${e.message}`);
|
|
491
|
+
}
|
|
356
492
|
try {
|
|
357
493
|
stopped = await teach.stopRecording(current.userId, { session: info });
|
|
358
494
|
} catch (e) {
|
|
@@ -360,9 +496,10 @@ async function handBack(expected, { reason = null } = {}) {
|
|
|
360
496
|
}
|
|
361
497
|
});
|
|
362
498
|
const line = handBackLine(current.name, reason);
|
|
499
|
+
const waited = botWasWaiting(current);
|
|
363
500
|
if (stopped && !stopped.discarded) wakeChat(current, teach.teachPrompt(stopped, { handBack: line }), "Teach me");
|
|
364
|
-
else wakeChat(current, line, "Browser");
|
|
365
|
-
console.log(`[browser] ${current.userId} handed the browser back${reason ? ` (${reason})` : ""}`);
|
|
501
|
+
else if (waited) wakeChat(current, line, "Browser");
|
|
502
|
+
console.log(`[browser] ${current.userId} handed the browser back${reason ? ` (${reason})` : ""}${waited || (stopped && !stopped.discarded) ? "" : "; the bot was not waiting for them, so it is not woken"}`);
|
|
366
503
|
return true;
|
|
367
504
|
}
|
|
368
505
|
|
|
@@ -379,15 +516,20 @@ async function control(body = {}) {
|
|
|
379
516
|
if (body.action === "take") return take(body);
|
|
380
517
|
if (body.action === "renew") return renew(body);
|
|
381
518
|
if (body.action === "release") return release(body);
|
|
519
|
+
if (body.action === "fit") return fitView(body);
|
|
382
520
|
return reply(400, { error: "bad-action" });
|
|
383
521
|
}
|
|
384
522
|
|
|
523
|
+
// navigate: true tells the front door this bot has the address bar (POST /api/browser/navigate);
|
|
524
|
+
// fit: true that a take can bring the driver's view (core/browser-drive.js) and input_text works.
|
|
385
525
|
function status() {
|
|
386
526
|
const lock = lockFile.liveLock(deps.now());
|
|
387
527
|
return reply(200, {
|
|
388
528
|
driving: !!lock,
|
|
389
529
|
driver: lock ? { name: lock.name, userId: lock.userId } : null,
|
|
390
530
|
recording: teach.activeSummary(),
|
|
531
|
+
navigate: true,
|
|
532
|
+
fit: drive.supported(),
|
|
391
533
|
});
|
|
392
534
|
}
|
|
393
535
|
|
|
@@ -403,31 +545,42 @@ async function input({ session, events, who } = {}) {
|
|
|
403
545
|
if (!msg) return reply(400, { error: "bad-event", index: i });
|
|
404
546
|
messages.push(msg);
|
|
405
547
|
}
|
|
406
|
-
|
|
407
|
-
const lock =
|
|
408
|
-
if (!lock
|
|
409
|
-
// Every accepted input extends the lock; the file is rewritten only every few seconds.
|
|
410
|
-
if (lockFile.lockExpiresAt(lock) - now < LOCK_TTL_MS - LOCK_REWRITE_MS) {
|
|
411
|
-
lock.expiresAt = iso(now + LOCK_TTL_MS);
|
|
412
|
-
lockFile.writeLock(lock);
|
|
413
|
-
}
|
|
548
|
+
// Every accepted input extends the lock.
|
|
549
|
+
const lock = extendDriverLock(session, who);
|
|
550
|
+
if (!lock) return reply(409, { error: "not-driving" });
|
|
414
551
|
if (!messages.length) return reply(200, { ok: true, accepted: 0 });
|
|
552
|
+
if (lock.fit && !drive.active(session)) reviveView(lock);
|
|
415
553
|
const entry = openInputSocket(session);
|
|
416
|
-
if (!entry) return reply(404, { error: "not-running" });
|
|
554
|
+
if (!entry && !drive.active(session)) return reply(404, { error: "not-running" });
|
|
417
555
|
const job = runQueued(session, messages.length, async () => {
|
|
418
|
-
if (!(await socketReady(entry))) return reply(503, { error: "browser-unavailable", accepted: 0 });
|
|
419
556
|
const recorder = teach.recorderFor(session, who.userId);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
557
|
+
const record = async (msg, frame, info) => {
|
|
558
|
+
if (!recorder) return;
|
|
559
|
+
// Teach me counts typing from key presses: typed text is shown to it as keys.
|
|
560
|
+
for (const ev of msg.type === "input_text" ? textAsKeys(msg.text) : [msg]) {
|
|
423
561
|
try {
|
|
424
|
-
await recorder.onEvent(
|
|
562
|
+
await recorder.onEvent(ev, { session: info, frame });
|
|
425
563
|
} catch (e) {
|
|
426
564
|
console.error(`[browser] recording a step failed: ${e.message}`);
|
|
427
565
|
}
|
|
428
566
|
}
|
|
567
|
+
};
|
|
568
|
+
// The driver's view: straight to Chrome, in order, without waiting for each answer.
|
|
569
|
+
if (drive.active(session)) {
|
|
570
|
+
const info = sessionInfo(session) || { name: session, dir: entry ? entry.dir : null };
|
|
571
|
+
for (const msg of messages) {
|
|
572
|
+
await record(msg, drive.lastFrame(), { name: info.name, dir: info.dir });
|
|
573
|
+
if (drive.dispatch([msg]) < 0) return reply(503, { error: "browser-unavailable" });
|
|
574
|
+
}
|
|
575
|
+
return reply(200, { ok: true, accepted: messages.length });
|
|
576
|
+
}
|
|
577
|
+
if (!entry || !(await socketReady(entry))) return reply(503, { error: "browser-unavailable", accepted: 0 });
|
|
578
|
+
let sent = 0;
|
|
579
|
+
for (const msg of messages) {
|
|
580
|
+
await record(msg, entry.frame, { name: entry.name, dir: entry.dir });
|
|
429
581
|
if (!entry.open || entry.closed) return reply(503, { error: "browser-unavailable", accepted: sent });
|
|
430
|
-
|
|
582
|
+
// agent-browser's socket takes no text: typed text goes as key presses.
|
|
583
|
+
for (const ev of msg.type === "input_text" ? textAsKeys(msg.text) : [msg]) entry.ws.send(JSON.stringify(ev));
|
|
431
584
|
sent += 1;
|
|
432
585
|
}
|
|
433
586
|
return reply(200, { ok: true, accepted: sent });
|
|
@@ -436,6 +589,72 @@ async function input({ session, events, who } = {}) {
|
|
|
436
589
|
return job;
|
|
437
590
|
}
|
|
438
591
|
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
// The address bar: open an address, back, forward, reload
|
|
594
|
+
|
|
595
|
+
// Log words. An address is cut to origin + path: query strings and fragments can carry tokens.
|
|
596
|
+
function navigationWords(action, href) {
|
|
597
|
+
if (action === "open") {
|
|
598
|
+
const where = view.safePageAddress(href) || "an address";
|
|
599
|
+
return { done: `went to ${where}`, failed: `go to ${where}` };
|
|
600
|
+
}
|
|
601
|
+
if (action === "reload") return { done: "reloaded the page", failed: "reload the page" };
|
|
602
|
+
return { done: `went ${action}`, failed: `go ${action}` };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// { session, action: "open" | "back" | "forward" | "reload", url (open), who: { userId } }
|
|
606
|
+
async function navigate({ session, action, url, who } = {}) {
|
|
607
|
+
if (!validSession(session) || !validWho(who)) return reply(400, { error: "bad-request" });
|
|
608
|
+
if (!NAVIGATIONS.has(action)) return reply(400, { error: "bad-action" });
|
|
609
|
+
let target = null;
|
|
610
|
+
if (action === "open") {
|
|
611
|
+
target = address.parseAddress(url);
|
|
612
|
+
if (target.error) return reply(target.error === "bad-url" ? 400 : 403, { error: target.error });
|
|
613
|
+
}
|
|
614
|
+
// Like an input, a navigation needs the live lock and extends it.
|
|
615
|
+
if (!extendDriverLock(session, who)) return reply(409, { error: "not-driving" });
|
|
616
|
+
if (!openInputSocket(session)) return reply(404, { error: "not-running" });
|
|
617
|
+
// The name is resolved from now on, while the input queued before this still runs.
|
|
618
|
+
const resolving = target && target.lookupName
|
|
619
|
+
? address.resolveAddress(target.lookupName, { lookup: deps.lookupHost || undefined })
|
|
620
|
+
: null;
|
|
621
|
+
const href = target ? target.url.href : null;
|
|
622
|
+
const job = runQueued(session, 1, async () => {
|
|
623
|
+
if (resolving) {
|
|
624
|
+
const found = await resolving;
|
|
625
|
+
if (found.error) return reply(found.error === "unknown-host" ? 400 : 403, { error: found.error });
|
|
626
|
+
}
|
|
627
|
+
// agent-browser starts a new browser for a session it does not know: one that ended while
|
|
628
|
+
// this waited is not asked.
|
|
629
|
+
const info = sessionInfo(session);
|
|
630
|
+
if (!info || !info.dir) return reply(404, { error: "not-running" });
|
|
631
|
+
const recorder = teach.recorderFor(session, who.userId);
|
|
632
|
+
if (recorder) {
|
|
633
|
+
const live = sockets.get(session);
|
|
634
|
+
try {
|
|
635
|
+
await recorder.onNavigate({ how: action, to: href }, { session: { name: info.name, dir: info.dir }, frame: live && !live.closed ? live.frame : null });
|
|
636
|
+
} catch (e) {
|
|
637
|
+
console.error(`[browser] recording a step failed: ${e.message}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const words = navigationWords(action, href);
|
|
641
|
+
const run = await runAgentBrowser(info, action === "open" ? ["open", href] : [action], {
|
|
642
|
+
timeoutMs: deps.navigateTimeoutMs,
|
|
643
|
+
execFile: deps.execFile || undefined,
|
|
644
|
+
});
|
|
645
|
+
if (!run.ok) {
|
|
646
|
+
// Never the command's own output or error: both can repeat the whole address.
|
|
647
|
+
const why = run.timedOut ? `agent-browser took longer than ${Math.ceil(deps.navigateTimeoutMs / 1000)} s and was stopped` : "agent-browser failed";
|
|
648
|
+
console.error(`[browser] ${who.userId} could not ${words.failed}: ${why}`);
|
|
649
|
+
return reply(503, { error: "browser-unavailable" });
|
|
650
|
+
}
|
|
651
|
+
console.log(`[browser] ${who.userId} ${words.done}`);
|
|
652
|
+
return reply(200, { ok: true });
|
|
653
|
+
});
|
|
654
|
+
if (!job) return reply(429, { error: "busy" });
|
|
655
|
+
return job;
|
|
656
|
+
}
|
|
657
|
+
|
|
439
658
|
// ---------------------------------------------------------------------------
|
|
440
659
|
// C4: Teach me, start / stop / cancel
|
|
441
660
|
|
|
@@ -501,7 +720,8 @@ function startSweeper({ intervalMs = SWEEP_INTERVAL_MS } = {}) {
|
|
|
501
720
|
return sweepTimer;
|
|
502
721
|
}
|
|
503
722
|
|
|
504
|
-
// Tests swap the clock, the session list, the socket factory, the scheduler
|
|
723
|
+
// Tests swap the clock, the session list, the socket factory, the scheduler, the run lookup, the
|
|
724
|
+
// agent-browser call (execFile), the DNS lookup and the address-bar time limit.
|
|
505
725
|
function _setDeps(overrides = {}) {
|
|
506
726
|
deps = { ...deps, ...overrides };
|
|
507
727
|
}
|
|
@@ -509,24 +729,23 @@ function _reset() {
|
|
|
509
729
|
for (const name of [...sockets.keys()]) closeInputSocket(name);
|
|
510
730
|
queues.clear();
|
|
511
731
|
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
connect: (url) => new WebSocket(url),
|
|
516
|
-
scheduler: null,
|
|
517
|
-
runFor: null,
|
|
518
|
-
};
|
|
732
|
+
reviving = null;
|
|
733
|
+
drive._reset();
|
|
734
|
+
deps = defaultDeps();
|
|
519
735
|
}
|
|
520
736
|
|
|
521
737
|
module.exports = {
|
|
522
738
|
LOCK_TTL_MS,
|
|
523
739
|
MAX_EVENTS,
|
|
740
|
+
NAVIGATE_TIMEOUT_MS,
|
|
524
741
|
SWEEP_INTERVAL_MS,
|
|
525
742
|
checkEvent,
|
|
526
743
|
chatOrigin,
|
|
527
744
|
control,
|
|
745
|
+
fitView,
|
|
528
746
|
handBackLine,
|
|
529
747
|
input,
|
|
748
|
+
navigate,
|
|
530
749
|
release,
|
|
531
750
|
renew,
|
|
532
751
|
startSweeper,
|