privateer-agent 0.13.0 → 0.14.1

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.
@@ -4,32 +4,57 @@
4
4
  // user agent; Linux → systemd --user unit. No root: everything lives under the
5
5
  // user's own home and login session.
6
6
  //
7
+ // The channels runner (chat platforms → agent turns) installs the same way, as its own
8
+ // unit: every function here takes a ServiceKind, defaulting to the harbor, so the
9
+ // desktop app's calls (installService(), serviceInfo(), …) are unchanged.
10
+ //
7
11
  // ORDERING NOTE: this module is import-safe (node builtins + our paths only, no Pi),
8
12
  // so the harbor CLI can load it without going through boot.ts.
9
- import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync, openSync, readSync, closeSync, fstatSync } from "node:fs";
10
14
  import { spawnSync } from "node:child_process";
11
- import { homedir } from "node:os";
15
+ import { homedir, userInfo } from "node:os";
12
16
  import { join, dirname, resolve, basename } from "node:path";
13
17
  import { fileURLToPath } from "node:url";
14
18
  import { globalDir } from "../config/paths.ts";
15
19
  import { harborIsRunning, sendToHarbor, describeRelay, formatDuration, type IpcResponse } from "./ipc.ts";
16
20
 
17
- const LABEL = "pro.privateer.harbor"; // launchd label / reverse-dns id
18
- const UNIT = "privateer-harbor.service"; // systemd --user unit name
19
-
20
- // Pre-rename service identity ("daemon"). Kept ONLY so install/uninstall can evict a
21
- // service a user installed before the harbor rename — otherwise it lingers as an
22
- // orphaned launchd agent / systemd unit still running the old launcher. Never written,
23
- // only torn down.
24
- const OLD_LABEL = "pro.privateer.daemon";
25
- const OLD_UNIT = "privateer-daemon.service";
21
+ export type ServiceKind = "harbor" | "channels";
22
+
23
+ interface ServiceSpec {
24
+ label: string; // launchd label / reverse-dns id
25
+ unit: string; // systemd --user unit name
26
+ launcher: string; // bin/ file the unit runs with "run"
27
+ description: string;
28
+ // Pre-rename identity, kept ONLY so install/uninstall can evict a service a user
29
+ // installed before the harbor rename ("daemon") — otherwise it lingers as an orphaned
30
+ // launchd agent / systemd unit still running the old launcher. Never written.
31
+ oldLabel?: string;
32
+ oldUnit?: string;
33
+ }
26
34
 
27
- // Absolute path to the node launcher that boots + runs the harbor (bin/privateer-harbor.mjs).
35
+ const SPECS: Record<ServiceKind, ServiceSpec> = {
36
+ harbor: {
37
+ label: "pro.privateer.harbor",
38
+ unit: "privateer-harbor.service",
39
+ launcher: "privateer-harbor.mjs",
40
+ description: "Privateer resident agent harbor (routines + app-driven task spawns)",
41
+ oldLabel: "pro.privateer.daemon",
42
+ oldUnit: "privateer-daemon.service",
43
+ },
44
+ channels: {
45
+ label: "pro.privateer.channels",
46
+ unit: "privateer-channels.service",
47
+ launcher: "privateer-channels.mjs",
48
+ description: "Privateer channels (Telegram/Slack/Discord/WhatsApp chats to agent turns)",
49
+ },
50
+ };
51
+
52
+ // Absolute path to the node launcher that boots + runs the service (bin/privateer-*.mjs).
28
53
  // Resolved from THIS module so it's correct for both a dev checkout and a global npm
29
54
  // install (…/node_modules/privateer-agent/bin/privateer-harbor.mjs).
30
- function harborLauncherPath(): string {
55
+ function launcherPath(kind: ServiceKind): string {
31
56
  const here = dirname(fileURLToPath(import.meta.url)); // …/src/harbor
32
- return resolve(here, "../../bin/privateer-harbor.mjs");
57
+ return resolve(here, "../../bin", SPECS[kind].launcher);
33
58
  }
34
59
 
35
60
  // The node binary to bake into the unit. We use the CURRENT interpreter (>=22, the
@@ -39,8 +64,16 @@ function nodeBinaryPath(): string {
39
64
  return process.execPath;
40
65
  }
41
66
 
42
- function harborLogPath(): string {
43
- return join(globalDir(), "harbor.log");
67
+ // The log FILE the service writes to (exported for the in-process rotation, logRotate.ts). macOS only: launchd appends stdout/stderr here.
68
+ // On Linux the unit logs to the journal (see logLocation), which rotates on its own.
69
+ export function logFilePath(kind: ServiceKind = "harbor"): string {
70
+ return join(globalDir(), `${kind}.log`);
71
+ }
72
+
73
+ // Where to read this service's log, for humans: a file on macOS, a journalctl command
74
+ // on Linux. Reporting harbor.log on Linux pointed people at a file nothing ever wrote.
75
+ function logLocation(kind: ServiceKind, platform: NodeJS.Platform = process.platform): string {
76
+ return platform === "linux" ? `journalctl --user -u ${SPECS[kind].unit}` : logFilePath(kind);
44
77
  }
45
78
 
46
79
  // Whether the interpreter we're baking in is an Electron binary rather than a plain
@@ -79,8 +112,8 @@ function forwardedEnv(): Record<string, string> {
79
112
 
80
113
  // ── macOS (launchd) ─────────────────────────────────────────────────────────────
81
114
 
82
- function launchAgentPath(): string {
83
- return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
115
+ function launchAgentPath(kind: ServiceKind = "harbor"): string {
116
+ return join(homedir(), "Library", "LaunchAgents", `${SPECS[kind].label}.plist`);
84
117
  }
85
118
 
86
119
  function xmlEscape(s: string): string {
@@ -93,20 +126,20 @@ function xmlEscape(s: string): string {
93
126
  // holding the machine lock (exit 0 every ~10s, appending the same line to harbor.log
94
127
  // forever — this is what produced a 7 MB log of "already running"), and a deliberate
95
128
  // shutdown, which launchd would undo. Matches the systemd unit's Restart=on-failure.
96
- export function launchdPlist(): string {
97
- const args = [nodeBinaryPath(), harborLauncherPath(), "run"];
129
+ export function launchdPlist(kind: ServiceKind = "harbor"): string {
130
+ const args = [nodeBinaryPath(), launcherPath(kind), "run"];
98
131
  const envVars = forwardedEnv();
99
132
  const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
100
133
  const envXml = Object.entries(envVars)
101
134
  .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
102
135
  .join("\n");
103
- const log = xmlEscape(harborLogPath());
136
+ const log = xmlEscape(logFilePath(kind));
104
137
  return `<?xml version="1.0" encoding="UTF-8"?>
105
138
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
106
139
  <plist version="1.0">
107
140
  <dict>
108
141
  <key>Label</key>
109
- <string>${LABEL}</string>
142
+ <string>${SPECS[kind].label}</string>
110
143
  <key>ProgramArguments</key>
111
144
  <array>
112
145
  ${argXml}
@@ -129,19 +162,21 @@ ${Object.keys(envVars).length ? ` <key>EnvironmentVariables</key>\n <dict>\n${
129
162
 
130
163
  // Evict a pre-rename launchd agent (pro.privateer.daemon) if one is installed, so the
131
164
  // harbor rename doesn't leave the old service running the old launcher alongside it.
132
- function evictOldLaunchd(): void {
133
- const oldPlist = join(homedir(), "Library", "LaunchAgents", `${OLD_LABEL}.plist`);
165
+ function evictOldLaunchd(kind: ServiceKind): void {
166
+ const old = SPECS[kind].oldLabel;
167
+ if (!old) return;
168
+ const oldPlist = join(homedir(), "Library", "LaunchAgents", `${old}.plist`);
134
169
  if (existsSync(oldPlist)) {
135
170
  spawnSync("launchctl", ["unload", "-w", oldPlist], { stdio: "ignore" });
136
171
  rmSync(oldPlist, { force: true });
137
172
  }
138
173
  }
139
174
 
140
- function installLaunchd(): void {
141
- evictOldLaunchd();
142
- const plist = launchAgentPath();
175
+ function installLaunchd(kind: ServiceKind): void {
176
+ evictOldLaunchd(kind);
177
+ const plist = launchAgentPath(kind);
143
178
  mkdirSync(dirname(plist), { recursive: true });
144
- writeFileSync(plist, launchdPlist());
179
+ writeFileSync(plist, launchdPlist(kind));
145
180
  // Unload a prior copy (ignore failure — it may not be loaded), then load with -w so
146
181
  // it's enabled across reboots.
147
182
  spawnSync("launchctl", ["unload", plist], { stdio: "ignore" });
@@ -151,9 +186,9 @@ function installLaunchd(): void {
151
186
  }
152
187
  }
153
188
 
154
- function uninstallLaunchd(): void {
155
- evictOldLaunchd();
156
- const plist = launchAgentPath();
189
+ function uninstallLaunchd(kind: ServiceKind): void {
190
+ evictOldLaunchd(kind);
191
+ const plist = launchAgentPath(kind);
157
192
  if (existsSync(plist)) {
158
193
  spawnSync("launchctl", ["unload", "-w", plist], { stdio: "ignore" });
159
194
  rmSync(plist, { force: true });
@@ -162,65 +197,122 @@ function uninstallLaunchd(): void {
162
197
 
163
198
  // ── Linux (systemd --user) ───────────────────────────────────────────────────────
164
199
 
165
- function systemdUnitPath(): string {
200
+ function systemdUnitPath(kind: ServiceKind = "harbor"): string {
166
201
  const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
167
- return join(base, "systemd", "user", UNIT);
202
+ return join(base, "systemd", "user", SPECS[kind].unit);
203
+ }
204
+
205
+ // One systemd word: double-quoted with C-style escapes, and `%` doubled — systemd
206
+ // expands %-specifiers inside ExecStart= and Environment=, so a path or value with a
207
+ // literal % would otherwise be rewritten (or rejected) when the unit loads.
208
+ function sdQuote(s: string): string {
209
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%")}"`;
168
210
  }
169
211
 
170
- function systemdUnit(): string {
171
- const exec = [nodeBinaryPath(), harborLauncherPath(), "run"].map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
212
+ // The unit, hardened as far as a --user unit can go everywhere:
213
+ // • NoNewPrivileges — nothing the agent runs can gain privileges (no sudo, no
214
+ // setuid helpers). A routine allowed `bash` runs with exactly the user's rights.
215
+ // • UMask=0077 — every file it creates (results, logs, staged media) is owner-only.
216
+ // • StartLimit* — Restart=on-failure with no limit restarted a harbor that crashes
217
+ // at boot every few seconds forever; five tries in five minutes, then it stays
218
+ // down and `systemctl --user status` says why.
219
+ // • KillMode=mixed — SIGTERM reaches the harbor alone, so its shutdown handler can
220
+ // revoke its sessions; anything still running (MCP servers it spawned) is killed
221
+ // after TimeoutStopSec.
222
+ // Deliberately NOT here: ProtectSystem/ProtectHome/PrivateTmp and the rest of the
223
+ // namespace family. In a user manager they need unprivileged user namespaces, and
224
+ // where those are off (many VPS kernels, hardened distros) systemd refuses to start
225
+ // the unit at all (status=226/NAMESPACE). Run the harbor as a dedicated user instead
226
+ // — that is the boundary that works on every box.
227
+ function systemdUnit(kind: ServiceKind = "harbor"): string {
228
+ const exec = [nodeBinaryPath(), launcherPath(kind), "run"].map(sdQuote).join(" ");
172
229
  const envLines = Object.entries(forwardedEnv())
173
- .map(([k, v]) => `Environment=${k}=${v}`)
230
+ .map(([k, v]) => `Environment=${sdQuote(`${k}=${v}`)}`)
174
231
  .join("\n");
175
232
  return `[Unit]
176
- Description=Privateer resident agent harbor (routines + app-driven task spawns)
233
+ Description=${SPECS[kind].description}
177
234
  After=network-online.target
178
235
  Wants=network-online.target
236
+ StartLimitIntervalSec=300
237
+ StartLimitBurst=5
179
238
 
180
239
  [Service]
181
240
  Type=simple
182
241
  ExecStart=${exec}
183
242
  Restart=on-failure
184
- RestartSec=5
185
- ${envLines}
186
-
243
+ RestartSec=10
244
+ UMask=0077
245
+ NoNewPrivileges=yes
246
+ KillMode=mixed
247
+ TimeoutStopSec=15
248
+ ${envLines ? `${envLines}\n` : ""}
187
249
  [Install]
188
250
  WantedBy=default.target
189
251
  `;
190
252
  }
191
253
 
254
+ /** The generated systemd unit text (exported for tests). */
255
+ export function systemdUnitText(kind: ServiceKind = "harbor"): string {
256
+ return systemdUnit(kind);
257
+ }
258
+
192
259
  // Evict a pre-rename systemd --user unit (privateer-daemon.service) if present, so the
193
260
  // harbor rename doesn't leave the old unit enabled alongside the new one.
194
- function evictOldSystemd(): void {
261
+ function evictOldSystemd(kind: ServiceKind): void {
262
+ const old = SPECS[kind].oldUnit;
263
+ if (!old) return;
195
264
  const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
196
- const oldUnit = join(base, "systemd", "user", OLD_UNIT);
265
+ const oldUnit = join(base, "systemd", "user", old);
197
266
  if (existsSync(oldUnit)) {
198
- spawnSync("systemctl", ["--user", "disable", "--now", OLD_UNIT], { stdio: "ignore" });
267
+ spawnSync("systemctl", ["--user", "disable", "--now", old], { stdio: "ignore" });
199
268
  rmSync(oldUnit, { force: true });
200
269
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
201
270
  }
202
271
  }
203
272
 
204
- function installSystemd(): void {
205
- evictOldSystemd();
206
- const unit = systemdUnitPath();
273
+ // The login name to linger. $USER is unset under cron, `su -c`, some container shells
274
+ // and CI — and `loginctl enable-linger ""` silently did nothing, so the service died
275
+ // with the SSH session that installed it. The passwd entry is the reliable answer.
276
+ function loginName(): string {
277
+ try {
278
+ return userInfo().username;
279
+ } catch {
280
+ return process.env.USER || process.env.LOGNAME || "";
281
+ }
282
+ }
283
+
284
+ function installSystemd(kind: ServiceKind): void {
285
+ // A box with no systemd user manager (a Docker/LXC container, WSL without systemd)
286
+ // used to fail with systemctl's own cryptic error. Say what to do instead.
287
+ const probe = spawnSync("systemctl", ["--user", "show-environment"], { encoding: "utf8" });
288
+ if (probe.error || probe.status !== 0) {
289
+ throw new Error(
290
+ `No systemd user manager here (${(probe.stderr || probe.error?.message || `exit ${probe.status}`).trim()}). ` +
291
+ `Run \`privateer ${kind} run\` under your own supervisor (a container's restart policy, supervisord, tmux) instead.`,
292
+ );
293
+ }
294
+ evictOldSystemd(kind);
295
+ const unit = systemdUnitPath(kind);
207
296
  mkdirSync(dirname(unit), { recursive: true });
208
- writeFileSync(unit, systemdUnit());
297
+ writeFileSync(unit, systemdUnit(kind));
209
298
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
210
299
  // enable-linger so the user service keeps running with no active login session —
211
300
  // the whole point of "reachable even when no shell is open". Best-effort: it needs
212
301
  // no root on most distros, but don't fail the install if it's disallowed.
213
- spawnSync("loginctl", ["enable-linger", process.env.USER || ""], { stdio: "ignore" });
214
- const r = spawnSync("systemctl", ["--user", "enable", "--now", UNIT], { encoding: "utf8" });
302
+ const user = loginName();
303
+ if (user) spawnSync("loginctl", ["enable-linger", user], { stdio: "ignore" });
304
+ // Clear a StartLimit hit from an earlier, broken install, so --now actually starts it.
305
+ spawnSync("systemctl", ["--user", "reset-failed", SPECS[kind].unit], { stdio: "ignore" });
306
+ const r = spawnSync("systemctl", ["--user", "enable", "--now", SPECS[kind].unit], { encoding: "utf8" });
215
307
  if (r.status !== 0) {
216
308
  throw new Error(`systemctl enable failed: ${(r.stderr || r.stdout || "").trim() || `exit ${r.status}`}`);
217
309
  }
218
310
  }
219
311
 
220
- function uninstallSystemd(): void {
221
- evictOldSystemd();
222
- const unit = systemdUnitPath();
223
- spawnSync("systemctl", ["--user", "disable", "--now", UNIT], { stdio: "ignore" });
312
+ function uninstallSystemd(kind: ServiceKind): void {
313
+ evictOldSystemd(kind);
314
+ const unit = systemdUnitPath(kind);
315
+ spawnSync("systemctl", ["--user", "disable", "--now", SPECS[kind].unit], { stdio: "ignore" });
224
316
  if (existsSync(unit)) rmSync(unit, { force: true });
225
317
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
226
318
  }
@@ -273,10 +365,14 @@ export function unitProgramPaths(platform: NodeJS.Platform, unitPath: string): s
273
365
  if (platform === "linux") {
274
366
  const line = body.match(/^ExecStart=(.*)$/m);
275
367
  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
- );
368
+ // Today's units write double-quoted words with C escapes and %% (sdQuote). Units
369
+ // installed before that wrote single-quoted words with '\'' for an embedded quote;
370
+ // both are still out there, so both parse.
371
+ const args = [...line[1].matchAll(/"((?:[^"\\]|\\.)*)"|'((?:[^']|'\\'')*)'|(\S+)/g)].map((m) => {
372
+ if (m[1] !== undefined) return m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%");
373
+ if (m[2] !== undefined) return m[2].replace(/'\\''/g, "'");
374
+ return m[3];
375
+ });
280
376
  return args.length ? args : null;
281
377
  }
282
378
  return null;
@@ -326,46 +422,69 @@ export function unitNeedsRefresh(platform: NodeJS.Platform, unitPath: string): b
326
422
  return false;
327
423
  }
328
424
  if (platform === "darwin" && /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(body)) return true;
425
+ // The Linux twin of the always-restart plist: no StartLimitBurst means a harbor that
426
+ // crashes at boot is restarted every few seconds, forever.
427
+ if (platform === "linux" && !/^StartLimitBurst=/m.test(body)) return true;
329
428
  const args = unitProgramPaths(platform, unitPath);
330
429
  if (args?.[0] && interpreterIsElectron(args[0]) && !/ELECTRON_RUN_AS_NODE/.test(body)) return true;
331
430
  return false;
332
431
  }
333
432
 
334
- function unitPathFor(platform: NodeJS.Platform): string {
335
- if (platform === "darwin") return launchAgentPath();
336
- if (platform === "linux") return systemdUnitPath();
433
+ function unitPathFor(platform: NodeJS.Platform, kind: ServiceKind): string {
434
+ if (platform === "darwin") return launchAgentPath(kind);
435
+ if (platform === "linux") return systemdUnitPath(kind);
337
436
  return "";
338
437
  }
339
438
 
340
- export function serviceInfo(): ServiceInfo {
439
+ export function serviceInfo(kind: ServiceKind = "harbor"): ServiceInfo {
341
440
  const platform = process.platform;
342
- const unitPath = unitPathFor(platform);
441
+ const unitPath = unitPathFor(platform, kind);
343
442
  return {
344
443
  platform,
345
444
  supported: platform === "darwin" || platform === "linux",
346
445
  installed: !!unitPath && existsSync(unitPath),
347
446
  unitPath,
348
- logPath: harborLogPath(),
447
+ logPath: logLocation(kind, platform),
349
448
  needsRefresh: unitNeedsRefresh(platform, unitPath),
350
449
  stale: !!unitPath && unitIsStale(platform, unitPath),
351
450
  };
352
451
  }
353
452
 
354
453
  // Install the service for the current platform. Idempotent (rewrites + reloads).
355
- export function installService(): ServiceInfo {
454
+ export function installService(kind: ServiceKind = "harbor"): ServiceInfo {
356
455
  const platform = process.platform;
357
- if (platform === "darwin") installLaunchd();
358
- else if (platform === "linux") installSystemd();
359
- else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer harbor\` yourself, or keep a terminal open.`);
360
- return serviceInfo();
456
+ if (platform === "darwin") installLaunchd(kind);
457
+ else if (platform === "linux") installSystemd(kind);
458
+ else throw new Error(`Auto-start isn't supported on ${platform}. Run \`privateer ${kind}\` yourself, or keep a terminal open.`);
459
+ return serviceInfo(kind);
361
460
  }
362
461
 
363
- export function uninstallService(): ServiceInfo {
462
+ export function uninstallService(kind: ServiceKind = "harbor"): ServiceInfo {
364
463
  const platform = process.platform;
365
- if (platform === "darwin") uninstallLaunchd();
366
- else if (platform === "linux") uninstallSystemd();
464
+ if (platform === "darwin") uninstallLaunchd(kind);
465
+ else if (platform === "linux") uninstallSystemd(kind);
367
466
  else throw new Error(`No service to remove on ${platform}.`);
368
- return serviceInfo();
467
+ return serviceInfo(kind);
468
+ }
469
+
470
+ // `privateer channels status`: the unit, and which platforms the runner is serving
471
+ // right now (its heartbeat — channels/status.ts). Imported lazily: status.ts is
472
+ // import-safe, but this module should not pay for it on the harbor's paths.
473
+ export async function channelsStatusReport(): Promise<string> {
474
+ const info = serviceInfo("channels");
475
+ const { readRunningPlatforms } = await import("../channels/status.ts");
476
+ const live = [...readRunningPlatforms()];
477
+ const lines = [
478
+ `platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer channels` manually)"}`,
479
+ `service: ${info.installed ? `installed (${info.unitPath})${info.stale ? " — STALE: run `privateer channels uninstall`" : info.needsRefresh ? " — needs rewriting; run `privateer channels install`" : ""}` : "not installed"}`,
480
+ `channels: ${live.length ? `serving ${live.join(", ")}` : "not running (no fresh heartbeat)"}`,
481
+ `logs: ${info.logPath}`,
482
+ ];
483
+ if (info.installed && !live.length) {
484
+ lines.push("hint: installed but not serving — check the log. With no channels.<platform> block in config.json it exits on purpose.");
485
+ }
486
+ lines.push("note: channel changes from the app apply on restart (`privateer channels install` restarts it).");
487
+ return lines.join("\n");
369
488
  }
370
489
 
371
490
  // Human-readable status for `privateer harbor status`: whether the service is
@@ -406,12 +525,20 @@ export async function statusReport(): Promise<string> {
406
525
  }
407
526
 
408
527
  // Best-effort read of the tail of the harbor log (for a `status --log` affordance or
409
- // error surfacing). Returns "" if absent.
528
+ // error surfacing). Returns "" if absent. Reads only the last `maxBytes` — the log can
529
+ // be megabytes, and this used to load all of it to keep 4 KB.
410
530
  export function tailHarborLog(maxBytes = 4_000): string {
531
+ let fd: number | undefined;
411
532
  try {
412
- const buf = readFileSync(harborLogPath(), "utf8");
413
- return buf.length > maxBytes ? buf.slice(buf.length - maxBytes) : buf;
533
+ fd = openSync(logFilePath("harbor"), "r");
534
+ const size = fstatSync(fd).size;
535
+ const len = Math.min(size, maxBytes);
536
+ const buf = Buffer.alloc(len);
537
+ readSync(fd, buf, 0, len, size - len);
538
+ return buf.toString("utf8");
414
539
  } catch {
415
540
  return "";
541
+ } finally {
542
+ if (fd !== undefined) closeSync(fd);
416
543
  }
417
544
  }
@@ -2,7 +2,7 @@ import { resolve, isAbsolute, relative, dirname, join, basename } from "node:pat
2
2
  import { realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
- import { isProtectedPath } from "./protected.ts";
5
+ import { isProtectedPath, isCredentialStorePath } from "./protected.ts";
6
6
  import type { PermissionRequest } from "./gate.ts";
7
7
 
8
8
  // NEW glue for the Pi rewrite. In 0.2 each tool built its own PermissionRequest
@@ -19,6 +19,11 @@ export interface ScopeOptions {
19
19
  cwd: string;
20
20
  confineToCwd?: boolean; // default true
21
21
  allowedOutsideRoots?: string[];
22
+ // Gate a READ of a protected file or credential store (isCredentialStorePath) even
23
+ // inside cwd, flagged `protected`. Off by default — an interactive session reading
24
+ // its own project's .env is normal. The harbor's unattended runs turn it on and pair
25
+ // it with GateController.hardConfine, which refuses anything flagged.
26
+ guardSecretReads?: boolean;
22
27
  }
23
28
 
24
29
  // Resolve symlinks so an in-cwd symlink can't smuggle a path outside scope past the
@@ -619,13 +624,16 @@ export function classifyToolCall(
619
624
  const p = firstPath(obj);
620
625
  if (!p) return null; // e.g. grep with no explicit path → in-cwd, no gate
621
626
  const abs = resolveInCwd(scope.cwd, p);
622
- if (!isOutsideScope(scope, abs)) return null;
627
+ const outside = isOutsideScope(scope, abs);
628
+ const secret = scope.guardSecretReads === true && (isProtectedPath(abs) || isCredentialStorePath(abs));
629
+ if (!outside && !secret) return null;
623
630
  return {
624
631
  tool: toolName,
625
632
  kind: "read",
626
- title: "Read outside working directory",
633
+ title: secret ? "Read a protected file" : "Read outside working directory",
627
634
  detail: abs,
628
- outside: true,
635
+ outside,
636
+ ...(secret ? { protected: true } : {}),
629
637
  path: abs,
630
638
  };
631
639
  }
@@ -37,6 +37,15 @@ export function looksLikeSecretExfil(command: string): boolean {
37
37
  return SECRET_FILE.test(command) && NETWORK_SINK.test(command);
38
38
  }
39
39
 
40
+ // Does this command look like it can move bytes off the machine? Private mode refuses
41
+ // these outright (permissions/privateMode.ts). Same heuristic as the exfil check, plus
42
+ // the git and package-manager verbs that upload: a regex over the raw text, not a shell
43
+ // parser, so it is a net, not a proof.
44
+ const UPLOAD_VERB = /\bgit\s+push\b|\b(npm|pnpm|yarn)\s+publish\b/i;
45
+ export function reachesNetwork(command: string): boolean {
46
+ return NETWORK_SINK.test(command) || UPLOAD_VERB.test(command);
47
+ }
48
+
40
49
  export function matchesDenylist(command: string, patterns: string[]): boolean {
41
50
  return patterns.some((src) => {
42
51
  if (!src.trim()) return false;
@@ -69,6 +69,12 @@ export interface ModeGateDeps {
69
69
  // budget RECORDS what it allows — asking it about a call the mode would refuse anyway
70
70
  // would spend budget on nothing.
71
71
  isSpendPreauthorized?: (req: PermissionRequest) => boolean | Promise<boolean>;
72
+ // Refuse every request that leaves cwd or touches a protected file, whatever the mode.
73
+ // For the harbor's unattended runs, which run in `bypass` (their safety is the tool
74
+ // allow-list) — and bypass allows outside/protected outright (mode.ts), which left
75
+ // their `confineToCwd` meaning nothing. Sits below no-quarter only: an operator who
76
+ // launched with --no-quarter asked for no gate at all.
77
+ hardConfine?: boolean;
72
78
  }
73
79
 
74
80
  // The permission gate used by the live TUI. It first applies the mode/allowlist
@@ -85,6 +91,8 @@ export class ModeGate implements PermissionGate {
85
91
  // first check so nothing below can force an "ask" back on.
86
92
  if (this.deps.getSkipAllPermissions?.()) return "allow";
87
93
 
94
+ if (this.deps.hardConfine && (req.outside || req.protected)) return "deny";
95
+
88
96
  const denylist = this.deps.denylist ?? [];
89
97
  const auto = decideAuto(req, this.deps.getMode(), this.deps.allowlist, denylist);
90
98