@proagentstore/cli 0.4.155 → 0.4.158
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/bootstrap.js +1 -1
- package/dist/browser-runner/coding/pane-redact.js +84 -0
- 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/crash-reports.js +9 -2
- package/dist/browser-runner/request-instance.js +42 -0
- package/dist/browser-runner/server.js +17 -17
- package/dist/{chunk-PKMXQJEJ.js → chunk-7O4S5477.js} +184 -7
- package/dist/index.js +119 -202
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { maskSecrets } from "../crash-reports.js";
|
|
2
|
+
import { tmuxExec } from "./tmux.js";
|
|
3
|
+
/** How long a delivered value is masked in captures. */
|
|
4
|
+
export const INJECTED_TTL_MS = 24 * 60 * 60_000;
|
|
5
|
+
const MAX_REMEMBERED = 50;
|
|
6
|
+
const injected = [];
|
|
7
|
+
/** Mask `value` in every capture from now on (for {@link INJECTED_TTL_MS}). Values under 4 characters are not maskable. */
|
|
8
|
+
export function rememberInjected(value, now = Date.now()) {
|
|
9
|
+
const v = value.trim();
|
|
10
|
+
if (v.length < 4)
|
|
11
|
+
return;
|
|
12
|
+
injected.push({ value: v, until: now + INJECTED_TTL_MS });
|
|
13
|
+
while (injected.length > MAX_REMEMBERED)
|
|
14
|
+
injected.shift();
|
|
15
|
+
}
|
|
16
|
+
/** For tests. */
|
|
17
|
+
export function forgetInjected() {
|
|
18
|
+
injected.length = 0;
|
|
19
|
+
}
|
|
20
|
+
/** Every remembered value in `text`, replaced character for character with `•` — across line wraps too. */
|
|
21
|
+
export function maskInjected(text, now = Date.now()) {
|
|
22
|
+
const live = injected.filter((e) => e.until > now);
|
|
23
|
+
if (!live.length || !text)
|
|
24
|
+
return text;
|
|
25
|
+
// Search the text with its newlines removed, then mask the ORIGINAL positions, so a value tmux wrapped
|
|
26
|
+
// onto the next row is still found and the pane keeps its shape.
|
|
27
|
+
const at = [];
|
|
28
|
+
let flat = "";
|
|
29
|
+
for (let i = 0; i < text.length; i++) {
|
|
30
|
+
if (text[i] === "\n" || text[i] === "\r")
|
|
31
|
+
continue;
|
|
32
|
+
at.push(i);
|
|
33
|
+
flat += text[i];
|
|
34
|
+
}
|
|
35
|
+
const chars = text.split("");
|
|
36
|
+
for (const { value } of live) {
|
|
37
|
+
for (let i = flat.indexOf(value); i !== -1; i = flat.indexOf(value, i + value.length)) {
|
|
38
|
+
for (let k = i; k < i + value.length; k++)
|
|
39
|
+
chars[at[k]] = "•";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return chars.join("");
|
|
43
|
+
}
|
|
44
|
+
/** A pane as it may leave this machine. */
|
|
45
|
+
export function redactPane(text, now = Date.now()) {
|
|
46
|
+
return maskInjected(maskSecrets(text), now);
|
|
47
|
+
}
|
|
48
|
+
const redacting = new WeakSet();
|
|
49
|
+
/** Mark this response's panes for redaction: a terminal route, unless the caller is inject confirming a delivery. */
|
|
50
|
+
export function redactPanesOf(res, path, body) {
|
|
51
|
+
if ((path.startsWith("/tmux/") || path.startsWith("/terminal/")) && body?.unmasked !== true)
|
|
52
|
+
redacting.add(res);
|
|
53
|
+
}
|
|
54
|
+
/** The body `json()` writes: its `pane`/`paneBefore` redacted when {@link redactPanesOf} marked the response. */
|
|
55
|
+
export function outgoing(res, body) {
|
|
56
|
+
if (!redacting.has(res) || !body || typeof body !== "object")
|
|
57
|
+
return body;
|
|
58
|
+
const out = { ...body };
|
|
59
|
+
for (const key of ["pane", "paneBefore"])
|
|
60
|
+
if (typeof out[key] === "string")
|
|
61
|
+
out[key] = redactPane(out[key]);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `POST /secure/scrub {target, value}`: after an echoed delivery, remember the value for masking and clear
|
|
66
|
+
* the tmux pane's scrollback. `cleared` is false for a non-tmux backend, whose history this cannot reach.
|
|
67
|
+
*/
|
|
68
|
+
export function scrubDelivered(body) {
|
|
69
|
+
const value = typeof body.value === "string" ? body.value : "";
|
|
70
|
+
const target = typeof body.target === "string" ? body.target.trim() : "";
|
|
71
|
+
if (!value || !target)
|
|
72
|
+
return { remembered: false, cleared: false };
|
|
73
|
+
rememberInjected(value);
|
|
74
|
+
const tmuxTarget = /^tmux:/i.test(target) ? target.slice(5) : /^(kitty|iterm2):/i.test(target) ? "" : target;
|
|
75
|
+
if (!tmuxTarget)
|
|
76
|
+
return { remembered: true, cleared: false };
|
|
77
|
+
try {
|
|
78
|
+
tmuxExec(["clear-history", "-t", tmuxTarget]);
|
|
79
|
+
return { remembered: true, cleared: true };
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return { remembered: true, cleared: false };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -57,12 +57,19 @@ export const MAX_REPORTS = 100;
|
|
|
57
57
|
export const MAX_QUARANTINED = 5;
|
|
58
58
|
const MAX_MESSAGE = 1000;
|
|
59
59
|
const MAX_STACK = 4000;
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Mask credential-shaped text: bearer tokens, API keys, JWTs, `token=…` pairs.
|
|
62
|
+
*
|
|
63
|
+
* THE redactor for everything this machine sends to the platform (#1166): crash reports, the update log
|
|
64
|
+
* (`bootstrap/update-log.ts`) and terminal panes (`coding/pane-redact.ts`). A PAGS session is TWO
|
|
65
|
+
* segments — `base64url(json).hmac` (`workers/api/src/lib/session.ts`) — so the token rule takes an
|
|
66
|
+
* optional third: a three-segment-only rule masked a bare session only when `Bearer`/`token=` preceded it.
|
|
67
|
+
*/
|
|
61
68
|
export function maskSecrets(text) {
|
|
62
69
|
return text
|
|
63
70
|
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [redacted]")
|
|
64
71
|
.replace(/\b(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|xox[abp]-[A-Za-z0-9-]{8,}|pags_runner_[A-Za-z0-9-]{8,})/g, "[redacted]")
|
|
65
|
-
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{
|
|
72
|
+
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}(?:\.[A-Za-z0-9_-]{8,})?/g, "[redacted-jwt]")
|
|
66
73
|
.replace(/\b((?:api[_-]?key|token|secret|password|authorization)\s*[=:]\s*)("?)[^\s"&]{4,}\2/gi, "$1[redacted]");
|
|
67
74
|
}
|
|
68
75
|
/** A stable, non-identifying key for an account — the same as the runner lock's (`runner-lock.ts`). */
|
|
@@ -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,5 @@
|
|
|
1
|
+
import { outgoing, redactPanesOf, scrubDelivered } from "./coding/pane-redact.js";
|
|
2
|
+
import { codingSessionRefusal, instanceOf, readJson } from "./request-instance.js";
|
|
1
3
|
import { createServer, } from "node:http";
|
|
2
4
|
import { URL } from "node:url";
|
|
3
5
|
import { recordRequestFailure } from "./request-failure.js";
|
|
@@ -75,6 +77,15 @@ export async function startRunnerServer(config) {
|
|
|
75
77
|
async function route(runner, req, res) {
|
|
76
78
|
const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
77
79
|
const path = url.pathname.replace(/\/$/, "") || "/";
|
|
80
|
+
// The agent this request came from, and whether it may touch the coding session it names (#1164, request-instance.ts).
|
|
81
|
+
const caller = instanceOf(req);
|
|
82
|
+
const refused = await codingSessionRefusal(runner.coding, req, path, caller);
|
|
83
|
+
if (refused)
|
|
84
|
+
return json(res, 403, { error: refused });
|
|
85
|
+
// Panes leave this machine redacted (#1166, coding/pane-redact.ts); a delivered secret is scrubbed.
|
|
86
|
+
redactPanesOf(res, path, req.method === "POST" && /^\/(tmux|terminal)\//.test(path) ? await readJson(req) : undefined);
|
|
87
|
+
if (req.method === "POST" && path === "/secure/scrub")
|
|
88
|
+
return json(res, 200, scrubDelivered(await readJson(req)));
|
|
78
89
|
if ((req.method === "GET" || req.method === "POST") && path === "/health") {
|
|
79
90
|
// #896: a health check that cannot say WHO answered is not enough to decide anything with.
|
|
80
91
|
// A contender for the single-instance lock has a pid and a port from the lock file; what it
|
|
@@ -290,7 +301,9 @@ async function route(runner, req, res) {
|
|
|
290
301
|
}
|
|
291
302
|
if (req.method === "POST" && path === "/coding/start") {
|
|
292
303
|
const b = await readJson(req);
|
|
293
|
-
|
|
304
|
+
const started = runner.coding.start(b);
|
|
305
|
+
runner.coding.bindInstance(b.sessionId, caller);
|
|
306
|
+
return json(res, 200, started);
|
|
294
307
|
}
|
|
295
308
|
if (req.method === "POST" && path === "/coding/capture") {
|
|
296
309
|
// `drainUsage` opts this caller in to consuming the session's measured engine spend (#267).
|
|
@@ -308,7 +321,8 @@ async function route(runner, req, res) {
|
|
|
308
321
|
return json(res, 200, runner.coding.end(b.sessionId));
|
|
309
322
|
}
|
|
310
323
|
if (req.method === "GET" && path === "/coding/sessions") {
|
|
311
|
-
|
|
324
|
+
// Each agent sees its own sessions (#1164); a local caller (the update controller) sees them all.
|
|
325
|
+
return json(res, 200, { sessions: runner.coding.list().filter((s) => runner.coding.visibleTo(s.sessionId, caller)) });
|
|
312
326
|
}
|
|
313
327
|
// Per-session CPU and memory, for the heartbeat (#924): which agent is loading this machine.
|
|
314
328
|
if (req.method === "GET" && path === "/coding/resources") {
|
|
@@ -827,20 +841,6 @@ function authorize(req, config) {
|
|
|
827
841
|
const headerToken = req.headers["x-pags-runner-token"];
|
|
828
842
|
return auth === `Bearer ${token}` || headerToken === token;
|
|
829
843
|
}
|
|
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
844
|
function clampLimit(value, fallback, max) {
|
|
845
845
|
const parsed = Number(value || fallback);
|
|
846
846
|
if (!Number.isFinite(parsed))
|
|
@@ -852,5 +852,5 @@ function json(res, status, body) {
|
|
|
852
852
|
"Content-Type": "application/json; charset=utf-8",
|
|
853
853
|
"X-Content-Type-Options": "nosniff",
|
|
854
854
|
});
|
|
855
|
-
res.end(JSON.stringify(body));
|
|
855
|
+
res.end(JSON.stringify(outgoing(res, body)));
|
|
856
856
|
}
|
|
@@ -207,21 +207,193 @@ async function installPayload(version, root = payloadRoot(), install = npmInstal
|
|
|
207
207
|
return payload;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
// src/crash-reports.ts
|
|
211
|
+
import { closeSync, copyFileSync, existsSync as existsSync2, fsyncSync, mkdirSync as mkdirSync3, openSync, readdirSync as readdirSync2, readFileSync as readFileSync3, renameSync as renameSync3, unlinkSync, writeSync } from "fs";
|
|
212
|
+
import { basename, dirname, join as join3 } from "path";
|
|
213
|
+
import { createHash, randomUUID } from "crypto";
|
|
214
|
+
var MAX_REPORTS = 100;
|
|
215
|
+
var MAX_QUARANTINED = 5;
|
|
216
|
+
var MAX_MESSAGE = 1e3;
|
|
217
|
+
var MAX_STACK = 4e3;
|
|
218
|
+
function maskSecrets(text) {
|
|
219
|
+
return text.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [redacted]").replace(/\b(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|xox[abp]-[A-Za-z0-9-]{8,}|pags_runner_[A-Za-z0-9-]{8,})/g, "[redacted]").replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{16,}(?:\.[A-Za-z0-9_-]{8,})?/g, "[redacted-jwt]").replace(/\b((?:api[_-]?key|token|secret|password|authorization)\s*[=:]\s*)("?)[^\s"&]{4,}\2/gi, "$1[redacted]");
|
|
220
|
+
}
|
|
221
|
+
function crashAccountKey(login) {
|
|
222
|
+
return createHash("sha256").update(login.trim().toLowerCase()).digest("hex").substring(0, 12);
|
|
223
|
+
}
|
|
224
|
+
function clip(text, max) {
|
|
225
|
+
if (text.length <= max) return text;
|
|
226
|
+
return `${text.substring(0, max)}
|
|
227
|
+
[cut: showing the first ${max} of ${text.length} characters]`;
|
|
228
|
+
}
|
|
229
|
+
var clean = (value, max) => clip(maskSecrets(String(value ?? "")), max);
|
|
230
|
+
var shortId = (value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? value : void 0;
|
|
231
|
+
function describeError(error) {
|
|
232
|
+
if (error instanceof Error) {
|
|
233
|
+
const cause = error.cause;
|
|
234
|
+
return {
|
|
235
|
+
message: clean(`${error.name}: ${error.message}`, MAX_MESSAGE),
|
|
236
|
+
stack: error.stack ? clean(error.stack, MAX_STACK) : void 0,
|
|
237
|
+
cause: cause === void 0 ? void 0 : clean(cause instanceof Error ? `${cause.name}: ${cause.message}` : cause, MAX_MESSAGE)
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { message: clean(error, MAX_MESSAGE) };
|
|
241
|
+
}
|
|
242
|
+
function crashReport(input) {
|
|
243
|
+
return {
|
|
244
|
+
id: shortId(input.id) ?? `crash_${randomUUID()}`,
|
|
245
|
+
at: input.at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
246
|
+
source: input.source,
|
|
247
|
+
kind: input.kind,
|
|
248
|
+
message: clean(input.message, MAX_MESSAGE),
|
|
249
|
+
...input.stack ? { stack: clean(input.stack, MAX_STACK) } : {},
|
|
250
|
+
...input.cause ? { cause: clean(input.cause, MAX_MESSAGE) } : {},
|
|
251
|
+
...input.exitCode !== void 0 ? { exitCode: input.exitCode } : {},
|
|
252
|
+
...input.signal !== void 0 ? { signal: input.signal } : {},
|
|
253
|
+
...shortId(input.build) ? { build: input.build } : {},
|
|
254
|
+
...shortId(input.account) ? { account: input.account } : {},
|
|
255
|
+
...shortId(input.instanceId) ? { instanceId: input.instanceId } : {},
|
|
256
|
+
...shortId(input.sessionId) ? { sessionId: input.sessionId } : {},
|
|
257
|
+
...shortId(input.taskId) ? { taskId: input.taskId } : {},
|
|
258
|
+
...input.route ? { route: clean(input.route, 200) } : {},
|
|
259
|
+
...input.tail?.length ? { tail: input.tail.slice(-20).map((line) => clean(line, 300)) } : {}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function writeJsonAtomic(path, data, mode = 384) {
|
|
263
|
+
mkdirSync3(dirname(path), { recursive: true });
|
|
264
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
265
|
+
const fd = openSync(tmp, "w", mode);
|
|
266
|
+
try {
|
|
267
|
+
writeSync(fd, `${JSON.stringify(data, null, 2)}
|
|
268
|
+
`);
|
|
269
|
+
fsyncSync(fd);
|
|
270
|
+
} finally {
|
|
271
|
+
closeSync(fd);
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
renameSync3(tmp, path);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
try {
|
|
277
|
+
unlinkSync(tmp);
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function removeStaleTemps(path) {
|
|
284
|
+
const dir = dirname(path);
|
|
285
|
+
const prefix = `${basename(path)}.`;
|
|
286
|
+
if (!existsSync2(dir)) return;
|
|
287
|
+
for (const name of readdirSync2(dir)) {
|
|
288
|
+
if (!name.startsWith(prefix) || !name.endsWith(".tmp")) continue;
|
|
289
|
+
try {
|
|
290
|
+
unlinkSync(join3(dir, name));
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function readJsonOrQuarantine(path) {
|
|
296
|
+
removeStaleTemps(path);
|
|
297
|
+
if (!existsSync2(path)) return { status: "missing" };
|
|
298
|
+
try {
|
|
299
|
+
return { status: "ok", value: JSON.parse(readFileSync3(path, "utf-8")) };
|
|
300
|
+
} catch (error) {
|
|
301
|
+
const quarantinedTo = `${path}.corrupt-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
302
|
+
try {
|
|
303
|
+
renameSync3(path, quarantinedTo);
|
|
304
|
+
} catch {
|
|
305
|
+
try {
|
|
306
|
+
copyFileSync(path, quarantinedTo);
|
|
307
|
+
} catch (copyError) {
|
|
308
|
+
return { status: "quarantined", quarantinedTo: "(not preserved)", error: `${describeError(error).message}; preserving it failed: ${describeError(copyError).message}` };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
pruneQuarantine(path);
|
|
312
|
+
return { status: "quarantined", quarantinedTo, error: describeError(error).message };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function pruneQuarantine(path) {
|
|
316
|
+
const dir = dirname(path);
|
|
317
|
+
const prefix = `${basename(path)}.corrupt-`;
|
|
318
|
+
const copies = readdirSync2(dir).filter((name) => name.startsWith(prefix)).sort();
|
|
319
|
+
const excess = copies.length - MAX_QUARANTINED;
|
|
320
|
+
for (const name of excess > 0 ? copies.slice(0, excess) : []) {
|
|
321
|
+
try {
|
|
322
|
+
unlinkSync(join3(dir, name));
|
|
323
|
+
} catch {
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
var CrashLog = class {
|
|
328
|
+
/** `base` stamps the reports this log writes about ITSELF (a quarantined file). */
|
|
329
|
+
constructor(path, base = { source: "runtime" }) {
|
|
330
|
+
this.path = path;
|
|
331
|
+
this.base = base;
|
|
332
|
+
}
|
|
333
|
+
path;
|
|
334
|
+
base;
|
|
335
|
+
/** Everything still on disk, oldest first. A corrupt file is quarantined and reported, then read as a fresh file. */
|
|
336
|
+
read() {
|
|
337
|
+
const got = readJsonOrQuarantine(this.path);
|
|
338
|
+
if (got.status === "ok") {
|
|
339
|
+
const reports = Array.isArray(got.value.reports) ? got.value.reports.filter((r) => !!r && typeof r.id === "string") : [];
|
|
340
|
+
return { reports, dropped: Number(got.value.dropped) || 0 };
|
|
341
|
+
}
|
|
342
|
+
if (got.status === "missing") return { reports: [], dropped: 0 };
|
|
343
|
+
const file = {
|
|
344
|
+
reports: [crashReport({ ...this.base, kind: "store_corrupt", message: `crash-report file was unreadable and was moved to ${basename(got.quarantinedTo)}: ${got.error}` })],
|
|
345
|
+
dropped: 0
|
|
346
|
+
};
|
|
347
|
+
this.write(file);
|
|
348
|
+
return file;
|
|
349
|
+
}
|
|
350
|
+
/** Append a report (bounded). Never throws: a crash path must not crash on its own record. */
|
|
351
|
+
record(report) {
|
|
352
|
+
try {
|
|
353
|
+
const file = this.read();
|
|
354
|
+
if (file.reports.some((r) => r.id === report.id)) return true;
|
|
355
|
+
file.reports.push(report);
|
|
356
|
+
const over = file.reports.length - MAX_REPORTS;
|
|
357
|
+
if (over > 0) {
|
|
358
|
+
file.reports = file.reports.slice(over);
|
|
359
|
+
file.dropped += over;
|
|
360
|
+
}
|
|
361
|
+
this.write(file);
|
|
362
|
+
return true;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
process.stderr.write(`[crash-reports] could not record ${report.kind}: ${describeError(error).message}
|
|
365
|
+
`);
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Drop reports by id — only by this file's own writer. */
|
|
370
|
+
remove(ids) {
|
|
371
|
+
if (!ids.length) return;
|
|
372
|
+
const file = this.read();
|
|
373
|
+
const gone = new Set(ids);
|
|
374
|
+
file.reports = file.reports.filter((r) => !gone.has(r.id));
|
|
375
|
+
this.write(file);
|
|
376
|
+
}
|
|
377
|
+
write(file) {
|
|
378
|
+
writeJsonAtomic(this.path, file);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
210
382
|
// src/bootstrap/update-log.ts
|
|
211
|
-
import { appendFileSync, existsSync as
|
|
383
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync2 } from "fs";
|
|
212
384
|
import { homedir as homedir2 } from "os";
|
|
213
|
-
import { dirname, join as
|
|
385
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
214
386
|
var MAX_BYTES = 256 * 1024;
|
|
215
387
|
var UPDATE_LOG_UPLOAD_CHARS = 4e3;
|
|
216
388
|
function updateLogPath(env = process.env) {
|
|
217
|
-
return env.PAGS_UPDATE_LOG?.trim() ||
|
|
389
|
+
return env.PAGS_UPDATE_LOG?.trim() || join4(env.PAGS_CONFIG_DIR?.trim() || join4(homedir2(), ".config", "proagentstore"), "update.log");
|
|
218
390
|
}
|
|
219
391
|
function logUpdateStep(step, env = process.env, now = /* @__PURE__ */ new Date()) {
|
|
220
392
|
const path = updateLogPath(env);
|
|
221
393
|
try {
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
const text =
|
|
394
|
+
mkdirSync4(dirname2(path), { recursive: true });
|
|
395
|
+
if (existsSync3(path) && statSync(path).size > MAX_BYTES) {
|
|
396
|
+
const text = readFileSync4(path, "utf8");
|
|
225
397
|
writeFileSync2(path, text.slice(text.indexOf("\n", text.length / 2) + 1));
|
|
226
398
|
}
|
|
227
399
|
appendFileSync(path, `${now.toISOString()} [${process.pid}] ${step.replace(/\s*\n\s*/g, " | ")}
|
|
@@ -231,7 +403,7 @@ function logUpdateStep(step, env = process.env, now = /* @__PURE__ */ new Date()
|
|
|
231
403
|
}
|
|
232
404
|
function updateLogTail(env = process.env, maxChars = UPDATE_LOG_UPLOAD_CHARS) {
|
|
233
405
|
try {
|
|
234
|
-
const text =
|
|
406
|
+
const text = maskSecrets(readFileSync4(updateLogPath(env), "utf8").trimEnd());
|
|
235
407
|
if (!text) return null;
|
|
236
408
|
if (text.length <= maxChars) return text;
|
|
237
409
|
const cut = text.slice(-maxChars);
|
|
@@ -276,6 +448,11 @@ export {
|
|
|
276
448
|
latestPublishedRelease,
|
|
277
449
|
latestPublishedVersion,
|
|
278
450
|
installPayload,
|
|
451
|
+
crashAccountKey,
|
|
452
|
+
crashReport,
|
|
453
|
+
writeJsonAtomic,
|
|
454
|
+
readJsonOrQuarantine,
|
|
455
|
+
CrashLog,
|
|
279
456
|
logUpdateStep,
|
|
280
457
|
updateLogUpload
|
|
281
458
|
};
|
package/dist/index.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import {
|
|
3
3
|
BOOTSTRAP_ENV,
|
|
4
4
|
CLI_PACKAGE,
|
|
5
|
+
CrashLog,
|
|
5
6
|
badVersions,
|
|
6
7
|
beginTrial,
|
|
8
|
+
crashAccountKey,
|
|
9
|
+
crashReport,
|
|
7
10
|
installPayload,
|
|
8
11
|
isVersion,
|
|
9
12
|
latestPublishedRelease,
|
|
@@ -11,10 +14,12 @@ import {
|
|
|
11
14
|
markPayloadHealthy,
|
|
12
15
|
olderThan,
|
|
13
16
|
payloadRoot,
|
|
17
|
+
readJsonOrQuarantine,
|
|
14
18
|
runNpm,
|
|
15
19
|
takeRollbackNotice,
|
|
16
|
-
updateLogUpload
|
|
17
|
-
|
|
20
|
+
updateLogUpload,
|
|
21
|
+
writeJsonAtomic
|
|
22
|
+
} from "./chunk-7O4S5477.js";
|
|
18
23
|
|
|
19
24
|
// src/index.ts
|
|
20
25
|
import { createRequire as createRequire3 } from "module";
|
|
@@ -1272,7 +1277,7 @@ var publishCommand = new Command7("publish").description("Publish an agent to Pr
|
|
|
1272
1277
|
|
|
1273
1278
|
// src/commands/runner/command.ts
|
|
1274
1279
|
import { spawn as spawn5 } from "child_process";
|
|
1275
|
-
import { randomUUID as
|
|
1280
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1276
1281
|
import { Command as Command8 } from "commander";
|
|
1277
1282
|
|
|
1278
1283
|
// src/commands/runner/process.ts
|
|
@@ -1426,6 +1431,78 @@ function sampleResources(read = os, now = Date.now(), extra = {}) {
|
|
|
1426
1431
|
};
|
|
1427
1432
|
}
|
|
1428
1433
|
|
|
1434
|
+
// src/commands/runner/route-policy.ts
|
|
1435
|
+
var ALWAYS = ["/health", "/capabilities", "/events"];
|
|
1436
|
+
var READ_ALWAYS = ["/tasks"];
|
|
1437
|
+
var BROWSER = ["/tasks", "/browser/", "/takeover", "/sessions"];
|
|
1438
|
+
var BY_RUNTIME = {
|
|
1439
|
+
// The coding engine, the website runtime builder's task, and the engine's own SIGN-IN: the cloud relays the
|
|
1440
|
+
// login page through `/browser/act` + `/browser/handoff` and its card through `/takeover` (coding-sessions-open.ts).
|
|
1441
|
+
coding: ["/coding/", "/tasks", "/browser/act", "/browser/handoff", "/takeover"],
|
|
1442
|
+
browser: BROWSER,
|
|
1443
|
+
local_browser: ["/local-browser/"],
|
|
1444
|
+
local_artifact: ["/local-artifact/"],
|
|
1445
|
+
local_apply: ["/local-apply/", ...BROWSER],
|
|
1446
|
+
local_native: ["/native/"]
|
|
1447
|
+
};
|
|
1448
|
+
var TERMINAL = ["/tmux/", "/terminal/", "/secure/"];
|
|
1449
|
+
var REPO_READS = ["/coding/tree", "/coding/read-file", "/coding/git", "/coding/search", "/coding/git-remote", "/coding/repo-check"];
|
|
1450
|
+
var SIGN_IN_TMUX = /* @__PURE__ */ new Set(["/tmux/session", "/tmux/run", "/tmux/send", "/tmux/capture"]);
|
|
1451
|
+
var SIGN_IN_SESSION = /^pags-signin-[a-z0-9_-]+$/;
|
|
1452
|
+
function hasTerminal(caps) {
|
|
1453
|
+
const tools = caps.tools ?? [];
|
|
1454
|
+
return (caps.surfaces ?? []).includes("tmux") || tools.some((t) => t.startsWith("tmux_") || t.startsWith("terminal_"));
|
|
1455
|
+
}
|
|
1456
|
+
function allowedPrefixes(caps) {
|
|
1457
|
+
const out = [...ALWAYS];
|
|
1458
|
+
if (!caps) return out;
|
|
1459
|
+
if (caps.runtime && BY_RUNTIME[caps.runtime]) out.push(...BY_RUNTIME[caps.runtime]);
|
|
1460
|
+
const tools = caps.tools ?? [];
|
|
1461
|
+
if (hasTerminal(caps)) out.push(...TERMINAL);
|
|
1462
|
+
if (tools.some((t) => t.startsWith("browser_"))) out.push("/browser/");
|
|
1463
|
+
if (tools.some((t) => t.startsWith("repo_"))) out.push(...REPO_READS);
|
|
1464
|
+
return out;
|
|
1465
|
+
}
|
|
1466
|
+
function covers(prefix, path) {
|
|
1467
|
+
if (prefix.endsWith("/")) return path.startsWith(prefix);
|
|
1468
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
1469
|
+
}
|
|
1470
|
+
function routeRefusal(caps, path, method = "POST", body) {
|
|
1471
|
+
const clean3 = path.split("?")[0] ?? path;
|
|
1472
|
+
if (/(^|\/)\.\.(\/|$)/.test(clean3)) return `Refused ${clean3}: a relayed path may not climb out of its route.`;
|
|
1473
|
+
if (allowedPrefixes(caps).some((p) => covers(p, clean3))) return null;
|
|
1474
|
+
if ((method === "GET" || method === "HEAD") && READ_ALWAYS.some((p) => covers(p, clean3))) return null;
|
|
1475
|
+
if (caps?.runtime === "coding" && SIGN_IN_TMUX.has(clean3)) {
|
|
1476
|
+
const session = body?.session;
|
|
1477
|
+
if (typeof session === "string" && SIGN_IN_SESSION.test(session)) return null;
|
|
1478
|
+
}
|
|
1479
|
+
return `Refused ${clean3}: this agent's declared capabilities do not include it, so this machine will not run it for this agent.`;
|
|
1480
|
+
}
|
|
1481
|
+
var AgentRoutePolicy = class {
|
|
1482
|
+
constructor(fetchInstances) {
|
|
1483
|
+
this.fetchInstances = fetchInstances;
|
|
1484
|
+
}
|
|
1485
|
+
fetchInstances;
|
|
1486
|
+
declared = /* @__PURE__ */ new Map();
|
|
1487
|
+
refreshing = null;
|
|
1488
|
+
learn(instances) {
|
|
1489
|
+
for (const inst of instances ?? []) this.declared.set(inst.id, inst.capabilities ?? null);
|
|
1490
|
+
}
|
|
1491
|
+
refresh() {
|
|
1492
|
+
this.refreshing ??= this.fetchInstances().then((instances) => this.learn(instances)).catch(() => void 0).finally(() => {
|
|
1493
|
+
this.refreshing = null;
|
|
1494
|
+
});
|
|
1495
|
+
return this.refreshing;
|
|
1496
|
+
}
|
|
1497
|
+
refusalFor(id) {
|
|
1498
|
+
return (method, path, body) => {
|
|
1499
|
+
const caps = this.declared.get(id);
|
|
1500
|
+
if (caps === void 0) void this.refresh();
|
|
1501
|
+
return routeRefusal(caps, path, method, body);
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1429
1506
|
// src/commands/runner/self-update.ts
|
|
1430
1507
|
import { spawn as spawn4 } from "child_process";
|
|
1431
1508
|
var RUNNER_UPDATE_PATH = "/pags/runner/update";
|
|
@@ -2803,6 +2880,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2803
2880
|
if (plan.target && !attached.has(plan.target)) forceNext.delete(plan.target);
|
|
2804
2881
|
return { status: 200, result: { attached: [...attached.keys()], ...plan.target ? { target: plan.target, holding: attached.has(plan.target) } : {} } };
|
|
2805
2882
|
};
|
|
2883
|
+
const routes = new AgentRoutePolicy(() => requestPags("GET", "/v1/instances/my/instances", { ...opts, pagsToken: pagsToken() }).then((r) => r.instances));
|
|
2884
|
+
await routes.refresh();
|
|
2806
2885
|
const attach = (id, label = `${id.slice(0, 8)}\u2026`) => {
|
|
2807
2886
|
if (attached.has(id)) return;
|
|
2808
2887
|
const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken: pagsToken() }, {}).then((r) => r.token);
|
|
@@ -2838,7 +2917,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2838
2917
|
runnerNode,
|
|
2839
2918
|
// The final automatic-update drain is an admission gate, not merely another poll:
|
|
2840
2919
|
// command work starts only when the old runner is still permitted to serve it.
|
|
2841
|
-
() => relayMutationAdmission.begin()
|
|
2920
|
+
() => relayMutationAdmission.begin(),
|
|
2921
|
+
routes.refusalFor(id)
|
|
2842
2922
|
)
|
|
2843
2923
|
);
|
|
2844
2924
|
if (label) writeLine(`Attached agent: ${label}`);
|
|
@@ -2934,6 +3014,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
2934
3014
|
"/v1/instances/my/instances",
|
|
2935
3015
|
{ ...opts, pagsToken: pagsToken() }
|
|
2936
3016
|
);
|
|
3017
|
+
routes.learn(res.instances);
|
|
2937
3018
|
const { attach: toAttach, detach: toDetach } = diffMembership(
|
|
2938
3019
|
attached.keys(),
|
|
2939
3020
|
res.instances ?? [],
|
|
@@ -2983,7 +3064,7 @@ async function readSessionResources(localUrl, runnerToken) {
|
|
|
2983
3064
|
}
|
|
2984
3065
|
var RTT_PROBE_PREFIX = "rtt:";
|
|
2985
3066
|
var RTT_ECHO_PREFIX = "rtt-echo:";
|
|
2986
|
-
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname5(), beginMutation = () => () => void 0) {
|
|
3067
|
+
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname5(), beginMutation = () => () => void 0, routeRefusal2 = () => null) {
|
|
2987
3068
|
let backoffMs = 1e3;
|
|
2988
3069
|
let reconnecting = false;
|
|
2989
3070
|
let closed = false;
|
|
@@ -3051,6 +3132,14 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
3051
3132
|
return;
|
|
3052
3133
|
}
|
|
3053
3134
|
const method = (cmd.method || "POST").toUpperCase();
|
|
3135
|
+
const refused = routeRefusal2(method, cmd.path, cmd.body);
|
|
3136
|
+
if (refused) {
|
|
3137
|
+
try {
|
|
3138
|
+
ws.send(JSON.stringify({ id: cmd.id, status: 403, result: { error: refused } }));
|
|
3139
|
+
} catch {
|
|
3140
|
+
}
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3054
3143
|
const releaseMutation = method !== "GET" && method !== "HEAD" ? beginMutation() : null;
|
|
3055
3144
|
if (method !== "GET" && method !== "HEAD" && !releaseMutation) {
|
|
3056
3145
|
try {
|
|
@@ -3154,200 +3243,28 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
3154
3243
|
};
|
|
3155
3244
|
}
|
|
3156
3245
|
|
|
3157
|
-
// src/crash-reports.ts
|
|
3158
|
-
import { closeSync as closeSync2, copyFileSync, existsSync as existsSync8, fsyncSync, mkdirSync as mkdirSync7, openSync as openSync2, readdirSync, readFileSync as readFileSync8, renameSync as renameSync3, unlinkSync as unlinkSync3, writeSync as writeSync2 } from "fs";
|
|
3159
|
-
import { basename, dirname as dirname4, join as join9 } from "path";
|
|
3160
|
-
import { createHash as createHash2, randomUUID as randomUUID3 } from "crypto";
|
|
3161
|
-
var MAX_REPORTS = 100;
|
|
3162
|
-
var MAX_QUARANTINED = 5;
|
|
3163
|
-
var MAX_MESSAGE = 1e3;
|
|
3164
|
-
var MAX_STACK = 4e3;
|
|
3165
|
-
function maskSecrets(text) {
|
|
3166
|
-
return text.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [redacted]").replace(/\b(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{8,}|gho_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|xox[abp]-[A-Za-z0-9-]{8,}|pags_runner_[A-Za-z0-9-]{8,})/g, "[redacted]").replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "[redacted-jwt]").replace(/\b((?:api[_-]?key|token|secret|password|authorization)\s*[=:]\s*)("?)[^\s"&]{4,}\2/gi, "$1[redacted]");
|
|
3167
|
-
}
|
|
3168
|
-
function crashAccountKey(login) {
|
|
3169
|
-
return createHash2("sha256").update(login.trim().toLowerCase()).digest("hex").substring(0, 12);
|
|
3170
|
-
}
|
|
3171
|
-
function clip(text, max) {
|
|
3172
|
-
if (text.length <= max) return text;
|
|
3173
|
-
return `${text.substring(0, max)}
|
|
3174
|
-
[cut: showing the first ${max} of ${text.length} characters]`;
|
|
3175
|
-
}
|
|
3176
|
-
var clean3 = (value, max) => clip(maskSecrets(String(value ?? "")), max);
|
|
3177
|
-
var shortId = (value) => typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? value : void 0;
|
|
3178
|
-
function describeError(error) {
|
|
3179
|
-
if (error instanceof Error) {
|
|
3180
|
-
const cause = error.cause;
|
|
3181
|
-
return {
|
|
3182
|
-
message: clean3(`${error.name}: ${error.message}`, MAX_MESSAGE),
|
|
3183
|
-
stack: error.stack ? clean3(error.stack, MAX_STACK) : void 0,
|
|
3184
|
-
cause: cause === void 0 ? void 0 : clean3(cause instanceof Error ? `${cause.name}: ${cause.message}` : cause, MAX_MESSAGE)
|
|
3185
|
-
};
|
|
3186
|
-
}
|
|
3187
|
-
return { message: clean3(error, MAX_MESSAGE) };
|
|
3188
|
-
}
|
|
3189
|
-
function crashReport(input) {
|
|
3190
|
-
return {
|
|
3191
|
-
id: shortId(input.id) ?? `crash_${randomUUID3()}`,
|
|
3192
|
-
at: input.at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
3193
|
-
source: input.source,
|
|
3194
|
-
kind: input.kind,
|
|
3195
|
-
message: clean3(input.message, MAX_MESSAGE),
|
|
3196
|
-
...input.stack ? { stack: clean3(input.stack, MAX_STACK) } : {},
|
|
3197
|
-
...input.cause ? { cause: clean3(input.cause, MAX_MESSAGE) } : {},
|
|
3198
|
-
...input.exitCode !== void 0 ? { exitCode: input.exitCode } : {},
|
|
3199
|
-
...input.signal !== void 0 ? { signal: input.signal } : {},
|
|
3200
|
-
...shortId(input.build) ? { build: input.build } : {},
|
|
3201
|
-
...shortId(input.account) ? { account: input.account } : {},
|
|
3202
|
-
...shortId(input.instanceId) ? { instanceId: input.instanceId } : {},
|
|
3203
|
-
...shortId(input.sessionId) ? { sessionId: input.sessionId } : {},
|
|
3204
|
-
...shortId(input.taskId) ? { taskId: input.taskId } : {},
|
|
3205
|
-
...input.route ? { route: clean3(input.route, 200) } : {},
|
|
3206
|
-
...input.tail?.length ? { tail: input.tail.slice(-20).map((line) => clean3(line, 300)) } : {}
|
|
3207
|
-
};
|
|
3208
|
-
}
|
|
3209
|
-
function writeJsonAtomic(path, data, mode = 384) {
|
|
3210
|
-
mkdirSync7(dirname4(path), { recursive: true });
|
|
3211
|
-
const tmp = `${path}.${process.pid}.${randomUUID3()}.tmp`;
|
|
3212
|
-
const fd = openSync2(tmp, "w", mode);
|
|
3213
|
-
try {
|
|
3214
|
-
writeSync2(fd, `${JSON.stringify(data, null, 2)}
|
|
3215
|
-
`);
|
|
3216
|
-
fsyncSync(fd);
|
|
3217
|
-
} finally {
|
|
3218
|
-
closeSync2(fd);
|
|
3219
|
-
}
|
|
3220
|
-
try {
|
|
3221
|
-
renameSync3(tmp, path);
|
|
3222
|
-
} catch (error) {
|
|
3223
|
-
try {
|
|
3224
|
-
unlinkSync3(tmp);
|
|
3225
|
-
} catch {
|
|
3226
|
-
}
|
|
3227
|
-
throw error;
|
|
3228
|
-
}
|
|
3229
|
-
}
|
|
3230
|
-
function removeStaleTemps(path) {
|
|
3231
|
-
const dir = dirname4(path);
|
|
3232
|
-
const prefix = `${basename(path)}.`;
|
|
3233
|
-
if (!existsSync8(dir)) return;
|
|
3234
|
-
for (const name of readdirSync(dir)) {
|
|
3235
|
-
if (!name.startsWith(prefix) || !name.endsWith(".tmp")) continue;
|
|
3236
|
-
try {
|
|
3237
|
-
unlinkSync3(join9(dir, name));
|
|
3238
|
-
} catch {
|
|
3239
|
-
}
|
|
3240
|
-
}
|
|
3241
|
-
}
|
|
3242
|
-
function readJsonOrQuarantine(path) {
|
|
3243
|
-
removeStaleTemps(path);
|
|
3244
|
-
if (!existsSync8(path)) return { status: "missing" };
|
|
3245
|
-
try {
|
|
3246
|
-
return { status: "ok", value: JSON.parse(readFileSync8(path, "utf-8")) };
|
|
3247
|
-
} catch (error) {
|
|
3248
|
-
const quarantinedTo = `${path}.corrupt-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
3249
|
-
try {
|
|
3250
|
-
renameSync3(path, quarantinedTo);
|
|
3251
|
-
} catch {
|
|
3252
|
-
try {
|
|
3253
|
-
copyFileSync(path, quarantinedTo);
|
|
3254
|
-
} catch (copyError) {
|
|
3255
|
-
return { status: "quarantined", quarantinedTo: "(not preserved)", error: `${describeError(error).message}; preserving it failed: ${describeError(copyError).message}` };
|
|
3256
|
-
}
|
|
3257
|
-
}
|
|
3258
|
-
pruneQuarantine(path);
|
|
3259
|
-
return { status: "quarantined", quarantinedTo, error: describeError(error).message };
|
|
3260
|
-
}
|
|
3261
|
-
}
|
|
3262
|
-
function pruneQuarantine(path) {
|
|
3263
|
-
const dir = dirname4(path);
|
|
3264
|
-
const prefix = `${basename(path)}.corrupt-`;
|
|
3265
|
-
const copies = readdirSync(dir).filter((name) => name.startsWith(prefix)).sort();
|
|
3266
|
-
const excess = copies.length - MAX_QUARANTINED;
|
|
3267
|
-
for (const name of excess > 0 ? copies.slice(0, excess) : []) {
|
|
3268
|
-
try {
|
|
3269
|
-
unlinkSync3(join9(dir, name));
|
|
3270
|
-
} catch {
|
|
3271
|
-
}
|
|
3272
|
-
}
|
|
3273
|
-
}
|
|
3274
|
-
var CrashLog = class {
|
|
3275
|
-
/** `base` stamps the reports this log writes about ITSELF (a quarantined file). */
|
|
3276
|
-
constructor(path, base = { source: "runtime" }) {
|
|
3277
|
-
this.path = path;
|
|
3278
|
-
this.base = base;
|
|
3279
|
-
}
|
|
3280
|
-
path;
|
|
3281
|
-
base;
|
|
3282
|
-
/** Everything still on disk, oldest first. A corrupt file is quarantined and reported, then read as a fresh file. */
|
|
3283
|
-
read() {
|
|
3284
|
-
const got = readJsonOrQuarantine(this.path);
|
|
3285
|
-
if (got.status === "ok") {
|
|
3286
|
-
const reports = Array.isArray(got.value.reports) ? got.value.reports.filter((r) => !!r && typeof r.id === "string") : [];
|
|
3287
|
-
return { reports, dropped: Number(got.value.dropped) || 0 };
|
|
3288
|
-
}
|
|
3289
|
-
if (got.status === "missing") return { reports: [], dropped: 0 };
|
|
3290
|
-
const file = {
|
|
3291
|
-
reports: [crashReport({ ...this.base, kind: "store_corrupt", message: `crash-report file was unreadable and was moved to ${basename(got.quarantinedTo)}: ${got.error}` })],
|
|
3292
|
-
dropped: 0
|
|
3293
|
-
};
|
|
3294
|
-
this.write(file);
|
|
3295
|
-
return file;
|
|
3296
|
-
}
|
|
3297
|
-
/** Append a report (bounded). Never throws: a crash path must not crash on its own record. */
|
|
3298
|
-
record(report) {
|
|
3299
|
-
try {
|
|
3300
|
-
const file = this.read();
|
|
3301
|
-
if (file.reports.some((r) => r.id === report.id)) return true;
|
|
3302
|
-
file.reports.push(report);
|
|
3303
|
-
const over = file.reports.length - MAX_REPORTS;
|
|
3304
|
-
if (over > 0) {
|
|
3305
|
-
file.reports = file.reports.slice(over);
|
|
3306
|
-
file.dropped += over;
|
|
3307
|
-
}
|
|
3308
|
-
this.write(file);
|
|
3309
|
-
return true;
|
|
3310
|
-
} catch (error) {
|
|
3311
|
-
process.stderr.write(`[crash-reports] could not record ${report.kind}: ${describeError(error).message}
|
|
3312
|
-
`);
|
|
3313
|
-
return false;
|
|
3314
|
-
}
|
|
3315
|
-
}
|
|
3316
|
-
/** Drop reports by id — only by this file's own writer. */
|
|
3317
|
-
remove(ids) {
|
|
3318
|
-
if (!ids.length) return;
|
|
3319
|
-
const file = this.read();
|
|
3320
|
-
const gone = new Set(ids);
|
|
3321
|
-
file.reports = file.reports.filter((r) => !gone.has(r.id));
|
|
3322
|
-
this.write(file);
|
|
3323
|
-
}
|
|
3324
|
-
write(file) {
|
|
3325
|
-
writeJsonAtomic(this.path, file);
|
|
3326
|
-
}
|
|
3327
|
-
};
|
|
3328
|
-
|
|
3329
3246
|
// src/crash-delivery.ts
|
|
3330
|
-
import { existsSync as
|
|
3247
|
+
import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
|
|
3331
3248
|
import { homedir as homedir4 } from "os";
|
|
3332
|
-
import { join as
|
|
3249
|
+
import { join as join9 } from "path";
|
|
3333
3250
|
var BATCH = 50;
|
|
3334
3251
|
var LEDGER_MAX = 1e3;
|
|
3335
3252
|
var TIMEOUT_MS = 1e4;
|
|
3336
3253
|
function runnerDataDir(dataDir, env = process.env) {
|
|
3337
|
-
return dataDir || env.PAGS_RUNNER_DATA_DIR ||
|
|
3254
|
+
return dataDir || env.PAGS_RUNNER_DATA_DIR || join9(homedir4(), ".config", "proagentstore", "browser-runner");
|
|
3338
3255
|
}
|
|
3339
3256
|
function crashPaths(configDir2, account, dataDir) {
|
|
3340
3257
|
const key = crashAccountKey(account);
|
|
3341
3258
|
return {
|
|
3342
|
-
runtimeFile:
|
|
3343
|
-
cliFile:
|
|
3344
|
-
ledgerFile:
|
|
3259
|
+
runtimeFile: join9(runnerDataDir(dataDir), "crash-reports.json"),
|
|
3260
|
+
cliFile: join9(configDir2, `runner-crashes-${key}.json`),
|
|
3261
|
+
ledgerFile: join9(configDir2, `crash-delivered-${key}.json`)
|
|
3345
3262
|
};
|
|
3346
3263
|
}
|
|
3347
3264
|
function readRuntimeReports(path) {
|
|
3348
|
-
if (!
|
|
3265
|
+
if (!existsSync8(path)) return [];
|
|
3349
3266
|
try {
|
|
3350
|
-
const parsed = JSON.parse(
|
|
3267
|
+
const parsed = JSON.parse(readFileSync8(path, "utf-8"));
|
|
3351
3268
|
return Array.isArray(parsed.reports) ? parsed.reports.filter((r) => r && typeof r.id === "string") : [];
|
|
3352
3269
|
} catch {
|
|
3353
3270
|
return [];
|
|
@@ -3478,7 +3395,7 @@ function createRunnerCommand() {
|
|
|
3478
3395
|
});
|
|
3479
3396
|
command.command("connect <instanceIds...>").description("Start ONE local runtime, connect via WebSocket relay, and register it for every given PAGS instance").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Runner bearer token. Defaults to PAGS_RUNNER_TOKEN or a generated token").option("--headless", "Run Playwright headless").option("--api-base <url>", "PAGS API base URL").option("--pags-token <token>", "PAGS session token. Defaults to PAGS_TOKEN").option("--runner-version <version>", "Runner version").option("--force", "Take over from another connected machine").option("--watch-instances", "Attach newly eligible agents while running, without a restart").action(async (instanceIds, opts) => {
|
|
3480
3397
|
takeSessionTokenFromEnv();
|
|
3481
|
-
const runnerToken = clean2(opts.token) || clean2(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${
|
|
3398
|
+
const runnerToken = clean2(opts.token) || clean2(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID3()}`;
|
|
3482
3399
|
const host = clean2(opts.host) || "127.0.0.1";
|
|
3483
3400
|
const port = clean2(opts.port) || String(await findFreePort2(49171));
|
|
3484
3401
|
const lockAccount = clean2(process.env.PAGS_LOCK_ACCOUNT) || "";
|
|
@@ -3697,27 +3614,27 @@ function createRunnerCommand() {
|
|
|
3697
3614
|
var runnerCommand = createRunnerCommand();
|
|
3698
3615
|
|
|
3699
3616
|
// src/commands/status.ts
|
|
3700
|
-
import { readFileSync as
|
|
3617
|
+
import { readFileSync as readFileSync10, statSync as statSync2, watchFile } from "fs";
|
|
3701
3618
|
import { Command as Command9 } from "commander";
|
|
3702
3619
|
|
|
3703
3620
|
// src/background.ts
|
|
3704
3621
|
import { spawn as nodeSpawn2 } from "child_process";
|
|
3705
|
-
import { closeSync as
|
|
3706
|
-
import { dirname as
|
|
3622
|
+
import { closeSync as closeSync2, existsSync as existsSync9, mkdirSync as mkdirSync7, openSync as openSync2, readFileSync as readFileSync9, renameSync as renameSync3, statSync, writeFileSync as writeFileSync6 } from "fs";
|
|
3623
|
+
import { dirname as dirname4, join as join10 } from "path";
|
|
3707
3624
|
var BACKGROUND_ENV = "PAGS_BACKGROUND";
|
|
3708
3625
|
var BACKGROUND_LOG_ENV = "PAGS_BACKGROUND_LOG";
|
|
3709
3626
|
var BACKGROUND_RESTART_ENV = "PAGS_BACKGROUND_RESTART";
|
|
3710
3627
|
var LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
3711
|
-
var backgroundLogPath = (account, env = process.env) =>
|
|
3712
|
-
var backgroundStatePath = (account, env = process.env) =>
|
|
3628
|
+
var backgroundLogPath = (account, env = process.env) => join10(configDir(env), "logs", `runner-${accountKey(account)}.log`);
|
|
3629
|
+
var backgroundStatePath = (account, env = process.env) => join10(configDir(env), `background-${accountKey(account)}.json`);
|
|
3713
3630
|
function writeBackgroundState(path, state) {
|
|
3714
|
-
|
|
3631
|
+
mkdirSync7(dirname4(path), { recursive: true });
|
|
3715
3632
|
writeFileSync6(path, `${JSON.stringify(state)}
|
|
3716
3633
|
`, { mode: 384 });
|
|
3717
3634
|
}
|
|
3718
3635
|
function readBackgroundState(path) {
|
|
3719
3636
|
try {
|
|
3720
|
-
const parsed = JSON.parse(
|
|
3637
|
+
const parsed = JSON.parse(readFileSync9(path, "utf-8"));
|
|
3721
3638
|
return parsed?.v === 1 && Number.isInteger(parsed.pid) ? parsed : null;
|
|
3722
3639
|
} catch {
|
|
3723
3640
|
return null;
|
|
@@ -3729,7 +3646,7 @@ function rotateLog(path, maxBytes = LOG_ROTATE_BYTES) {
|
|
|
3729
3646
|
} catch {
|
|
3730
3647
|
return false;
|
|
3731
3648
|
}
|
|
3732
|
-
|
|
3649
|
+
renameSync3(path, `${path}.1`);
|
|
3733
3650
|
return true;
|
|
3734
3651
|
}
|
|
3735
3652
|
function shellQuote(arg, platform = process.platform) {
|
|
@@ -3741,16 +3658,16 @@ function backgroundRestartCommand(execPath, script, upArgs, platform = process.p
|
|
|
3741
3658
|
return platform === "win32" ? `set ${BACKGROUND_RESTART_ENV}=1&& ${argv}` : `${BACKGROUND_RESTART_ENV}=1 ${argv}`;
|
|
3742
3659
|
}
|
|
3743
3660
|
function spawnDetached(command, args, env, logPath, spawn6 = nodeSpawn2) {
|
|
3744
|
-
|
|
3661
|
+
mkdirSync7(dirname4(logPath), { recursive: true });
|
|
3745
3662
|
rotateLog(logPath);
|
|
3746
|
-
const fd =
|
|
3663
|
+
const fd = openSync2(logPath, "a", 384);
|
|
3747
3664
|
try {
|
|
3748
3665
|
const child = spawn6(command, args, { detached: true, stdio: ["ignore", fd, fd], env, windowsHide: true });
|
|
3749
3666
|
child.unref();
|
|
3750
3667
|
if (!child.pid) throw new Error("the runner process did not start");
|
|
3751
3668
|
return child.pid;
|
|
3752
3669
|
} finally {
|
|
3753
|
-
|
|
3670
|
+
closeSync2(fd);
|
|
3754
3671
|
}
|
|
3755
3672
|
}
|
|
3756
3673
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -3774,8 +3691,8 @@ async function waitUntilTakeable(read, opts = {}) {
|
|
|
3774
3691
|
}
|
|
3775
3692
|
}
|
|
3776
3693
|
function tailLines(path, n) {
|
|
3777
|
-
if (!
|
|
3778
|
-
const lines =
|
|
3694
|
+
if (!existsSync9(path)) return null;
|
|
3695
|
+
const lines = readFileSync9(path, "utf-8").split("\n");
|
|
3779
3696
|
if (lines.at(-1) === "") lines.pop();
|
|
3780
3697
|
return lines.slice(Math.max(0, lines.length - n));
|
|
3781
3698
|
}
|
|
@@ -3830,7 +3747,7 @@ var logsCommand = new Command9("logs").description("Show the background runner's
|
|
|
3830
3747
|
watchFile(path, { interval: 1e3 }, (cur) => {
|
|
3831
3748
|
if (cur.size < offset) offset = 0;
|
|
3832
3749
|
if (cur.size === offset) return;
|
|
3833
|
-
process.stdout.write(
|
|
3750
|
+
process.stdout.write(readFileSync10(path).subarray(offset, cur.size));
|
|
3834
3751
|
offset = cur.size;
|
|
3835
3752
|
});
|
|
3836
3753
|
});
|
|
@@ -4100,7 +4017,7 @@ async function startInBackground(start, deps = processDeps()) {
|
|
|
4100
4017
|
|
|
4101
4018
|
// src/service-install.ts
|
|
4102
4019
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
4103
|
-
import { mkdirSync as
|
|
4020
|
+
import { mkdirSync as mkdirSync8, rmSync, writeFileSync as writeFileSync7 } from "fs";
|
|
4104
4021
|
import { posix, win32 as win322 } from "path";
|
|
4105
4022
|
var SERVICE_LABEL = "online.proagentstore.pags-up";
|
|
4106
4023
|
var SYSTEMD_UNIT = "pags-up.service";
|
|
@@ -4293,7 +4210,7 @@ function serviceRemovalPlan(ctx) {
|
|
|
4293
4210
|
};
|
|
4294
4211
|
}
|
|
4295
4212
|
var defaultWrite = (path, content) => {
|
|
4296
|
-
|
|
4213
|
+
mkdirSync8(path.replace(/[\\/][^\\/]*$/, ""), { recursive: true });
|
|
4297
4214
|
writeFileSync7(path, content);
|
|
4298
4215
|
};
|
|
4299
4216
|
var defaultRun = (file, args) => void execFileSync3(file, args, { stdio: "pipe", timeout: 6e4 });
|