@proagentstore/cli 0.1.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.
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { startRunnerServer } from "./server.js";
5
+ function arg(name, fallback) {
6
+ const index = process.argv.indexOf(name);
7
+ if (index === -1)
8
+ return fallback;
9
+ return process.argv[index + 1] || fallback;
10
+ }
11
+ function flag(name) {
12
+ return process.argv.includes(name);
13
+ }
14
+ function configFromArgs() {
15
+ const dataDir = arg("--data-dir") ||
16
+ process.env.PAGS_RUNNER_DATA_DIR ||
17
+ join(homedir(), ".config", "proagentstore", "browser-runner");
18
+ return {
19
+ host: arg("--host", process.env.PAGS_RUNNER_HOST || "127.0.0.1") || "127.0.0.1",
20
+ port: Number(arg("--port", process.env.PAGS_RUNNER_PORT || "49171")),
21
+ dataDir,
22
+ token: arg("--token", process.env.PAGS_RUNNER_TOKEN),
23
+ instanceId: arg("--instance-id", process.env.PAGS_INSTANCE_ID),
24
+ headless: flag("--headless") || process.env.PAGS_RUNNER_HEADLESS === "1",
25
+ };
26
+ }
27
+ if (flag("--help") || flag("-h")) {
28
+ process.stdout.write(`ProAgentStore browser runner
29
+
30
+ Usage:
31
+ pags-browser-runner [--host 127.0.0.1] [--port 49171] [--data-dir path] [--token token] [--instance-id id] [--headless]
32
+
33
+ Endpoints:
34
+ GET /health
35
+ GET /capabilities
36
+ POST /tasks
37
+ GET /tasks/:id
38
+ POST /tasks/:id/approve
39
+ POST /tasks/:id/cancel
40
+ GET /events
41
+ `);
42
+ process.exit(0);
43
+ }
44
+ const config = configFromArgs();
45
+ const started = await startRunnerServer(config);
46
+ process.stdout.write(`PAGS browser runner listening at ${started.url}\n`);
47
+ process.stdout.write(`Data dir: ${config.dataDir}\n`);
48
+ process.stdout.write(`Brain placement: PAGS; runner role: tool-executor\n`);
49
+ if (config.token)
50
+ process.stdout.write("Auth: bearer token required\n");
51
+ if (config.instanceId)
52
+ process.stdout.write(`Instance binding: ${config.instanceId}\n`);
53
+ const shutdown = async () => {
54
+ await started.close();
55
+ process.exit(0);
56
+ };
57
+ process.on("SIGINT", () => void shutdown());
58
+ process.on("SIGTERM", () => void shutdown());
@@ -0,0 +1,203 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { RunnerStore } from "./store.js";
5
+ const CAPABILITIES = [
6
+ "browser.playwright",
7
+ "browser.screenshot",
8
+ "downloads",
9
+ "file.upload",
10
+ "human.approval",
11
+ ];
12
+ const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]);
13
+ export class RunnerInputError extends Error {
14
+ status = 400;
15
+ }
16
+ export class LocalRunner {
17
+ config;
18
+ browserContext = null;
19
+ store;
20
+ constructor(config) {
21
+ this.config = config;
22
+ mkdirSync(config.dataDir, { recursive: true });
23
+ this.store = new RunnerStore(config.dataDir);
24
+ }
25
+ capabilities() {
26
+ return {
27
+ runtime: "local-browser-runner",
28
+ brainPlacement: "pags",
29
+ runnerRole: "tool-executor",
30
+ capabilities: CAPABILITIES,
31
+ taskTypes: ["echo", "browser.open"],
32
+ approvalRequiredFor: ["browser.open"],
33
+ };
34
+ }
35
+ createTask(request) {
36
+ const normalized = normalizeCreateTaskRequest(request);
37
+ const now = new Date().toISOString();
38
+ const requiresApproval = normalized.requiresApproval || APPROVAL_REQUIRED_TASKS.has(normalized.type);
39
+ const task = {
40
+ id: `task_${crypto.randomUUID()}`,
41
+ type: normalized.type,
42
+ status: requiresApproval ? "needs_approval" : "queued",
43
+ input: normalized.input,
44
+ requiresApproval,
45
+ approval: requiresApproval
46
+ ? {
47
+ prompt: normalized.approvalPrompt || `Approve task ${normalized.type}`,
48
+ }
49
+ : undefined,
50
+ createdAt: now,
51
+ updatedAt: now,
52
+ };
53
+ this.store.putTask(task);
54
+ this.store.addEvent({
55
+ taskId: task.id,
56
+ type: "task.created",
57
+ message: `Task created: ${task.type}`,
58
+ data: { status: task.status },
59
+ });
60
+ if (task.status === "queued")
61
+ void this.runTask(task.id);
62
+ return task;
63
+ }
64
+ async approveTask(id) {
65
+ const task = this.requireTask(id);
66
+ if (task.status !== "needs_approval") {
67
+ throw new Error(`Task is not waiting for approval: ${task.status}`);
68
+ }
69
+ task.status = "queued";
70
+ task.updatedAt = new Date().toISOString();
71
+ task.approval = {
72
+ prompt: task.approval?.prompt || `Approve task ${task.type}`,
73
+ approvedAt: task.updatedAt,
74
+ };
75
+ this.store.putTask(task);
76
+ this.store.addEvent({
77
+ taskId: task.id,
78
+ type: "task.approved",
79
+ message: `Task approved: ${task.type}`,
80
+ });
81
+ await this.runTask(task.id);
82
+ return this.requireTask(id);
83
+ }
84
+ cancelTask(id) {
85
+ const task = this.requireTask(id);
86
+ if (task.status === "completed" || task.status === "failed")
87
+ return task;
88
+ task.status = "cancelled";
89
+ task.updatedAt = new Date().toISOString();
90
+ task.completedAt = task.updatedAt;
91
+ this.store.putTask(task);
92
+ this.store.addEvent({
93
+ taskId: task.id,
94
+ type: "task.cancelled",
95
+ message: `Task cancelled: ${task.type}`,
96
+ });
97
+ return task;
98
+ }
99
+ async close() {
100
+ await this.browserContext?.close();
101
+ this.browserContext = null;
102
+ }
103
+ async runTask(id) {
104
+ const task = this.requireTask(id);
105
+ if (task.status !== "queued")
106
+ return;
107
+ task.status = "running";
108
+ task.updatedAt = new Date().toISOString();
109
+ this.store.putTask(task);
110
+ this.store.addEvent({
111
+ taskId: task.id,
112
+ type: "task.running",
113
+ message: `Task running: ${task.type}`,
114
+ });
115
+ try {
116
+ const output = await this.execute(task);
117
+ task.status = "completed";
118
+ task.output = output;
119
+ task.updatedAt = new Date().toISOString();
120
+ task.completedAt = task.updatedAt;
121
+ this.store.putTask(task);
122
+ this.store.addEvent({
123
+ taskId: task.id,
124
+ type: "task.completed",
125
+ message: `Task completed: ${task.type}`,
126
+ data: output,
127
+ });
128
+ }
129
+ catch (error) {
130
+ task.status = "failed";
131
+ task.error = error instanceof Error ? error.message : String(error);
132
+ task.updatedAt = new Date().toISOString();
133
+ task.completedAt = task.updatedAt;
134
+ this.store.putTask(task);
135
+ this.store.addEvent({
136
+ taskId: task.id,
137
+ type: "task.failed",
138
+ message: task.error,
139
+ });
140
+ }
141
+ }
142
+ async execute(task) {
143
+ if (task.type === "echo") {
144
+ return task.input;
145
+ }
146
+ if (task.type === "browser.open") {
147
+ const url = String(task.input.url || "");
148
+ if (!url || !/^https?:\/\//.test(url)) {
149
+ throw new Error("browser.open requires an http(s) url");
150
+ }
151
+ const context = await this.getBrowserContext();
152
+ const page = context.pages()[0] || (await context.newPage());
153
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
154
+ return {
155
+ url: page.url(),
156
+ title: await page.title(),
157
+ };
158
+ }
159
+ throw new Error(`Unknown task type: ${task.type}`);
160
+ }
161
+ requireTask(id) {
162
+ const task = this.store.getTask(id);
163
+ if (!task)
164
+ throw new Error(`Task not found: ${id}`);
165
+ return task;
166
+ }
167
+ async getBrowserContext() {
168
+ if (this.browserContext)
169
+ return this.browserContext;
170
+ const profileDir = join(this.config.dataDir, "browser-profile");
171
+ const downloadsPath = join(this.config.dataDir, "downloads");
172
+ mkdirSync(profileDir, { recursive: true });
173
+ mkdirSync(downloadsPath, { recursive: true });
174
+ const playwright = await import("playwright").catch((error) => {
175
+ throw new Error(`Playwright is not installed. Run pnpm install in the PAGS platform repo. ${error instanceof Error ? error.message : String(error)}`);
176
+ });
177
+ this.browserContext = await playwright.chromium.launchPersistentContext(profileDir, {
178
+ headless: this.config.headless,
179
+ acceptDownloads: true,
180
+ downloadsPath,
181
+ });
182
+ return this.browserContext;
183
+ }
184
+ }
185
+ function isRecord(value) {
186
+ return typeof value === "object" && value !== null && !Array.isArray(value);
187
+ }
188
+ function normalizeCreateTaskRequest(request) {
189
+ if (!isRecord(request) || typeof request.type !== "string" || !request.type.trim()) {
190
+ throw new RunnerInputError("task type required");
191
+ }
192
+ return {
193
+ type: request.type.trim().slice(0, 120),
194
+ input: isRecord(request.input) ? request.input : {},
195
+ requiresApproval: request.requiresApproval === true,
196
+ approvalPrompt: typeof request.approvalPrompt === "string"
197
+ ? request.approvalPrompt.trim().slice(0, 500)
198
+ : "",
199
+ };
200
+ }
201
+ export function fileUrl(path) {
202
+ return pathToFileURL(path).toString();
203
+ }
@@ -0,0 +1,124 @@
1
+ import { createServer, } from "node:http";
2
+ import { URL } from "node:url";
3
+ import { LocalRunner, RunnerInputError } from "./runner.js";
4
+ export function createRunnerServer(runner) {
5
+ return createServer(async (req, res) => {
6
+ try {
7
+ if (!authorize(req, runner.config)) {
8
+ return json(res, 401, { error: "Unauthorized" });
9
+ }
10
+ await route(runner, req, res);
11
+ }
12
+ catch (error) {
13
+ const status = error instanceof RunnerInputError ? error.status : 500;
14
+ json(res, status, {
15
+ error: error instanceof Error ? error.message : String(error),
16
+ });
17
+ }
18
+ });
19
+ }
20
+ export async function startRunnerServer(config) {
21
+ const runner = new LocalRunner(config);
22
+ const server = createRunnerServer(runner);
23
+ await new Promise((resolve) => {
24
+ server.listen(config.port, config.host, resolve);
25
+ });
26
+ const address = server.address();
27
+ const actualPort = address.port;
28
+ return {
29
+ runner,
30
+ url: `http://${config.host}:${actualPort}`,
31
+ async close() {
32
+ await runner.close();
33
+ await new Promise((resolve, reject) => {
34
+ server.close((error) => (error ? reject(error) : resolve()));
35
+ });
36
+ },
37
+ };
38
+ }
39
+ async function route(runner, req, res) {
40
+ const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
41
+ const path = url.pathname.replace(/\/$/, "") || "/";
42
+ if (req.method === "GET" && path === "/health") {
43
+ return json(res, 200, {
44
+ ok: true,
45
+ service: "proagentstore-browser-runner",
46
+ brainPlacement: "pags",
47
+ instanceId: runner.config.instanceId,
48
+ });
49
+ }
50
+ if (req.method === "GET" && path === "/capabilities") {
51
+ return json(res, 200, runner.capabilities());
52
+ }
53
+ if (req.method === "GET" && path === "/sessions") {
54
+ return json(res, 200, { sessions: runner.store.listSessions() });
55
+ }
56
+ if (req.method === "POST" && path === "/sessions") {
57
+ return json(res, 201, runner.store.createSession());
58
+ }
59
+ if (req.method === "GET" && path === "/tasks") {
60
+ return json(res, 200, { tasks: runner.store.listTasks() });
61
+ }
62
+ if (req.method === "POST" && path === "/tasks") {
63
+ const body = await readJson(req);
64
+ return json(res, 202, runner.createTask(body));
65
+ }
66
+ const taskMatch = path.match(/^\/tasks\/([^/]+)$/);
67
+ if (req.method === "GET" && taskMatch) {
68
+ const task = runner.store.getTask(taskMatch[1]);
69
+ if (!task)
70
+ return json(res, 404, { error: "Task not found" });
71
+ return json(res, 200, task);
72
+ }
73
+ const approveMatch = path.match(/^\/tasks\/([^/]+)\/approve$/);
74
+ if (req.method === "POST" && approveMatch) {
75
+ return json(res, 200, await runner.approveTask(approveMatch[1]));
76
+ }
77
+ const cancelMatch = path.match(/^\/tasks\/([^/]+)\/cancel$/);
78
+ if (req.method === "POST" && cancelMatch) {
79
+ return json(res, 200, runner.cancelTask(cancelMatch[1]));
80
+ }
81
+ if (req.method === "GET" && path === "/events") {
82
+ const limit = clampLimit(url.searchParams.get("limit"), 100, 500);
83
+ return json(res, 200, { events: runner.store.listEvents(limit) });
84
+ }
85
+ return json(res, 404, { error: "Not found" });
86
+ }
87
+ function authorize(req, config) {
88
+ const token = config.token;
89
+ if (config.instanceId && req.headers["x-pags-instance-id"] !== config.instanceId) {
90
+ return false;
91
+ }
92
+ if (!token)
93
+ return true;
94
+ const auth = req.headers.authorization || "";
95
+ const headerToken = req.headers["x-pags-runner-token"];
96
+ return auth === `Bearer ${token}` || headerToken === token;
97
+ }
98
+ async function readJson(req) {
99
+ const chunks = [];
100
+ for await (const chunk of req)
101
+ chunks.push(Buffer.from(chunk));
102
+ const raw = Buffer.concat(chunks).toString("utf-8");
103
+ if (!raw)
104
+ return {};
105
+ try {
106
+ return JSON.parse(raw);
107
+ }
108
+ catch {
109
+ throw new RunnerInputError("Request body must be valid JSON");
110
+ }
111
+ }
112
+ function clampLimit(value, fallback, max) {
113
+ const parsed = Number(value || fallback);
114
+ if (!Number.isFinite(parsed))
115
+ return fallback;
116
+ return Math.max(1, Math.min(max, Math.trunc(parsed)));
117
+ }
118
+ function json(res, status, body) {
119
+ res.writeHead(status, {
120
+ "Content-Type": "application/json; charset=utf-8",
121
+ "X-Content-Type-Options": "nosniff",
122
+ });
123
+ res.end(JSON.stringify(body));
124
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ function emptyStore() {
4
+ return {
5
+ sessions: [],
6
+ tasks: [],
7
+ events: [],
8
+ };
9
+ }
10
+ export class RunnerStore {
11
+ filePath;
12
+ constructor(dataDir) {
13
+ this.filePath = join(dataDir, "runner-store.json");
14
+ }
15
+ listSessions() {
16
+ return this.read().sessions;
17
+ }
18
+ createSession() {
19
+ const data = this.read();
20
+ const now = new Date().toISOString();
21
+ const session = {
22
+ id: `session_${crypto.randomUUID()}`,
23
+ createdAt: now,
24
+ status: "active",
25
+ };
26
+ data.sessions.unshift(session);
27
+ this.write(data);
28
+ return session;
29
+ }
30
+ listTasks() {
31
+ return this.read().tasks;
32
+ }
33
+ getTask(id) {
34
+ return this.read().tasks.find((task) => task.id === id) || null;
35
+ }
36
+ putTask(task) {
37
+ const data = this.read();
38
+ const index = data.tasks.findIndex((row) => row.id === task.id);
39
+ if (index === -1)
40
+ data.tasks.unshift(task);
41
+ else
42
+ data.tasks[index] = task;
43
+ this.write(data);
44
+ return task;
45
+ }
46
+ addEvent(event) {
47
+ const data = this.read();
48
+ const row = {
49
+ ...event,
50
+ id: `event_${crypto.randomUUID()}`,
51
+ createdAt: new Date().toISOString(),
52
+ };
53
+ data.events.unshift(row);
54
+ data.events = data.events.slice(0, 500);
55
+ this.write(data);
56
+ return row;
57
+ }
58
+ listEvents(limit = 100) {
59
+ return this.read().events.slice(0, limit);
60
+ }
61
+ read() {
62
+ if (!existsSync(this.filePath))
63
+ return emptyStore();
64
+ try {
65
+ const parsed = JSON.parse(readFileSync(this.filePath, "utf-8"));
66
+ return {
67
+ sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
68
+ tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
69
+ events: Array.isArray(parsed.events) ? parsed.events : [],
70
+ };
71
+ }
72
+ catch {
73
+ return emptyStore();
74
+ }
75
+ }
76
+ write(data) {
77
+ mkdirSync(dirname(this.filePath), { recursive: true });
78
+ writeFileSync(this.filePath, `${JSON.stringify(data, null, 2)}\n`, {
79
+ mode: 0o600,
80
+ });
81
+ }
82
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { startTestJobServer } from "./test-job-server.js";
3
+ function arg(name, fallback) {
4
+ const index = process.argv.indexOf(name);
5
+ if (index === -1)
6
+ return fallback;
7
+ return process.argv[index + 1] || fallback;
8
+ }
9
+ function flag(name) {
10
+ return process.argv.includes(name);
11
+ }
12
+ if (flag("--help") || flag("-h")) {
13
+ process.stdout.write(`ProAgentStore test job server
14
+
15
+ Usage:
16
+ pags-test-job-server [--port 49210]
17
+
18
+ Routes:
19
+ GET /jobs/software-engineer
20
+ POST /apply
21
+ GET /success/:id
22
+ GET /submissions
23
+ `);
24
+ process.exit(0);
25
+ }
26
+ const port = Number(arg("--port", process.env.PAGS_TEST_JOB_PORT || "49210"));
27
+ const server = await startTestJobServer(port);
28
+ process.stdout.write(`PAGS test job server listening at ${server.url}\n`);
29
+ process.stdout.write(`Job URL: ${server.jobUrl}\n`);
30
+ const shutdown = async () => {
31
+ await server.close();
32
+ process.exit(0);
33
+ };
34
+ process.on("SIGINT", () => void shutdown());
35
+ process.on("SIGTERM", () => void shutdown());