@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.
- package/dist/browser-runner/coding/runtime.js +32 -3
- package/dist/browser-runner/coding/secret-file.js +32 -10
- package/dist/browser-runner/coding/workdir-guard.js +80 -0
- package/dist/browser-runner/request-instance.js +42 -0
- package/dist/browser-runner/server.js +11 -16
- package/dist/index.js +86 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
22
|
-
|
|
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
|
|
27
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|