privateer-agent 0.12.11 → 0.12.12
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/package.json +1 -1
- package/src/harbor/service.ts +117 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.12",
|
|
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",
|
package/src/harbor/service.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
|
-
import { join, dirname, resolve } from "node:path";
|
|
12
|
+
import { join, dirname, resolve, basename } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { globalDir } from "../config/paths.ts";
|
|
15
15
|
import { harborIsRunning, sendToHarbor, describeRelay, formatDuration, type IpcResponse } from "./ipc.ts";
|
|
@@ -43,12 +43,37 @@ function harborLogPath(): string {
|
|
|
43
43
|
return join(globalDir(), "harbor.log");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Whether the interpreter we're baking in is an Electron binary rather than a plain
|
|
47
|
+
// `node`. It is when the desktop app installs the service: harborManager calls
|
|
48
|
+
// installService() in the Electron MAIN process, so nodeBinaryPath() resolves to
|
|
49
|
+
// …/Privateer.app/Contents/MacOS/Privateer. Same story one step removed for the
|
|
50
|
+
// desktop CLI shim, which is that binary under another name.
|
|
51
|
+
//
|
|
52
|
+
// This matters because an Electron binary is a GUI app by default and only behaves as
|
|
53
|
+
// Node when ELECTRON_RUN_AS_NODE is set — a fact the shim and harborManager both know
|
|
54
|
+
// (they set it on every spawn) and this module used not to. A unit written without it
|
|
55
|
+
// doesn't run the harbor: at every login launchd starts the whole desktop app with a
|
|
56
|
+
// script path in argv.
|
|
57
|
+
function interpreterIsElectron(binary: string): boolean {
|
|
58
|
+
// The current process is the honest answer when we're the one being baked in.
|
|
59
|
+
if (binary === process.execPath) return Boolean((process.versions as Record<string, string>).electron);
|
|
60
|
+
// An already-installed unit, read back: judge it by the name. Every Node we ship or
|
|
61
|
+
// expect — the standalone bundle's, an npm global's, a system one — is called `node`.
|
|
62
|
+
return !/^node(\.exe)?$/i.test(basename(binary));
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
// Env we forward into the service so a non-default home / server URL survives. Kept
|
|
47
66
|
// tiny and explicit — the harbor reads the rest from ~/.privateer.
|
|
67
|
+
//
|
|
68
|
+
// ELECTRON_RUN_AS_NODE is NOT forwarded from the environment, it is derived: the
|
|
69
|
+
// desktop app's main process doesn't have it set (it's a GUI process) and still needs
|
|
70
|
+
// it in the unit. Keying off the binary instead of the ambient env is what makes the
|
|
71
|
+
// answer the same however installService() was reached.
|
|
48
72
|
function forwardedEnv(): Record<string, string> {
|
|
49
73
|
const env: Record<string, string> = {};
|
|
50
74
|
if (process.env.PRIVATEER_HOME) env.PRIVATEER_HOME = process.env.PRIVATEER_HOME;
|
|
51
75
|
if (process.env.PRIVATEER_SERVER_URL) env.PRIVATEER_SERVER_URL = process.env.PRIVATEER_SERVER_URL;
|
|
76
|
+
if (interpreterIsElectron(nodeBinaryPath())) env.ELECTRON_RUN_AS_NODE = "1";
|
|
52
77
|
return env;
|
|
53
78
|
}
|
|
54
79
|
|
|
@@ -86,7 +111,7 @@ export function launchdPlist(): string {
|
|
|
86
111
|
<array>
|
|
87
112
|
${argXml}
|
|
88
113
|
</array>
|
|
89
|
-
${
|
|
114
|
+
${Object.keys(envVars).length ? ` <key>EnvironmentVariables</key>\n <dict>\n${envXml}\n </dict>\n` : ""} <key>RunAtLoad</key>
|
|
90
115
|
<true/>
|
|
91
116
|
<key>KeepAlive</key>
|
|
92
117
|
<dict>
|
|
@@ -208,24 +233,102 @@ export interface ServiceInfo {
|
|
|
208
233
|
installed: boolean;
|
|
209
234
|
unitPath: string;
|
|
210
235
|
logPath: string;
|
|
211
|
-
/** Installed unit predates a fix and should be rewritten — see
|
|
236
|
+
/** Installed unit predates a fix and should be rewritten — see unitNeedsRefresh(). */
|
|
212
237
|
needsRefresh: boolean;
|
|
238
|
+
/**
|
|
239
|
+
* The installed unit points at a binary that no longer exists, so it can never
|
|
240
|
+
* start again. Rewriting won't help — it has to be REMOVED. See unitIsStale().
|
|
241
|
+
*/
|
|
242
|
+
stale: boolean;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function xmlUnescape(s: string): string {
|
|
246
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The command baked into an INSTALLED unit — [interpreter, launcher, "run"] — read
|
|
251
|
+
* back off disk. Null when there's nothing to read or the file doesn't parse, so
|
|
252
|
+
* every caller's fallback is "can't tell", never "assume the worst".
|
|
253
|
+
*
|
|
254
|
+
* We parse rather than regenerate-and-compare on purpose: what we'd generate today
|
|
255
|
+
* legitimately differs from a unit written by another copy of the agent (a dev
|
|
256
|
+
* checkout, an npm global, the desktop app), and the questions below are about the
|
|
257
|
+
* unit that is actually loaded.
|
|
258
|
+
*/
|
|
259
|
+
export function unitProgramPaths(platform: NodeJS.Platform, unitPath: string): string[] | null {
|
|
260
|
+
if (!unitPath || !existsSync(unitPath)) return null;
|
|
261
|
+
let body: string;
|
|
262
|
+
try {
|
|
263
|
+
body = readFileSync(unitPath, "utf8");
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
if (platform === "darwin") {
|
|
268
|
+
const array = body.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
|
269
|
+
if (!array) return null;
|
|
270
|
+
const args = [...array[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => xmlUnescape(m[1]));
|
|
271
|
+
return args.length ? args : null;
|
|
272
|
+
}
|
|
273
|
+
if (platform === "linux") {
|
|
274
|
+
const line = body.match(/^ExecStart=(.*)$/m);
|
|
275
|
+
if (!line) return null;
|
|
276
|
+
// Written as single-quoted words with '\'' for an embedded quote (systemdUnit()).
|
|
277
|
+
const args = [...line[1].matchAll(/'((?:[^']|'\\'')*)'|(\S+)/g)].map((m) =>
|
|
278
|
+
m[1] !== undefined ? m[1].replace(/'\\''/g, "'") : m[2],
|
|
279
|
+
);
|
|
280
|
+
return args.length ? args : null;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Is the installed unit pointing at software that's gone?
|
|
287
|
+
*
|
|
288
|
+
* The unit bakes ABSOLUTE paths — the interpreter (process.execPath) and the harbor
|
|
289
|
+
* launcher, resolved from wherever the installing copy of the agent lived. When the
|
|
290
|
+
* desktop app is what installed it, both of those are inside Privateer.app, and
|
|
291
|
+
* dragging the app to the Trash leaves a launchd job that fires at every login,
|
|
292
|
+
* forever, running a binary that no longer exists. Nothing else notices: launchd has
|
|
293
|
+
* no opinion about a missing program beyond failing to start it.
|
|
294
|
+
*
|
|
295
|
+
* So this is the check that makes "uninstall the app" finishable — see the desktop's
|
|
296
|
+
* reapStaleService(), which tears the unit down the moment it sees one.
|
|
297
|
+
*/
|
|
298
|
+
export function unitIsStale(platform: NodeJS.Platform, unitPath: string): boolean {
|
|
299
|
+
const args = unitProgramPaths(platform, unitPath);
|
|
300
|
+
if (!args || args.length < 2) return false; // unreadable / unrecognised — say nothing
|
|
301
|
+
return !existsSync(args[0]) || !existsSync(args[1]);
|
|
213
302
|
}
|
|
214
303
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
304
|
+
/**
|
|
305
|
+
* Does the INSTALLED unit need rewriting? Deliberately narrow: a full text compare
|
|
306
|
+
* against what we'd generate today would flag every service installed by a different
|
|
307
|
+
* copy of the CLI (a dev checkout resolves a different launcher path), which is not a
|
|
308
|
+
* problem and not something the user should be nagged about. Two things are worth
|
|
309
|
+
* flagging, both of which mean the unit does not do what it says:
|
|
310
|
+
*
|
|
311
|
+
* • the pre-fix launchd `KeepAlive: true`, which restarts the harbor even after a
|
|
312
|
+
* clean exit — the log-spam loop described above launchdPlist();
|
|
313
|
+
* • an Electron interpreter with no ELECTRON_RUN_AS_NODE — see forwardedEnv(). Such
|
|
314
|
+
* a unit starts the desktop APP at login instead of a harbor. Every unit the
|
|
315
|
+
* desktop app wrote before that fix has this shape.
|
|
316
|
+
*
|
|
317
|
+
* A stale unit is NOT reported here: rewriting one only re-bakes the same dead path.
|
|
318
|
+
*/
|
|
221
319
|
export function unitNeedsRefresh(platform: NodeJS.Platform, unitPath: string): boolean {
|
|
222
|
-
if (
|
|
320
|
+
if (!existsSync(unitPath)) return false;
|
|
321
|
+
if (unitIsStale(platform, unitPath)) return false;
|
|
322
|
+
let body: string;
|
|
223
323
|
try {
|
|
224
|
-
|
|
225
|
-
return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(plist);
|
|
324
|
+
body = readFileSync(unitPath, "utf8");
|
|
226
325
|
} catch {
|
|
227
326
|
return false;
|
|
228
327
|
}
|
|
328
|
+
if (platform === "darwin" && /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(body)) return true;
|
|
329
|
+
const args = unitProgramPaths(platform, unitPath);
|
|
330
|
+
if (args?.[0] && interpreterIsElectron(args[0]) && !/ELECTRON_RUN_AS_NODE/.test(body)) return true;
|
|
331
|
+
return false;
|
|
229
332
|
}
|
|
230
333
|
|
|
231
334
|
function unitPathFor(platform: NodeJS.Platform): string {
|
|
@@ -244,6 +347,7 @@ export function serviceInfo(): ServiceInfo {
|
|
|
244
347
|
unitPath,
|
|
245
348
|
logPath: harborLogPath(),
|
|
246
349
|
needsRefresh: unitNeedsRefresh(platform, unitPath),
|
|
350
|
+
stale: !!unitPath && unitIsStale(platform, unitPath),
|
|
247
351
|
};
|
|
248
352
|
}
|
|
249
353
|
|
|
@@ -284,7 +388,7 @@ export async function statusReport(): Promise<string> {
|
|
|
284
388
|
const pid = status?.pid ? `pid ${status.pid}${up}` : "answering IPC";
|
|
285
389
|
const lines = [
|
|
286
390
|
`platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer harbor` manually)"}`,
|
|
287
|
-
`service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
|
|
391
|
+
`service: ${info.installed ? `installed (${info.unitPath})${info.stale ? " — STALE: it points at software that's been removed; run `privateer harbor uninstall`" : info.needsRefresh ? " — needs rewriting; run `privateer harbor install`" : ""}` : "not installed"}`,
|
|
288
392
|
`harbor: ${live ? `running (${pid})` : "not reachable"}`,
|
|
289
393
|
];
|
|
290
394
|
if (live) lines.push(`relay: ${describeRelay(status?.relay)}`);
|