@proagentstore/cli 0.4.156 → 0.4.157
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.
|
@@ -16,6 +16,13 @@ const MAX_PANE = 64 * 1024;
|
|
|
16
16
|
export class CodingRuntime {
|
|
17
17
|
reposBaseDir;
|
|
18
18
|
sessions = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* Which agent instance each session belongs to (#1164). One `pags up` serves every agent on the
|
|
21
|
+
* machine, and every relayed request names the instance whose socket it arrived on — so a session
|
|
22
|
+
* started by one agent must not be driven, read or ended through another's. Bound at `/coding/start`;
|
|
23
|
+
* a session with no binding (one started before this, or by a local caller) answers to anyone, as before.
|
|
24
|
+
*/
|
|
25
|
+
owners = new Map();
|
|
19
26
|
/**
|
|
20
27
|
* Coding turns that are running here (#896, #1007).
|
|
21
28
|
*
|
|
@@ -363,6 +370,22 @@ export class CodingRuntime {
|
|
|
363
370
|
this.takeovers.delete(sessionId);
|
|
364
371
|
return { ok: true, usage, acts, authResolved };
|
|
365
372
|
}
|
|
373
|
+
/** Record the instance a session belongs to — the first one to start it (#1164). */
|
|
374
|
+
bindInstance(sessionId, instanceId) {
|
|
375
|
+
if (sessionId && instanceId && !this.owners.has(sessionId))
|
|
376
|
+
this.owners.set(sessionId, instanceId);
|
|
377
|
+
}
|
|
378
|
+
/** Why `instanceId` may not touch this session, or null (#1164). An unbound session, or no caller instance, is not refused. */
|
|
379
|
+
instanceRefusal(sessionId, instanceId) {
|
|
380
|
+
const owner = this.owners.get(sessionId);
|
|
381
|
+
if (!owner || !instanceId || owner === instanceId)
|
|
382
|
+
return null;
|
|
383
|
+
return `Coding session ${sessionId} belongs to another agent on this machine; a request from this agent cannot reach it.`;
|
|
384
|
+
}
|
|
385
|
+
/** Is this session visible to `instanceId` — its own, unbound, or no caller instance (#1164)? */
|
|
386
|
+
visibleTo(sessionId, instanceId) {
|
|
387
|
+
return this.instanceRefusal(sessionId, instanceId) === null;
|
|
388
|
+
}
|
|
366
389
|
list() {
|
|
367
390
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
368
391
|
sessionId,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { RunnerInputError } from "./errors.js";
|
|
2
|
+
/** The relayed instance id header, or undefined. */
|
|
3
|
+
export function instanceOf(req) {
|
|
4
|
+
const raw = req.headers["x-pags-instance-id"];
|
|
5
|
+
const id = (Array.isArray(raw) ? raw[0] : raw)?.trim();
|
|
6
|
+
return id || undefined;
|
|
7
|
+
}
|
|
8
|
+
/** The coding session a `/coding/*` request is about: its body's `sessionId`, or the takeover path's id. */
|
|
9
|
+
export function codingSessionIdOf(path, body) {
|
|
10
|
+
const m = path.match(/^\/coding\/takeover\/([^/]+)\//);
|
|
11
|
+
if (m)
|
|
12
|
+
return decodeURIComponent(m[1]);
|
|
13
|
+
return typeof body?.sessionId === "string" && body.sessionId ? body.sessionId : null;
|
|
14
|
+
}
|
|
15
|
+
/** Why `caller` may not reach the coding session this request names, or null. */
|
|
16
|
+
export async function codingSessionRefusal(coding, req, path, caller) {
|
|
17
|
+
if (!path.startsWith("/coding/"))
|
|
18
|
+
return null;
|
|
19
|
+
const sessionId = codingSessionIdOf(path, req.method === "POST" ? await readJson(req) : undefined);
|
|
20
|
+
return sessionId ? coding.instanceRefusal(sessionId, caller) : null;
|
|
21
|
+
}
|
|
22
|
+
/** A body read once and kept, so the session guard and the route handler both see it. */
|
|
23
|
+
const bodies = new WeakMap();
|
|
24
|
+
export async function readJson(req) {
|
|
25
|
+
if (bodies.has(req))
|
|
26
|
+
return bodies.get(req);
|
|
27
|
+
const chunks = [];
|
|
28
|
+
for await (const chunk of req)
|
|
29
|
+
chunks.push(Buffer.from(chunk));
|
|
30
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
31
|
+
let parsed = {};
|
|
32
|
+
if (raw) {
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new RunnerInputError("Request body must be valid JSON");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
bodies.set(req, parsed);
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { codingSessionRefusal, instanceOf, readJson } from "./request-instance.js";
|
|
1
2
|
import { createServer, } from "node:http";
|
|
2
3
|
import { URL } from "node:url";
|
|
3
4
|
import { recordRequestFailure } from "./request-failure.js";
|
|
@@ -75,6 +76,11 @@ export async function startRunnerServer(config) {
|
|
|
75
76
|
async function route(runner, req, res) {
|
|
76
77
|
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
77
78
|
const path = url.pathname.replace(/\/$/, "") || "/";
|
|
79
|
+
// The agent this request came from, and whether it may touch the coding session it names (#1164, request-instance.ts).
|
|
80
|
+
const caller = instanceOf(req);
|
|
81
|
+
const refused = await codingSessionRefusal(runner.coding, req, path, caller);
|
|
82
|
+
if (refused)
|
|
83
|
+
return json(res, 403, { error: refused });
|
|
78
84
|
if ((req.method === "GET" || req.method === "POST") && path === "/health") {
|
|
79
85
|
// #896: a health check that cannot say WHO answered is not enough to decide anything with.
|
|
80
86
|
// A contender for the single-instance lock has a pid and a port from the lock file; what it
|
|
@@ -290,7 +296,9 @@ async function route(runner, req, res) {
|
|
|
290
296
|
}
|
|
291
297
|
if (req.method === "POST" && path === "/coding/start") {
|
|
292
298
|
const b = await readJson(req);
|
|
293
|
-
|
|
299
|
+
const started = runner.coding.start(b);
|
|
300
|
+
runner.coding.bindInstance(b.sessionId, caller);
|
|
301
|
+
return json(res, 200, started);
|
|
294
302
|
}
|
|
295
303
|
if (req.method === "POST" && path === "/coding/capture") {
|
|
296
304
|
// `drainUsage` opts this caller in to consuming the session's measured engine spend (#267).
|
|
@@ -308,7 +316,8 @@ async function route(runner, req, res) {
|
|
|
308
316
|
return json(res, 200, runner.coding.end(b.sessionId));
|
|
309
317
|
}
|
|
310
318
|
if (req.method === "GET" && path === "/coding/sessions") {
|
|
311
|
-
|
|
319
|
+
// Each agent sees its own sessions (#1164); a local caller (the update controller) sees them all.
|
|
320
|
+
return json(res, 200, { sessions: runner.coding.list().filter((s) => runner.coding.visibleTo(s.sessionId, caller)) });
|
|
312
321
|
}
|
|
313
322
|
// Per-session CPU and memory, for the heartbeat (#924): which agent is loading this machine.
|
|
314
323
|
if (req.method === "GET" && path === "/coding/resources") {
|
|
@@ -827,20 +836,6 @@ function authorize(req, config) {
|
|
|
827
836
|
const headerToken = req.headers["x-pags-runner-token"];
|
|
828
837
|
return auth === `Bearer ${token}` || headerToken === token;
|
|
829
838
|
}
|
|
830
|
-
async function readJson(req) {
|
|
831
|
-
const chunks = [];
|
|
832
|
-
for await (const chunk of req)
|
|
833
|
-
chunks.push(Buffer.from(chunk));
|
|
834
|
-
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
835
|
-
if (!raw)
|
|
836
|
-
return {};
|
|
837
|
-
try {
|
|
838
|
-
return JSON.parse(raw);
|
|
839
|
-
}
|
|
840
|
-
catch {
|
|
841
|
-
throw new RunnerInputError("Request body must be valid JSON");
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
839
|
function clampLimit(value, fallback, max) {
|
|
845
840
|
const parsed = Number(value || fallback);
|
|
846
841
|
if (!Number.isFinite(parsed))
|
package/dist/index.js
CHANGED
|
@@ -1426,6 +1426,78 @@ function sampleResources(read = os, now = Date.now(), extra = {}) {
|
|
|
1426
1426
|
};
|
|
1427
1427
|
}
|
|
1428
1428
|
|
|
1429
|
+
// src/commands/runner/route-policy.ts
|
|
1430
|
+
var ALWAYS = ["/health", "/capabilities", "/events"];
|
|
1431
|
+
var READ_ALWAYS = ["/tasks"];
|
|
1432
|
+
var BROWSER = ["/tasks", "/browser/", "/takeover", "/sessions"];
|
|
1433
|
+
var BY_RUNTIME = {
|
|
1434
|
+
// The coding engine, the website runtime builder's task, and the engine's own SIGN-IN: the cloud relays the
|
|
1435
|
+
// login page through `/browser/act` + `/browser/handoff` and its card through `/takeover` (coding-sessions-open.ts).
|
|
1436
|
+
coding: ["/coding/", "/tasks", "/browser/act", "/browser/handoff", "/takeover"],
|
|
1437
|
+
browser: BROWSER,
|
|
1438
|
+
local_browser: ["/local-browser/"],
|
|
1439
|
+
local_artifact: ["/local-artifact/"],
|
|
1440
|
+
local_apply: ["/local-apply/", ...BROWSER],
|
|
1441
|
+
local_native: ["/native/"]
|
|
1442
|
+
};
|
|
1443
|
+
var TERMINAL = ["/tmux/", "/terminal/", "/secure/"];
|
|
1444
|
+
var REPO_READS = ["/coding/tree", "/coding/read-file", "/coding/git", "/coding/search", "/coding/git-remote", "/coding/repo-check"];
|
|
1445
|
+
var SIGN_IN_TMUX = /* @__PURE__ */ new Set(["/tmux/session", "/tmux/run", "/tmux/send", "/tmux/capture"]);
|
|
1446
|
+
var SIGN_IN_SESSION = /^pags-signin-[a-z0-9_-]+$/;
|
|
1447
|
+
function hasTerminal(caps) {
|
|
1448
|
+
const tools = caps.tools ?? [];
|
|
1449
|
+
return (caps.surfaces ?? []).includes("tmux") || tools.some((t) => t.startsWith("tmux_") || t.startsWith("terminal_"));
|
|
1450
|
+
}
|
|
1451
|
+
function allowedPrefixes(caps) {
|
|
1452
|
+
const out = [...ALWAYS];
|
|
1453
|
+
if (!caps) return out;
|
|
1454
|
+
if (caps.runtime && BY_RUNTIME[caps.runtime]) out.push(...BY_RUNTIME[caps.runtime]);
|
|
1455
|
+
const tools = caps.tools ?? [];
|
|
1456
|
+
if (hasTerminal(caps)) out.push(...TERMINAL);
|
|
1457
|
+
if (tools.some((t) => t.startsWith("browser_"))) out.push("/browser/");
|
|
1458
|
+
if (tools.some((t) => t.startsWith("repo_"))) out.push(...REPO_READS);
|
|
1459
|
+
return out;
|
|
1460
|
+
}
|
|
1461
|
+
function covers(prefix, path) {
|
|
1462
|
+
if (prefix.endsWith("/")) return path.startsWith(prefix);
|
|
1463
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
1464
|
+
}
|
|
1465
|
+
function routeRefusal(caps, path, method = "POST", body) {
|
|
1466
|
+
const clean4 = path.split("?")[0] ?? path;
|
|
1467
|
+
if (/(^|\/)\.\.(\/|$)/.test(clean4)) return `Refused ${clean4}: a relayed path may not climb out of its route.`;
|
|
1468
|
+
if (allowedPrefixes(caps).some((p) => covers(p, clean4))) return null;
|
|
1469
|
+
if ((method === "GET" || method === "HEAD") && READ_ALWAYS.some((p) => covers(p, clean4))) return null;
|
|
1470
|
+
if (caps?.runtime === "coding" && SIGN_IN_TMUX.has(clean4)) {
|
|
1471
|
+
const session = body?.session;
|
|
1472
|
+
if (typeof session === "string" && SIGN_IN_SESSION.test(session)) return null;
|
|
1473
|
+
}
|
|
1474
|
+
return `Refused ${clean4}: this agent's declared capabilities do not include it, so this machine will not run it for this agent.`;
|
|
1475
|
+
}
|
|
1476
|
+
var AgentRoutePolicy = class {
|
|
1477
|
+
constructor(fetchInstances) {
|
|
1478
|
+
this.fetchInstances = fetchInstances;
|
|
1479
|
+
}
|
|
1480
|
+
fetchInstances;
|
|
1481
|
+
declared = /* @__PURE__ */ new Map();
|
|
1482
|
+
refreshing = null;
|
|
1483
|
+
learn(instances) {
|
|
1484
|
+
for (const inst of instances ?? []) this.declared.set(inst.id, inst.capabilities ?? null);
|
|
1485
|
+
}
|
|
1486
|
+
refresh() {
|
|
1487
|
+
this.refreshing ??= this.fetchInstances().then((instances) => this.learn(instances)).catch(() => void 0).finally(() => {
|
|
1488
|
+
this.refreshing = null;
|
|
1489
|
+
});
|
|
1490
|
+
return this.refreshing;
|
|
1491
|
+
}
|
|
1492
|
+
refusalFor(id) {
|
|
1493
|
+
return (method, path, body) => {
|
|
1494
|
+
const caps = this.declared.get(id);
|
|
1495
|
+
if (caps === void 0) void this.refresh();
|
|
1496
|
+
return routeRefusal(caps, path, method, body);
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1429
1501
|
// src/commands/runner/self-update.ts
|
|
1430
1502
|
import { spawn as spawn4 } from "child_process";
|
|
1431
1503
|
var RUNNER_UPDATE_PATH = "/pags/runner/update";
|
|
@@ -2803,6 +2875,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2803
2875
|
if (plan.target && !attached.has(plan.target)) forceNext.delete(plan.target);
|
|
2804
2876
|
return { status: 200, result: { attached: [...attached.keys()], ...plan.target ? { target: plan.target, holding: attached.has(plan.target) } : {} } };
|
|
2805
2877
|
};
|
|
2878
|
+
const routes = new AgentRoutePolicy(() => requestPags("GET", "/v1/instances/my/instances", { ...opts, pagsToken: pagsToken() }).then((r) => r.instances));
|
|
2879
|
+
await routes.refresh();
|
|
2806
2880
|
const attach = (id, label = `${id.slice(0, 8)}\u2026`) => {
|
|
2807
2881
|
if (attached.has(id)) return;
|
|
2808
2882
|
const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken: pagsToken() }, {}).then((r) => r.token);
|
|
@@ -2838,7 +2912,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2838
2912
|
runnerNode,
|
|
2839
2913
|
// The final automatic-update drain is an admission gate, not merely another poll:
|
|
2840
2914
|
// command work starts only when the old runner is still permitted to serve it.
|
|
2841
|
-
() => relayMutationAdmission.begin()
|
|
2915
|
+
() => relayMutationAdmission.begin(),
|
|
2916
|
+
routes.refusalFor(id)
|
|
2842
2917
|
)
|
|
2843
2918
|
);
|
|
2844
2919
|
if (label) writeLine(`Attached agent: ${label}`);
|
|
@@ -2934,6 +3009,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2934
3009
|
"/v1/instances/my/instances",
|
|
2935
3010
|
{ ...opts, pagsToken: pagsToken() }
|
|
2936
3011
|
);
|
|
3012
|
+
routes.learn(res.instances);
|
|
2937
3013
|
const { attach: toAttach, detach: toDetach } = diffMembership(
|
|
2938
3014
|
attached.keys(),
|
|
2939
3015
|
res.instances ?? [],
|
|
@@ -2983,7 +3059,7 @@ async function readSessionResources(localUrl, runnerToken) {
|
|
|
2983
3059
|
}
|
|
2984
3060
|
var RTT_PROBE_PREFIX = "rtt:";
|
|
2985
3061
|
var RTT_ECHO_PREFIX = "rtt-echo:";
|
|
2986
|
-
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname5(), beginMutation = () => () => void 0) {
|
|
3062
|
+
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname5(), beginMutation = () => () => void 0, routeRefusal2 = () => null) {
|
|
2987
3063
|
let backoffMs = 1e3;
|
|
2988
3064
|
let reconnecting = false;
|
|
2989
3065
|
let closed = false;
|
|
@@ -3051,6 +3127,14 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
3051
3127
|
return;
|
|
3052
3128
|
}
|
|
3053
3129
|
const method = (cmd.method || "POST").toUpperCase();
|
|
3130
|
+
const refused = routeRefusal2(method, cmd.path, cmd.body);
|
|
3131
|
+
if (refused) {
|
|
3132
|
+
try {
|
|
3133
|
+
ws.send(JSON.stringify({ id: cmd.id, status: 403, result: { error: refused } }));
|
|
3134
|
+
} catch {
|
|
3135
|
+
}
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3054
3138
|
const releaseMutation = method !== "GET" && method !== "HEAD" ? beginMutation() : null;
|
|
3055
3139
|
if (method !== "GET" && method !== "HEAD" && !releaseMutation) {
|
|
3056
3140
|
try {
|