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.
- package/README.md +62 -3
- package/bin/headless-flags.d.mts +7 -0
- package/bin/headless-flags.mjs +40 -0
- package/bin/model-args.d.mts +9 -0
- package/bin/model-args.mjs +43 -0
- package/bin/privateer-channels.mjs +30 -0
- package/bin/privateer-launch.mjs +31 -6
- package/extensions/privateer-brand.ts +15 -10
- package/extensions/privateer-gate.ts +58 -2
- package/extensions/privateer-hints.ts +3 -2
- package/package.json +2 -1
- package/src/acp/run.ts +29 -4
- package/src/acp/server.ts +0 -0
- package/src/channels/run.ts +30 -6
- package/src/cli/channelsCli.ts +67 -0
- package/src/config/mediaModels.ts +3 -2
- package/src/config/piiAllow.ts +3 -2
- package/src/config/whatsNew.json +10 -0
- package/src/engine/stallWatchdog.ts +61 -0
- package/src/ext/permissionGate.ts +21 -0
- package/src/harbor/accountLease.ts +76 -0
- package/src/harbor/closeSession.ts +20 -0
- package/src/harbor/index.ts +147 -58
- package/src/harbor/logRotate.ts +31 -0
- package/src/harbor/service.ts +202 -75
- package/src/permissions/classify.ts +12 -4
- package/src/permissions/danger.ts +9 -0
- package/src/permissions/modeGate.ts +8 -0
- package/src/permissions/privateMode.ts +136 -0
- package/src/permissions/protected.ts +36 -1
- package/src/remote/channelsControl.ts +4 -3
- package/src/remote/liveTaskSession.ts +18 -2
- package/src/remote/mcpControl.ts +4 -3
- package/src/routines/store.ts +41 -12
- package/src/util/atomicWrite.ts +46 -0
package/src/harbor/service.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
55
|
+
function launcherPath(kind: ServiceKind): string {
|
|
31
56
|
const here = dirname(fileURLToPath(import.meta.url)); // …/src/harbor
|
|
32
|
-
return resolve(here, "../../bin
|
|
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
|
-
|
|
43
|
-
|
|
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", `${
|
|
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(),
|
|
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(
|
|
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>${
|
|
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
|
|
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",
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
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=
|
|
185
|
-
|
|
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",
|
|
265
|
+
const oldUnit = join(base, "systemd", "user", old);
|
|
197
266
|
if (existsSync(oldUnit)) {
|
|
198
|
-
spawnSync("systemctl", ["--user", "disable", "--now",
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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",
|
|
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
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|