jevway 0.1.2 → 0.3.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 +227 -149
- package/SETUP.md +212 -0
- package/dist/browser/downloads.js +103 -0
- package/dist/browser/frames.js +21 -1
- package/dist/browser/snapshot.js +53 -0
- package/dist/browser/tab.js +59 -10
- package/dist/cli/daemon-call.js +78 -0
- package/dist/cli/dispatch.js +76 -0
- package/dist/cli/flags.js +152 -0
- package/dist/cli/help.js +46 -0
- package/dist/cli/inbox-command.js +199 -0
- package/dist/cli/journey-command.js +162 -0
- package/dist/cli/look-command.js +54 -0
- package/dist/cli/main.js +64 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/run-command.js +221 -0
- package/dist/cli/session-commands.js +132 -0
- package/dist/daemon/client.js +175 -0
- package/dist/daemon/paths.js +35 -0
- package/dist/daemon/protocol.js +28 -0
- package/dist/daemon/server.js +339 -0
- package/dist/inbox/inbox-file.js +43 -0
- package/dist/inbox/inbox-registry.js +33 -0
- package/dist/inbox/inbox.js +127 -0
- package/dist/inbox/mail-api.js +52 -0
- package/dist/inbox/message.js +34 -0
- package/dist/jev/replay.js +7 -3
- package/dist/jev/retry.js +29 -0
- package/dist/jev/secrets.js +4 -3
- package/dist/journeys/journey-doc.js +2 -1
- package/dist/journeys/journey-session.js +14 -3
- package/dist/journeys/render-viewer.js +163 -150
- package/dist/mcp/inbox-tools.js +82 -0
- package/dist/mcp/instructions.js +27 -0
- package/dist/mcp/journey-tools.js +117 -0
- package/dist/mcp/results.js +53 -0
- package/dist/mcp/server.js +57 -0
- package/dist/mcp/task-tools.js +213 -0
- package/dist/mcp.js +10 -337
- package/dist/notify/desktop.js +40 -0
- package/dist/notify/person-messages.js +50 -0
- package/dist/output/layout.js +86 -0
- package/dist/output/manifest.js +86 -0
- package/dist/output/run-folder.js +80 -0
- package/dist/recording/describe-step.js +69 -0
- package/dist/{task → recording}/recording-page.js +164 -18
- package/dist/{task → recording}/recording.js +33 -91
- package/dist/recording/run-page.js +155 -0
- package/dist/session/session-service.js +213 -0
- package/dist/setup/atomic-write.js +13 -0
- package/dist/setup/clients.js +180 -0
- package/dist/setup/command-output.js +15 -0
- package/dist/setup/doctor-command.js +16 -7
- package/dist/setup/doctor.js +17 -2
- package/dist/setup/env-file.js +22 -0
- package/dist/setup/setup-command.js +104 -0
- package/dist/setup/setup-wizard.js +216 -0
- package/dist/setup/skill-command.js +124 -0
- package/dist/setup/terminal-prompter.js +51 -0
- package/dist/setup/unified-diff.js +69 -0
- package/dist/task/ask-user.js +182 -0
- package/dist/task/downloads.js +63 -0
- package/dist/task/person-wait.js +104 -0
- package/dist/task/present.js +111 -0
- package/dist/task/run-task.js +332 -32
- package/dist/task/start-task.js +70 -11
- package/dist/task/totp.js +124 -0
- package/dist/task/upload-files.js +237 -5
- package/package.json +10 -8
- package/skill/jevway/SKILL.md +54 -15
- package/skill/jevway-inbox/SKILL.md +61 -0
- package/skill/jevway-inbox/scripts/inbox.mjs +209 -0
- package/INSTALL-FOR-AGENTS.md +0 -138
- package/dist/cli.js +0 -112
package/dist/task/run-task.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { tmpdir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { findBotWall } from '../browser/bot-wall.js';
|
|
2
4
|
import { PAGE_LEVEL_KINDS, StalePage, TargetUnreachable } from '../browser/tab.js';
|
|
3
5
|
import { showsTarget } from '../browser/words.js';
|
|
@@ -9,13 +11,21 @@ import { ANY_HOST, narrowHosts, outsideHosts } from './hosts.js';
|
|
|
9
11
|
import { scrubberFor } from '../jev/secrets.js';
|
|
10
12
|
import { describeFailure, runChecks } from './checks.js';
|
|
11
13
|
import { deepLink, quoteSpans } from './evidence.js';
|
|
12
|
-
import {
|
|
14
|
+
import { writeCall } from '../output/run-folder.js';
|
|
15
|
+
import { Recording } from '../recording/recording.js';
|
|
13
16
|
import { RunStopped, startRunLimit } from './run-limit.js';
|
|
14
|
-
import { checkUploadFiles, fileNames } from './upload-files.js';
|
|
17
|
+
import { checkUploadFiles, fetchUploadUrls, fileNames } from './upload-files.js';
|
|
18
|
+
import { checkTotpSecrets, totpMasks, typingCode } from './totp.js';
|
|
19
|
+
import { downloadNote, keepDownload, sizeWords } from './downloads.js';
|
|
20
|
+
import { isTransientModelError } from '../jev/retry.js';
|
|
21
|
+
import { notificationText, passedText, waitingText } from '../notify/person-messages.js';
|
|
22
|
+
import { ssoProviderOf, stillSigningIn, waitForPersonLoop } from './person-wait.js';
|
|
15
23
|
// How much of the final page's visible text a summary carries: enough to read a list or a settings section, not a whole page.
|
|
16
24
|
const PAGE_TEXT_CHARS = 2500;
|
|
17
25
|
// An agent must know when the text was cut, to read the rest with another call or chrome-devtools.
|
|
18
26
|
export const pageTextFor = (text) => text.length > PAGE_TEXT_CHARS ? `${text.slice(0, PAGE_TEXT_CHARS)}\n…[${text.length - PAGE_TEXT_CHARS} more characters not shown]` : text;
|
|
27
|
+
// The widest end-page screenshot a run folder keeps, in pixels: enough to read, light enough to inline in a result.
|
|
28
|
+
const END_PAGE_WIDTH = 1280;
|
|
19
29
|
// How much of the page title a needs_confirmation reason quotes.
|
|
20
30
|
const CONFIRM_TITLE_CHARS = 80;
|
|
21
31
|
// How much of a page dialog's text a step or a repeat reason quotes.
|
|
@@ -41,8 +51,13 @@ const DEFAULTS = {
|
|
|
41
51
|
hosts: ANY_HOST,
|
|
42
52
|
// Prior art (jkudish/jev-browser) waits 8 s; npm's headless challenge never cleared in 50 s, so this bounds patience.
|
|
43
53
|
wallWaitMs: 8000,
|
|
54
|
+
// A task built without the runner never waits for a person; the runner sets the call's wait (default 120 s when visible).
|
|
55
|
+
waitForPersonSeconds: 0,
|
|
44
56
|
checks: [],
|
|
45
57
|
};
|
|
58
|
+
// While waiting for a person: how often the page is read (a full observe, so not every 250 ms) and how often the countdown is told.
|
|
59
|
+
const PERSON_POLL_MS = 1000;
|
|
60
|
+
const PERSON_TICK_MS = 10_000;
|
|
46
61
|
// How often a bot check is looked at again while it gets the chance to clear itself.
|
|
47
62
|
const WALL_POLL_MS = 250;
|
|
48
63
|
// How often a page that has not reacted yet is looked at again while waiting for it to change.
|
|
@@ -104,7 +119,6 @@ const ESCAPE = { id: 'press_escape', kind: 'escape', label: 'Press Escape to clo
|
|
|
104
119
|
// Boxes on small targets land a row off (13 px checkboxes); pad before asking whether one covers the chosen control.
|
|
105
120
|
const BOX_PADDING = 16;
|
|
106
121
|
// A model call that fails like this (5xx, 499 "unexpected error", rate limit, network, timeout) is retried once; any other error is final.
|
|
107
|
-
const TRANSIENT_MODEL_ERROR = /^(5\d\d|429|499)\b|timeout|timed out|ECONNRESET|ETIMEDOUT|fetch failed|socket hang up/i;
|
|
108
122
|
// Pause before that one retry, so a rate limit or a restarting upstream has a moment to clear.
|
|
109
123
|
const MODEL_RETRY_MS = 800;
|
|
110
124
|
// Stands in for the page in a summary when the very first observe fails.
|
|
@@ -133,6 +147,8 @@ const BLANK_PAGE = {
|
|
|
133
147
|
export class BrowserTask {
|
|
134
148
|
trace = [];
|
|
135
149
|
#options;
|
|
150
|
+
/** Whether a person can see the browser and how to tell them; fixed when the session starts. */
|
|
151
|
+
#person;
|
|
136
152
|
#history = [];
|
|
137
153
|
#latencies = [];
|
|
138
154
|
#inputTokens = 0;
|
|
@@ -183,6 +199,10 @@ export class BrowserTask {
|
|
|
183
199
|
#toggled = new Map();
|
|
184
200
|
/** Nodes of fields the model chose with no supplied value to type: each is refused once before the caller is asked. */
|
|
185
201
|
#refusedForValue = new Set();
|
|
202
|
+
/** The caller's authenticator secrets by key: only `decide` (as a marker) and the typing see them. */
|
|
203
|
+
#totp = new Map();
|
|
204
|
+
/** Secrets that are not supplied values, by key: each authenticator secret as given and as base32, every code typed. Kept for the session. */
|
|
205
|
+
#moreSecrets = [];
|
|
186
206
|
/** Trace entries before this index are already scrubbed of secret values. */
|
|
187
207
|
#traceScrubbed = 0;
|
|
188
208
|
/** The page each decision was made on: a `done_when` score counts for the caller's checks only on the page it judged. */
|
|
@@ -199,15 +219,36 @@ export class BrowserTask {
|
|
|
199
219
|
#tabs;
|
|
200
220
|
#ask;
|
|
201
221
|
#recording;
|
|
222
|
+
/** The run folder each call writes to, every call's summary so far, and what the last call left there. */
|
|
223
|
+
#output;
|
|
224
|
+
#summaries = [];
|
|
225
|
+
#lastCall;
|
|
226
|
+
/** This call's step listener, and how many of the call's steps it was told. */
|
|
227
|
+
#onStep;
|
|
228
|
+
#stepsTold = 0;
|
|
229
|
+
#onWait;
|
|
202
230
|
/** This call's stop signal (caller cancel or time budget); set while a call runs. */
|
|
203
231
|
#limit;
|
|
204
|
-
|
|
232
|
+
/** URL files this session fetched, by URL: a later call reuses the file. */
|
|
233
|
+
#fetchedUploads = new Map();
|
|
234
|
+
/** Downloads already reported, by guid, and what this call's steps downloaded, for the page text Jev reads. */
|
|
235
|
+
#downloadsSeen = new Set();
|
|
236
|
+
#downloadedThisCall = [];
|
|
237
|
+
constructor(tab, ask, { recordDir, output, totp, person, ...options }) {
|
|
205
238
|
this.#tab = tab;
|
|
239
|
+
this.#person = person ?? { visible: true };
|
|
206
240
|
this.#tabs = [tab];
|
|
207
241
|
this.#ask = ask;
|
|
242
|
+
const authenticators = checkTotpSecrets(totp, options.values);
|
|
208
243
|
this.#options = { ...DEFAULTS, values: {}, secretKeys: [], ...definedOnly(options), files: checkUploadFiles(options.files) };
|
|
209
|
-
|
|
210
|
-
|
|
244
|
+
this.#takeTotp(authenticators);
|
|
245
|
+
this.#output = output ?? (recordDir ? { dir: recordDir, record: true } : undefined);
|
|
246
|
+
if (this.#output?.record)
|
|
247
|
+
this.#recording = new Recording(this.#output.dir, (text) => this.#scrub(text));
|
|
248
|
+
}
|
|
249
|
+
/** What the last call left in the run folder, and its end page as JPEG bytes (absent when the capture failed); none without `output`. */
|
|
250
|
+
get lastCall() {
|
|
251
|
+
return this.#lastCall;
|
|
211
252
|
}
|
|
212
253
|
/** The model a call's requests went to, and what its input tokens cost at that model's published rate. */
|
|
213
254
|
#costOf(inputTokens) {
|
|
@@ -216,7 +257,7 @@ export class BrowserTask {
|
|
|
216
257
|
}
|
|
217
258
|
/** Replaces every value the caller marked secret with its marker, in a string or a plain object's strings. */
|
|
218
259
|
#scrub(value) {
|
|
219
|
-
return scrubberFor(this.#options.values, this.#options.secretKeys)(value);
|
|
260
|
+
return scrubberFor(this.#options.values, this.#options.secretKeys, this.#moreSecrets)(value);
|
|
220
261
|
}
|
|
221
262
|
// jevway: a page is scrubbed of secrets as soon as the run reads it, so every label, value, history line, page
|
|
222
263
|
// message and reason is built from scrubbed text before anything cuts it short (a cut secret no longer matches the
|
|
@@ -226,7 +267,7 @@ export class BrowserTask {
|
|
|
226
267
|
const page = await this.#tab.observe();
|
|
227
268
|
if (!this.#options.secretKeys.length)
|
|
228
269
|
return page;
|
|
229
|
-
const scrub = scrubberFor(this.#options.values, this.#options.secretKeys);
|
|
270
|
+
const scrub = scrubberFor(this.#options.values, this.#options.secretKeys, this.#moreSecrets);
|
|
230
271
|
return {
|
|
231
272
|
...page,
|
|
232
273
|
url: scrub(page.url),
|
|
@@ -241,6 +282,11 @@ export class BrowserTask {
|
|
|
241
282
|
})),
|
|
242
283
|
};
|
|
243
284
|
}
|
|
285
|
+
/** Reads the session's tab with no model call and no input, its secret values already masked: what `jevway_look` shows. With `pdf`, the page as a PDF too. */
|
|
286
|
+
async look({ pdf = false } = {}) {
|
|
287
|
+
const page = await this.#observe();
|
|
288
|
+
return pdf ? { ...page, pdf: await this.#tab.pdf() } : page;
|
|
289
|
+
}
|
|
244
290
|
/** The tab the session is on now: a tab its clicks opened, once followed, or the one it started on. */
|
|
245
291
|
get tab() {
|
|
246
292
|
return this.#tab;
|
|
@@ -292,14 +338,18 @@ export class BrowserTask {
|
|
|
292
338
|
}
|
|
293
339
|
/** Merge new values, a new goal, narrower hosts (a widening list throws), or a one-time confirmation for the next irreversible click. */
|
|
294
340
|
update(patch) {
|
|
295
|
-
const { confirmIrreversible, confirmTarget, values, secretKeys, files, recordDir: _recordDir, allowedHosts, blockedHosts, ...options } = patch;
|
|
296
|
-
// First, so a list that would widen the hosts throws before anything else changes.
|
|
341
|
+
const { confirmIrreversible, confirmTarget, values, secretKeys, files, totp, recordDir: _recordDir, output: _output, person: _person, allowedHosts, blockedHosts, ...options } = patch;
|
|
342
|
+
// First, so a list that would widen the hosts, or a bad authenticator secret, throws before anything else changes.
|
|
297
343
|
const hosts = narrowHosts(this.#options.hosts, { allowed: allowedHosts, blocked: blockedHosts });
|
|
344
|
+
const authenticators = checkTotpSecrets(totp, values);
|
|
345
|
+
const newGoal = options.goal !== undefined && options.goal !== this.#options.goal;
|
|
298
346
|
// A new goal may be to flip back what the old one flipped.
|
|
299
|
-
if (
|
|
347
|
+
if (newGoal)
|
|
300
348
|
this.#toggled.clear();
|
|
301
349
|
this.#options = {
|
|
302
350
|
...this.#options,
|
|
351
|
+
// The old goal's done_when may already hold, and would end the new goal before it starts.
|
|
352
|
+
...(newGoal && options.doneWhen === undefined && { doneWhen: undefined }),
|
|
303
353
|
...definedOnly(options),
|
|
304
354
|
hosts,
|
|
305
355
|
values: { ...this.#options.values, ...values },
|
|
@@ -307,6 +357,7 @@ export class BrowserTask {
|
|
|
307
357
|
secretKeys: [...new Set([...this.#options.secretKeys, ...(secretKeys ?? [])])],
|
|
308
358
|
files: { ...this.#options.files, ...checkUploadFiles(files) },
|
|
309
359
|
};
|
|
360
|
+
this.#takeTotp(authenticators, values);
|
|
310
361
|
if (!confirmIrreversible)
|
|
311
362
|
return;
|
|
312
363
|
// A confirmation answers the question jevway asked, so a flag carried onto a later call allows nothing (an unused earlier one stands).
|
|
@@ -318,11 +369,41 @@ export class BrowserTask {
|
|
|
318
369
|
else if (!this.#confirmedCommit)
|
|
319
370
|
this.#confirmIgnored = this.#lastStatus;
|
|
320
371
|
}
|
|
372
|
+
// The newest plain value or authenticator secret for a key replaces the older one; either stays secret for the session.
|
|
373
|
+
#takeTotp(authenticators, values = {}) {
|
|
374
|
+
for (const key of Object.keys(values))
|
|
375
|
+
this.#totp.delete(key);
|
|
376
|
+
for (const [key, secret] of authenticators)
|
|
377
|
+
this.#totp.set(key, secret);
|
|
378
|
+
this.#moreSecrets.push(...totpMasks(authenticators));
|
|
379
|
+
if (!authenticators.size)
|
|
380
|
+
return;
|
|
381
|
+
this.#options.values = Object.fromEntries(Object.entries(this.#options.values).filter(([key]) => !authenticators.has(key)));
|
|
382
|
+
this.#options.secretKeys = [...new Set([...this.#options.secretKeys, ...authenticators.keys()])];
|
|
383
|
+
}
|
|
384
|
+
// An authenticator key types the code current now (the next one when its period is about to end), masked before it is typed.
|
|
385
|
+
async #textFor(key, values) {
|
|
386
|
+
const secret = this.#totp.get(key);
|
|
387
|
+
if (!secret)
|
|
388
|
+
return values[key];
|
|
389
|
+
const code = await typingCode(secret, { now: Date.now, pause: (ms) => this.#limit?.pause(ms) ?? Promise.resolve() });
|
|
390
|
+
this.#moreSecrets.push([key, code]);
|
|
391
|
+
return code;
|
|
392
|
+
}
|
|
321
393
|
/**
|
|
322
394
|
* Runs until done or a hand-back. `signal` is the caller's cancel (the MCP request's): the run stops at its next
|
|
323
|
-
* check, `blocked` "Cancelled by the caller"; an input already under way finishes first.
|
|
395
|
+
* check, `blocked` "Cancelled by the caller"; an input already under way finishes first. `onStep` hears each step of
|
|
396
|
+
* this call (numbered from 1) once the run moves past it, with the page's URL then; `onWait` the countdown while it
|
|
397
|
+
* waits for a person at a wall. A listener that throws is ignored.
|
|
324
398
|
*/
|
|
325
|
-
async run({ signal } = {}) {
|
|
399
|
+
async run({ signal, onStep, onWait } = {}) {
|
|
400
|
+
// URL files are fetched before the run reads a page; a refused one fails the call naming its key.
|
|
401
|
+
const uploads = this.#output ? join(this.#output.dir, 'uploads') : join(tmpdir(), `jevway-uploads-${process.pid}`);
|
|
402
|
+
this.#options.files = await fetchUploadUrls(this.#options.files, { dir: uploads, hosts: this.#options.hosts, known: this.#fetchedUploads, signal });
|
|
403
|
+
this.#downloadedThisCall = [];
|
|
404
|
+
this.#onStep = onStep;
|
|
405
|
+
this.#onWait = onWait;
|
|
406
|
+
this.#stepsTold = 0;
|
|
326
407
|
// The recording is for people: a browser failure while recording must not change or hang the run's outcome.
|
|
327
408
|
await this.#recording?.start(this.#tab).catch(() => undefined);
|
|
328
409
|
let settled;
|
|
@@ -340,9 +421,100 @@ export class BrowserTask {
|
|
|
340
421
|
for (; this.#traceScrubbed < this.trace.length; this.#traceScrubbed++)
|
|
341
422
|
this.trace[this.#traceScrubbed] = this.#scrub(this.trace[this.#traceScrubbed]);
|
|
342
423
|
const summary = this.#scrub(settled);
|
|
343
|
-
|
|
424
|
+
this.#onStep = undefined;
|
|
425
|
+
this.#onWait = undefined;
|
|
426
|
+
await this.#recording?.finish(this.#tab, summary, { goal: this.#options.goal, doneWhen: this.#options.doneWhen }).catch(() => undefined);
|
|
427
|
+
if (this.#output)
|
|
428
|
+
await this.#writeCall(this.#output.dir, summary);
|
|
344
429
|
return summary;
|
|
345
430
|
}
|
|
431
|
+
// What a call leaves in the run folder; like the recording, it never changes or hangs the run's outcome.
|
|
432
|
+
async #writeCall(dir, summary) {
|
|
433
|
+
this.#summaries.push(summary);
|
|
434
|
+
const screenshot = await this.#endPage().catch(() => undefined);
|
|
435
|
+
try {
|
|
436
|
+
const files = writeCall({ dir, call: this.#summaries.length, status: summary.status, shot: screenshot, summaries: this.#summaries, recorded: Boolean(this.#recording) });
|
|
437
|
+
this.#lastCall = { files, ...(screenshot && { screenshot }) };
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
this.#lastCall = undefined;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** The tab's viewport as a light JPEG for agents: quality 70, at most 1280 pixels wide. Pixels: a secret typed into a visible field shows. */
|
|
444
|
+
async #endPage() {
|
|
445
|
+
const ratio = Number(await this.#tab.evaluate('devicePixelRatio')) || 1;
|
|
446
|
+
const width = this.#ending?.page.w || END_PAGE_WIDTH;
|
|
447
|
+
const scale = Math.min(1, END_PAGE_WIDTH / (width * ratio));
|
|
448
|
+
return Buffer.from(await this.#tab.screenshot({ format: 'jpeg', quality: 70, scale }), 'base64');
|
|
449
|
+
}
|
|
450
|
+
/** Tells the wait listener the countdown; like a step listener, it only hears. */
|
|
451
|
+
#tellWait(message, secondsLeft, url) {
|
|
452
|
+
try {
|
|
453
|
+
this.#onWait?.({ message: this.#scrub(message), seconds_left: secondsLeft }, this.#scrub(url));
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// The caller's progress line never decides how the run goes.
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// jevway: with the browser in view, a person can get past a wall the run never touches. The tab comes forward, one
|
|
460
|
+
// notification says what to do, and the page is read (no model, no input) until the wall is gone or the time is up.
|
|
461
|
+
async #waitForPerson(wall, page, { isWall, clearReads, limit }) {
|
|
462
|
+
const waitMs = this.#person.visible ? this.#options.waitForPersonSeconds * 1000 : 0;
|
|
463
|
+
if (waitMs <= 0 || limit.stopped())
|
|
464
|
+
return { outcome: 'off', page };
|
|
465
|
+
const tab = this.#tab;
|
|
466
|
+
for (const [method, params] of [['Page.bringToFront', {}], ['Target.activateTarget', { targetId: tab.targetId }]]) {
|
|
467
|
+
try {
|
|
468
|
+
await tab.send(method, params);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// Best effort: an old Chrome or a closed window still gets the notification.
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
this.#person.notify?.(notificationText(wall));
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// A notifier only hears.
|
|
479
|
+
}
|
|
480
|
+
const { outcome, page: read } = await waitForPersonLoop({
|
|
481
|
+
waitMs,
|
|
482
|
+
pollMs: this.#person.pollMs ?? PERSON_POLL_MS,
|
|
483
|
+
tickMs: this.#person.tickMs ?? PERSON_TICK_MS,
|
|
484
|
+
clearReads,
|
|
485
|
+
read: async () => {
|
|
486
|
+
// A tab the page opens meanwhile is the person's (a "Sign in with Google" popup): never followed.
|
|
487
|
+
tab.takeOpened();
|
|
488
|
+
if (tab.closed)
|
|
489
|
+
return undefined;
|
|
490
|
+
return this.#observe().catch((error) => {
|
|
491
|
+
if (error instanceof StalePage)
|
|
492
|
+
return page;
|
|
493
|
+
throw error;
|
|
494
|
+
});
|
|
495
|
+
},
|
|
496
|
+
isWall,
|
|
497
|
+
pause: limit.pause,
|
|
498
|
+
stopped: () => Boolean(limit.stopped()),
|
|
499
|
+
tell: (secondsLeft) => this.#tellWait(waitingText(wall, secondsLeft), secondsLeft, page.url),
|
|
500
|
+
});
|
|
501
|
+
if (outcome === 'passed')
|
|
502
|
+
this.#history.push({ operation: 'WAIT_FOR_PERSON', element: passedText(wall), page_changed: true, confidence: 1 });
|
|
503
|
+
return { outcome, page: read ?? page };
|
|
504
|
+
}
|
|
505
|
+
/** Tells the step listener about this call's steps it has not heard yet; the page's URL is the one the run is on now. */
|
|
506
|
+
#tellSteps(firstStep, url) {
|
|
507
|
+
if (!this.#onStep)
|
|
508
|
+
return;
|
|
509
|
+
for (; firstStep + this.#stepsTold < this.#history.length; this.#stepsTold++) {
|
|
510
|
+
try {
|
|
511
|
+
this.#onStep(this.#scrub(this.#history[firstStep + this.#stepsTold]), this.#stepsTold + 1, url);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
// A listener is the caller's (progress, a log line): it never decides how the run goes.
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
346
518
|
async #loop(signal) {
|
|
347
519
|
// The page's own scripts can read what it is told to look for, so a secret reaches the page only when typed into its field.
|
|
348
520
|
this.#tab.want(Object.entries(this.#options.values).filter(([key]) => !this.#options.secretKeys.includes(key)).map(([, value]) => value), { files: Object.keys(this.#options.files).length > 0 });
|
|
@@ -370,6 +542,7 @@ export class BrowserTask {
|
|
|
370
542
|
const stop = (status, reason, decision, { overridable = false } = {}) => {
|
|
371
543
|
// A dialog that opened late still belongs on the step that caused it.
|
|
372
544
|
this.#reportDialogs(firstStep);
|
|
545
|
+
this.#tellSteps(firstStep, page.url);
|
|
373
546
|
// A hand-back asks a new question, so a confirmation still pending (a refused one) is spent.
|
|
374
547
|
if (status === 'needs_confirmation')
|
|
375
548
|
this.#spendConfirmation();
|
|
@@ -429,8 +602,8 @@ export class BrowserTask {
|
|
|
429
602
|
const now = await this.#observe().catch(() => undefined);
|
|
430
603
|
if (now && (await findBotWall(this.#tab, now))) {
|
|
431
604
|
page = now;
|
|
432
|
-
const { wall } = await wallAfterWait();
|
|
433
|
-
return wall ? stopAtWall(wall, decision) : undefined;
|
|
605
|
+
const { wall, waited } = await wallAfterWait();
|
|
606
|
+
return wall ? stopAtWall(wall, decision, waited) : undefined;
|
|
434
607
|
}
|
|
435
608
|
}
|
|
436
609
|
return stop('done', reason, decision);
|
|
@@ -446,8 +619,19 @@ export class BrowserTask {
|
|
|
446
619
|
return stop(status, reason, decision, { overridable: true });
|
|
447
620
|
};
|
|
448
621
|
// jevway: a bot check offers nothing to act on, and the run never tries to get past one. It gets wallWaitMs to clear
|
|
449
|
-
// itself (the page read again meanwhile),
|
|
622
|
+
// itself (the page read again meanwhile), then, in a visible browser, a person gets waitForPersonSeconds to pass it;
|
|
623
|
+
// once per call: one that comes back after clearing ends the run at once.
|
|
450
624
|
let wallWaited = false;
|
|
625
|
+
// Each kind of wall waits for a person once per call.
|
|
626
|
+
const personWaited = new Set();
|
|
627
|
+
const waitForPerson = async (wall, isWall, clearReads) => {
|
|
628
|
+
if (personWaited.has(wall.kind))
|
|
629
|
+
return 'off';
|
|
630
|
+
personWaited.add(wall.kind);
|
|
631
|
+
const waited = await this.#waitForPerson(wall, page, { isWall, clearReads, limit });
|
|
632
|
+
page = waited.page;
|
|
633
|
+
return waited.outcome;
|
|
634
|
+
};
|
|
451
635
|
const wallAfterWait = async () => {
|
|
452
636
|
let wall = await findBotWall(this.#tab, page);
|
|
453
637
|
if (!wall)
|
|
@@ -468,14 +652,24 @@ export class BrowserTask {
|
|
|
468
652
|
});
|
|
469
653
|
wall = await findBotWall(this.#tab, page);
|
|
470
654
|
}
|
|
471
|
-
|
|
655
|
+
if (!wall || limit.stopped())
|
|
656
|
+
return { wall, cleared: !wall };
|
|
657
|
+
const outcome = await waitForPerson({ kind: 'bot_check', check: wall.name }, async (now) => Boolean(await findBotWall(this.#tab, now)), 1);
|
|
658
|
+
// A tab that went away is the loop's to handle, as after any step.
|
|
659
|
+
if (outcome === 'passed' || outcome === 'gone')
|
|
660
|
+
return { cleared: true, byPerson: true };
|
|
661
|
+
return { wall, cleared: false, ...(outcome === 'ran_out' && { waited: this.#options.waitForPersonSeconds }) };
|
|
472
662
|
};
|
|
473
|
-
const stopAtWall = (wall, decision) => {
|
|
663
|
+
const stopAtWall = (wall, decision, waited) => {
|
|
474
664
|
// A cancel or the time budget that cut the wait short is the reason the run ended.
|
|
475
665
|
const halt = limit.stopped();
|
|
476
666
|
if (halt)
|
|
477
667
|
return stop(halt.status, halt.reason, decision);
|
|
478
|
-
|
|
668
|
+
const { visible } = this.#person;
|
|
669
|
+
return {
|
|
670
|
+
...stop('blocked', wallReason(wall, { visible, waited }), decision),
|
|
671
|
+
wall: { kind: 'bot_check', origin: originOf(page.url), name: wall.provider, person_can_act: visible, ...(waited !== undefined && { waited_seconds: waited }) },
|
|
672
|
+
};
|
|
479
673
|
};
|
|
480
674
|
// Browser and model errors end the run as `blocked`: the calling agent keeps the session and decides what next.
|
|
481
675
|
try {
|
|
@@ -492,6 +686,7 @@ export class BrowserTask {
|
|
|
492
686
|
const switchedLate = await this.#switchTabs();
|
|
493
687
|
if (switchedLate)
|
|
494
688
|
page = await this.#afterSwitch(switchedLate, firstStep);
|
|
689
|
+
this.#tellSteps(firstStep, page.url);
|
|
495
690
|
// Never a decision on a page outside the hosts: a start page, a redirect or a late navigation that left them ends the run.
|
|
496
691
|
const outside = outsideHosts(page.url, hosts);
|
|
497
692
|
if (outside)
|
|
@@ -500,9 +695,31 @@ export class BrowserTask {
|
|
|
500
695
|
for (const tab of this.#tabs)
|
|
501
696
|
tab.disarmConfirm();
|
|
502
697
|
// Checked before a decision is spent: a challenge page has nothing for Jev to pick.
|
|
503
|
-
const { wall } = await wallAfterWait();
|
|
698
|
+
const { wall, waited, byPerson } = await wallAfterWait();
|
|
504
699
|
if (wall)
|
|
505
|
-
return stopAtWall(wall);
|
|
700
|
+
return stopAtWall(wall, undefined, waited);
|
|
701
|
+
// A person passed it (or its tab went away): the checks above run again on the page now.
|
|
702
|
+
if (byPerson)
|
|
703
|
+
continue;
|
|
704
|
+
// An identity provider's sign-in for another site is the person's: with a visible browser it waits for them.
|
|
705
|
+
const provider = ssoProviderOf(page.url);
|
|
706
|
+
if (provider) {
|
|
707
|
+
const origin = originOf(page.url);
|
|
708
|
+
const outcome = await waitForPerson({ kind: 'sso', provider }, (now) => Boolean(ssoProviderOf(now.url)), 2);
|
|
709
|
+
if (outcome === 'passed' || outcome === 'gone')
|
|
710
|
+
continue;
|
|
711
|
+
if (outcome === 'stopped') {
|
|
712
|
+
const halt = limit.stopped();
|
|
713
|
+
return stop(halt.status, halt.reason);
|
|
714
|
+
}
|
|
715
|
+
if (outcome === 'ran_out') {
|
|
716
|
+
const waited = this.#options.waitForPersonSeconds;
|
|
717
|
+
return {
|
|
718
|
+
...stop('blocked', ssoReason(provider, origin, waited)),
|
|
719
|
+
wall: { kind: 'sign_in', origin, name: provider.toLowerCase(), person_can_act: true, waited_seconds: waited },
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
}
|
|
506
723
|
if (++decisions > maxSteps * 2)
|
|
507
724
|
return stop('step_budget', `Made ${maxSteps * 2} decisions without finishing`);
|
|
508
725
|
let decision;
|
|
@@ -564,9 +781,18 @@ export class BrowserTask {
|
|
|
564
781
|
continue;
|
|
565
782
|
}
|
|
566
783
|
if (signIn) {
|
|
784
|
+
const wallPage = page;
|
|
785
|
+
const outcome = await waitForPerson({ kind: 'sign_in', host: hostOf(page.url) }, (now) => stillSigningIn(now, wallPage), 2);
|
|
786
|
+
if (outcome === 'passed' || outcome === 'gone')
|
|
787
|
+
continue;
|
|
788
|
+
const halt = limit.stopped();
|
|
789
|
+
if (halt)
|
|
790
|
+
return stop(halt.status, halt.reason, decision);
|
|
791
|
+
const { visible } = this.#person;
|
|
792
|
+
const waited = outcome === 'ran_out' ? this.#options.waitForPersonSeconds : undefined;
|
|
567
793
|
return {
|
|
568
|
-
...stop('blocked',
|
|
569
|
-
wall: signIn,
|
|
794
|
+
...stop('blocked', signInReason(signIn.origin, { visible, waited }), decision),
|
|
795
|
+
wall: { ...signIn, person_can_act: visible, ...(waited !== undefined && { waited_seconds: waited }) },
|
|
570
796
|
};
|
|
571
797
|
}
|
|
572
798
|
return stop('blocked', error, decision);
|
|
@@ -686,7 +912,7 @@ export class BrowserTask {
|
|
|
686
912
|
continue;
|
|
687
913
|
}
|
|
688
914
|
const action = decision.action;
|
|
689
|
-
const text = decision.valueKey ?
|
|
915
|
+
const text = decision.valueKey ? await this.#textFor(decision.valueKey, values) : undefined;
|
|
690
916
|
let element;
|
|
691
917
|
// jevway: the dialog a confirmed click opens is the same action asking again; any other dialog still gets the answer that changes nothing.
|
|
692
918
|
if (isRiskyClick(decision) && this.#confirmedCommit)
|
|
@@ -696,6 +922,7 @@ export class BrowserTask {
|
|
|
696
922
|
operation: decision.operation,
|
|
697
923
|
element: action.label,
|
|
698
924
|
valueKey: decision.valueKey ?? undefined,
|
|
925
|
+
fileKey: decision.fileKey ?? undefined,
|
|
699
926
|
option: action.kind === 'select' ? String(action.value) : undefined,
|
|
700
927
|
confidence: round(Math.min(decision.confidence, decision.targetConfidence ?? 1)),
|
|
701
928
|
}, decision.operation === 'CLICK_AT' ? decision.nextClick.rect : action.rect);
|
|
@@ -867,6 +1094,7 @@ export class BrowserTask {
|
|
|
867
1094
|
if (decision.operation === 'CLICK' && decision.via !== 'disclosure' && action.expanded === 'false' && page.actions.some((a) => a.node === action.node && a.expanded === 'true')) {
|
|
868
1095
|
this.#openedByRun.add(action.node);
|
|
869
1096
|
}
|
|
1097
|
+
await this.#reportDownloads(firstStep);
|
|
870
1098
|
const repeatedDialog = this.#reportDialogs(firstStep, decision.operation === 'CLICK' ? { action, path: pathBefore, unsaved: unsavedBefore } : undefined);
|
|
871
1099
|
this.#tab.disarmConfirm();
|
|
872
1100
|
if (repeatedDialog)
|
|
@@ -1038,6 +1266,34 @@ export class BrowserTask {
|
|
|
1038
1266
|
this.#history[last] = { ...this.#history[last], not_executed: saveFirst };
|
|
1039
1267
|
return repeated;
|
|
1040
1268
|
}
|
|
1269
|
+
// jevway: the downloads the last action started, once finished (30 s at most), kept in the run folder and noted on its step.
|
|
1270
|
+
async #reportDownloads(firstStep) {
|
|
1271
|
+
const notes = [];
|
|
1272
|
+
for (const tab of this.#tabs) {
|
|
1273
|
+
for (const download of await tab.downloadsAfterInput()) {
|
|
1274
|
+
// A tab this one opened reports its own downloads, which its opener claimed too.
|
|
1275
|
+
if (this.#downloadsSeen.has(download.guid))
|
|
1276
|
+
continue;
|
|
1277
|
+
this.#downloadsSeen.add(download.guid);
|
|
1278
|
+
const named = { ...download, name: this.#scrub(download.name) };
|
|
1279
|
+
let kept;
|
|
1280
|
+
try {
|
|
1281
|
+
kept = keepDownload(named, this.#output?.dir);
|
|
1282
|
+
}
|
|
1283
|
+
catch {
|
|
1284
|
+
kept = { name: named.name, bytes: named.bytes, state: named.state, ...(named.filePath && { path: named.filePath }) };
|
|
1285
|
+
}
|
|
1286
|
+
notes.push(downloadNote(kept));
|
|
1287
|
+
if (kept.state === 'completed')
|
|
1288
|
+
this.#downloadedThisCall.push(`${kept.name} (${sizeWords(kept.bytes)})`);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
const last = this.#history.length - 1;
|
|
1292
|
+
if (notes.length && last >= firstStep) {
|
|
1293
|
+
const entry = this.#history[last];
|
|
1294
|
+
this.#history[last] = { ...entry, element: `${entry.element} ${notes.map((note) => `(${note})`).join(' ')}` };
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1041
1297
|
// What the run left unsaved on the page a click was made on: the last field typed there, else the control last ticked.
|
|
1042
1298
|
#unsavedOn(path, ticked) {
|
|
1043
1299
|
const typed = this.#typedFields.filter((field) => field.path === path).at(-1);
|
|
@@ -1086,7 +1342,7 @@ export class BrowserTask {
|
|
|
1086
1342
|
return await this.#decide(page);
|
|
1087
1343
|
}
|
|
1088
1344
|
catch (error) {
|
|
1089
|
-
if (!(error instanceof ModelCallError) || !
|
|
1345
|
+
if (!(error instanceof ModelCallError) || !isTransientModelError(error))
|
|
1090
1346
|
throw error;
|
|
1091
1347
|
await (this.#limit?.pause(MODEL_RETRY_MS) ?? new Promise((resolve) => setTimeout(resolve, MODEL_RETRY_MS)));
|
|
1092
1348
|
return this.#decide(page);
|
|
@@ -1125,7 +1381,8 @@ export class BrowserTask {
|
|
|
1125
1381
|
const decision = await decide(ask, {
|
|
1126
1382
|
page: this.#withSaid(this.#withKeys(page)),
|
|
1127
1383
|
goal,
|
|
1128
|
-
|
|
1384
|
+
// An authenticator key is offered like a secret value: Jev sees its marker, never the secret.
|
|
1385
|
+
values: { ...values, ...Object.fromEntries([...this.#totp].map(([key, secret]) => [key, secret.base32])) },
|
|
1129
1386
|
secretKeys,
|
|
1130
1387
|
files: fileNames(this.#options.files),
|
|
1131
1388
|
history: this.#history.slice(-10),
|
|
@@ -1175,12 +1432,17 @@ export class BrowserTask {
|
|
|
1175
1432
|
// jevway: what the page said after the last submit is the first line of the text Jev reads. The done check judges the current
|
|
1176
1433
|
// page's text only, so a "saved" message that scrolled away (or sits above the viewport) is no evidence unless the text carries it.
|
|
1177
1434
|
// It stays while the run is on the path where the message appeared; the caller's page_text is never changed.
|
|
1435
|
+
// A download shows nothing on the page, so what this call downloaded leads the text too.
|
|
1178
1436
|
#withSaid(page) {
|
|
1179
1437
|
if (this.#said && this.#said.path !== pathOf(page.url))
|
|
1180
1438
|
this.#said = undefined;
|
|
1181
|
-
|
|
1439
|
+
const lines = [
|
|
1440
|
+
...(this.#downloadedThisCall.length ? [`[Downloaded this call] ${this.#downloadedThisCall.join(', ')}`] : []),
|
|
1441
|
+
...(this.#said ? [`[Page message after "${this.#said.label}"] ${this.#said.text}`] : []),
|
|
1442
|
+
];
|
|
1443
|
+
if (!lines.length)
|
|
1182
1444
|
return page;
|
|
1183
|
-
return { ...page, text:
|
|
1445
|
+
return { ...page, text: `${lines.join('\n')}\n${page.text}` };
|
|
1184
1446
|
}
|
|
1185
1447
|
// The lines the page now shows that this run never saw on its path. The previous message of a submit doesn't count as seen, so a repeated one is quoted again.
|
|
1186
1448
|
#saidAfter(label, page, textBefore) {
|
|
@@ -1474,12 +1736,25 @@ const controlOf = (decision, page) => ({
|
|
|
1474
1736
|
label: decision.operation === 'CLICK_AT' ? '' : bareLabel(decision.action?.label ?? ''),
|
|
1475
1737
|
coordinate: decision.operation === 'CLICK_AT',
|
|
1476
1738
|
});
|
|
1739
|
+
// How many of a field's choices a needs_input lists, and how much of each: page text, so kept short.
|
|
1740
|
+
const FIELD_CHOICES = 10;
|
|
1741
|
+
const FIELD_CHOICE_CHARS = 80;
|
|
1742
|
+
// The choices the page lists for a field: a select of the same name, or the option rows of its open list (an expanded combobox).
|
|
1743
|
+
const choicesFor = (field, page) => {
|
|
1744
|
+
const prefix = `${field.label} → `;
|
|
1745
|
+
const selectOptions = page.actions.filter((action) => action.kind === 'select' && action.label.startsWith(prefix)).map((action) => action.label.slice(prefix.length));
|
|
1746
|
+
const openRows = field.expanded === 'true' ? page.actions.filter((action) => action.kind === 'click' && action.role === 'option').map((action) => bareLabel(action.label)) : [];
|
|
1747
|
+
return [...new Set([...selectOptions, ...openRows].map((label) => label.trim().slice(0, FIELD_CHOICE_CHARS)).filter(Boolean))].slice(0, FIELD_CHOICES);
|
|
1748
|
+
};
|
|
1477
1749
|
// The fields a needs_input wants: the one Jev chose to type into, or every empty field a BLOCKED found no value for, in page order.
|
|
1478
1750
|
const missingFields = (decision, page) => {
|
|
1479
1751
|
const fields = decision.operation === 'TYPE_TEXT' && decision.action
|
|
1480
1752
|
? [decision.action]
|
|
1481
1753
|
: decision.fieldsWithoutValue.map((label) => page.actions.find((action) => action.kind === 'fill' && action.label === label) ?? { label });
|
|
1482
|
-
return fields.map((field) =>
|
|
1754
|
+
return fields.map((field) => {
|
|
1755
|
+
const options = choicesFor(field, page);
|
|
1756
|
+
return { label: bareLabel(field.label), ...(field.role && { role: field.role }), path: pathnameOf(page.url), ...(options.length && { options }) };
|
|
1757
|
+
});
|
|
1483
1758
|
};
|
|
1484
1759
|
const sameControl = (a, b) => a.path === b.path && a.label === b.label && a.coordinate === b.coordinate;
|
|
1485
1760
|
const pathnameOf = (url) => {
|
|
@@ -1601,7 +1876,32 @@ const round = (value) => Math.round(value * 1000) / 1000;
|
|
|
1601
1876
|
class ModelCallError extends Error {
|
|
1602
1877
|
}
|
|
1603
1878
|
const messageOf = (error) => (error instanceof Error ? error.message : String(error));
|
|
1604
|
-
// The hand-back for a bot wall: what it is, what made it one, and who can pass it.
|
|
1605
|
-
const wallReason = (wall
|
|
1879
|
+
// The hand-back for a bot wall: what it is, what made it one, whether a person was waited for, and who can pass it.
|
|
1880
|
+
const wallReason = (wall, { visible, waited }) => {
|
|
1881
|
+
const seen = wall.evidence.map((e) => (e.startsWith('title ') ? e : `"${e}"`)).join(', ');
|
|
1882
|
+
const wait = waited === undefined ? '' : `, and nobody passed it in the ${waited} s jevway waited with the tab in front`;
|
|
1883
|
+
const who = visible
|
|
1884
|
+
? 'A person can pass it in this tab, then continue the session.'
|
|
1885
|
+
: 'This browser is headless, so nobody can pass it here: a visible browser is needed (JEVWAY_BROWSER=auto or launch).';
|
|
1886
|
+
return `The page is a bot check (${wall.name}; seen: ${seen}) that did not clear by itself${wait}; jevway never tries to get past one. ${who}`;
|
|
1887
|
+
};
|
|
1888
|
+
// The hand-back for a sign-in wall no supplied value passes.
|
|
1889
|
+
const signInReason = (origin, { visible, waited }) => {
|
|
1890
|
+
const wait = waited === undefined ? '' : `, and nobody signed in during the ${waited} s jevway waited with the tab in front`;
|
|
1891
|
+
const what = visible
|
|
1892
|
+
? 'sign in there (or pass the values), then call again.'
|
|
1893
|
+
: 'pass the values, or sign in with a visible browser (JEVWAY_BROWSER=auto or launch; this one is headless), then call again.';
|
|
1894
|
+
return `The page asks to sign in at ${origin} and no supplied value fits its fields${wait}; ${what}`;
|
|
1895
|
+
};
|
|
1896
|
+
// The hand-back for an SSO sign-in nobody finished while the run waited.
|
|
1897
|
+
const ssoReason = (provider, origin, waited) => `The page asks to sign in with ${provider} at ${origin} for another site, and nobody finished it during the ${waited} s jevway waited with the tab in front; sign in there, then call again.`;
|
|
1898
|
+
const hostOf = (url) => {
|
|
1899
|
+
try {
|
|
1900
|
+
return new URL(url).host;
|
|
1901
|
+
}
|
|
1902
|
+
catch {
|
|
1903
|
+
return url;
|
|
1904
|
+
}
|
|
1905
|
+
};
|
|
1606
1906
|
// Anything that is not a model error came from the tab (CDP, page never settling, an unconfirmed action).
|
|
1607
1907
|
const describeError = (error) => `${error instanceof ModelCallError ? 'Model' : 'Browser'} error: ${messageOf(error).slice(0, 200)}`;
|