@proagentstore/cli 0.1.18 → 0.1.19
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/dist/browser-runner/browser-actions.js +177 -0
- package/dist/browser-runner/browser-profile.js +78 -0
- package/dist/browser-runner/challenge.js +86 -0
- package/dist/browser-runner/errors.js +13 -0
- package/dist/browser-runner/human-mouse.js +71 -0
- package/dist/browser-runner/runner.js +566 -430
- package/dist/browser-runner/server.js +56 -0
- package/dist/browser-runner/store.js +24 -0
- package/dist/browser-runner/test-job-server.js +29 -2
- package/dist/index.js +68 -37
- package/package.json +1 -1
|
@@ -1,30 +1,58 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { captureScreenshotDataUrl, challengeSolved, detectHumanChallenge } from "./challenge.js";
|
|
7
|
+
import { resolveRealChromeProfileDir, seedProfileCopy } from "./browser-profile.js";
|
|
8
|
+
import { performBrowserAction } from "./browser-actions.js";
|
|
9
|
+
import { HumanHandoffError, RunnerInputError } from "./errors.js";
|
|
6
10
|
import { RunnerStore } from "./store.js";
|
|
11
|
+
/** True for a plain object. */
|
|
12
|
+
function isRecord(value) {
|
|
13
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
/** Coerce a value to a trimmed string ("" for non-strings). */
|
|
16
|
+
function stringValue(value) {
|
|
17
|
+
return typeof value === "string" ? value.trim() : "";
|
|
18
|
+
}
|
|
19
|
+
export { HumanHandoffError, RunnerInputError } from "./errors.js";
|
|
7
20
|
const CAPABILITIES = [
|
|
8
21
|
"browser.playwright",
|
|
9
22
|
"browser.screenshot",
|
|
10
23
|
"downloads",
|
|
11
24
|
"file.upload",
|
|
12
25
|
"human.approval",
|
|
26
|
+
"human.takeover",
|
|
13
27
|
];
|
|
14
|
-
|
|
28
|
+
/** How many times a task may attempt an action before handing off to a human. */
|
|
29
|
+
export const MAX_AUTONOMOUS_ATTEMPTS = 3;
|
|
30
|
+
const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]);
|
|
15
31
|
const require = createRequire(import.meta.url);
|
|
16
|
-
export class RunnerInputError extends Error {
|
|
17
|
-
status = 400;
|
|
18
|
-
}
|
|
19
32
|
export class LocalRunner {
|
|
20
33
|
config;
|
|
21
34
|
browserContext = null;
|
|
22
35
|
chromiumInstallChecked = false;
|
|
36
|
+
/**
|
|
37
|
+
* The single page the brain and the human takeover both act on. Tracked so
|
|
38
|
+
* that when an ATS opens a new tab/popup, the active page follows it — the
|
|
39
|
+
* remote brain and the human never diverge onto different pages.
|
|
40
|
+
*/
|
|
41
|
+
activePage = null;
|
|
42
|
+
fileAttachArmed = new WeakSet();
|
|
43
|
+
applyResumePath = null;
|
|
23
44
|
store;
|
|
45
|
+
/** Live human-takeover sessions, keyed by task id (the page is kept alive). */
|
|
46
|
+
takeovers = new Map();
|
|
24
47
|
constructor(config) {
|
|
25
48
|
this.config = config;
|
|
26
49
|
mkdirSync(config.dataDir, { recursive: true });
|
|
27
50
|
this.store = new RunnerStore(config.dataDir);
|
|
51
|
+
// Tasks paused/running on a previous process are orphaned now — their
|
|
52
|
+
// pages and takeover sessions are gone. Fail them so the board is clean.
|
|
53
|
+
const expired = this.store.expireInFlightTasks();
|
|
54
|
+
if (expired > 0)
|
|
55
|
+
console.log(`[runner] expired ${expired} orphaned in-flight task(s) from a previous session`);
|
|
28
56
|
}
|
|
29
57
|
capabilities() {
|
|
30
58
|
return {
|
|
@@ -34,7 +62,7 @@ export class LocalRunner {
|
|
|
34
62
|
runtimePlane: "fags",
|
|
35
63
|
runnerRole: "tool-executor",
|
|
36
64
|
capabilities: CAPABILITIES,
|
|
37
|
-
taskTypes: ["echo", "browser.open", "job.
|
|
65
|
+
taskTypes: ["echo", "browser.open", "job.apply_agent"],
|
|
38
66
|
approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
|
|
39
67
|
};
|
|
40
68
|
}
|
|
@@ -42,6 +70,23 @@ export class LocalRunner {
|
|
|
42
70
|
const normalized = normalizeCreateTaskRequest(request);
|
|
43
71
|
validateTaskInput(normalized.type, normalized.input);
|
|
44
72
|
const now = new Date().toISOString();
|
|
73
|
+
// Agent-driven applications are steered by the remote Workflow brain via the
|
|
74
|
+
// /browser/* endpoints — the runner never auto-executes them. The task exists
|
|
75
|
+
// for the console board, the activity trace, and takeover keying.
|
|
76
|
+
if (normalized.type === "job.apply_agent") {
|
|
77
|
+
const task = {
|
|
78
|
+
id: `task_${crypto.randomUUID()}`,
|
|
79
|
+
type: normalized.type,
|
|
80
|
+
status: "running",
|
|
81
|
+
input: normalized.input,
|
|
82
|
+
requiresApproval: false,
|
|
83
|
+
createdAt: now,
|
|
84
|
+
updatedAt: now,
|
|
85
|
+
};
|
|
86
|
+
this.store.putTask(task);
|
|
87
|
+
this.addTaskEvent(task, "task.created", "Job application started (agent-driven)", { status: "running", url: normalized.input.url });
|
|
88
|
+
return task;
|
|
89
|
+
}
|
|
45
90
|
const requiresApproval = normalized.requiresApproval || APPROVAL_REQUIRED_TASKS.has(normalized.type);
|
|
46
91
|
const task = {
|
|
47
92
|
id: `task_${crypto.randomUUID()}`,
|
|
@@ -90,6 +135,8 @@ export class LocalRunner {
|
|
|
90
135
|
task.completedAt = task.updatedAt;
|
|
91
136
|
this.store.putTask(task);
|
|
92
137
|
this.addTaskEvent(task, "task.cancelled", `Task cancelled: ${task.type}`);
|
|
138
|
+
// Tear down any live takeover session for this task (free the page + CDP).
|
|
139
|
+
void this.endTakeover(id).catch(() => undefined);
|
|
93
140
|
return task;
|
|
94
141
|
}
|
|
95
142
|
async close() {
|
|
@@ -114,6 +161,16 @@ export class LocalRunner {
|
|
|
114
161
|
this.addTaskEvent(task, "task.completed", `Task completed: ${task.type}`, output);
|
|
115
162
|
}
|
|
116
163
|
catch (error) {
|
|
164
|
+
if (error instanceof HumanHandoffError) {
|
|
165
|
+
// Not a failure — the autonomous attempt is paused for a human to
|
|
166
|
+
// take over (solve a challenge, finish a step the model can't).
|
|
167
|
+
task.status = "needs_human";
|
|
168
|
+
task.error = error.message;
|
|
169
|
+
task.updatedAt = new Date().toISOString();
|
|
170
|
+
this.store.putTask(task);
|
|
171
|
+
this.addTaskEvent(task, "job.human_handoff_required", error.message, error.handoff);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
117
174
|
task.status = "failed";
|
|
118
175
|
task.error = error instanceof Error ? error.message : String(error);
|
|
119
176
|
task.updatedAt = new Date().toISOString();
|
|
@@ -130,6 +187,222 @@ export class LocalRunner {
|
|
|
130
187
|
data,
|
|
131
188
|
});
|
|
132
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Before submitting, bail to a human if the page shows an anti-bot challenge.
|
|
192
|
+
* The model can't solve CAPTCHAs, so retrying is pointless — hand off instead.
|
|
193
|
+
*/
|
|
194
|
+
async guardHumanChallenge(task, page) {
|
|
195
|
+
const challenge = await detectHumanChallenge(page);
|
|
196
|
+
if (!challenge)
|
|
197
|
+
return;
|
|
198
|
+
this.addTaskEvent(task, "job.human_challenge_detected", `Human challenge detected: ${challenge}`, {
|
|
199
|
+
challengeType: challenge,
|
|
200
|
+
url: page.url(),
|
|
201
|
+
});
|
|
202
|
+
const screenshotBase64 = await captureScreenshotDataUrl(page);
|
|
203
|
+
// Keep this page alive and register a remote-control session so a human
|
|
204
|
+
// can view the screen and solve the challenge from the PAGS console.
|
|
205
|
+
this.takeovers.set(task.id, { page });
|
|
206
|
+
throw new HumanHandoffError(`Human verification required (${challenge}). A person must take over to solve it.`, { reason: "challenge", challengeType: challenge, url: page.url(), attempts: 1, screenshotBase64 });
|
|
207
|
+
}
|
|
208
|
+
/** Active human-takeover task ids (for status/discovery). */
|
|
209
|
+
listTakeovers() {
|
|
210
|
+
return [...this.takeovers.keys()];
|
|
211
|
+
}
|
|
212
|
+
requireTakeover(taskId) {
|
|
213
|
+
const session = this.takeovers.get(taskId);
|
|
214
|
+
if (!session) {
|
|
215
|
+
throw new RunnerInputError("No active human-takeover session for this task");
|
|
216
|
+
}
|
|
217
|
+
return session;
|
|
218
|
+
}
|
|
219
|
+
/** Begin a continuous CDP screencast for a takeover (no viewport disruption). */
|
|
220
|
+
async ensureScreencast(taskId) {
|
|
221
|
+
const session = this.requireTakeover(taskId);
|
|
222
|
+
if (!session.cdp) {
|
|
223
|
+
session.cdp = await session.page.context().newCDPSession(session.page);
|
|
224
|
+
}
|
|
225
|
+
if (session.screencasting)
|
|
226
|
+
return;
|
|
227
|
+
session.screencasting = true;
|
|
228
|
+
const cdp = session.cdp;
|
|
229
|
+
cdp.on("Page.screencastFrame", (params) => {
|
|
230
|
+
session.latestFrame = params.data;
|
|
231
|
+
if (params.metadata?.deviceWidth)
|
|
232
|
+
session.width = Math.round(params.metadata.deviceWidth);
|
|
233
|
+
if (params.metadata?.deviceHeight)
|
|
234
|
+
session.height = Math.round(params.metadata.deviceHeight);
|
|
235
|
+
cdp.send("Page.screencastFrameAck", { sessionId: params.sessionId }).catch(() => undefined);
|
|
236
|
+
});
|
|
237
|
+
await cdp.send("Page.startScreencast", { format: "jpeg", quality: 50, everyNthFrame: 1 });
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The latest screencast frame of the taken-over page plus its CSS viewport
|
|
241
|
+
* size. Uses a continuous CDP screencast (not page.screenshot, which forces
|
|
242
|
+
* a viewport metrics override and makes the real window flicker/zoom).
|
|
243
|
+
*/
|
|
244
|
+
async takeoverFrame(taskId) {
|
|
245
|
+
const session = this.requireTakeover(taskId);
|
|
246
|
+
await this.ensureScreencast(taskId);
|
|
247
|
+
// Until the first screencast frame arrives, fall back to a single shot.
|
|
248
|
+
if (!session.latestFrame) {
|
|
249
|
+
const buf = await session.page.screenshot({ type: "jpeg", quality: 55 }).catch(() => null);
|
|
250
|
+
if (buf)
|
|
251
|
+
session.latestFrame = buf.toString("base64");
|
|
252
|
+
const viewport = await session.page
|
|
253
|
+
.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight }))
|
|
254
|
+
.catch(() => ({ width: 0, height: 0 }));
|
|
255
|
+
session.width = session.width || viewport.width;
|
|
256
|
+
session.height = session.height || viewport.height;
|
|
257
|
+
}
|
|
258
|
+
// Report the page's real CSS viewport — that is exactly the coordinate
|
|
259
|
+
// space CDP Input expects, so a relayed click lands precisely on target on
|
|
260
|
+
// any display (scaled / retina included). Screencast metadata can diverge
|
|
261
|
+
// from innerWidth on fractional-scale displays; innerWidth never lies.
|
|
262
|
+
// Re-read the CSS viewport EVERY frame — the real-profile Chrome window can be
|
|
263
|
+
// resized/zoomed/maximized after takeover starts, and a cached value offsets
|
|
264
|
+
// every relayed click (the captcha box is small, so even a few px misses).
|
|
265
|
+
const vp = await session.page
|
|
266
|
+
.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight }))
|
|
267
|
+
.catch(() => null);
|
|
268
|
+
if (vp?.w) {
|
|
269
|
+
session.cssW = vp.w;
|
|
270
|
+
session.cssH = vp.h;
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
frame: `data:image/jpeg;base64,${session.latestFrame ?? ""}`,
|
|
274
|
+
width: session.cssW ?? session.width ?? 1280,
|
|
275
|
+
height: session.cssH ?? session.height ?? 720,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** Relay a human's mouse/keyboard input into the real page via CDP. */
|
|
279
|
+
async takeoverInput(taskId, input) {
|
|
280
|
+
const session = this.requireTakeover(taskId);
|
|
281
|
+
if (!session.cdp) {
|
|
282
|
+
session.cdp = await session.page.context().newCDPSession(session.page);
|
|
283
|
+
}
|
|
284
|
+
const cdp = session.cdp;
|
|
285
|
+
const x = input.x ?? 0;
|
|
286
|
+
const y = input.y ?? 0;
|
|
287
|
+
switch (input.type) {
|
|
288
|
+
case "move":
|
|
289
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
290
|
+
return;
|
|
291
|
+
case "down":
|
|
292
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", buttons: 1, clickCount: 1 });
|
|
293
|
+
return;
|
|
294
|
+
case "up":
|
|
295
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", buttons: 0, clickCount: 1 });
|
|
296
|
+
return;
|
|
297
|
+
case "click":
|
|
298
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", buttons: 1, clickCount: 1 });
|
|
299
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", buttons: 0, clickCount: 1 });
|
|
300
|
+
return;
|
|
301
|
+
case "scroll":
|
|
302
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: input.deltaX ?? 0, deltaY: input.deltaY ?? 0 });
|
|
303
|
+
return;
|
|
304
|
+
case "text":
|
|
305
|
+
await cdp.send("Input.insertText", { text: input.text ?? "" });
|
|
306
|
+
return;
|
|
307
|
+
case "key": {
|
|
308
|
+
// Special keys (Enter, Backspace, Delete, Escape, Tab, arrows…)
|
|
309
|
+
// need the virtual key code + code to register, not just the name.
|
|
310
|
+
const key = input.key ?? "";
|
|
311
|
+
const base = { key };
|
|
312
|
+
if (input.code)
|
|
313
|
+
base.code = input.code;
|
|
314
|
+
if (typeof input.keyCode === "number") {
|
|
315
|
+
base.windowsVirtualKeyCode = input.keyCode;
|
|
316
|
+
base.nativeVirtualKeyCode = input.keyCode;
|
|
317
|
+
}
|
|
318
|
+
const text = key === "Enter" ? "\r" : undefined;
|
|
319
|
+
await cdp.send("Input.dispatchKeyEvent", {
|
|
320
|
+
type: text ? "keyDown" : "rawKeyDown",
|
|
321
|
+
...base,
|
|
322
|
+
...(text ? { text } : {}),
|
|
323
|
+
});
|
|
324
|
+
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", ...base });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Resume after a human takeover: verify the challenge is actually gone, then
|
|
331
|
+
* finish the application (submit). If a challenge or an unfilled required
|
|
332
|
+
* field remains, it does NOT submit — it reports what's left so the human can
|
|
333
|
+
* fix it in the live view and try again. Completes the task on success.
|
|
334
|
+
*/
|
|
335
|
+
async resumeTakeover(taskId) {
|
|
336
|
+
const session = this.requireTakeover(taskId);
|
|
337
|
+
const task = this.store.getTask(taskId);
|
|
338
|
+
if (!task)
|
|
339
|
+
throw new RunnerInputError("Task not found");
|
|
340
|
+
const page = session.page;
|
|
341
|
+
// Agent-driven applications are steered by the remote workflow. "Resume"
|
|
342
|
+
// here just signals the human finished the stuck step (or solved a captcha);
|
|
343
|
+
// the workflow polls humanDone and continues driving. Do NOT complete/submit.
|
|
344
|
+
if (task.type === "job.apply_agent") {
|
|
345
|
+
session.humanDone = true;
|
|
346
|
+
this.addTaskEvent(task, "job.resumed", "Human finished the step — handing back to the agent");
|
|
347
|
+
return { submitted: false, resumed: true, reason: "handed back to the agent — the agent is continuing" };
|
|
348
|
+
}
|
|
349
|
+
const challenge = await detectHumanChallenge(page);
|
|
350
|
+
if (challenge && !(await challengeSolved(page))) {
|
|
351
|
+
this.addTaskEvent(task, "job.human_challenge_present", `Challenge not yet solved: ${challenge}`);
|
|
352
|
+
return { submitted: false, reason: `The ${challenge} challenge isn't solved yet — complete it in the live view, then submit again.` };
|
|
353
|
+
}
|
|
354
|
+
// Only browser.open reaches here now (job.apply_agent returns above) — it just
|
|
355
|
+
// needs the challenge cleared, no form submit.
|
|
356
|
+
this.addTaskEvent(task, "job.resumed", "Human cleared the challenge; resuming");
|
|
357
|
+
const output = {
|
|
358
|
+
taskType: task.type,
|
|
359
|
+
submitted: false,
|
|
360
|
+
challengeCleared: true,
|
|
361
|
+
resumedAfterHuman: true,
|
|
362
|
+
finalUrl: page.url(),
|
|
363
|
+
title: await page.title().catch(() => ""),
|
|
364
|
+
visibleText: (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 1_500),
|
|
365
|
+
};
|
|
366
|
+
this.addTaskEvent(task, "task.resumed.completed", "Challenge cleared after human takeover", output);
|
|
367
|
+
task.status = "completed";
|
|
368
|
+
task.output = output;
|
|
369
|
+
task.error = undefined;
|
|
370
|
+
task.updatedAt = new Date().toISOString();
|
|
371
|
+
task.completedAt = task.updatedAt;
|
|
372
|
+
this.store.putTask(task);
|
|
373
|
+
this.addTaskEvent(task, "task.completed", `Task completed: ${task.type}`, output);
|
|
374
|
+
await this.endTakeover(taskId);
|
|
375
|
+
return { submitted: true, output };
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Always attach files via Playwright, never a native OS dialog: intercept any
|
|
379
|
+
* file chooser this page opens (e.g. a résumé upload) and set the file
|
|
380
|
+
* programmatically. Works even when a REMOTE human clicks the upload button
|
|
381
|
+
* during a takeover — no local file picker is ever needed.
|
|
382
|
+
*/
|
|
383
|
+
// Auto-attach the résumé to ANY file chooser on the page — whether the brain uses
|
|
384
|
+
// the `upload` tool or just CLICKS a styled upload widget (SuccessFactors, Workday…
|
|
385
|
+
// render the real <input type=file> hidden behind a button/drop-zone). Playwright's
|
|
386
|
+
// filechooser event fires BEFORE the native OS dialog, so the file attaches without
|
|
387
|
+
// a blocking dialog, regardless of the upload DOM. Idempotent per page.
|
|
388
|
+
armFileAutoAttach(page, filePath) {
|
|
389
|
+
if (this.fileAttachArmed.has(page))
|
|
390
|
+
return;
|
|
391
|
+
this.fileAttachArmed.add(page);
|
|
392
|
+
page.on("filechooser", (chooser) => {
|
|
393
|
+
chooser.setFiles(filePath).catch(() => undefined);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
/** End a takeover session (human finished or gave up). */
|
|
397
|
+
async endTakeover(taskId) {
|
|
398
|
+
const session = this.takeovers.get(taskId);
|
|
399
|
+
if (session?.cdp) {
|
|
400
|
+
if (session.screencasting)
|
|
401
|
+
await session.cdp.send("Page.stopScreencast").catch(() => undefined);
|
|
402
|
+
await session.cdp.detach().catch(() => undefined);
|
|
403
|
+
}
|
|
404
|
+
this.takeovers.delete(taskId);
|
|
405
|
+
}
|
|
133
406
|
async execute(task) {
|
|
134
407
|
if (task.type === "echo") {
|
|
135
408
|
return task.input;
|
|
@@ -141,8 +414,34 @@ export class LocalRunner {
|
|
|
141
414
|
}
|
|
142
415
|
const context = await this.getBrowserContext();
|
|
143
416
|
const page = context.pages()[0] || (await context.newPage());
|
|
417
|
+
// Auto-attach a file (e.g. résumé) via Playwright for any upload the
|
|
418
|
+
// human triggers in the takeover — never a native picker.
|
|
419
|
+
const attachPath = stringValue(task.input.resumePath ?? task.input.attachPath);
|
|
420
|
+
if (attachPath && existsSync(resolve(attachPath))) {
|
|
421
|
+
this.armFileAutoAttach(page, resolve(attachPath));
|
|
422
|
+
this.addTaskEvent(task, "browser.file.armed", "File will auto-attach via Playwright", {
|
|
423
|
+
file: basename(resolve(attachPath)),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
144
426
|
this.addTaskEvent(task, "browser.goto.started", "Opening browser page", { url });
|
|
145
427
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
428
|
+
// If the page presents an anti-bot challenge, hand off to a human to
|
|
429
|
+
// solve it via takeover (the same path job applications use).
|
|
430
|
+
await this.guardHumanChallenge(task, page);
|
|
431
|
+
// holdForTakeover: hand the page straight to a human to drive — for
|
|
432
|
+
// complex multi-step sites (e.g. a Dayforce/Workday ATS) the agent
|
|
433
|
+
// can't auto-fill, the human completes it in the live view.
|
|
434
|
+
if (task.input.holdForTakeover) {
|
|
435
|
+
const screenshotBase64 = await captureScreenshotDataUrl(page);
|
|
436
|
+
this.takeovers.set(task.id, { page });
|
|
437
|
+
this.addTaskEvent(task, "browser.takeover.ready", "Page ready for human takeover", { url: page.url() });
|
|
438
|
+
throw new HumanHandoffError("Ready for you to take over and complete this page.", {
|
|
439
|
+
reason: "assist",
|
|
440
|
+
url: page.url(),
|
|
441
|
+
attempts: 0,
|
|
442
|
+
screenshotBase64,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
146
445
|
const output = {
|
|
147
446
|
url: page.url(),
|
|
148
447
|
title: await page.title(),
|
|
@@ -150,168 +449,8 @@ export class LocalRunner {
|
|
|
150
449
|
this.addTaskEvent(task, "browser.goto.completed", "Browser page loaded", output);
|
|
151
450
|
return output;
|
|
152
451
|
}
|
|
153
|
-
if (task.type === "job.apply_basic") {
|
|
154
|
-
return this.applyToBasicJob(task);
|
|
155
|
-
}
|
|
156
|
-
if (task.type === "job.apply_authenticated") {
|
|
157
|
-
return this.applyToAuthenticatedJob(task);
|
|
158
|
-
}
|
|
159
452
|
throw new Error(`Unknown task type: ${task.type}`);
|
|
160
453
|
}
|
|
161
|
-
async applyToBasicJob(task) {
|
|
162
|
-
const job = normalizeJobApplicationInput(task.input);
|
|
163
|
-
const context = await this.getBrowserContext();
|
|
164
|
-
const page = context.pages()[0] || (await context.newPage());
|
|
165
|
-
this.addTaskEvent(task, "browser.goto.started", "Opening job application page", {
|
|
166
|
-
url: job.url,
|
|
167
|
-
});
|
|
168
|
-
await page.goto(job.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
169
|
-
this.addTaskEvent(task, "browser.goto.completed", "Job application page loaded", {
|
|
170
|
-
url: page.url(),
|
|
171
|
-
title: await page.title(),
|
|
172
|
-
});
|
|
173
|
-
this.addTaskEvent(task, "job.form.fill.started", "Filling application form", {
|
|
174
|
-
candidate: {
|
|
175
|
-
fullName: job.candidate.fullName,
|
|
176
|
-
email: job.candidate.email,
|
|
177
|
-
},
|
|
178
|
-
resumeFile: basename(job.resumePath),
|
|
179
|
-
});
|
|
180
|
-
const filledFields = await fillBasicApplicationForm(page, job);
|
|
181
|
-
this.addTaskEvent(task, "job.form.filled", "Application form fields completed", {
|
|
182
|
-
fieldsFilled: filledFields,
|
|
183
|
-
resumeFile: basename(job.resumePath),
|
|
184
|
-
});
|
|
185
|
-
const beforeSubmitUrl = page.url();
|
|
186
|
-
const navigation = page.waitForNavigation({
|
|
187
|
-
waitUntil: "domcontentloaded",
|
|
188
|
-
timeout: 15_000,
|
|
189
|
-
}).catch(() => null);
|
|
190
|
-
this.addTaskEvent(task, "job.form.submit.started", "Submitting application form", {
|
|
191
|
-
url: beforeSubmitUrl,
|
|
192
|
-
});
|
|
193
|
-
await submitApplicationForm(page);
|
|
194
|
-
await navigation;
|
|
195
|
-
await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
|
|
196
|
-
const output = {
|
|
197
|
-
taskType: "job.apply_basic",
|
|
198
|
-
submitted: true,
|
|
199
|
-
beforeSubmitUrl,
|
|
200
|
-
finalUrl: page.url(),
|
|
201
|
-
title: await page.title(),
|
|
202
|
-
fieldsFilled: filledFields,
|
|
203
|
-
resumeFile: basename(job.resumePath),
|
|
204
|
-
visibleText: (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 2_000),
|
|
205
|
-
};
|
|
206
|
-
this.addTaskEvent(task, "job.form.submit.completed", "Application submission completed", output);
|
|
207
|
-
return output;
|
|
208
|
-
}
|
|
209
|
-
/**
|
|
210
|
-
* Apply to a job that requires login/registration first.
|
|
211
|
-
* Flow: navigate → detect login requirement → register or login → fill form → submit
|
|
212
|
-
*/
|
|
213
|
-
async applyToAuthenticatedJob(task) {
|
|
214
|
-
const input = normalizeAuthenticatedJobInput(task.input);
|
|
215
|
-
const context = await this.getBrowserContext();
|
|
216
|
-
const page = context.pages()[0] || (await context.newPage());
|
|
217
|
-
// Step 1: Navigate to job page
|
|
218
|
-
this.addTaskEvent(task, "browser.goto.started", "Opening job page", { url: input.url });
|
|
219
|
-
await page.goto(input.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
220
|
-
this.addTaskEvent(task, "browser.goto.completed", "Job page loaded", {
|
|
221
|
-
url: page.url(),
|
|
222
|
-
title: await page.title(),
|
|
223
|
-
});
|
|
224
|
-
// Step 2: Check if we need to authenticate
|
|
225
|
-
const needsAuth = await detectAuthRequirement(page);
|
|
226
|
-
if (needsAuth) {
|
|
227
|
-
this.addTaskEvent(task, "job.auth.required", "Authentication required", {
|
|
228
|
-
loginDetected: true,
|
|
229
|
-
});
|
|
230
|
-
// Navigate to login page
|
|
231
|
-
const loginUrl = input.loginUrl || await findLoginUrl(page, input.url);
|
|
232
|
-
if (!loginUrl)
|
|
233
|
-
throw new Error("Login required but no login URL found");
|
|
234
|
-
await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
235
|
-
if (input.registration && !input.accountExists) {
|
|
236
|
-
// Register first
|
|
237
|
-
this.addTaskEvent(task, "job.auth.register.started", "Registering new account");
|
|
238
|
-
const registerUrl = await findRegisterUrl(page, loginUrl);
|
|
239
|
-
if (registerUrl && registerUrl !== loginUrl) {
|
|
240
|
-
await page.goto(registerUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
241
|
-
}
|
|
242
|
-
await fillRegistrationForm(page, input.registration);
|
|
243
|
-
await submitForm(page);
|
|
244
|
-
await page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
|
|
245
|
-
this.addTaskEvent(task, "job.auth.register.completed", "Registration submitted", {
|
|
246
|
-
url: page.url(),
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
else if (input.credentials) {
|
|
250
|
-
// Login with existing credentials
|
|
251
|
-
this.addTaskEvent(task, "job.auth.login.started", "Logging in");
|
|
252
|
-
await fillLoginForm(page, input.credentials);
|
|
253
|
-
await submitForm(page);
|
|
254
|
-
await page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
|
|
255
|
-
this.addTaskEvent(task, "job.auth.login.completed", "Login submitted", {
|
|
256
|
-
url: page.url(),
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
else {
|
|
260
|
-
throw new Error("Login required but no credentials or registration details provided");
|
|
261
|
-
}
|
|
262
|
-
// Verify we're logged in (check for common indicators)
|
|
263
|
-
const stillNeedsAuth = await detectAuthRequirement(page);
|
|
264
|
-
if (stillNeedsAuth) {
|
|
265
|
-
// Try navigating back to the apply page — some sites redirect after login
|
|
266
|
-
const applyUrl = input.url.includes("/apply") ? input.url : `${input.url}`;
|
|
267
|
-
await page.goto(applyUrl, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
268
|
-
const thirdCheck = await detectAuthRequirement(page);
|
|
269
|
-
if (thirdCheck) {
|
|
270
|
-
throw new Error("Authentication failed — still seeing login/register prompts after login attempt");
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
// Navigate to apply page (may have been redirected after login)
|
|
274
|
-
if (!page.url().includes("apply")) {
|
|
275
|
-
await page.goto(input.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
// Step 3: Look for the apply form (might be on current page or linked)
|
|
279
|
-
const applyLink = await page.locator('a[href*="apply"], a:has-text("Apply")').first();
|
|
280
|
-
if (await applyLink.count() > 0) {
|
|
281
|
-
await applyLink.click();
|
|
282
|
-
await page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => undefined);
|
|
283
|
-
}
|
|
284
|
-
// Step 4: Fill the application form
|
|
285
|
-
this.addTaskEvent(task, "job.form.fill.started", "Filling application form");
|
|
286
|
-
const filledFields = await fillBasicApplicationForm(page, input);
|
|
287
|
-
this.addTaskEvent(task, "job.form.filled", "Form fields completed", {
|
|
288
|
-
fieldsFilled: filledFields,
|
|
289
|
-
resumeFile: basename(input.resumePath),
|
|
290
|
-
});
|
|
291
|
-
// Step 5: Submit
|
|
292
|
-
const beforeSubmitUrl = page.url();
|
|
293
|
-
const navigation = page.waitForNavigation({
|
|
294
|
-
waitUntil: "domcontentloaded",
|
|
295
|
-
timeout: 15_000,
|
|
296
|
-
}).catch(() => null);
|
|
297
|
-
this.addTaskEvent(task, "job.form.submit.started", "Submitting application");
|
|
298
|
-
await submitApplicationForm(page);
|
|
299
|
-
await navigation;
|
|
300
|
-
await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
|
|
301
|
-
const output = {
|
|
302
|
-
taskType: "job.apply_authenticated",
|
|
303
|
-
submitted: true,
|
|
304
|
-
authenticated: needsAuth,
|
|
305
|
-
beforeSubmitUrl,
|
|
306
|
-
finalUrl: page.url(),
|
|
307
|
-
title: await page.title(),
|
|
308
|
-
fieldsFilled: filledFields,
|
|
309
|
-
resumeFile: basename(input.resumePath),
|
|
310
|
-
visibleText: (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 2_000),
|
|
311
|
-
};
|
|
312
|
-
this.addTaskEvent(task, "job.form.submit.completed", "Application submitted", output);
|
|
313
|
-
return output;
|
|
314
|
-
}
|
|
315
454
|
requireTask(id) {
|
|
316
455
|
const task = this.store.getTask(id);
|
|
317
456
|
if (!task)
|
|
@@ -329,13 +468,269 @@ export class LocalRunner {
|
|
|
329
468
|
const playwright = await import("playwright").catch((error) => {
|
|
330
469
|
throw new Error(`Playwright is not installed. Run pnpm install in the PAGS platform repo. ${error instanceof Error ? error.message : String(error)}`);
|
|
331
470
|
});
|
|
332
|
-
|
|
471
|
+
const baseOpts = {
|
|
333
472
|
headless: this.config.headless,
|
|
334
473
|
acceptDownloads: true,
|
|
335
474
|
downloadsPath,
|
|
336
|
-
|
|
475
|
+
// Light anti-detection: drop the most obvious automation tell so fewer
|
|
476
|
+
// CAPTCHAs trigger in the first place (the human still solves the rest).
|
|
477
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
478
|
+
};
|
|
479
|
+
// Prefer the real Chrome build (better TLS/fingerprint → fewer CAPTCHAs);
|
|
480
|
+
// fall back to bundled Chromium if Chrome isn't installed. Disable with
|
|
481
|
+
// PAGS_RUNNER_CHROMIUM=1.
|
|
482
|
+
//
|
|
483
|
+
// Real-profile mode (PAGS_RUNNER_REAL_PROFILE=1, or an explicit
|
|
484
|
+
// PAGS_RUNNER_CHROME_USER_DATA_DIR) uses YOUR actual Chrome profile —
|
|
485
|
+
// cookies, logins, browsing history — which is the strongest way to look
|
|
486
|
+
// human to CAPTCHA reputation scoring. Requires your normal Chrome to be
|
|
487
|
+
// CLOSED (Chrome locks a profile to one running instance). Otherwise we
|
|
488
|
+
// use a dedicated chrome-profile so we never fight your open browser.
|
|
489
|
+
const preferChrome = process.env.PAGS_RUNNER_CHROMIUM !== "1";
|
|
490
|
+
const realProfileDir = resolveRealChromeProfileDir();
|
|
491
|
+
try {
|
|
492
|
+
if (!preferChrome)
|
|
493
|
+
throw new Error("chromium forced");
|
|
494
|
+
if (realProfileDir) {
|
|
495
|
+
// The seed is a snapshot; PAGS_RUNNER_REFRESH_PROFILE=1 re-copies it
|
|
496
|
+
// so logins/cookies pick up changes from your everyday Chrome.
|
|
497
|
+
const seedDir = join(this.config.dataDir, "real-profile-copy");
|
|
498
|
+
if (process.env.PAGS_RUNNER_REFRESH_PROFILE === "1")
|
|
499
|
+
rmSync(seedDir, { recursive: true, force: true });
|
|
500
|
+
const seededDir = seedProfileCopy(realProfileDir, seedDir);
|
|
501
|
+
if (!seededDir)
|
|
502
|
+
throw new Error(`could not read your real Chrome profile at ${realProfileDir.userDataDir}`);
|
|
503
|
+
this.browserContext = await playwright.chromium.launchPersistentContext(seededDir, {
|
|
504
|
+
...baseOpts,
|
|
505
|
+
channel: "chrome",
|
|
506
|
+
args: [...baseOpts.args, "--profile-directory=Default"],
|
|
507
|
+
});
|
|
508
|
+
console.log(`[runner] launched a private copy of your real Chrome profile (signed-in sessions seeded from "${realProfileDir.profile}")`);
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
this.browserContext = await playwright.chromium.launchPersistentContext(join(this.config.dataDir, "chrome-profile"), { ...baseOpts, channel: "chrome" });
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
if (realProfileDir) {
|
|
516
|
+
const msg = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
517
|
+
console.warn(`[runner] real-profile launch failed (${msg}); falling back to a dedicated profile. Fully quit Chrome (Cmd+Q) to use your real profile.`);
|
|
518
|
+
}
|
|
519
|
+
this.browserContext = await playwright.chromium.launchPersistentContext(profileDir, baseOpts);
|
|
520
|
+
}
|
|
521
|
+
await this.browserContext
|
|
522
|
+
.addInitScript(() => {
|
|
523
|
+
Object.defineProperty(navigator, "webdriver", { get: () => undefined });
|
|
524
|
+
})
|
|
525
|
+
.catch(() => undefined);
|
|
526
|
+
// Follow the active page: when the ATS opens a new tab/popup it becomes the
|
|
527
|
+
// page both the brain and the human takeover act on. Without this, the
|
|
528
|
+
// runner blindly used pages()[0] and the two could diverge.
|
|
529
|
+
this.browserContext.on("page", (p) => this.trackPage(p));
|
|
530
|
+
const initial = this.browserContext.pages()[0];
|
|
531
|
+
if (initial)
|
|
532
|
+
this.trackPage(initial);
|
|
337
533
|
return this.browserContext;
|
|
338
534
|
}
|
|
535
|
+
/** Mark a page as the active one; fall back to the newest remaining page when it closes. */
|
|
536
|
+
trackPage(page) {
|
|
537
|
+
this.activePage = page;
|
|
538
|
+
if (this.applyResumePath)
|
|
539
|
+
this.armFileAutoAttach(page, this.applyResumePath);
|
|
540
|
+
page.once("close", () => {
|
|
541
|
+
if (this.activePage === page) {
|
|
542
|
+
this.activePage = this.browserContext?.pages().at(-1) ?? null;
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/** The page the brain drives and the human takes over — created on demand. */
|
|
547
|
+
async getActivePage() {
|
|
548
|
+
const context = await this.getBrowserContext();
|
|
549
|
+
if (this.activePage && !this.activePage.isClosed())
|
|
550
|
+
return this.activePage;
|
|
551
|
+
const page = context.pages().at(-1) ?? (await context.newPage());
|
|
552
|
+
this.activePage = page;
|
|
553
|
+
return page;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* What the remote brain "sees": a compact ARIA tree (roles + accessible names
|
|
557
|
+
* + values + states) of the active page — the same representation the brain
|
|
558
|
+
* acts on via {@link browserAct}. No raw HTML/screenshots → cheap on tokens.
|
|
559
|
+
*/
|
|
560
|
+
async browserSnapshot(taskId) {
|
|
561
|
+
// If the user pressed Stop, the task is cancelled — tell the brain to halt.
|
|
562
|
+
if (taskId && this.store.getTask(taskId)?.status === "cancelled") {
|
|
563
|
+
return { url: "", title: "", snapshot: "", challenge: null, truncated: false, cancelled: true };
|
|
564
|
+
}
|
|
565
|
+
const page = await this.getActivePage();
|
|
566
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
|
|
567
|
+
const full = await page.locator("body").ariaSnapshot().catch(() => "");
|
|
568
|
+
const MAX = 16_000;
|
|
569
|
+
const snapshot = full.length > MAX ? `${full.slice(0, MAX)}\n… [snapshot truncated]` : full;
|
|
570
|
+
return {
|
|
571
|
+
url: page.url(),
|
|
572
|
+
title: await page.title().catch(() => ""),
|
|
573
|
+
snapshot,
|
|
574
|
+
challenge: await detectHumanChallenge(page),
|
|
575
|
+
truncated: full.length > MAX,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Perform one brain-issued action on the active page. Elements are addressed
|
|
580
|
+
* by ARIA role + accessible name (from the snapshot) via Playwright's
|
|
581
|
+
* getByRole — robust and selector-free. Returns the resulting page state.
|
|
582
|
+
*/
|
|
583
|
+
async browserAct(action, resumePath) {
|
|
584
|
+
const page = await this.getActivePage();
|
|
585
|
+
// Arm résumé auto-attach so a file chooser never blocks the flow (see method).
|
|
586
|
+
if (resumePath && existsSync(resolve(resumePath))) {
|
|
587
|
+
this.applyResumePath = resolve(resumePath);
|
|
588
|
+
}
|
|
589
|
+
if (this.applyResumePath)
|
|
590
|
+
this.armFileAutoAttach(page, this.applyResumePath);
|
|
591
|
+
// The selector-free action switch lives in browser-actions.ts (kept this
|
|
592
|
+
// file from ballooning); the post-action settle + result stay here.
|
|
593
|
+
await performBrowserAction(page, action, (p, loc) => this.clickRobustly(p, loc));
|
|
594
|
+
// A click may trigger SPA navigation or open a new tab/popup. Give it a beat
|
|
595
|
+
// to settle and follow the (possibly new) active page, so the next snapshot
|
|
596
|
+
// reflects the new state — not the element the brain just clicked.
|
|
597
|
+
await page.waitForTimeout(700).catch(() => undefined);
|
|
598
|
+
const active = await this.getActivePage();
|
|
599
|
+
await active.waitForLoadState("domcontentloaded", { timeout: 6_000 }).catch(() => undefined);
|
|
600
|
+
// SPA route changes (Dayforce/Workday) swap the view via XHR without a full
|
|
601
|
+
// load, so domcontentloaded fires instantly and the snapshot can catch the
|
|
602
|
+
// PRE-swap DOM (still showing the button just clicked → a wasted re-click).
|
|
603
|
+
// Wait for the network to go idle after navigation-ish actions.
|
|
604
|
+
if (action.action === "click" || action.action === "navigate" || action.action === "key") {
|
|
605
|
+
await active.waitForLoadState("networkidle", { timeout: 3_500 }).catch(() => undefined);
|
|
606
|
+
}
|
|
607
|
+
return {
|
|
608
|
+
ok: true,
|
|
609
|
+
url: active.url(),
|
|
610
|
+
title: await active.title().catch(() => ""),
|
|
611
|
+
challenge: await detectHumanChallenge(active),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Click an element through escalating fallbacks: normal → force (bypass
|
|
616
|
+
* actionability) → scroll-into-view + click the element's CENTER COORDINATES
|
|
617
|
+
* with the mouse. The coordinate click defeats custom-styled controls (hidden
|
|
618
|
+
* inputs, overlays) that swallow locator clicks. Returns false only if the
|
|
619
|
+
* element can't be located/positioned at all.
|
|
620
|
+
*/
|
|
621
|
+
async clickRobustly(page, loc) {
|
|
622
|
+
if (await loc.click({ timeout: 6_000 }).then(() => true).catch(() => false))
|
|
623
|
+
return true;
|
|
624
|
+
if (await loc.click({ force: true, timeout: 4_000 }).then(() => true).catch(() => false))
|
|
625
|
+
return true;
|
|
626
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 3_000 }).catch(() => undefined);
|
|
627
|
+
const box = await loc.boundingBox().catch(() => null);
|
|
628
|
+
if (!box)
|
|
629
|
+
return false;
|
|
630
|
+
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2).catch(() => undefined);
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
// ── Agent-driven application lifecycle (called by the remote Workflow brain) ──
|
|
634
|
+
/** Append a decision/event to the agent task's activity (the console trace). */
|
|
635
|
+
browserEvent(taskId, type, message, data) {
|
|
636
|
+
const task = this.store.getTask(taskId);
|
|
637
|
+
if (task)
|
|
638
|
+
this.addTaskEvent(task, type, message, data);
|
|
639
|
+
return { ok: true };
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Hand off for a CAPTCHA in the SAME session: register the live active page
|
|
643
|
+
* for remote takeover and flip the task to needs_human so the console shows
|
|
644
|
+
* the take-over view. The brain pauses; the human solves it on this page.
|
|
645
|
+
*/
|
|
646
|
+
async browserHandoff(taskId, label, reason = "challenge") {
|
|
647
|
+
const page = await this.getActivePage();
|
|
648
|
+
this.takeovers.set(taskId, { page, reason, humanDone: false });
|
|
649
|
+
const screenshotBase64 = await captureScreenshotDataUrl(page);
|
|
650
|
+
const task = this.store.getTask(taskId);
|
|
651
|
+
if (task) {
|
|
652
|
+
task.status = "needs_human";
|
|
653
|
+
task.updatedAt = new Date().toISOString();
|
|
654
|
+
this.store.putTask(task);
|
|
655
|
+
const message = reason === "needs_input"
|
|
656
|
+
? `The agent needs a value from you: ${label}. Enter it and the agent continues.`
|
|
657
|
+
: reason === "stuck"
|
|
658
|
+
? `Take over: the agent is stuck on "${label}". Do that one step (e.g. tick the box / click continue) in the live view, then click Resume.`
|
|
659
|
+
: `Take over to solve the ${label} — the agent will continue once it's solved.`;
|
|
660
|
+
this.addTaskEvent(task, "job.human_handoff_required", message, {
|
|
661
|
+
reason,
|
|
662
|
+
challengeType: reason === "challenge" ? label : undefined,
|
|
663
|
+
stuckOn: reason === "stuck" ? label : undefined,
|
|
664
|
+
inputField: reason === "needs_input" ? label : undefined,
|
|
665
|
+
url: page.url(),
|
|
666
|
+
screenshotBase64,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return { ok: true, screenshotBase64 };
|
|
670
|
+
}
|
|
671
|
+
/** Whether the brain can resume: a solved captcha (auto), a human "Resume" for a stuck step, or a provided value. */
|
|
672
|
+
async browserHandoffStatus(taskId) {
|
|
673
|
+
const session = this.takeovers.get(taskId);
|
|
674
|
+
const page = session?.page ?? (await this.getActivePage());
|
|
675
|
+
if (page.isClosed())
|
|
676
|
+
return { solved: true, challenge: null };
|
|
677
|
+
// A needs_input handoff resumes once the user supplies the value.
|
|
678
|
+
if (session?.reason === "needs_input")
|
|
679
|
+
return { solved: !!session.inputValue, challenge: null, value: session.inputValue };
|
|
680
|
+
// A stuck handoff resumes only when the human explicitly clicks Resume —
|
|
681
|
+
// there's nothing to auto-detect.
|
|
682
|
+
if (session?.reason === "stuck")
|
|
683
|
+
return { solved: !!session.humanDone, challenge: null };
|
|
684
|
+
// A challenge resumes when the token/widget clears OR the human clicks Done —
|
|
685
|
+
// custom captchas (e.g. PageUp's "not a robot") have no detectable token, so
|
|
686
|
+
// the human's explicit Done is the authority; never strand them.
|
|
687
|
+
const challenge = await detectHumanChallenge(page);
|
|
688
|
+
const solved = !challenge || (await challengeSolved(page)) || !!session?.humanDone;
|
|
689
|
+
return { solved, challenge };
|
|
690
|
+
}
|
|
691
|
+
/** The user supplied the value the agent asked for (ask-and-hold). */
|
|
692
|
+
browserSubmitInput(taskId, value) {
|
|
693
|
+
const session = this.takeovers.get(taskId);
|
|
694
|
+
if (session)
|
|
695
|
+
session.inputValue = value;
|
|
696
|
+
const task = this.store.getTask(taskId);
|
|
697
|
+
if (task) {
|
|
698
|
+
task.status = "running";
|
|
699
|
+
task.updatedAt = new Date().toISOString();
|
|
700
|
+
this.store.putTask(task);
|
|
701
|
+
this.addTaskEvent(task, "job.input_provided", "You provided the requested value — the agent is continuing");
|
|
702
|
+
}
|
|
703
|
+
return { ok: true };
|
|
704
|
+
}
|
|
705
|
+
/** End the takeover and return the task to running so the brain drives again. */
|
|
706
|
+
async browserResume(taskId) {
|
|
707
|
+
await this.endTakeover(taskId).catch(() => undefined);
|
|
708
|
+
const task = this.store.getTask(taskId);
|
|
709
|
+
if (task) {
|
|
710
|
+
task.status = "running";
|
|
711
|
+
task.updatedAt = new Date().toISOString();
|
|
712
|
+
this.store.putTask(task);
|
|
713
|
+
this.addTaskEvent(task, "job.resumed", "Challenge solved — agent resuming the application.");
|
|
714
|
+
}
|
|
715
|
+
return { ok: true };
|
|
716
|
+
}
|
|
717
|
+
/** Finalize an agent-driven application (submitted/expired → completed; else failed). */
|
|
718
|
+
async browserComplete(taskId, outcome, detail) {
|
|
719
|
+
await this.endTakeover(taskId).catch(() => undefined);
|
|
720
|
+
const task = this.store.getTask(taskId);
|
|
721
|
+
if (task) {
|
|
722
|
+
const success = outcome === "submitted" || outcome === "ready" || outcome === "expired";
|
|
723
|
+
task.status = success ? "completed" : "failed";
|
|
724
|
+
task.output = { outcome, detail };
|
|
725
|
+
if (!success)
|
|
726
|
+
task.error = detail || outcome;
|
|
727
|
+
task.updatedAt = new Date().toISOString();
|
|
728
|
+
task.completedAt = task.updatedAt;
|
|
729
|
+
this.store.putTask(task);
|
|
730
|
+
this.addTaskEvent(task, success ? "task.completed" : "task.failed", detail || `Application ${outcome}`, { outcome, detail });
|
|
731
|
+
}
|
|
732
|
+
return { ok: true };
|
|
733
|
+
}
|
|
339
734
|
ensureChromiumInstalled() {
|
|
340
735
|
if (this.chromiumInstallChecked)
|
|
341
736
|
return;
|
|
@@ -352,164 +747,6 @@ export class LocalRunner {
|
|
|
352
747
|
}
|
|
353
748
|
}
|
|
354
749
|
}
|
|
355
|
-
export function normalizeJobApplicationInput(input) {
|
|
356
|
-
const url = stringValue(input.url);
|
|
357
|
-
if (!url || !/^https?:\/\//.test(url)) {
|
|
358
|
-
throw new RunnerInputError("job application requires an http(s) url");
|
|
359
|
-
}
|
|
360
|
-
const resumePath = resolve(stringValue(input.resumePath));
|
|
361
|
-
if (!resumePath || !existsSync(resumePath)) {
|
|
362
|
-
throw new RunnerInputError("job application requires an existing local resumePath");
|
|
363
|
-
}
|
|
364
|
-
const candidate = isRecord(input.candidate) ? input.candidate : {};
|
|
365
|
-
const fullName = stringValue(candidate.fullName);
|
|
366
|
-
const email = stringValue(candidate.email);
|
|
367
|
-
if (!fullName)
|
|
368
|
-
throw new RunnerInputError("candidate.fullName is required");
|
|
369
|
-
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
370
|
-
throw new RunnerInputError("candidate.email must be a valid email address");
|
|
371
|
-
}
|
|
372
|
-
return {
|
|
373
|
-
url,
|
|
374
|
-
resumePath,
|
|
375
|
-
candidate: {
|
|
376
|
-
fullName,
|
|
377
|
-
email,
|
|
378
|
-
phone: optionalString(candidate.phone),
|
|
379
|
-
location: optionalString(candidate.location),
|
|
380
|
-
linkedin: optionalString(candidate.linkedin),
|
|
381
|
-
portfolio: optionalString(candidate.portfolio),
|
|
382
|
-
workAuthorization: optionalString(candidate.workAuthorization),
|
|
383
|
-
},
|
|
384
|
-
coverNote: optionalString(input.coverNote),
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
async function fillBasicApplicationForm(page, job) {
|
|
388
|
-
const filled = [];
|
|
389
|
-
const candidate = job.candidate;
|
|
390
|
-
const fillSpecs = [
|
|
391
|
-
["fullName", candidate.fullName, [
|
|
392
|
-
'input[name="fullName"]',
|
|
393
|
-
'input[name="full_name"]',
|
|
394
|
-
'input[name="name"]',
|
|
395
|
-
'input[autocomplete="name"]',
|
|
396
|
-
]],
|
|
397
|
-
["email", candidate.email, [
|
|
398
|
-
'input[name="email"]',
|
|
399
|
-
'input[type="email"]',
|
|
400
|
-
'input[autocomplete="email"]',
|
|
401
|
-
]],
|
|
402
|
-
["phone", candidate.phone, [
|
|
403
|
-
'input[name="phone"]',
|
|
404
|
-
'input[name="mobile"]',
|
|
405
|
-
'input[autocomplete="tel"]',
|
|
406
|
-
]],
|
|
407
|
-
["location", candidate.location, [
|
|
408
|
-
'input[name="location"]',
|
|
409
|
-
'input[autocomplete="address-level2"]',
|
|
410
|
-
'input[autocomplete="address-level1"]',
|
|
411
|
-
]],
|
|
412
|
-
["linkedin", candidate.linkedin, [
|
|
413
|
-
'input[name="linkedin"]',
|
|
414
|
-
'input[name="linkedIn"]',
|
|
415
|
-
'input[placeholder*="LinkedIn" i]',
|
|
416
|
-
]],
|
|
417
|
-
["portfolio", candidate.portfolio, [
|
|
418
|
-
'input[name="portfolio"]',
|
|
419
|
-
'input[name="website"]',
|
|
420
|
-
'input[placeholder*="Portfolio" i]',
|
|
421
|
-
'input[placeholder*="Website" i]',
|
|
422
|
-
]],
|
|
423
|
-
["coverNote", job.coverNote, [
|
|
424
|
-
'textarea[name="coverNote"]',
|
|
425
|
-
'textarea[name="cover_letter"]',
|
|
426
|
-
'textarea[name="coverLetter"]',
|
|
427
|
-
'textarea[placeholder*="cover" i]',
|
|
428
|
-
'textarea[placeholder*="message" i]',
|
|
429
|
-
]],
|
|
430
|
-
];
|
|
431
|
-
for (const [name, value, selectors] of fillSpecs) {
|
|
432
|
-
if (value && await fillFirst(page, selectors, value))
|
|
433
|
-
filled.push(name);
|
|
434
|
-
}
|
|
435
|
-
if (candidate.workAuthorization && await selectOrFillFirst(page, [
|
|
436
|
-
'select[name="workAuthorization"]',
|
|
437
|
-
'select[name="work_authorization"]',
|
|
438
|
-
'select[name*="authorization" i]',
|
|
439
|
-
], candidate.workAuthorization)) {
|
|
440
|
-
filled.push("workAuthorization");
|
|
441
|
-
}
|
|
442
|
-
const resumeInput = page.locator('input[type="file"][name*="resume" i], input[type="file"][name*="cv" i], input[type="file"]').first();
|
|
443
|
-
if (await resumeInput.count() === 0) {
|
|
444
|
-
throw new Error("No resume upload field found on the application form");
|
|
445
|
-
}
|
|
446
|
-
await resumeInput.setInputFiles(job.resumePath);
|
|
447
|
-
filled.push("resume");
|
|
448
|
-
return filled;
|
|
449
|
-
}
|
|
450
|
-
async function fillFirst(page, selectors, value) {
|
|
451
|
-
for (const selector of selectors) {
|
|
452
|
-
const locator = page.locator(selector).first();
|
|
453
|
-
if (await locator.count() === 0)
|
|
454
|
-
continue;
|
|
455
|
-
try {
|
|
456
|
-
await locator.fill(value, { timeout: 5_000 });
|
|
457
|
-
return true;
|
|
458
|
-
}
|
|
459
|
-
catch {
|
|
460
|
-
/* try next selector */
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
return false;
|
|
464
|
-
}
|
|
465
|
-
async function selectOrFillFirst(page, selectors, value) {
|
|
466
|
-
for (const selector of selectors) {
|
|
467
|
-
const locator = page.locator(selector).first();
|
|
468
|
-
if (await locator.count() === 0)
|
|
469
|
-
continue;
|
|
470
|
-
try {
|
|
471
|
-
await locator.selectOption({ label: value }, { timeout: 5_000 });
|
|
472
|
-
return true;
|
|
473
|
-
}
|
|
474
|
-
catch {
|
|
475
|
-
try {
|
|
476
|
-
await locator.fill(value, { timeout: 5_000 });
|
|
477
|
-
return true;
|
|
478
|
-
}
|
|
479
|
-
catch {
|
|
480
|
-
/* try next selector */
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
return false;
|
|
485
|
-
}
|
|
486
|
-
async function submitApplicationForm(page) {
|
|
487
|
-
const submit = page.locator('form button[type="submit"], form input[type="submit"], button:has-text("Submit"), input[type="submit"]').first();
|
|
488
|
-
if (await submit.count() > 0) {
|
|
489
|
-
await submit.click({ timeout: 10_000 });
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
const form = page.locator("form").first();
|
|
493
|
-
if (await form.count() === 0)
|
|
494
|
-
throw new Error("No application form found to submit");
|
|
495
|
-
await form.evaluate((node) => {
|
|
496
|
-
const htmlForm = node;
|
|
497
|
-
if (typeof htmlForm.requestSubmit === "function")
|
|
498
|
-
htmlForm.requestSubmit();
|
|
499
|
-
else
|
|
500
|
-
htmlForm.submit();
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
function isRecord(value) {
|
|
504
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
505
|
-
}
|
|
506
|
-
function stringValue(value) {
|
|
507
|
-
return String(value || "").trim();
|
|
508
|
-
}
|
|
509
|
-
function optionalString(value) {
|
|
510
|
-
const text = stringValue(value);
|
|
511
|
-
return text || undefined;
|
|
512
|
-
}
|
|
513
750
|
function normalizeCreateTaskRequest(request) {
|
|
514
751
|
if (!isRecord(request) || typeof request.type !== "string" || !request.type.trim()) {
|
|
515
752
|
throw new RunnerInputError("task type required");
|
|
@@ -523,111 +760,10 @@ function normalizeCreateTaskRequest(request) {
|
|
|
523
760
|
: "",
|
|
524
761
|
};
|
|
525
762
|
}
|
|
526
|
-
function validateTaskInput(
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
if (type === "job.apply_authenticated") {
|
|
532
|
-
normalizeAuthenticatedJobInput(input);
|
|
533
|
-
}
|
|
763
|
+
function validateTaskInput(_type, _input) {
|
|
764
|
+
// The agent-driven apply (job.apply_agent) validates inputs in the workflow;
|
|
765
|
+
// no runner-side schema validation needed for the remaining task types.
|
|
534
766
|
}
|
|
535
767
|
export function fileUrl(path) {
|
|
536
768
|
return pathToFileURL(path).toString();
|
|
537
769
|
}
|
|
538
|
-
// ── Authenticated job application helpers ─────────────────────────────────
|
|
539
|
-
export function normalizeAuthenticatedJobInput(input) {
|
|
540
|
-
const base = normalizeJobApplicationInput(input);
|
|
541
|
-
const credentials = isRecord(input.credentials) ? input.credentials : undefined;
|
|
542
|
-
const registration = isRecord(input.registration) ? input.registration : undefined;
|
|
543
|
-
return {
|
|
544
|
-
...base,
|
|
545
|
-
credentials: credentials ? {
|
|
546
|
-
email: stringValue(credentials.email),
|
|
547
|
-
password: stringValue(credentials.password),
|
|
548
|
-
} : undefined,
|
|
549
|
-
loginUrl: optionalString(input.loginUrl),
|
|
550
|
-
accountExists: input.accountExists === true,
|
|
551
|
-
registration: registration ? {
|
|
552
|
-
fullName: stringValue(registration.fullName),
|
|
553
|
-
email: stringValue(registration.email),
|
|
554
|
-
password: stringValue(registration.password),
|
|
555
|
-
} : undefined,
|
|
556
|
-
};
|
|
557
|
-
}
|
|
558
|
-
async function detectAuthRequirement(page) {
|
|
559
|
-
const body = await page.locator("body").innerHTML({ timeout: 5_000 }).catch(() => "");
|
|
560
|
-
const lower = body.toLowerCase();
|
|
561
|
-
// Check for login/register prompts
|
|
562
|
-
const hasLoginForm = await page.locator('input[type="password"], form[action*="login"], form[action*="signin"]').count() > 0;
|
|
563
|
-
const hasLoginText = /sign\s*in|log\s*in|create\s*(an\s*)?account|register\s*to\s*apply/i.test(lower);
|
|
564
|
-
const hasLoginLink = await page.locator('a[href*="login"], a[href*="signin"], a[href*="register"]').count() > 0;
|
|
565
|
-
return hasLoginForm || (hasLoginText && hasLoginLink);
|
|
566
|
-
}
|
|
567
|
-
async function findLoginUrl(page, baseUrl) {
|
|
568
|
-
const loginLink = page.locator('a[href*="login"], a[href*="signin"], a:has-text("Sign in"), a:has-text("Log in")').first();
|
|
569
|
-
if (await loginLink.count() > 0) {
|
|
570
|
-
const href = await loginLink.getAttribute("href");
|
|
571
|
-
if (href)
|
|
572
|
-
return new URL(href, baseUrl).toString();
|
|
573
|
-
}
|
|
574
|
-
return null;
|
|
575
|
-
}
|
|
576
|
-
async function findRegisterUrl(page, baseUrl) {
|
|
577
|
-
const regLink = page.locator('a[href*="register"], a[href*="signup"], a:has-text("Create account"), a:has-text("Sign up")').first();
|
|
578
|
-
if (await regLink.count() > 0) {
|
|
579
|
-
const href = await regLink.getAttribute("href");
|
|
580
|
-
if (href)
|
|
581
|
-
return new URL(href, baseUrl).toString();
|
|
582
|
-
}
|
|
583
|
-
return null;
|
|
584
|
-
}
|
|
585
|
-
async function fillLoginForm(page, credentials) {
|
|
586
|
-
await fillFirst(page, [
|
|
587
|
-
'input[name="email"]',
|
|
588
|
-
'input[type="email"]',
|
|
589
|
-
'input[autocomplete="email"]',
|
|
590
|
-
'input[autocomplete="username"]',
|
|
591
|
-
'input[name="username"]',
|
|
592
|
-
], credentials.email);
|
|
593
|
-
await fillFirst(page, [
|
|
594
|
-
'input[name="password"]',
|
|
595
|
-
'input[type="password"]',
|
|
596
|
-
'input[autocomplete="current-password"]',
|
|
597
|
-
], credentials.password);
|
|
598
|
-
}
|
|
599
|
-
async function fillRegistrationForm(page, registration) {
|
|
600
|
-
await fillFirst(page, [
|
|
601
|
-
'input[name="fullName"]',
|
|
602
|
-
'input[name="full_name"]',
|
|
603
|
-
'input[name="name"]',
|
|
604
|
-
'input[autocomplete="name"]',
|
|
605
|
-
], registration.fullName);
|
|
606
|
-
await fillFirst(page, [
|
|
607
|
-
'input[name="email"]',
|
|
608
|
-
'input[type="email"]',
|
|
609
|
-
'input[autocomplete="email"]',
|
|
610
|
-
], registration.email);
|
|
611
|
-
await fillFirst(page, [
|
|
612
|
-
'input[name="password"]',
|
|
613
|
-
'input[type="password"]',
|
|
614
|
-
'input[autocomplete="new-password"]',
|
|
615
|
-
], registration.password);
|
|
616
|
-
}
|
|
617
|
-
async function submitForm(page) {
|
|
618
|
-
const submit = page.locator('button[type="submit"], input[type="submit"]').first();
|
|
619
|
-
if (await submit.count() > 0) {
|
|
620
|
-
await submit.click({ timeout: 10_000 });
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
const form = page.locator("form").first();
|
|
624
|
-
if (await form.count() > 0) {
|
|
625
|
-
await form.evaluate((node) => {
|
|
626
|
-
const f = node;
|
|
627
|
-
if (typeof f.requestSubmit === "function")
|
|
628
|
-
f.requestSubmit();
|
|
629
|
-
else
|
|
630
|
-
f.submit();
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
}
|