@proagentstore/cli 0.4.155 → 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.
@@ -1,3 +1,4 @@
1
+ import { confineWorkDir } from "./workdir-guard.js";
1
2
  import { actDigest } from "./input-ledger.js";
2
3
  import { parkRestore, parkSnapshot } from "./park-snapshot.js";
3
4
  import { runObjective } from "./run-objective.js";
@@ -15,6 +16,13 @@ const MAX_PANE = 64 * 1024;
15
16
  export class CodingRuntime {
16
17
  reposBaseDir;
17
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();
18
26
  /**
19
27
  * Coding turns that are running here (#896, #1007).
20
28
  *
@@ -84,21 +92,26 @@ export class CodingRuntime {
84
92
  * workDir (authoritative even for managed clone dirs); fall back to an explicit path
85
93
  * (the cloud passes the D1 `repo.workdir` when the session map is empty after a runner
86
94
  * restart). Expands a leading `~` the same way start() does.
95
+ *
96
+ * An explicit path is CONFINED (#1165): never the home folder or above it, never inside a
97
+ * credential directory — so `{ workDir: "~", path: ".ssh/id_ed25519" }` is refused rather
98
+ * than a confined read of a private key. See `workdir-guard.ts`.
87
99
  */
88
100
  resolveWorkDir(input) {
89
101
  if (input.sessionId) {
90
102
  const s = this.sessions.get(input.sessionId);
103
+ // The session's own folder answers to the same rule: a session started in `~` must not reopen it.
91
104
  if (s)
92
- return s.config.workDir;
105
+ return confineWorkDir(s.config.workDir, { allow: [this.reposBaseDir] });
93
106
  }
94
107
  if (input.workDir)
95
- return resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()));
108
+ return confineWorkDir(input.workDir, { allow: [this.reposBaseDir] });
96
109
  throw new InspectError("no session or workDir to inspect");
97
110
  }
98
111
  /** Where a parked run's checkout is (#1077): its declared folder, else the managed clone dir `start()` uses. */
99
112
  parkWorkDir(input) {
100
113
  if (input.workDir)
101
- return resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()));
114
+ return confineWorkDir(input.workDir, { allow: [this.reposBaseDir] });
102
115
  if (input.repoId)
103
116
  return join(this.reposBaseDir, sanitizeSessionName(input.repoId));
104
117
  throw new InspectError("no workDir or repoId for the parked checkout");
@@ -357,6 +370,22 @@ export class CodingRuntime {
357
370
  this.takeovers.delete(sessionId);
358
371
  return { ok: true, usage, acts, authResolved };
359
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
+ }
360
389
  list() {
361
390
  return [...this.sessions.entries()].map(([sessionId, s]) => ({
362
391
  sessionId,
@@ -1,7 +1,8 @@
1
- import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
1
+ import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { RunnerInputError } from "../errors.js";
5
+ import { isWithin, realPathOf } from "./workdir-guard.js";
5
6
  /**
6
7
  * The runner's half of the machine-to-machine secret handoff (#918): read a secret file so the
7
8
  * cloud can encrypt it, and write one back from the cloud's decrypted copy.
@@ -18,23 +19,44 @@ import { RunnerInputError } from "../errors.js";
18
19
  */
19
20
  /** Big enough for any `.env`, key file or credentials JSON; small enough that this is not a file-transfer tool. */
20
21
  export const SECRET_MAX_BYTES = 64 * 1024;
21
- /** `~` expanded; a relative path resolves against the home directory, which is where `pags up` users name things from. */
22
- function resolvePath(raw) {
22
+ /**
23
+ * The ONE folder the secret handoff reads from and writes to (#1165).
24
+ *
25
+ * It used to take any path the owner's account could reach: `/secure/read` would hand over
26
+ * `~/.ssh/id_ed25519`, and `/secure/write` with `overwrite: true` could replace `~/.ssh/authorized_keys`.
27
+ * Now a relative path is relative to this folder, and an absolute or `~/…` path must already be inside
28
+ * it, resolved through symlinks, so a link planted in it cannot point back out. Moving a delivered
29
+ * file to where it is used is the operator's step, in the open.
30
+ */
31
+ export function secureFilesDir(home = homedir()) {
32
+ return join(home, ".config", "proagentstore", "secure-files");
33
+ }
34
+ /** `~` expanded; relative to {@link secureFilesDir}; refused unless it stays inside that folder. */
35
+ function resolvePath(raw, home = homedir()) {
23
36
  const p = String(raw ?? "").trim();
24
37
  if (!p)
25
38
  throw new RunnerInputError("A `path` is required.");
26
- const expanded = p.replace(/^~(?=$|\/)/, homedir());
27
- return isAbsolute(expanded) ? resolve(expanded) : resolve(homedir(), expanded);
39
+ const root = secureFilesDir(home);
40
+ const expanded = p.replace(/^~(?=$|\/)/, home);
41
+ const path = isAbsolute(expanded) ? resolve(expanded) : resolve(root, expanded);
42
+ mkdirSync(root, { recursive: true, mode: 0o700 });
43
+ if (path === root || !isWithin(realPathOf(path, home), realPathOf(root, home))) {
44
+ throw new RunnerInputError(`${p} is outside ${root}. The secret handoff reads and writes only inside that folder — name a file there, e.g. \`app.env\`.`);
45
+ }
46
+ return path;
28
47
  }
29
- export function readSecretFile(input) {
30
- const path = resolvePath(input.path);
48
+ export function readSecretFile(input, home = homedir()) {
49
+ const path = resolvePath(input.path, home);
31
50
  let st;
32
51
  try {
33
- st = statSync(path);
52
+ st = lstatSync(path);
34
53
  }
35
54
  catch {
36
55
  throw new RunnerInputError(`No file at ${path}.`);
37
56
  }
57
+ // Not followed (#1165): a symlink in the folder would read whatever it points at.
58
+ if (st.isSymbolicLink())
59
+ throw new RunnerInputError(`${path} is a symbolic link; the secret handoff reads regular files only.`);
38
60
  if (!st.isFile())
39
61
  throw new RunnerInputError(`${path} is not a regular file.`);
40
62
  if (st.size === 0)
@@ -63,8 +85,8 @@ function parseMode(raw) {
63
85
  throw new RunnerInputError(`mode ${s} is group/world-writable or executable; a secret file must not be.`);
64
86
  return mode;
65
87
  }
66
- export function writeSecretFile(input) {
67
- const path = resolvePath(input.path);
88
+ export function writeSecretFile(input, home = homedir()) {
89
+ const path = resolvePath(input.path, home);
68
90
  if (typeof input.value !== "string" || !input.value)
69
91
  throw new RunnerInputError("Nothing to write.");
70
92
  const mode = parseMode(input.mode);
@@ -0,0 +1,80 @@
1
+ /**
2
+ * What a caller-supplied `workDir` may be (#1165).
3
+ *
4
+ * The read-only inspection routes (`read-file`, `tree`, `search`, `git`) confine every PATH to the
5
+ * `workDir` — but took the `workDir` itself from the request. `{ workDir: "~", path: ".ssh/id_ed25519" }`
6
+ * was a perfectly confined read of a private key. The cloud sends the owner's registered repo folder
7
+ * (`coding_repos.workdir`), and registration accepts any local path, `~` included; this runner has no
8
+ * copy of that registry to check against. So it refuses what can never be a repository it should
9
+ * read inside, whoever asked:
10
+ *
11
+ * • the filesystem root, the home folder, or any folder ABOVE the home folder — a root that holds
12
+ * everything the owner has;
13
+ * • a folder inside a credential directory ({@link CREDENTIAL_DIRS}) — `~/.ssh`, `~/.gnupg`, the
14
+ * cloud CLIs' config, and ProAgentStore's own (`~/.config/proagentstore`, which holds the account
15
+ * session), except the managed clones the runner itself makes there.
16
+ *
17
+ * Resolved through symlinks first (the nearest existing ancestor, for a folder a clone will create), so
18
+ * `~/dev/innocent -> ~/.ssh` is the credential directory it points at.
19
+ */
20
+ import { existsSync, realpathSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
23
+ import { InspectError } from "./inspect.js";
24
+ /** Folders under the home folder whose contents are credentials, never a repository to read. */
25
+ export const CREDENTIAL_DIRS = [
26
+ ".ssh",
27
+ ".gnupg",
28
+ ".aws",
29
+ ".azure",
30
+ ".kube",
31
+ ".docker",
32
+ ".password-store",
33
+ ".config/gcloud",
34
+ ".config/gh",
35
+ ".config/proagentstore",
36
+ "Library/Keychains",
37
+ ];
38
+ /** `~` expanded; resolved through symlinks as far as the path exists. */
39
+ export function realPathOf(raw, home = homedir()) {
40
+ const expanded = raw.trim().replace(/^~(?=$|[\\/])/, home);
41
+ let p = resolve(isAbsolute(expanded) ? expanded : join(home, expanded));
42
+ const tail = [];
43
+ while (!existsSync(p)) {
44
+ const up = dirname(p);
45
+ if (up === p)
46
+ break;
47
+ tail.unshift(basename(p));
48
+ p = up;
49
+ }
50
+ let real = p;
51
+ try {
52
+ real = realpathSync(p);
53
+ }
54
+ catch {
55
+ // Unreadable: judge the path as written.
56
+ }
57
+ return tail.length ? join(real, ...tail) : real;
58
+ }
59
+ /** Is `child` the same as `parent` or inside it? */
60
+ export function isWithin(child, parent) {
61
+ const rel = relative(parent, child);
62
+ return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
63
+ }
64
+ /**
65
+ * The `workDir` a request may name, or an {@link InspectError} saying why not. `allow` lists folders the
66
+ * runner itself manages inside a credential directory — the managed clone root — which stay usable.
67
+ */
68
+ export function confineWorkDir(raw, opts = {}) {
69
+ const home = realPathOf(opts.home ?? homedir(), opts.home ?? homedir());
70
+ const path = realPathOf(raw, opts.home ?? homedir());
71
+ if (isWithin(home, path)) {
72
+ throw new InspectError(`"${raw}" is ${path === home ? "the home folder" : "a folder that contains the home folder"}, not a repository — register the repository's own folder.`);
73
+ }
74
+ if ((opts.allow ?? []).some((a) => isWithin(path, realPathOf(a, home))))
75
+ return path;
76
+ const credential = CREDENTIAL_DIRS.find((d) => isWithin(path, realPathOf(join(home, d), home)));
77
+ if (credential)
78
+ throw new InspectError(`"${raw}" is inside ~/${credential}, which holds credentials — it cannot be read as a repository.`);
79
+ return path;
80
+ }
@@ -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.155",
3
+ "version": "0.4.157",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",