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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.11",
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",
@@ -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
- ${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVariables</key>\n <dict>\n${envXml}\n </dict>\n` : ""} <key>RunAtLoad</key>
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 needsRefresh(). */
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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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
- // Does the INSTALLED unit need rewriting? Deliberately narrow: a full text compare
216
- // against what we'd generate today would flag every service installed by a different
217
- // copy of the CLI (a dev checkout resolves a different launcher path), which is not a
218
- // problem and not something the user should be nagged about. The one thing worth
219
- // flagging is the pre-fix launchd `KeepAlive: true`, which restarts the harbor even
220
- // after a clean exit the log-spam loop described above launchdPlist().
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 (platform !== "darwin" || !existsSync(unitPath)) return false;
320
+ if (!existsSync(unitPath)) return false;
321
+ if (unitIsStale(platform, unitPath)) return false;
322
+ let body: string;
223
323
  try {
224
- const plist = readFileSync(unitPath, "utf8");
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)}`);