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.
@@ -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
- runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)]);
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
- runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)]);
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.17",
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