@proagentstore/cli 0.4.158 → 0.4.159
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.
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { maskSecrets } from "../crash-reports.js";
|
|
2
|
+
import { machineSecureTypeDeps, secureType } from "./secure-type.js";
|
|
2
3
|
import { tmuxExec } from "./tmux.js";
|
|
3
4
|
/** How long a delivered value is masked in captures. */
|
|
4
5
|
export const INJECTED_TTL_MS = 24 * 60 * 60_000;
|
|
@@ -82,3 +83,7 @@ export function scrubDelivered(body) {
|
|
|
82
83
|
return { remembered: true, cleared: false };
|
|
83
84
|
}
|
|
84
85
|
}
|
|
86
|
+
/** The two secret routes a delivery uses: `/secure/type` (the value over stdin, #1143) and `/secure/scrub` (after it, #1166). */
|
|
87
|
+
export async function secureTerminalRoute(path, body) {
|
|
88
|
+
return path === "/secure/scrub" ? scrubDelivered(body) : secureType(body, await machineSecureTypeDeps());
|
|
89
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type a secret into a terminal WITHOUT putting it in any process's argv (#1143).
|
|
3
|
+
*
|
|
4
|
+
* `secure_input_inject` used to type through `/tmux/send` / `/terminal/send`, which run
|
|
5
|
+
* `tmux send-keys -t <session> -l <value>` and `kitty @ send-text --match id:<n> <value>`: the plaintext sat
|
|
6
|
+
* in a child process's argv for the life of the call, readable by any local user through `ps` or
|
|
7
|
+
* `/proc/<pid>/cmdline`, and by whatever records process launches. Here the value travels over STDIN:
|
|
8
|
+
*
|
|
9
|
+
* • tmux — `load-buffer -b <one-use name> -` (the value on stdin), then `paste-buffer -d -b <name> -t <session>`,
|
|
10
|
+
* which deletes the buffer as it pastes; a paste that fails deletes it anyway.
|
|
11
|
+
* • kitty — `kitty @ send-text --match id:<n> --stdin` (the value on stdin).
|
|
12
|
+
* • iTerm2 — refused: its `write text` takes the value inside an AppleScript argument, and always presses Return.
|
|
13
|
+
*
|
|
14
|
+
* Its own route (`POST /secure/type`), so ordinary `tmux_send_keys` text is unaffected, and so there is one body
|
|
15
|
+
* this runner promises never to log or put in an error: errors name the target, never the value — and with the
|
|
16
|
+
* value off argv, a failed command's own message (which quotes its argv) cannot carry it either.
|
|
17
|
+
*/
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { RunnerInputError } from "../errors.js";
|
|
21
|
+
/** Deliver `value` to `target` (a bare tmux session, `tmux:<session>` or `kitty:<id>`); the pane before and after. */
|
|
22
|
+
export async function secureType(body, deps) {
|
|
23
|
+
const target = typeof body.target === "string" ? body.target.trim() : "";
|
|
24
|
+
const value = typeof body.value === "string" ? body.value : "";
|
|
25
|
+
if (!target)
|
|
26
|
+
throw new RunnerInputError("A `target` is required.");
|
|
27
|
+
if (!value)
|
|
28
|
+
throw new RunnerInputError("Nothing to type.");
|
|
29
|
+
const opts = (input) => ({ input, encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
|
|
30
|
+
const kitty = /^kitty:(.+)$/i.exec(target);
|
|
31
|
+
if (kitty) {
|
|
32
|
+
const paneBefore = deps.capture(kitty[1], "kitty");
|
|
33
|
+
try {
|
|
34
|
+
deps.exec("kitty", ["@", "send-text", "--match", `id:${kitty[1]}`, "--stdin"], opts(value));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error(`Typing into ${target} failed: kitty remote control is unavailable (allow_remote_control yes).`);
|
|
38
|
+
}
|
|
39
|
+
const pane = deps.capture(kitty[1], "kitty");
|
|
40
|
+
return { target, pane, paneBefore, changed: pane !== paneBefore };
|
|
41
|
+
}
|
|
42
|
+
if (/^iterm2:/i.test(target))
|
|
43
|
+
throw new RunnerInputError("iTerm2 cannot be typed into without the value in a script argument; use a tmux or kitty target.");
|
|
44
|
+
const session = target.replace(/^tmux:/i, "");
|
|
45
|
+
if (!deps.exists(session))
|
|
46
|
+
throw new RunnerInputError(`No tmux session "${session}".`, 404);
|
|
47
|
+
const paneBefore = deps.capture(session, "tmux");
|
|
48
|
+
const buffer = `pags-secure-${randomUUID()}`;
|
|
49
|
+
try {
|
|
50
|
+
deps.exec("tmux", ["load-buffer", "-b", buffer, "-"], opts(value));
|
|
51
|
+
deps.exec("tmux", ["paste-buffer", "-d", "-b", buffer, "-t", session], opts(""));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new Error(`Typing into "${session}" failed: tmux did not accept the paste.`);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
// `-d` deleted it on a paste that ran; this is the paste that did not.
|
|
58
|
+
try {
|
|
59
|
+
deps.exec("tmux", ["delete-buffer", "-b", buffer], opts(""));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Already gone — the normal case.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const pane = await deps.settle(session);
|
|
66
|
+
return { target, pane, paneBefore, changed: pane !== paneBefore };
|
|
67
|
+
}
|
|
68
|
+
/** The real hands. */
|
|
69
|
+
export async function machineSecureTypeDeps() {
|
|
70
|
+
const { capturePane, sessionExists, waitForPaneSettle } = await import("./tmux.js");
|
|
71
|
+
const { captureTerminalTarget } = await import("./terminal.js");
|
|
72
|
+
return {
|
|
73
|
+
exec: (file, args, opts) => execFileSync(file, args, opts),
|
|
74
|
+
exists: sessionExists,
|
|
75
|
+
capture: (t, backend) => (backend === "kitty" ? captureTerminalTarget(t, { backend: "kitty" }) : capturePane(t, 200)),
|
|
76
|
+
settle: (session) => waitForPaneSettle(session, { quietMs: 750, timeoutMs: 3_000 }),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { outgoing, redactPanesOf,
|
|
1
|
+
import { outgoing, redactPanesOf, secureTerminalRoute } from "./coding/pane-redact.js";
|
|
2
2
|
import { codingSessionRefusal, instanceOf, readJson } from "./request-instance.js";
|
|
3
3
|
import { createServer, } from "node:http";
|
|
4
4
|
import { URL } from "node:url";
|
|
@@ -84,8 +84,8 @@ async function route(runner, req, res) {
|
|
|
84
84
|
return json(res, 403, { error: refused });
|
|
85
85
|
// Panes leave this machine redacted (#1166, coding/pane-redact.ts); a delivered secret is scrubbed.
|
|
86
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,
|
|
87
|
+
if (req.method === "POST" && (path === "/secure/scrub" || path === "/secure/type"))
|
|
88
|
+
return json(res, 200, await secureTerminalRoute(path, await readJson(req)));
|
|
89
89
|
if ((req.method === "GET" || req.method === "POST") && path === "/health") {
|
|
90
90
|
// #896: a health check that cannot say WHO answered is not enough to decide anything with.
|
|
91
91
|
// A contender for the single-instance lock has a pid and a port from the lock file; what it
|