@proagentstore/cli 0.4.68 → 0.4.70
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.
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { RunnerInputError } from "../errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* The runner's half of the machine-to-machine secret handoff (#918): read a secret file so the
|
|
7
|
+
* cloud can encrypt it, and write one back from the cloud's decrypted copy.
|
|
8
|
+
*
|
|
9
|
+
* The value crosses the relay between the API worker and this process and goes nowhere else. Both
|
|
10
|
+
* functions are built so it cannot leak by accident:
|
|
11
|
+
*
|
|
12
|
+
* • no shell, no tmux, no subprocess — a pane or a process table is exactly where a secret is
|
|
13
|
+
* visible to everything that later reads the machine (`tmux_capture_pane`, `ps`);
|
|
14
|
+
* • every error names the PATH and never the bytes, because a runner error becomes the cloud's
|
|
15
|
+
* error string, and that string reaches the model;
|
|
16
|
+
* • `readSecretFile` returns the value to its caller (the relay) and `writeSecretFile` returns
|
|
17
|
+
* only a byte count.
|
|
18
|
+
*/
|
|
19
|
+
/** Big enough for any `.env`, key file or credentials JSON; small enough that this is not a file-transfer tool. */
|
|
20
|
+
export const SECRET_MAX_BYTES = 64 * 1024;
|
|
21
|
+
/** `~` expanded; a relative path resolves against the home directory, which is where `pags up` users name things from. */
|
|
22
|
+
function resolvePath(raw) {
|
|
23
|
+
const p = String(raw ?? "").trim();
|
|
24
|
+
if (!p)
|
|
25
|
+
throw new RunnerInputError("A `path` is required.");
|
|
26
|
+
const expanded = p.replace(/^~(?=$|\/)/, homedir());
|
|
27
|
+
return isAbsolute(expanded) ? resolve(expanded) : resolve(homedir(), expanded);
|
|
28
|
+
}
|
|
29
|
+
export function readSecretFile(input) {
|
|
30
|
+
const path = resolvePath(input.path);
|
|
31
|
+
let st;
|
|
32
|
+
try {
|
|
33
|
+
st = statSync(path);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new RunnerInputError(`No file at ${path}.`);
|
|
37
|
+
}
|
|
38
|
+
if (!st.isFile())
|
|
39
|
+
throw new RunnerInputError(`${path} is not a regular file.`);
|
|
40
|
+
if (st.size === 0)
|
|
41
|
+
throw new RunnerInputError(`${path} is empty — there is nothing to hand off.`);
|
|
42
|
+
if (st.size > SECRET_MAX_BYTES)
|
|
43
|
+
throw new RunnerInputError(`${path} is ${st.size} bytes; the secret handoff carries at most ${SECRET_MAX_BYTES}.`);
|
|
44
|
+
const buf = readFileSync(path);
|
|
45
|
+
const value = buf.toString("utf8");
|
|
46
|
+
// The store holds text. A file that does not survive a UTF-8 round trip would arrive changed,
|
|
47
|
+
// which for a key file is worse than refusing.
|
|
48
|
+
if (!Buffer.from(value, "utf8").equals(buf))
|
|
49
|
+
throw new RunnerInputError(`${path} is not UTF-8 text; the secret handoff carries text files only.`);
|
|
50
|
+
return { path, value, bytes: buf.length };
|
|
51
|
+
}
|
|
52
|
+
/** `"600"` / `"0600"` / `"640"` → a file mode, owner-readable at least, never group/world-writable or executable. */
|
|
53
|
+
function parseMode(raw) {
|
|
54
|
+
if (raw == null || raw === "")
|
|
55
|
+
return 0o600;
|
|
56
|
+
const s = String(raw).trim();
|
|
57
|
+
if (!/^0?[0-7]{3}$/.test(s))
|
|
58
|
+
throw new RunnerInputError(`mode must be octal like "600", got "${s}".`);
|
|
59
|
+
const mode = Number.parseInt(s, 8);
|
|
60
|
+
if ((mode & 0o400) === 0)
|
|
61
|
+
throw new RunnerInputError(`mode ${s} would make the file unreadable by its owner.`);
|
|
62
|
+
if ((mode & 0o022) !== 0 || (mode & 0o111) !== 0)
|
|
63
|
+
throw new RunnerInputError(`mode ${s} is group/world-writable or executable; a secret file must not be.`);
|
|
64
|
+
return mode;
|
|
65
|
+
}
|
|
66
|
+
export function writeSecretFile(input) {
|
|
67
|
+
const path = resolvePath(input.path);
|
|
68
|
+
if (typeof input.value !== "string" || !input.value)
|
|
69
|
+
throw new RunnerInputError("Nothing to write.");
|
|
70
|
+
const mode = parseMode(input.mode);
|
|
71
|
+
let replaced = false;
|
|
72
|
+
try {
|
|
73
|
+
const st = lstatSync(path);
|
|
74
|
+
if (!st.isFile())
|
|
75
|
+
throw new RunnerInputError(`${path} exists and is not a regular file.`);
|
|
76
|
+
if (input.overwrite !== true)
|
|
77
|
+
throw new RunnerInputError(`${path} already exists. Pass overwrite: true to replace it.`);
|
|
78
|
+
replaced = true;
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (e instanceof RunnerInputError)
|
|
82
|
+
throw e;
|
|
83
|
+
// Not there yet: the normal case.
|
|
84
|
+
}
|
|
85
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
86
|
+
// Written beside the target and renamed over it, so a reader never sees half a secret, and
|
|
87
|
+
// created with the final mode so it is never briefly world-readable.
|
|
88
|
+
const tmp = join(dirname(path), `.${basename(path)}.pags-${process.pid}-${Date.now()}.tmp`);
|
|
89
|
+
const data = Buffer.from(input.value, "utf8");
|
|
90
|
+
const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, mode);
|
|
91
|
+
try {
|
|
92
|
+
writeSync(fd, data);
|
|
93
|
+
fsyncSync(fd);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
closeSync(fd);
|
|
97
|
+
rmSync(tmp, { force: true });
|
|
98
|
+
throw new Error(`Could not write ${path}: ${e.code ?? "write failed"}.`);
|
|
99
|
+
}
|
|
100
|
+
closeSync(fd);
|
|
101
|
+
renameSync(tmp, path);
|
|
102
|
+
return { path, bytes: data.length, replaced };
|
|
103
|
+
}
|
|
@@ -541,6 +541,18 @@ async function route(runner, req, res) {
|
|
|
541
541
|
}
|
|
542
542
|
return json(res, 200, { session, created: true, workDir });
|
|
543
543
|
}
|
|
544
|
+
// ── secret handoff (#918) ──────────────────────────────────────────────
|
|
545
|
+
// Machine-to-machine secret transfer for `tmux_secure_put` / `tmux_secure_get`. The value moves
|
|
546
|
+
// only between this process and the cloud's encrypted store — never through a pane, a shell or
|
|
547
|
+
// a subprocess. See coding/secret-file.ts.
|
|
548
|
+
if (req.method === "POST" && path === "/secure/read") {
|
|
549
|
+
const { readSecretFile } = await import("./coding/secret-file.js");
|
|
550
|
+
return json(res, 200, readSecretFile(await readJson(req)));
|
|
551
|
+
}
|
|
552
|
+
if (req.method === "POST" && path === "/secure/write") {
|
|
553
|
+
const { writeSecretFile } = await import("./coding/secret-file.js");
|
|
554
|
+
return json(res, 200, writeSecretFile(await readJson(req)));
|
|
555
|
+
}
|
|
544
556
|
// ── generic terminal connector ──────────────────────────────────────────
|
|
545
557
|
// One local-terminal vocabulary over backend-specific adapters. tmux is fully
|
|
546
558
|
// controllable; kitty needs remote control enabled; iTerm2 needs macOS Automation access.
|
package/dist/index.js
CHANGED
|
@@ -1517,7 +1517,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1517
1517
|
await registerRuntime(openedId, reconnect ? false : force);
|
|
1518
1518
|
reportRegistration();
|
|
1519
1519
|
},
|
|
1520
|
-
answerControl
|
|
1520
|
+
answerControl,
|
|
1521
|
+
runnerNode
|
|
1521
1522
|
)
|
|
1522
1523
|
);
|
|
1523
1524
|
if (label) writeLine(`Attached agent: ${label}`);
|
|
@@ -1622,7 +1623,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1622
1623
|
tick();
|
|
1623
1624
|
}
|
|
1624
1625
|
}
|
|
1625
|
-
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl) {
|
|
1626
|
+
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl, runnerNode = hostname3()) {
|
|
1626
1627
|
let backoffMs = 1e3;
|
|
1627
1628
|
let reconnecting = false;
|
|
1628
1629
|
let closed = false;
|
|
@@ -1648,7 +1649,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1648
1649
|
backoffMs = Math.min(backoffMs * 2, 3e4);
|
|
1649
1650
|
return;
|
|
1650
1651
|
}
|
|
1651
|
-
const params = new URLSearchParams({ token: relayToken, node:
|
|
1652
|
+
const params = new URLSearchParams({ token: relayToken, node: runnerNode });
|
|
1652
1653
|
if (force) params.set("force", "1");
|
|
1653
1654
|
const url = `${wsBase}/v1/relay/${encodeURIComponent(instanceId)}/connect?${params.toString()}`;
|
|
1654
1655
|
const ws = new WebSocket(url);
|