@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: hostname3() });
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.68",
3
+ "version": "0.4.70",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",