privateer-agent 0.12.17 → 0.12.19
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 +7 -14
- package/bin/run-to-completion.d.mts +18 -0
- package/bin/run-to-completion.mjs +73 -0
- package/package.json +1 -1
- package/src/crypto/terminalKey.ts +9 -1
- package/src/harbor/index.ts +30 -4
- package/src/harbor/ipc.ts +17 -0
- package/src/remote/relayClient.ts +24 -2
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.19",
|
|
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
|
package/src/harbor/index.ts
CHANGED
|
@@ -651,6 +651,16 @@ export class Harbor {
|
|
|
651
651
|
}
|
|
652
652
|
|
|
653
653
|
private async tick(): Promise<void> {
|
|
654
|
+
// Retry the relay every tick, not just at startup. syncRelay() bails when the
|
|
655
|
+
// account isn't signed in on this machine — and a harbor that came up in that
|
|
656
|
+
// state used to stay off the relay FOREVER, because start() was the only caller
|
|
657
|
+
// that mattered (the three IPC commands that also call it are ones the desktop
|
|
658
|
+
// app never sends). Signing in afterwards wrote credentials.json and changed
|
|
659
|
+
// nothing: the harbor kept answering IPC, so the app called it running, while
|
|
660
|
+
// its relay socket had never been opened — the permanent "Connecting" with a
|
|
661
|
+
// harbor that fires no routine and answers no spawn. Cheap and idempotent: it
|
|
662
|
+
// returns immediately once a client exists, or while remote access is off.
|
|
663
|
+
this.syncRelay();
|
|
654
664
|
void this.flushPendingCloud();
|
|
655
665
|
const now = Date.now();
|
|
656
666
|
for (const r of loadRoutines()) {
|
|
@@ -1269,19 +1279,35 @@ export class Harbor {
|
|
|
1269
1279
|
private relayStatus(): RelayStatus {
|
|
1270
1280
|
const termId = routineRelayId();
|
|
1271
1281
|
if (this.relayTerminated) {
|
|
1272
|
-
return { termId, connected: false, detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
|
|
1282
|
+
return { termId, connected: false, reason: "terminated", detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
|
|
1273
1283
|
}
|
|
1274
1284
|
if (!this.relay) {
|
|
1285
|
+
// "relay not started" with credentials present is now a sub-tick window, not a
|
|
1286
|
+
// permanent state: tick() re-runs syncRelay(), so a harbor that came up signed
|
|
1287
|
+
// out connects on its own once you sign in. Reported as "connecting" because
|
|
1288
|
+
// that is what it now is.
|
|
1289
|
+
const signedIn = hasCredentials();
|
|
1275
1290
|
return {
|
|
1276
1291
|
termId,
|
|
1277
1292
|
connected: false,
|
|
1278
|
-
|
|
1293
|
+
reason: signedIn ? "connecting" : "signed-out",
|
|
1294
|
+
detail: signedIn
|
|
1279
1295
|
? "relay not started"
|
|
1280
|
-
: "no account signed in on this machine — run `privateer` and /login
|
|
1296
|
+
: "no account signed in on this machine — run `privateer` and /login",
|
|
1281
1297
|
};
|
|
1282
1298
|
}
|
|
1283
1299
|
const conn = this.relay.connectionStatus();
|
|
1284
|
-
|
|
1300
|
+
// The client knows why it isn't up — a refused ticket, an unreachable server —
|
|
1301
|
+
// and reporting a flat "connecting…" over the top of that is what made a harbor
|
|
1302
|
+
// that will never connect look like one that is about to.
|
|
1303
|
+
if (!conn.connected) {
|
|
1304
|
+
return {
|
|
1305
|
+
termId,
|
|
1306
|
+
connected: false,
|
|
1307
|
+
reason: conn.refused ? "refused" : "connecting",
|
|
1308
|
+
detail: conn.detail ?? "connecting…",
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1285
1311
|
return { termId, connected: true, upSec: conn.upSec, quietSec: conn.quietSec };
|
|
1286
1312
|
}
|
|
1287
1313
|
|
package/src/harbor/ipc.ts
CHANGED
|
@@ -42,6 +42,21 @@ export type IpcRequest =
|
|
|
42
42
|
| { cmd: "run-now"; idOrName: string }
|
|
43
43
|
| { cmd: "reload" };
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Why a harbor isn't on the relay, as a code rather than a sentence.
|
|
47
|
+
*
|
|
48
|
+
* `detail` below is written for a terminal — it names CLI commands and is English
|
|
49
|
+
* only — so the app can't put it in front of a user. It used to have nothing else
|
|
50
|
+
* to go on, and said "give it a moment" about every one of these, including the
|
|
51
|
+
* three that never clear on their own. This is the machine-readable half.
|
|
52
|
+
*
|
|
53
|
+
* terminated remote access was switched off from the app; needs a restart
|
|
54
|
+
* signed-out no account credentials on this machine
|
|
55
|
+
* refused the server said no (plan's agent cap, rejected ticket) — standing
|
|
56
|
+
* connecting genuinely still trying; this one really is worth a moment
|
|
57
|
+
*/
|
|
58
|
+
export type RelayReason = "terminated" | "signed-out" | "refused" | "connecting";
|
|
59
|
+
|
|
45
60
|
/**
|
|
46
61
|
* The harbor's view of its own relay connection, reported by `status`.
|
|
47
62
|
*
|
|
@@ -62,6 +77,8 @@ export interface RelayStatus {
|
|
|
62
77
|
quietSec?: number;
|
|
63
78
|
/** Why it isn't connected, when we know: signed out, turned off from the app, … */
|
|
64
79
|
detail?: string;
|
|
80
|
+
/** The same fact as `detail`, for a caller that has to localize it. */
|
|
81
|
+
reason?: RelayReason;
|
|
65
82
|
}
|
|
66
83
|
|
|
67
84
|
export interface IpcResponse {
|
|
@@ -383,6 +383,11 @@ export class RelayClient {
|
|
|
383
383
|
private reconnectDelay = RECONNECT_MS;
|
|
384
384
|
// Last refusal reason reported, so a 4xx is logged once instead of on every retry.
|
|
385
385
|
private refusal: string | null = null;
|
|
386
|
+
// Why the last connection attempt failed, kept for connectionStatus(). A refusal
|
|
387
|
+
// (4xx) outranks it — that one is a decision, not a hiccup — but without either,
|
|
388
|
+
// every un-connected relay reports as a bare "connecting…", which is the same
|
|
389
|
+
// sentence whether the socket opens in two seconds or never opens again.
|
|
390
|
+
private lastFailure: string | null = null;
|
|
386
391
|
// Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
|
|
387
392
|
private bufKind: "text" | "reasoning" | null = null;
|
|
388
393
|
private buf = "";
|
|
@@ -518,6 +523,7 @@ export class RelayClient {
|
|
|
518
523
|
ws.on("open", () => {
|
|
519
524
|
opened = true;
|
|
520
525
|
this.refusal = null; // a later refusal is news again
|
|
526
|
+
this.lastFailure = null; // whatever kept us out is history
|
|
521
527
|
this.reconnectDelay = RECONNECT_MS; // reachable again — next blip retries fast
|
|
522
528
|
this.connectedAt = Date.now();
|
|
523
529
|
this.startHeartbeat(ws);
|
|
@@ -537,6 +543,9 @@ export class RelayClient {
|
|
|
537
543
|
// attach/detach frame said. Re-learned on the next attach or inbound frame.
|
|
538
544
|
this.controllerHere = false;
|
|
539
545
|
this.cb.onDisconnected?.();
|
|
546
|
+
// A socket that never opened failed for a reason worth reporting; one that
|
|
547
|
+
// opened and dropped is an ordinary blip the reconnect handles.
|
|
548
|
+
this.lastFailure = opened ? null : lastErr || "the relay connection closed before it opened";
|
|
540
549
|
if (!this.closed) {
|
|
541
550
|
this.cb.onStatus?.(
|
|
542
551
|
opened
|
|
@@ -560,6 +569,7 @@ export class RelayClient {
|
|
|
560
569
|
const status = (err as { status?: number })?.status;
|
|
561
570
|
const refused = typeof status === "number" && status >= 400 && status < 500;
|
|
562
571
|
if (refused) {
|
|
572
|
+
this.lastFailure = msg;
|
|
563
573
|
this.settleFirstConnect(err instanceof Error ? err : new Error(msg));
|
|
564
574
|
// A refusal is a decision, not a hiccup: hammering the same request every few
|
|
565
575
|
// seconds can't change it, and for a harbor — whose onStatus goes to a log file,
|
|
@@ -578,6 +588,7 @@ export class RelayClient {
|
|
|
578
588
|
}
|
|
579
589
|
// Transient (network/route/5xx): stay on the fast retry.
|
|
580
590
|
this.refusal = null;
|
|
591
|
+
this.lastFailure = msg;
|
|
581
592
|
this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
|
|
582
593
|
this.scheduleReconnect();
|
|
583
594
|
} finally {
|
|
@@ -970,8 +981,19 @@ export class RelayClient {
|
|
|
970
981
|
// quiet for longer than the server's 25s ping cadence is the shape of the half-open
|
|
971
982
|
// failure the watchdog exists to catch, so it is worth showing rather than a bare
|
|
972
983
|
// "connected".
|
|
973
|
-
connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number } {
|
|
974
|
-
|
|
984
|
+
connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number; detail?: string; refused?: boolean } {
|
|
985
|
+
// Not connected: say WHY when we know. A standing refusal (the plan's agent cap,
|
|
986
|
+
// a rejected ticket) is the answer that matters most — it will not clear on its
|
|
987
|
+
// own, and it's the one case a caller should present as a decision rather than a
|
|
988
|
+
// wait — so it wins over the last transient error and is flagged as itself.
|
|
989
|
+
if (!this.isConnected()) {
|
|
990
|
+
const detail = this.refusal ?? this.lastFailure;
|
|
991
|
+
return {
|
|
992
|
+
connected: false,
|
|
993
|
+
...(detail ? { detail } : {}),
|
|
994
|
+
...(this.refusal ? { refused: true } : {}),
|
|
995
|
+
};
|
|
996
|
+
}
|
|
975
997
|
const now = Date.now();
|
|
976
998
|
return {
|
|
977
999
|
connected: true,
|