@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
- return json(res, 200, runner.coding.start(b));
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
- return json(res, 200, { sessions: runner.coding.list() });
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.156",
3
+ "version": "0.4.157",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",