@proagentstore/cli 0.1.18 → 0.1.20
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/index.js +9 -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 +82 -38
- package/package.json +1 -1
|
@@ -84,6 +84,62 @@ async function route(runner, req, res) {
|
|
|
84
84
|
const limit = clampLimit(url.searchParams.get("limit"), 100, 500);
|
|
85
85
|
return json(res, 200, { events: runner.store.listEvents(limit) });
|
|
86
86
|
}
|
|
87
|
+
// ── Brain-driven browser control (remote LLM acts on the live page) ─────
|
|
88
|
+
if (req.method === "POST" && path === "/browser/snapshot") {
|
|
89
|
+
const b = await readJson(req).catch(() => ({}));
|
|
90
|
+
return json(res, 200, await runner.browserSnapshot(b.taskId));
|
|
91
|
+
}
|
|
92
|
+
if (req.method === "POST" && path === "/browser/act") {
|
|
93
|
+
const body = await readJson(req);
|
|
94
|
+
return json(res, 200, await runner.browserAct(body, body.resumePath));
|
|
95
|
+
}
|
|
96
|
+
if (req.method === "POST" && path === "/browser/event") {
|
|
97
|
+
const b = await readJson(req);
|
|
98
|
+
return json(res, 200, runner.browserEvent(b.taskId, b.type, b.message, b.data));
|
|
99
|
+
}
|
|
100
|
+
if (req.method === "POST" && path === "/browser/handoff") {
|
|
101
|
+
const b = await readJson(req);
|
|
102
|
+
return json(res, 200, await runner.browserHandoff(b.taskId, b.label ?? b.challenge ?? "this step", b.reason ?? "challenge"));
|
|
103
|
+
}
|
|
104
|
+
if (req.method === "POST" && path === "/browser/handoff-status") {
|
|
105
|
+
const b = await readJson(req);
|
|
106
|
+
return json(res, 200, await runner.browserHandoffStatus(b.taskId));
|
|
107
|
+
}
|
|
108
|
+
if (req.method === "POST" && path === "/browser/resume") {
|
|
109
|
+
const b = await readJson(req);
|
|
110
|
+
return json(res, 200, await runner.browserResume(b.taskId));
|
|
111
|
+
}
|
|
112
|
+
if (req.method === "POST" && path === "/browser/input") {
|
|
113
|
+
const b = await readJson(req);
|
|
114
|
+
return json(res, 200, runner.browserSubmitInput(b.taskId, String(b.value ?? "")));
|
|
115
|
+
}
|
|
116
|
+
if (req.method === "POST" && path === "/browser/complete") {
|
|
117
|
+
const b = await readJson(req);
|
|
118
|
+
return json(res, 200, await runner.browserComplete(b.taskId, b.outcome, b.detail));
|
|
119
|
+
}
|
|
120
|
+
// ── Human takeover (remote view + control) ──────────────────────────────
|
|
121
|
+
if (req.method === "GET" && path === "/takeover") {
|
|
122
|
+
return json(res, 200, { takeovers: runner.listTakeovers() });
|
|
123
|
+
}
|
|
124
|
+
const frameMatch = path.match(/^\/takeover\/([^/]+)\/frame$/);
|
|
125
|
+
if (req.method === "GET" && frameMatch) {
|
|
126
|
+
return json(res, 200, await runner.takeoverFrame(frameMatch[1]));
|
|
127
|
+
}
|
|
128
|
+
const inputMatch = path.match(/^\/takeover\/([^/]+)\/input$/);
|
|
129
|
+
if (req.method === "POST" && inputMatch) {
|
|
130
|
+
const body = await readJson(req);
|
|
131
|
+
await runner.takeoverInput(inputMatch[1], body);
|
|
132
|
+
return json(res, 200, { ok: true });
|
|
133
|
+
}
|
|
134
|
+
const resumeMatch = path.match(/^\/takeover\/([^/]+)\/resume$/);
|
|
135
|
+
if (req.method === "POST" && resumeMatch) {
|
|
136
|
+
return json(res, 200, await runner.resumeTakeover(resumeMatch[1]));
|
|
137
|
+
}
|
|
138
|
+
const endMatch = path.match(/^\/takeover\/([^/]+)\/end$/);
|
|
139
|
+
if (req.method === "POST" && endMatch) {
|
|
140
|
+
await runner.endTakeover(endMatch[1]);
|
|
141
|
+
return json(res, 200, { ok: true });
|
|
142
|
+
}
|
|
87
143
|
return json(res, 404, { error: "Not found" });
|
|
88
144
|
}
|
|
89
145
|
function authorize(req, config) {
|
|
@@ -33,6 +33,30 @@ export class RunnerStore {
|
|
|
33
33
|
getTask(id) {
|
|
34
34
|
return this.read().tasks.find((task) => task.id === id) || null;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* On process startup, any persisted task still in an in-flight state
|
|
38
|
+
* (needs_human / running) is orphaned — its live browser page and takeover
|
|
39
|
+
* session died with the previous process and can't be resumed. Fail them so
|
|
40
|
+
* they don't linger as stale "Needs you" cards. Returns the count expired.
|
|
41
|
+
*/
|
|
42
|
+
expireInFlightTasks() {
|
|
43
|
+
const data = this.read();
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
let expired = 0;
|
|
46
|
+
for (const task of data.tasks) {
|
|
47
|
+
if (task.status === "needs_human" || task.status === "running") {
|
|
48
|
+
task.status = "failed";
|
|
49
|
+
task.error =
|
|
50
|
+
"Runner restarted — this paused task was orphaned (its browser session is gone). Re-run it to try again.";
|
|
51
|
+
task.updatedAt = now;
|
|
52
|
+
task.completedAt = now;
|
|
53
|
+
expired += 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (expired > 0)
|
|
57
|
+
this.write(data);
|
|
58
|
+
return expired;
|
|
59
|
+
}
|
|
36
60
|
putTask(task) {
|
|
37
61
|
const data = this.read();
|
|
38
62
|
const index = data.tasks.findIndex((row) => row.id === task.id);
|
|
@@ -32,7 +32,12 @@ async function route(req, res, submissions) {
|
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
34
|
if (req.method === "GET" && url.pathname === "/jobs/software-engineer") {
|
|
35
|
-
html(res, 200, jobPage(
|
|
35
|
+
html(res, 200, jobPage({
|
|
36
|
+
challenge: url.searchParams.get("challenge") === "1",
|
|
37
|
+
hcaptcha: url.searchParams.get("hcaptcha") === "1",
|
|
38
|
+
invisibleRecaptcha: url.searchParams.get("invisible_recaptcha") === "1",
|
|
39
|
+
recaptcha: url.searchParams.get("recaptcha") === "1",
|
|
40
|
+
}));
|
|
36
41
|
return;
|
|
37
42
|
}
|
|
38
43
|
if (req.method === "POST" && url.pathname === "/apply") {
|
|
@@ -57,7 +62,28 @@ async function route(req, res, submissions) {
|
|
|
57
62
|
}
|
|
58
63
|
html(res, 404, "<h1>Not found</h1>");
|
|
59
64
|
}
|
|
60
|
-
function jobPage() {
|
|
65
|
+
function jobPage(opts = {}) {
|
|
66
|
+
let challenge = "";
|
|
67
|
+
if (opts.hcaptcha) {
|
|
68
|
+
// Real hCaptcha widget using hCaptcha's official always-passes TEST sitekey
|
|
69
|
+
// — lets a human actually solve a challenge end-to-end with zero real-world impact.
|
|
70
|
+
challenge =
|
|
71
|
+
'<div class="h-captcha" data-sitekey="10000000-ffff-ffff-ffff-000000000001"></div>\n <script src="https://js.hcaptcha.com/1/api.js" async defer></script>';
|
|
72
|
+
}
|
|
73
|
+
else if (opts.invisibleRecaptcha) {
|
|
74
|
+
// The invisible v3 badge — present on countless pages, needs NO human. Must
|
|
75
|
+
// NOT be treated as a challenge (the false positive that bailed to the user).
|
|
76
|
+
challenge =
|
|
77
|
+
'<div class="grecaptcha-badge"><iframe title="reCAPTCHA" src="https://example.test/recaptcha/api2/anchor?size=invisible&k=x" style="width:256px;height:60px;display:block"></iframe></div>';
|
|
78
|
+
}
|
|
79
|
+
else if (opts.recaptcha) {
|
|
80
|
+
// A VISIBLE "I'm not a robot" checkbox — a real human challenge.
|
|
81
|
+
challenge =
|
|
82
|
+
'<div class="g-recaptcha"><iframe title="reCAPTCHA" src="https://example.test/recaptcha/api2/anchor?k=x" style="width:304px;height:78px;display:block"></iframe></div>';
|
|
83
|
+
}
|
|
84
|
+
else if (opts.challenge) {
|
|
85
|
+
challenge = '<div class="cf-turnstile" data-sitekey="test">Verify you are human</div>';
|
|
86
|
+
}
|
|
61
87
|
return page("Senior Software Engineer", `
|
|
62
88
|
<main>
|
|
63
89
|
<section class="job">
|
|
@@ -82,6 +108,7 @@ function jobPage() {
|
|
|
82
108
|
</label>
|
|
83
109
|
<label>Resume <input name="resume" type="file" accept=".pdf,.txt,.doc,.docx" required /></label>
|
|
84
110
|
<label>Cover note <textarea name="coverNote" rows="5" required></textarea></label>
|
|
111
|
+
${challenge}
|
|
85
112
|
<button type="submit">Submit application</button>
|
|
86
113
|
</form>
|
|
87
114
|
</main>
|
package/dist/index.js
CHANGED
|
@@ -618,8 +618,21 @@ import { chmod, mkdir, writeFile } from "fs/promises";
|
|
|
618
618
|
import { gunzipSync } from "zlib";
|
|
619
619
|
import { arch, homedir as homedir2, platform } from "os";
|
|
620
620
|
import { resolve as resolve4 } from "path";
|
|
621
|
+
import { createServer as createServer2 } from "net";
|
|
621
622
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
622
623
|
import { Command as Command6 } from "commander";
|
|
624
|
+
async function findFreePort2(start) {
|
|
625
|
+
for (let p = start; p < start + 25; p++) {
|
|
626
|
+
const free = await new Promise((res) => {
|
|
627
|
+
const s = createServer2();
|
|
628
|
+
s.once("error", () => res(false));
|
|
629
|
+
s.once("listening", () => s.close(() => res(true)));
|
|
630
|
+
s.listen(p, "127.0.0.1");
|
|
631
|
+
});
|
|
632
|
+
if (free) return p;
|
|
633
|
+
}
|
|
634
|
+
return start;
|
|
635
|
+
}
|
|
623
636
|
var runnerCommand = createRunnerCommand();
|
|
624
637
|
function runnerBaseUrl(url) {
|
|
625
638
|
return (clean(url) || clean(process.env.PAGS_RUNNER_URL) || "http://127.0.0.1:49171").replace(/\/$/, "");
|
|
@@ -628,7 +641,7 @@ function pagsApiBase(url) {
|
|
|
628
641
|
return (clean(url) || clean(process.env.PAGS_API_BASE) || "https://api.proagentstore.online").replace(/\/$/, "");
|
|
629
642
|
}
|
|
630
643
|
function pagsHeaders(token) {
|
|
631
|
-
const resolved = clean(token) || clean(process.env.PAGS_TOKEN);
|
|
644
|
+
const resolved = clean(token) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
|
|
632
645
|
return resolved ? { Authorization: `Bearer ${resolved}` } : {};
|
|
633
646
|
}
|
|
634
647
|
function runnerRequestHeaders(opts) {
|
|
@@ -898,7 +911,7 @@ function createRunnerCommand() {
|
|
|
898
911
|
command.command("connect <instanceId>").description("Start the local FAGS runtime, open a Cloudflare quick tunnel, and register it with PAGS").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Runner bearer token. Defaults to PAGS_RUNNER_TOKEN or a generated token").option("--headless", "Run Playwright headless").option("--api-base <url>", "PAGS API base URL").option("--pags-token <token>", "PAGS session token. Defaults to PAGS_TOKEN").option("--cloudflared <path>", "cloudflared executable").option("--runner-version <version>", "Runner version").option("--skip-probe", "Skip PAGS FAGS-runtime probe after registration").action(async (instanceId, opts) => {
|
|
899
912
|
const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`;
|
|
900
913
|
const host = clean(opts.host) || "127.0.0.1";
|
|
901
|
-
const port = clean(opts.port) ||
|
|
914
|
+
const port = clean(opts.port) || String(await findFreePort2(49171));
|
|
902
915
|
const localUrl = `http://${host}:${port}`;
|
|
903
916
|
const runnerOpts = {
|
|
904
917
|
...opts,
|
|
@@ -934,55 +947,86 @@ function createRunnerCommand() {
|
|
|
934
947
|
try {
|
|
935
948
|
await waitForLocalRunner({ url: localUrl, token: runnerToken, instanceId });
|
|
936
949
|
writeLine(`Local FAGS runtime healthy at ${localUrl}`);
|
|
937
|
-
const
|
|
950
|
+
const cloudflaredPath = await resolveCloudflaredCommand(opts.cloudflared);
|
|
951
|
+
const openTunnel = () => new Promise((resolveTunnel, reject) => {
|
|
938
952
|
let output = "";
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
tunnel.stdout?.on("data", onData);
|
|
952
|
-
tunnel.stderr?.on("data", onData);
|
|
953
|
-
tunnel.on("error", reject);
|
|
954
|
-
tunnel.on("exit", (code) => {
|
|
955
|
-
if (code && code !== 0) reject(new Error(`cloudflared exited with code ${code}`));
|
|
956
|
-
});
|
|
957
|
-
}).catch(reject);
|
|
953
|
+
const proc = spawn2(cloudflaredPath, buildCloudflaredArgs(localUrl), {
|
|
954
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
955
|
+
shell: process.platform === "win32"
|
|
956
|
+
});
|
|
957
|
+
const onData = (data) => {
|
|
958
|
+
output += data.toString("utf-8");
|
|
959
|
+
const parsed = parseCloudflaredTunnelUrl(output);
|
|
960
|
+
if (parsed) resolveTunnel({ url: parsed, proc });
|
|
961
|
+
};
|
|
962
|
+
proc.stdout?.on("data", onData);
|
|
963
|
+
proc.stderr?.on("data", onData);
|
|
964
|
+
proc.on("error", reject);
|
|
958
965
|
setTimeout(() => reject(new Error("timed out waiting for cloudflared tunnel URL")), 45e3).unref();
|
|
959
966
|
});
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
967
|
+
const registerRuntime = async (url) => {
|
|
968
|
+
const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId });
|
|
969
|
+
await requestPags("POST", `/v1/instances/${apiPathSegment(instanceId)}/runtime`, opts, {
|
|
970
|
+
endpointUrl: url,
|
|
971
|
+
token: runnerToken,
|
|
972
|
+
placement: "local",
|
|
973
|
+
capabilities: Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [],
|
|
974
|
+
runnerVersion: clean(opts.runnerVersion) || ""
|
|
975
|
+
});
|
|
976
|
+
};
|
|
977
|
+
const first = await openTunnel();
|
|
978
|
+
tunnel = first.proc;
|
|
979
|
+
let tunnelUrl = first.url;
|
|
980
|
+
await registerRuntime(tunnelUrl);
|
|
973
981
|
writeLine("Runtime registered with PAGS \u2713");
|
|
974
982
|
writeLine("");
|
|
975
983
|
writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
976
984
|
writeLine(" \u2705 CONNECTED \u2014 Browser runner is live");
|
|
977
985
|
writeLine(` Instance: ${instanceId.slice(0, 8)}...`);
|
|
978
986
|
writeLine(` Tunnel: ${tunnelUrl}`);
|
|
979
|
-
writeLine("
|
|
980
|
-
writeLine(" Keep this terminal open. Press Ctrl+C to disconnect.");
|
|
987
|
+
writeLine(" Auto-reconnect is on \u2014 keep this terminal open. Ctrl+C to disconnect.");
|
|
981
988
|
writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
989
|
+
let stopped = false;
|
|
990
|
+
const heartbeatPath = `/v1/instances/${apiPathSegment(instanceId)}/runtime/heartbeat`;
|
|
991
|
+
const probeTunnel = async (url) => {
|
|
992
|
+
try {
|
|
993
|
+
const res = await fetch(`${url.replace(/\/$/, "")}/health`, {
|
|
994
|
+
headers: { Authorization: `Bearer ${runnerToken}`, "X-PAGS-Instance-Id": instanceId },
|
|
995
|
+
signal: AbortSignal.timeout(8e3)
|
|
996
|
+
});
|
|
997
|
+
return res.ok;
|
|
998
|
+
} catch {
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
void (async () => {
|
|
1003
|
+
while (!stopped) {
|
|
1004
|
+
await new Promise((r) => setTimeout(r, 3e4));
|
|
1005
|
+
if (stopped) break;
|
|
1006
|
+
if (await probeTunnel(tunnelUrl)) {
|
|
1007
|
+
await requestPags("POST", heartbeatPath, opts, {}).catch(() => void 0);
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
writeLine("\u26A0 Tunnel unreachable \u2014 reconnecting\u2026");
|
|
1011
|
+
try {
|
|
1012
|
+
if (tunnel && !tunnel.killed) tunnel.kill("SIGTERM");
|
|
1013
|
+
} catch {
|
|
1014
|
+
}
|
|
1015
|
+
try {
|
|
1016
|
+
const next = await openTunnel();
|
|
1017
|
+
tunnel = next.proc;
|
|
1018
|
+
tunnelUrl = next.url;
|
|
1019
|
+
await registerRuntime(tunnelUrl);
|
|
1020
|
+
writeLine(`\u2705 Reconnected: ${tunnelUrl}`);
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
writeError(`reconnect failed (${error instanceof Error ? error.message : String(error)}); retrying in 30s`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
})();
|
|
982
1026
|
await new Promise((resolvePromise) => {
|
|
983
1027
|
runner.on("exit", () => resolvePromise());
|
|
984
|
-
tunnel?.on("exit", () => resolvePromise());
|
|
985
1028
|
});
|
|
1029
|
+
stopped = true;
|
|
986
1030
|
} catch (error) {
|
|
987
1031
|
shutdown();
|
|
988
1032
|
throw error;
|