privateer-agent 0.14.0 → 0.14.2
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 +28 -4
- 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 +20 -5
- package/extensions/privateer-gate.ts +35 -1
- package/extensions/privateer-hints.ts +3 -2
- package/extensions/privateer-speak.ts +5 -1
- package/extensions/privateer-truncation.ts +43 -0
- package/package.json +2 -2
- package/src/channels/run.ts +24 -5
- package/src/cli/channelsCli.ts +67 -0
- package/src/config/mediaModels.ts +3 -2
- package/src/config/moatManifest.json +1 -0
- package/src/config/piiAllow.ts +3 -2
- package/src/config/whatsNew.json +4 -4
- package/src/engine/stallWatchdog.ts +61 -0
- package/src/engine/truncationRecovery.ts +91 -0
- package/src/harbor/index.ts +4 -0
- package/src/harbor/logRotate.ts +31 -0
- package/src/harbor/service.ts +202 -75
- package/src/providers/account.ts +71 -17
- package/src/remote/channelsControl.ts +4 -3
- package/src/remote/mcpControl.ts +4 -3
- package/src/routines/store.ts +41 -12
- package/src/terminals/backends.ts +187 -10
- package/src/terminals/index.ts +23 -0
- package/src/tools/terminal.ts +2 -1
- package/src/util/atomicWrite.ts +46 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// What to do when a run ends on a reply the model never finished.
|
|
2
|
+
//
|
|
3
|
+
// A provider that stops a reply with `finish_reason: "length"` hit the turn's output
|
|
4
|
+
// cap. The TUI renders it as "Response was truncated before completion." and pi then
|
|
5
|
+
// does one of two things:
|
|
6
|
+
//
|
|
7
|
+
// - output ended BELOW the model's maxTokens: pi reads it as context pressure and
|
|
8
|
+
// compacts and retries the turn once (agent-session _checkCompaction,
|
|
9
|
+
// isRecoverableLength). That case is pi's, and this module stays out of it.
|
|
10
|
+
// - output used the WHOLE budget: pi treats it as final and the run just ends. The
|
|
11
|
+
// agent stops mid-task with nothing to resume it but the user typing "continue".
|
|
12
|
+
//
|
|
13
|
+
// The second case is what real sessions hit, and nearly always the same way: a
|
|
14
|
+
// thinking model spends the entire budget reasoning (measured: z-ai/glm-5.3-flash,
|
|
15
|
+
// 16384 of 16384 output tokens, 45–67k characters of reasoning, no text and no tool
|
|
16
|
+
// call — nine times across one user's logs). Raising maxTokens to the model's real cap
|
|
17
|
+
// (providers/account.ts) makes that rarer; this makes it recoverable when it happens.
|
|
18
|
+
//
|
|
19
|
+
// So: when a run ends on a full-budget length stop, queue one follow-up asking the
|
|
20
|
+
// model to pick up where it was cut off, and keep its reasoning short. Pi continues a
|
|
21
|
+
// message queued from an agent_end handler inside the same run (_handlePostAgentRun →
|
|
22
|
+
// hasQueuedMessages), so headless `-p` runs wait for it too.
|
|
23
|
+
//
|
|
24
|
+
// Bounded: at most MAX_AUTO_CONTINUES in a row. A model that truncates every time
|
|
25
|
+
// would otherwise loop forever, billing a full budget per lap. Any reply that ends
|
|
26
|
+
// some other way resets the count.
|
|
27
|
+
//
|
|
28
|
+
// IMPORT-SAFETY: no Pi imports — the shapes below are the few fields read.
|
|
29
|
+
|
|
30
|
+
export const MAX_AUTO_CONTINUES = 2;
|
|
31
|
+
|
|
32
|
+
export interface TruncationAssistant {
|
|
33
|
+
role?: string;
|
|
34
|
+
stopReason?: string;
|
|
35
|
+
usage?: { output?: number };
|
|
36
|
+
content?: { type?: string }[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type TruncationDecision =
|
|
40
|
+
| { action: "none" }
|
|
41
|
+
| { action: "continue"; attempt: number; prompt: string }
|
|
42
|
+
| { action: "give-up" };
|
|
43
|
+
|
|
44
|
+
export function lastAssistant(messages: unknown): TruncationAssistant | undefined {
|
|
45
|
+
if (!Array.isArray(messages)) return undefined;
|
|
46
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
47
|
+
const m = messages[i] as TruncationAssistant | undefined;
|
|
48
|
+
if (m?.role === "assistant") return m;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a length stop used the model's whole output budget. Mirrors the negation of
|
|
55
|
+
* pi-ai's isRecoverableLength: below the budget is pi's compact-and-retry, at it is
|
|
56
|
+
* ours. An unknown budget (0) means we cannot tell, so we leave it alone.
|
|
57
|
+
*/
|
|
58
|
+
export function isFullBudgetLengthStop(message: TruncationAssistant | undefined, modelMaxTokens: number): boolean {
|
|
59
|
+
if (message?.stopReason !== "length") return false;
|
|
60
|
+
if (!(modelMaxTokens > 0)) return false;
|
|
61
|
+
const output = message.usage?.output;
|
|
62
|
+
return typeof output === "number" && output >= modelMaxTokens;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The follow-up text. Says what happened in terms the model can act on: a cut-off
|
|
67
|
+
* spent on reasoning needs a terser plan, a cut-off mid-tool-call needs the call
|
|
68
|
+
* re-issued (its arguments were incomplete and never ran).
|
|
69
|
+
*/
|
|
70
|
+
export function continuationPrompt(message: TruncationAssistant): string {
|
|
71
|
+
const kinds = new Set((message.content ?? []).map((c) => c?.type));
|
|
72
|
+
const base = "Your previous reply hit the output limit and was cut off before it finished.";
|
|
73
|
+
if (kinds.has("toolCall")) {
|
|
74
|
+
return `${base} The tool call at the end was incomplete and did not run — issue it again in full. Keep any reasoning brief.`;
|
|
75
|
+
}
|
|
76
|
+
if (!kinds.has("text")) {
|
|
77
|
+
return `${base} It was spent entirely on reasoning. Don't restate it: keep thinking short and go straight to the next tool call or your answer.`;
|
|
78
|
+
}
|
|
79
|
+
return `${base} Continue exactly where you left off, without repeating what you already wrote. Keep any reasoning brief.`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function decideTruncationRecovery(
|
|
83
|
+
messages: unknown,
|
|
84
|
+
modelMaxTokens: number,
|
|
85
|
+
consecutive: number,
|
|
86
|
+
): TruncationDecision {
|
|
87
|
+
const last = lastAssistant(messages);
|
|
88
|
+
if (!isFullBudgetLengthStop(last, modelMaxTokens)) return { action: "none" };
|
|
89
|
+
if (consecutive >= MAX_AUTO_CONTINUES) return { action: "give-up" };
|
|
90
|
+
return { action: "continue", attempt: consecutive + 1, prompt: continuationPrompt(last!) };
|
|
91
|
+
}
|
package/src/harbor/index.ts
CHANGED
|
@@ -66,6 +66,8 @@ import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlre
|
|
|
66
66
|
import { serializeBuild } from "./buildLock.ts";
|
|
67
67
|
import { createAccountLease } from "./accountLease.ts";
|
|
68
68
|
import { closeSession } from "./closeSession.ts";
|
|
69
|
+
import { rotateLogIfLarge } from "./logRotate.ts";
|
|
70
|
+
import { logFilePath } from "./service.ts";
|
|
69
71
|
import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/hosted.ts";
|
|
70
72
|
import { relayExposureAllowed } from "../config/relayExposure.ts";
|
|
71
73
|
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
@@ -715,6 +717,8 @@ export class Harbor {
|
|
|
715
717
|
// returns immediately once a client exists, or while remote access is off.
|
|
716
718
|
this.syncRelay();
|
|
717
719
|
void this.flushPendingCloud();
|
|
720
|
+
// Bound the log launchd appends us to (see logRotate.ts). A stat a minute.
|
|
721
|
+
if (rotateLogIfLarge(logFilePath("harbor"))) log("rotated harbor.log (previous kept as harbor.log.1)");
|
|
718
722
|
const now = Date.now();
|
|
719
723
|
for (const r of loadRoutines()) {
|
|
720
724
|
if (!r.enabled || this.running.has(r.id)) continue;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Keep a service's log file bounded.
|
|
2
|
+
//
|
|
3
|
+
// On macOS launchd appends the harbor's stdout/stderr to ~/.privateer/harbor.log and
|
|
4
|
+
// nothing ever trimmed it — one boot loop once grew it to 7 MB of the same line. The
|
|
5
|
+
// harbor can't rename the file out from under launchd (launchd keeps writing to the
|
|
6
|
+
// renamed file), so this copies it to <log>.1 and truncates it in place. launchd opened
|
|
7
|
+
// it O_APPEND, so its next write lands at the new end rather than leaving a hole.
|
|
8
|
+
// The few lines written between the copy and the truncate are lost; a log is allowed
|
|
9
|
+
// that. On Linux the unit logs to the journal and there is no file — a no-op.
|
|
10
|
+
//
|
|
11
|
+
// IMPORT-SAFETY: node builtins only.
|
|
12
|
+
import { copyFileSync, statSync, truncateSync, chmodSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
export const LOG_MAX_BYTES = Number(process.env.PRIVATEER_LOG_MAX_BYTES) || 5 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/** Rotate `path` when it has grown past `maxBytes`. True when it rotated. Never throws. */
|
|
17
|
+
export function rotateLogIfLarge(path: string, maxBytes: number = LOG_MAX_BYTES): boolean {
|
|
18
|
+
try {
|
|
19
|
+
if (statSync(path).size <= maxBytes) return false;
|
|
20
|
+
copyFileSync(path, `${path}.1`);
|
|
21
|
+
try {
|
|
22
|
+
chmodSync(`${path}.1`, 0o600);
|
|
23
|
+
} catch {
|
|
24
|
+
/* non-POSIX */
|
|
25
|
+
}
|
|
26
|
+
truncateSync(path, 0);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false; // no file (Linux, a manual foreground run), or unwritable
|
|
30
|
+
}
|
|
31
|
+
}
|
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
|
}
|