privateer-agent 0.12.17 → 0.12.18
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/bin/privateer-launch.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import path from "node:path";
|
|
|
29
29
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
30
|
import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
|
|
31
31
|
import { routeUpdate } from "./update-route.mjs";
|
|
32
|
+
import { runToCompletion } from "./run-to-completion.mjs";
|
|
32
33
|
|
|
33
34
|
const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
|
|
34
35
|
const REPO = path.resolve(HERE, "..");
|
|
@@ -132,18 +133,6 @@ if (sub === "--version" || sub === "-V") {
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
// Faithfully propagate a child's exit/signal, mirroring bash `exec`.
|
|
135
|
-
function runToCompletion(cmd, cmdArgs, opts = {}) {
|
|
136
|
-
const child = spawn(cmd, cmdArgs, { stdio: "inherit", env: process.env, ...opts });
|
|
137
|
-
child.on("exit", (code, signal) => {
|
|
138
|
-
if (signal) process.kill(process.pid, signal);
|
|
139
|
-
else process.exit(code ?? 0);
|
|
140
|
-
});
|
|
141
|
-
child.on("error", (e) => {
|
|
142
|
-
console.error(`privateer: failed to launch — ${e.message}`);
|
|
143
|
-
process.exit(1);
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
136
|
// npm gives no usable progress, so on a TTY show a braille spinner while it runs
|
|
148
137
|
// and keep its output buffered — shown only if the install fails. Non-TTY (CI,
|
|
149
138
|
// piped) keeps the old passthrough behaviour. The global package is replaced in
|
|
@@ -291,7 +280,9 @@ if (sub === "update") {
|
|
|
291
280
|
else if (sub === "harbor" || sub === "daemon") {
|
|
292
281
|
sweepLegacyShims(); // a harbor-only machine upgrades too — see the function's note
|
|
293
282
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
294
|
-
|
|
283
|
+
// A resident background process, stopped by launchd/systemd/scripts with a plain
|
|
284
|
+
// `kill` — which reaches only this launcher. See runToCompletion.
|
|
285
|
+
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)], { forwardSignals: true });
|
|
295
286
|
}
|
|
296
287
|
|
|
297
288
|
// --- `privateer verify` ----------------------------------------------------
|
|
@@ -314,7 +305,9 @@ else if (sub === "verify") {
|
|
|
314
305
|
else if (sub === "acp") {
|
|
315
306
|
sweepLegacyShims(); // silent: only ever removes files
|
|
316
307
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
317
|
-
|
|
308
|
+
// Long-lived and driven over stdio by an editor, which stops it by terminating
|
|
309
|
+
// the process rather than by a keystroke. Same leak, same fix.
|
|
310
|
+
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)], { forwardSignals: true });
|
|
318
311
|
}
|
|
319
312
|
|
|
320
313
|
// --- normal launch: resolve the moat, then exec Pi's TUI with it -----------
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Types for the launcher's child-process handoff. Same reason as update-route.d.mts:
|
|
2
|
+
// the implementation is plain .mjs because bin/ runs under a bare `node`, before any
|
|
3
|
+
// transpiler exists — but the test that pins the signal behaviour is TypeScript.
|
|
4
|
+
|
|
5
|
+
import type { SpawnOptions } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
export interface RunToCompletionOptions extends SpawnOptions {
|
|
8
|
+
/** Pass SIGTERM/SIGHUP on to the child and wait for it, instead of dying alone and
|
|
9
|
+
* leaving it reparented to init. Off by default — only the long-lived headless
|
|
10
|
+
* children (harbor, acp) need it; see the implementation's note on why SIGINT and
|
|
11
|
+
* SIGQUIT are deliberately NOT forwarded. */
|
|
12
|
+
forwardSignals?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Spawn a child and hand it this process's lifetime: its exit code, its signal
|
|
16
|
+
* death, and — with `forwardSignals` — the termination signals sent to us. Never
|
|
17
|
+
* returns normally; the process exits with the child. */
|
|
18
|
+
export function runToCompletion(cmd: string, cmdArgs: string[], opts?: RunToCompletionOptions): void;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn a child and hand it this process's lifetime — including its termination
|
|
3
|
+
* signals.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from the launcher for the same reason update-route.mjs was: the
|
|
6
|
+
* reasoning is subtle enough to want a test next to it, and the launcher itself
|
|
7
|
+
* runs on import so nothing inside it can be unit-tested in place.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
// How long a forwarded SIGTERM has to work before we stop being polite. A harbor
|
|
12
|
+
// shuts down in milliseconds; this only matters for a child wedged mid-write.
|
|
13
|
+
const SIGNAL_GRACE_MS = 5000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `forwardSignals` — pass a termination signal on to the child and wait for it.
|
|
17
|
+
*
|
|
18
|
+
* WHY THIS EXISTS. Every subcommand here re-execs a second Node process, and
|
|
19
|
+
* without this the launcher dies alone: the child is reparented to init and keeps
|
|
20
|
+
* running. For the TUI that is nearly invisible (Ctrl-C is delivered by the
|
|
21
|
+
* terminal to the whole foreground process group, so both die anyway), but for the
|
|
22
|
+
* long-lived headless children it is a real leak — `privateer harbor` is started by
|
|
23
|
+
* launchd/systemd and by scripts, both of which stop a process with a plain
|
|
24
|
+
* `kill`, and that signal reaches only the launcher.
|
|
25
|
+
*
|
|
26
|
+
* Measured 2026-08-23 while building the E2E CLI fixture: killing
|
|
27
|
+
* `privateer harbor run` left the harbor alive, reparented to init, and STILL
|
|
28
|
+
* CONNECTED TO THE RELAY — holding the account's live-agent slot (one, on the free
|
|
29
|
+
* plan) with no way to find it except by pid. The harbor already handles SIGTERM
|
|
30
|
+
* and shuts down cleanly; nothing was ever passing it on.
|
|
31
|
+
*
|
|
32
|
+
* **Only SIGTERM and SIGHUP are forwarded, deliberately.** SIGINT and SIGQUIT are
|
|
33
|
+
* generated by the terminal and delivered to the entire foreground process group,
|
|
34
|
+
* so the child has already had them — forwarding would deliver a SECOND one, and a
|
|
35
|
+
* TUI that treats the first Ctrl-C as "clear the line" and the second as "quit"
|
|
36
|
+
* would exit on a single keypress.
|
|
37
|
+
*
|
|
38
|
+
* The parent then waits for the child rather than exiting under it, so the exit
|
|
39
|
+
* code still belongs to the child, and escalates to SIGKILL after a grace period:
|
|
40
|
+
* a child that ignores SIGTERM must not be able to outlive the launcher, which is
|
|
41
|
+
* the whole failure being fixed.
|
|
42
|
+
*/
|
|
43
|
+
export function runToCompletion(cmd, cmdArgs, opts = {}) {
|
|
44
|
+
const { forwardSignals = false, ...spawnOpts } = opts;
|
|
45
|
+
const child = spawn(cmd, cmdArgs, { stdio: "inherit", env: process.env, ...spawnOpts });
|
|
46
|
+
|
|
47
|
+
let killTimer = null;
|
|
48
|
+
if (forwardSignals) {
|
|
49
|
+
for (const sig of ["SIGTERM", "SIGHUP"]) {
|
|
50
|
+
process.on(sig, () => {
|
|
51
|
+
try { child.kill(sig); } catch { /* already gone */ }
|
|
52
|
+
// Do NOT exit here: installing a listener suppresses Node's default
|
|
53
|
+
// terminate-now behaviour, so we stay up until the child's own exit fires
|
|
54
|
+
// below and can report its code.
|
|
55
|
+
if (killTimer) return;
|
|
56
|
+
killTimer = setTimeout(() => {
|
|
57
|
+
try { child.kill("SIGKILL"); } catch { /* already gone */ }
|
|
58
|
+
}, SIGNAL_GRACE_MS);
|
|
59
|
+
killTimer.unref?.();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
child.on("exit", (code, signal) => {
|
|
65
|
+
if (killTimer) clearTimeout(killTimer);
|
|
66
|
+
if (signal) process.kill(process.pid, signal);
|
|
67
|
+
else process.exit(code ?? 0);
|
|
68
|
+
});
|
|
69
|
+
child.on("error", (e) => {
|
|
70
|
+
console.error(`privateer: failed to launch — ${e.message}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
});
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.18",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* app's seal(): X25519 → HKDF-SHA256 → AES-256-GCM.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
21
|
+
import { readFileSync, writeFileSync, chmodSync, mkdirSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
23
23
|
import { x25519 } from "@noble/curves/ed25519";
|
|
24
24
|
import { globalDir } from "../config/paths.ts";
|
|
@@ -67,6 +67,14 @@ function loadOrCreate(): { publicKey: Uint8Array; secretKey: Uint8Array } {
|
|
|
67
67
|
const secretKey = x25519.utils.randomPrivateKey();
|
|
68
68
|
const publicKey = x25519.getPublicKey(secretKey);
|
|
69
69
|
const file: TerminalKeyFile = { v: 1, publicKey: b64(publicKey), secretKey: b64(secretKey) };
|
|
70
|
+
// The home may not exist yet: the FIRST thing a fresh machine does is /login, and
|
|
71
|
+
// requestDeviceCode swallows a throw from here (a login must not fail over a key it
|
|
72
|
+
// can live without). Without the mkdir that swallow turned an absent directory into
|
|
73
|
+
// a login that silently carried no pubkey — so the app had nothing to pin and every
|
|
74
|
+
// app-sealed secret to this terminal stayed impossible until the next re-link.
|
|
75
|
+
// saveCredentials writes into the same directory and has always created it; this is
|
|
76
|
+
// the same rule, one step earlier in the flow.
|
|
77
|
+
mkdirSync(globalDir(), { recursive: true, mode: 0o700 });
|
|
70
78
|
// Create 0600 from the start — passing `mode` to writeFileSync avoids the TOCTOU
|
|
71
79
|
// window where a fresh file briefly carries umask perms (group/world-readable)
|
|
72
80
|
// before a follow-up chmod. `mode` only applies on CREATE, so also chmod to fix an
|