@proagentstore/cli 0.4.68 → 0.4.69
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.
|