@proagentstore/cli 0.4.30 → 0.4.32
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/headless.js +11 -2
- package/dist/browser-runner/coding/repo.js +63 -0
- package/dist/browser-runner/coding/runtime.js +3 -3
- package/dist/browser-runner/coding/tmux.js +0 -48
- package/dist/browser-runner/index.js +6 -1
- package/dist/browser-runner/server.js +37 -37
- package/package.json +1 -1
|
@@ -24,7 +24,16 @@ import { dirname, join } from "node:path";
|
|
|
24
24
|
import { handlerFor } from "./handlers.js";
|
|
25
25
|
export class HeadlessSession {
|
|
26
26
|
config;
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* A human-readable label for this engine process (#247).
|
|
29
|
+
*
|
|
30
|
+
* Was `sessionName`, formatted `pags-<client>-<id>` — which looked exactly like a tmux
|
|
31
|
+
* target and was reported to the console as `tmuxSession`. The engine has not used tmux
|
|
32
|
+
* since it moved to the stream-json interface, so a user who did the obvious thing with a
|
|
33
|
+
* name like that (`tmux attach -t pags-claude-…`) got "session not found" and reasonably
|
|
34
|
+
* concluded their engine was broken. It addresses nothing — it is only ever displayed.
|
|
35
|
+
*/
|
|
36
|
+
engineLabel;
|
|
28
37
|
proc = null;
|
|
29
38
|
buf = "";
|
|
30
39
|
transcript = [];
|
|
@@ -55,7 +64,7 @@ export class HeadlessSession {
|
|
|
55
64
|
spawnFailed = false;
|
|
56
65
|
constructor(config) {
|
|
57
66
|
this.config = config;
|
|
58
|
-
this.
|
|
67
|
+
this.engineLabel = `${config.clientType}:${config.id}`;
|
|
59
68
|
this.claudeSessionId = readState(config.statePath, config.id);
|
|
60
69
|
// Claude is the structured engine; everything else is a raw CLI.
|
|
61
70
|
this.mode = config.clientType === "claude" ? "stream-json" : "raw";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Repo/workdir helpers for the coding engine.
|
|
6
|
+
*
|
|
7
|
+
* These lived in `coding/tmux.ts` and have nothing to do with tmux — `ensureRepo` runs `git
|
|
8
|
+
* clone`. The coding engine stopped using tmux when it moved to the structured stream-json
|
|
9
|
+
* interface, so anyone cleaning up that module found its two most load-bearing functions
|
|
10
|
+
* inside it (#247). Split out so the tmux module is only tmux, and only the terminal-operator
|
|
11
|
+
* agents depend on it.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* A safe, collision-resistant label derived from an arbitrary string.
|
|
15
|
+
*
|
|
16
|
+
* Named for tmux because that is where it started, but the coding engine uses it purely as a
|
|
17
|
+
* display/identity label — no tmux target is derived from it (#247).
|
|
18
|
+
*/
|
|
19
|
+
export function sanitizeSessionName(label) {
|
|
20
|
+
return label.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "session";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
|
|
24
|
+
* — an existing checkout is left alone (no clobber). For private repos a GitHub
|
|
25
|
+
* App installation token is injected as `x-access-token` into an https URL. The
|
|
26
|
+
* coding CLI then runs in this directory.
|
|
27
|
+
*
|
|
28
|
+
* Returns the absolute working directory. Throws on clone failure so the caller
|
|
29
|
+
* can surface it (a session can't start without its repo).
|
|
30
|
+
*/
|
|
31
|
+
export function ensureRepo(dir, opts = {}) {
|
|
32
|
+
// A real checkout (has .git) is reused as-is.
|
|
33
|
+
if (existsSync(join(dir, ".git")))
|
|
34
|
+
return dir;
|
|
35
|
+
if (!opts.cloneUrl) {
|
|
36
|
+
// No source to clone from — just make the directory the engine will run in.
|
|
37
|
+
if (!existsSync(dir))
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
// The dir exists but has no `.git`. It could be a half-cloned/empty managed dir
|
|
42
|
+
// (safe to clear) OR a real user directory the caller passed as an explicit workDir
|
|
43
|
+
// (deleting it = data loss). NEVER recursively delete a non-empty non-git dir — refuse
|
|
44
|
+
// instead, so a mis-wired workDir+cloneUrl can't nuke a user's files. An empty dir is
|
|
45
|
+
// fine to remove (git clone needs an empty/absent target).
|
|
46
|
+
if (existsSync(dir)) {
|
|
47
|
+
const entries = readdirSync(dir);
|
|
48
|
+
if (entries.length > 0) {
|
|
49
|
+
throw new Error(`Refusing to clone into non-empty directory "${dir}" (no .git found) — move it aside or point at an empty path.`);
|
|
50
|
+
}
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
let url = opts.cloneUrl;
|
|
54
|
+
if (opts.token && /^https:\/\//.test(url)) {
|
|
55
|
+
url = url.replace(/^https:\/\//, `https://x-access-token:${opts.token}@`);
|
|
56
|
+
}
|
|
57
|
+
const args = ["clone", "--depth", "1"];
|
|
58
|
+
if (opts.branch)
|
|
59
|
+
args.push("--branch", opts.branch);
|
|
60
|
+
args.push(url, dir);
|
|
61
|
+
execFileSync("git", args, { stdio: "pipe", timeout: 180_000 });
|
|
62
|
+
return dir;
|
|
63
|
+
}
|
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
4
4
|
import { InspectError, readGitRemoteOrigin, readRepoFile, repoTree, runRepoGit } from "./inspect.js";
|
|
5
|
-
import { ensureRepo, sanitizeSessionName } from "./
|
|
5
|
+
import { ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
6
6
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
7
7
|
const MAX_PANE = 64 * 1024;
|
|
8
8
|
export class CodingRuntime {
|
|
@@ -135,14 +135,14 @@ export class CodingRuntime {
|
|
|
135
135
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
136
136
|
sessionId,
|
|
137
137
|
alive: s.alive,
|
|
138
|
-
|
|
138
|
+
engineLabel: s.engineLabel,
|
|
139
139
|
}));
|
|
140
140
|
}
|
|
141
141
|
/** Rich diagnostics for every tracked session — the console's transparency view. */
|
|
142
142
|
diagnostics() {
|
|
143
143
|
return [...this.sessions.entries()].map(([sessionId, s]) => ({
|
|
144
144
|
sessionId,
|
|
145
|
-
|
|
145
|
+
engineLabel: s.engineLabel,
|
|
146
146
|
alive: s.alive,
|
|
147
147
|
runState: s.alive ? s.runState() : "idle",
|
|
148
148
|
ready: s.alive ? s.ready : false,
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
2
|
/**
|
|
5
3
|
* Low-level tmux primitives for the coding runtime.
|
|
6
4
|
*
|
|
@@ -127,49 +125,3 @@ export function capturePane(target, lines = 200) {
|
|
|
127
125
|
const captured = tmuxExec(["capture-pane", "-p", "-t", target, "-S", `-${lines}`, "-J"]);
|
|
128
126
|
return stripAnsi(captured).trim();
|
|
129
127
|
}
|
|
130
|
-
/** A safe, collision-resistant tmux session name derived from an arbitrary label. */
|
|
131
|
-
export function sanitizeSessionName(label) {
|
|
132
|
-
return label.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "session";
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
|
|
136
|
-
* — an existing checkout is left alone (no clobber). For private repos a GitHub
|
|
137
|
-
* App installation token is injected as `x-access-token` into an https URL. The
|
|
138
|
-
* coding CLI then runs in this directory.
|
|
139
|
-
*
|
|
140
|
-
* Returns the absolute working directory. Throws on clone failure so the caller
|
|
141
|
-
* can surface it (a session can't start without its repo).
|
|
142
|
-
*/
|
|
143
|
-
export function ensureRepo(dir, opts = {}) {
|
|
144
|
-
// A real checkout (has .git) is reused as-is.
|
|
145
|
-
if (existsSync(join(dir, ".git")))
|
|
146
|
-
return dir;
|
|
147
|
-
if (!opts.cloneUrl) {
|
|
148
|
-
// No source to clone from — make the directory so tmux can cd into it.
|
|
149
|
-
if (!existsSync(dir))
|
|
150
|
-
mkdirSync(dir, { recursive: true });
|
|
151
|
-
return dir;
|
|
152
|
-
}
|
|
153
|
-
// The dir exists but has no `.git`. It could be a half-cloned/empty managed dir
|
|
154
|
-
// (safe to clear) OR a real user directory the caller passed as an explicit workDir
|
|
155
|
-
// (deleting it = data loss). NEVER recursively delete a non-empty non-git dir — refuse
|
|
156
|
-
// instead, so a mis-wired workDir+cloneUrl can't nuke a user's files. An empty dir is
|
|
157
|
-
// fine to remove (git clone needs an empty/absent target).
|
|
158
|
-
if (existsSync(dir)) {
|
|
159
|
-
const entries = readdirSync(dir);
|
|
160
|
-
if (entries.length > 0) {
|
|
161
|
-
throw new Error(`Refusing to clone into non-empty directory "${dir}" (no .git found) — move it aside or point at an empty path.`);
|
|
162
|
-
}
|
|
163
|
-
rmSync(dir, { recursive: true, force: true });
|
|
164
|
-
}
|
|
165
|
-
let url = opts.cloneUrl;
|
|
166
|
-
if (opts.token && /^https:\/\//.test(url)) {
|
|
167
|
-
url = url.replace(/^https:\/\//, `https://x-access-token:${opts.token}@`);
|
|
168
|
-
}
|
|
169
|
-
const args = ["clone", "--depth", "1"];
|
|
170
|
-
if (opts.branch)
|
|
171
|
-
args.push("--branch", opts.branch);
|
|
172
|
-
args.push(url, dir);
|
|
173
|
-
execFileSync("git", args, { stdio: "pipe", timeout: 180_000 });
|
|
174
|
-
return dir;
|
|
175
|
-
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { startRunnerServer } from "./server.js";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
5
6
|
// Resilience: a stray error in any runtime must NOT take the whole runner down —
|
|
6
7
|
// that drops the tunnel and forces the user to restart `pags up` (and lose their
|
|
7
8
|
// session). Log it and keep serving. Per-component handlers catch most things;
|
|
@@ -29,7 +30,11 @@ function configFromArgs() {
|
|
|
29
30
|
host: arg("--host", process.env.PAGS_RUNNER_HOST || "127.0.0.1") || "127.0.0.1",
|
|
30
31
|
port: Number(arg("--port", process.env.PAGS_RUNNER_PORT || "49171")),
|
|
31
32
|
dataDir,
|
|
32
|
-
|
|
33
|
+
// Never start unauthenticated (#245). This surface drives a coding CLI with permissions
|
|
34
|
+
// skipped, and `authorize` now fails closed — so a missing token would make the runner
|
|
35
|
+
// answer nothing rather than answer everyone. Generate one and print it, mirroring what
|
|
36
|
+
// `pags runner connect` has always done.
|
|
37
|
+
token: arg("--token", process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`,
|
|
33
38
|
instanceId: arg("--instance-id", process.env.PAGS_INSTANCE_ID),
|
|
34
39
|
headless: flag("--headless") || process.env.PAGS_RUNNER_HEADLESS === "1",
|
|
35
40
|
};
|
|
@@ -162,18 +162,12 @@ async function route(runner, req, res) {
|
|
|
162
162
|
return json(res, 200, { sessions: runner.coding.list() });
|
|
163
163
|
}
|
|
164
164
|
if ((req.method === "GET" || req.method === "POST") && path === "/coding/diagnostics") {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return json(res, 200, {
|
|
172
|
-
tracked,
|
|
173
|
-
orphanedTmux,
|
|
174
|
-
tmuxTotal: allTmux.length,
|
|
175
|
-
pagsTmuxTotal: pagsTmux.length,
|
|
176
|
-
});
|
|
165
|
+
// No tmux figures here any more (#247). The coding engine spawns a child process
|
|
166
|
+
// directly, so `pagsTmuxTotal` was structurally always 0 and `tmuxTotal` counted the
|
|
167
|
+
// user's own unrelated sessions — this is the panel someone opens BECAUSE something is
|
|
168
|
+
// wrong, and it pointed them at a false cause. The terminal-operator agents, which do
|
|
169
|
+
// use tmux, have their own /tmux/* endpoints and are unaffected.
|
|
170
|
+
return json(res, 200, { tracked: runner.coding.diagnostics() });
|
|
177
171
|
}
|
|
178
172
|
if (req.method === "POST" && path === "/coding/browse") {
|
|
179
173
|
const { readdirSync, statSync } = await import("node:fs");
|
|
@@ -197,30 +191,14 @@ async function route(runner, req, res) {
|
|
|
197
191
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e), dir });
|
|
198
192
|
}
|
|
199
193
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
targets = pagsTmux.filter((n) => !tracked.has(n));
|
|
209
|
-
}
|
|
210
|
-
else if (b.sessions?.length) {
|
|
211
|
-
targets = b.sessions.filter((n) => typeof n === "string" && n.startsWith("pags-"));
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
// Kill all pags-* tmux sessions
|
|
215
|
-
targets = tmuxList().filter((n) => n.startsWith("pags-"));
|
|
216
|
-
runner.coding.closeAll();
|
|
217
|
-
}
|
|
218
|
-
let killed = 0;
|
|
219
|
-
for (const name of targets) {
|
|
220
|
-
if (tmuxKill(name))
|
|
221
|
-
killed++;
|
|
222
|
-
}
|
|
223
|
-
return json(res, 200, { killed, sessions: targets });
|
|
194
|
+
// Close every tracked coding session. The path still says "kill-tmux" ON PURPOSE: an older
|
|
195
|
+
// runner must keep answering a newer API, and renaming it would 404 across that skew (#247).
|
|
196
|
+
// The tmux half is gone — it only ever targeted `pags-*` sessions, which the coding engine
|
|
197
|
+
// has never created. `closeAll()` is the part that always worked, and is now the whole job.
|
|
198
|
+
if (req.method === "POST" && (path === "/coding/kill-tmux" || path === "/coding/close-sessions")) {
|
|
199
|
+
const closed = runner.coding.diagnostics().map((s) => s.sessionId);
|
|
200
|
+
runner.coding.closeAll();
|
|
201
|
+
return json(res, 200, { closed: closed.length, sessions: closed });
|
|
224
202
|
}
|
|
225
203
|
// ── Read-only code inspection (the Co-pilot/Chat's "eyes" — no CLI driving) ──
|
|
226
204
|
// Confined to the session's workDir by inspect.ts; errors surface as 400.
|
|
@@ -413,13 +391,35 @@ async function route(runner, req, res) {
|
|
|
413
391
|
}
|
|
414
392
|
return json(res, 404, { error: "Not found" });
|
|
415
393
|
}
|
|
394
|
+
/**
|
|
395
|
+
* Authorize a request to the local runner (#245).
|
|
396
|
+
*
|
|
397
|
+
* This surface drives a coding CLI that `pags up` launches with
|
|
398
|
+
* `--dangerously-skip-permissions` / `--sandbox danger-full-access`, so "who may POST here" is
|
|
399
|
+
* the whole security boundary. Two things were the wrong way round:
|
|
400
|
+
*
|
|
401
|
+
* 1. **No token used to mean ALLOW.** `pags up` always generates one, so that path was safe —
|
|
402
|
+
* but `pags-browser-runner` run directly (its own --help documents this) passes
|
|
403
|
+
* `token: undefined`, and served the entire surface unauthenticated. Now it fails CLOSED;
|
|
404
|
+
* the standalone entrypoint generates a token instead of starting open.
|
|
405
|
+
*
|
|
406
|
+
* 2. **A browser could reach it.** Binding to loopback is not isolation: a page the user is
|
|
407
|
+
* visiting cannot READ a cross-origin response, but it can still SEND the POST, and the
|
|
408
|
+
* server sets no CORS headers and did no Origin check. The token already made that
|
|
409
|
+
* unguessable — but nothing legitimate that calls this runner is a browser (the cloud
|
|
410
|
+
* dispatches over the relay; the CLI calls it directly), and neither sends `Origin`. So the
|
|
411
|
+
* presence of that header is by itself proof the caller is a web page, and is refused before
|
|
412
|
+
* the token is even considered. Also closes DNS-rebinding, which loopback does not.
|
|
413
|
+
*/
|
|
416
414
|
function authorize(req, config) {
|
|
415
|
+
if (req.headers.origin)
|
|
416
|
+
return false;
|
|
417
417
|
const token = config.token;
|
|
418
418
|
if (config.instanceId && req.headers["x-pags-instance-id"] !== config.instanceId) {
|
|
419
419
|
return false;
|
|
420
420
|
}
|
|
421
421
|
if (!token)
|
|
422
|
-
return
|
|
422
|
+
return false;
|
|
423
423
|
const auth = req.headers.authorization || "";
|
|
424
424
|
const headerToken = req.headers["x-pags-runner-token"];
|
|
425
425
|
return auth === `Bearer ${token}` || headerToken === token;
|