privateer-agent 0.14.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 +22 -2
- 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/package.json +1 -1
- package/src/channels/run.ts +24 -5
- 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 +3 -3
- package/src/engine/stallWatchdog.ts +61 -0
- package/src/harbor/index.ts +4 -0
- package/src/harbor/logRotate.ts +31 -0
- package/src/harbor/service.ts +202 -75
- package/src/remote/channelsControl.ts +4 -3
- package/src/remote/mcpControl.ts +4 -3
- package/src/routines/store.ts +41 -12
- package/src/util/atomicWrite.ts +46 -0
package/README.md
CHANGED
|
@@ -479,10 +479,30 @@ privateer harbor uninstall
|
|
|
479
479
|
```
|
|
480
480
|
|
|
481
481
|
Installs as a **launchd user agent** on macOS or a **`systemd --user` unit** on Linux — no
|
|
482
|
-
root, no sudo. (There's no Windows service path yet; use `privateer harbor run`.)
|
|
482
|
+
root, no sudo. (There's no Windows service path yet; use `privateer harbor run`.) Logs go to
|
|
483
|
+
`~/.privateer/harbor.log` on macOS (kept under 5 MB, one previous copy as `harbor.log.1`) and
|
|
484
|
+
to the journal on Linux: `journalctl --user -u privateer-harbor`.
|
|
483
485
|
|
|
484
486
|
Everything the harbor does still runs through the permission gate. Actions needing approval
|
|
485
|
-
surface in the app; routines you approved once run on their own schedule.
|
|
487
|
+
surface in the app; routines you approved once run on their own schedule. Unattended runs
|
|
488
|
+
are confined to their working directory and can't read credential stores (`~/.ssh`,
|
|
489
|
+
`~/.aws`, Privateer's own login and config files), whatever tools they're allowed.
|
|
490
|
+
|
|
491
|
+
**Chat channels** (Telegram, Slack, Discord, WhatsApp) run beside it, as their own service:
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
privateer channels install # login service; re-run after changing channels from the app
|
|
495
|
+
privateer channels status # which platforms are live right now
|
|
496
|
+
privateer channels run # or in the foreground
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
**On a VPS:** run both as a dedicated, unprivileged user, never root. A routine you allow
|
|
500
|
+
`bash` runs with that user's full rights. Sign in over SSH with `privateer` and `/login` (it
|
|
501
|
+
prints a code to approve in the app). Nothing needs an inbound port, except WhatsApp's
|
|
502
|
+
webhook. The Linux unit sets `NoNewPrivileges`, `UMask=0077` and a restart limit. It skips
|
|
503
|
+
systemd's namespace sandboxing on purpose, because that fails to start on kernels without
|
|
504
|
+
unprivileged user namespaces. In a container with no systemd, use `privateer harbor run`
|
|
505
|
+
under the container's own restart policy.
|
|
486
506
|
|
|
487
507
|
## Workflows
|
|
488
508
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Which `--model` the launcher hands Pi, if any.
|
|
2
|
+
//
|
|
3
|
+
// Plain .mjs for the same reason as headless-flags.mjs: bin/ runs under a bare `node`,
|
|
4
|
+
// and the launcher runs on import, so the rule lives where a test can reach it.
|
|
5
|
+
// See tests/modelArgs.test.ts.
|
|
6
|
+
//
|
|
7
|
+
// Precedence:
|
|
8
|
+
// 1. a --model the user typed (already in the args) → add nothing
|
|
9
|
+
// 2. PRIVATEER_MODEL → the computed model
|
|
10
|
+
// 3. a saved pick in settings.json → usually add nothing and let Pi resolve it, which
|
|
11
|
+
// falls back sanely when a BYO model has vanished from the registry. EXCEPT a
|
|
12
|
+
// `privateer/*` pick on a signed-in machine, which is passed explicitly (below).
|
|
13
|
+
// 4. nothing saved → the computed model
|
|
14
|
+
//
|
|
15
|
+
// Why the exception. Pi resolves a saved default only when that provider HAS AUTH at
|
|
16
|
+
// the moment the session starts (model-resolver.js findInitialModel, step 3), and if not
|
|
17
|
+
// it silently takes the first model that does: with an OPENROUTER_API_KEY around, that
|
|
18
|
+
// is Pi's openrouter default, moonshotai/kimi-k2.6. The account provider's auth is the
|
|
19
|
+
// one `privateer` entry in auth.json, which is machine-global and removed whenever ANY
|
|
20
|
+
// Privateer process exits (it revokes its own account session on the way out). So a
|
|
21
|
+
// launch that happened to follow another process's exit came up on OpenRouter with no
|
|
22
|
+
// message at all, while settings.json still said privateer/near/…. A `-p` run then sent
|
|
23
|
+
// its work to a ZDR provider instead of the enclave the user picked; under --private it
|
|
24
|
+
// refused every tool and said the model was "zdr-policy", which is how this was found.
|
|
25
|
+
//
|
|
26
|
+
// An explicit --model resolves against every REGISTERED model, auth or not
|
|
27
|
+
// (resolveCliModel → getModels()), and the account credential is armed at
|
|
28
|
+
// session_start, before the first prompt. Being signed in is the condition because
|
|
29
|
+
// that is when arming will succeed; signed out, the saved pick couldn't run anyway and
|
|
30
|
+
// Pi's own resolution is no worse.
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ launchArgs: string[], envModel?: string, savedDefault?: string | null, signedIn: boolean, computed: string }} o
|
|
34
|
+
* @returns {string[]} args to prepend: [] or ["--model", spec]
|
|
35
|
+
*/
|
|
36
|
+
export function modelArgs({ launchArgs, envModel, savedDefault, signedIn, computed }) {
|
|
37
|
+
if (launchArgs.includes("--model")) return [];
|
|
38
|
+
if (envModel) return ["--model", computed];
|
|
39
|
+
if (savedDefault) {
|
|
40
|
+
return signedIn && savedDefault.startsWith("privateer/") ? ["--model", savedDefault] : [];
|
|
41
|
+
}
|
|
42
|
+
return ["--model", computed];
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launcher for the channels runner (Telegram/Slack/Discord/WhatsApp → agent turns).
|
|
3
|
+
// Mirrors bin/privateer-harbor.mjs: load dev keys from the repo .env WITHOUT changing
|
|
4
|
+
// cwd, register tsx so TS resolves regardless of the invocation cwd, then hand off to
|
|
5
|
+
// the channels CLI dispatcher (whose `run` imports ./boot.ts before any Pi code).
|
|
6
|
+
//
|
|
7
|
+
// Invoked two ways: interactively via the launcher (`privateer channels …`), and by the
|
|
8
|
+
// installed launchd/systemd service (`node privateer-channels.mjs run`). This is what
|
|
9
|
+
// makes channels runnable from an npm install; it used to need a source checkout
|
|
10
|
+
// (`npm run channels`, tsx and a .env).
|
|
11
|
+
import { register } from "tsx/esm/api";
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
|
+
import { dirname, resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const repo = resolve(here, "..");
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
process.loadEnvFile(resolve(repo, ".env"));
|
|
20
|
+
} catch {
|
|
21
|
+
/* no .env — rely on the ambient environment / ~/.privateer */
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Same reason as the harbor: channel sessions load the moat in code, so subagent
|
|
25
|
+
// children need the moat-injecting wrapper rather than a bare `pi`.
|
|
26
|
+
process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
|
|
27
|
+
|
|
28
|
+
register();
|
|
29
|
+
const { runChannelsCli } = await import(pathToFileURL(resolve(repo, "src/cli/channelsCli.ts")).href);
|
|
30
|
+
await runChannelsCli(process.argv.slice(2));
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
|
|
|
31
31
|
import { routeUpdate } from "./update-route.mjs";
|
|
32
32
|
import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, PRIVATE_ENV, authProblem, extractHeadlessFlags, extractPrivateFlag, privateProblem } from "./headless-flags.mjs";
|
|
33
33
|
import { runToCompletion } from "./run-to-completion.mjs";
|
|
34
|
+
import { modelArgs } from "./model-args.mjs";
|
|
34
35
|
import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
|
|
35
36
|
import { configureCompileCache } from "./startup-cache.mjs";
|
|
36
37
|
|
|
@@ -555,6 +556,16 @@ else if (sub === "harbor" || sub === "daemon") {
|
|
|
555
556
|
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-harbor.mjs"), ...args.slice(1)], { forwardSignals: true });
|
|
556
557
|
}
|
|
557
558
|
|
|
559
|
+
// --- `privateer channels [run|install|uninstall|status]` -------------------
|
|
560
|
+
// The chat-platform bridges (Telegram/Slack/Discord/WhatsApp). Same shape as the
|
|
561
|
+
// harbor: a resident process, its own launcher, its own optional login service.
|
|
562
|
+
else if (sub === "channels") {
|
|
563
|
+
sweepLegacyShims();
|
|
564
|
+
sweepBashLogs();
|
|
565
|
+
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
566
|
+
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-channels.mjs"), ...args.slice(1)], { forwardSignals: true });
|
|
567
|
+
}
|
|
568
|
+
|
|
558
569
|
// --- `privateer verify` ----------------------------------------------------
|
|
559
570
|
// Check the install that is on this disk right now. Every other trust signal we
|
|
560
571
|
// publish is an install-time one (npm provenance at `npm i`, a checksum inside
|
|
@@ -817,14 +828,15 @@ else {
|
|
|
817
828
|
// 1. a --model the user typed on the privateer command line (already in `args`)
|
|
818
829
|
// 2. PRIVATEER_MODEL — deliberate override, folded into MODEL above
|
|
819
830
|
// 3. a saved pick in settings.json → pass NO flag; Pi resolves it itself (and
|
|
820
|
-
// falls back sanely if that model has vanished from the registry)
|
|
831
|
+
// falls back sanely if that model has vanished from the registry). Except a
|
|
832
|
+
// signed-in `privateer/*` pick, passed explicitly — see bin/model-args.mjs for
|
|
833
|
+
// the silent OpenRouter fallback that prevents
|
|
821
834
|
// 4. nothing saved (first run / fresh home) → the computed MODEL above
|
|
822
835
|
//
|
|
823
836
|
// A FUNCTION, called per launch: a fresh agent (bin/fresh-supervisor.mjs) re-reads it,
|
|
824
837
|
// so a model picked in the old session boots the new one instead of being stomped by
|
|
825
838
|
// a first-run --model computed before that pick existed.
|
|
826
839
|
const modelArgsFor = (launchArgs) => {
|
|
827
|
-
const userPassedModel = launchArgs.includes("--model");
|
|
828
840
|
let savedDefault = null;
|
|
829
841
|
try {
|
|
830
842
|
const s = JSON.parse(fs.readFileSync(path.join(AGENT_DIR, "settings.json"), "utf8"));
|
|
@@ -835,7 +847,7 @@ else {
|
|
|
835
847
|
savedDefault = `${s.defaultProvider}/${s.defaultModel}`;
|
|
836
848
|
}
|
|
837
849
|
} catch { /* absent/unreadable → no saved pick */ }
|
|
838
|
-
return
|
|
850
|
+
return modelArgs({ launchArgs, envModel: process.env.PRIVATEER_MODEL, savedDefault, signedIn, computed: MODEL });
|
|
839
851
|
};
|
|
840
852
|
|
|
841
853
|
// Dev convenience: load provider keys from the repo's .env if present.
|
|
@@ -866,7 +878,9 @@ else {
|
|
|
866
878
|
// command, or `privateer fresh` from another terminal can swap this agent for a new
|
|
867
879
|
// process with the old tree reaped. Print/export/version runs have nothing to swap.
|
|
868
880
|
const tuiArgs = (launchArgs) => [...nodeArgs, CLI, ...modelArgsFor(launchArgs), ...extArgs, ...skillArgs, ...launchArgs];
|
|
869
|
-
|
|
881
|
+
// forwardSignals: a `kill` reaches only this launcher, and a -p run left behind keeps
|
|
882
|
+
// working (and spending) with nobody waiting for its answer.
|
|
883
|
+
if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args), { forwardSignals: true });
|
|
870
884
|
else runSupervised(NODE_BIN, (first) => tuiArgs(first ? args : filterRespawnArgs(args)), { home: PRIVATEER_HOME });
|
|
871
885
|
}
|
|
872
886
|
|
|
@@ -1047,7 +1061,8 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
|
|
|
1047
1061
|
"Commands:",
|
|
1048
1062
|
` ${cmd} init [directory] Create a starter PRIVATEER.md project-context file`,
|
|
1049
1063
|
` ${cmd} update [--all] Fetch the latest release or newer tool packs`,
|
|
1050
|
-
` ${cmd} harbor <command> Manage the
|
|
1064
|
+
` ${cmd} harbor <command> Manage Harbor, the always-on agent behind the app (run/install/uninstall/status)`,
|
|
1065
|
+
` ${cmd} channels <command> Run chat channels: Telegram, Slack, Discord, WhatsApp (run/install/uninstall/status)`,
|
|
1051
1066
|
` ${cmd} verify Check local installation integrity and patch state`,
|
|
1052
1067
|
` ${cmd} acp Run as an Agent Client Protocol server (JSON-RPC on stdio)`,
|
|
1053
1068
|
` ${cmd} fresh [n] Replace a running terminal's agent with a new one (stops everything it started)`,
|
|
@@ -41,6 +41,7 @@ import { childSpendAllows } from "../src/permissions/childSpend.ts";
|
|
|
41
41
|
import { CliSpendLedger, headlessSpendGuidance, isSpendRequest, readCliSpendGrant } from "../src/permissions/cliSpend.ts";
|
|
42
42
|
import { BILLED_MEDIA_TOOLS } from "../src/permissions/classify.ts";
|
|
43
43
|
import { privateModeFromEnv } from "../src/permissions/privateMode.ts";
|
|
44
|
+
import { REPLY_TIMEOUT_ENV, StallWatchdog, replyTimeoutMs } from "../src/engine/stallWatchdog.ts";
|
|
44
45
|
import type { PermissionRequest } from "../src/permissions/gate.ts";
|
|
45
46
|
import { HeadlessAppApprover } from "../src/remote/headlessApproval.ts";
|
|
46
47
|
import { describeErrorText } from "../src/engine/errors.ts";
|
|
@@ -772,11 +773,44 @@ export default function privateerControl(pi: any): void {
|
|
|
772
773
|
};
|
|
773
774
|
});
|
|
774
775
|
|
|
776
|
+
// A headless run must not wait forever on a provider that went silent (see
|
|
777
|
+
// src/engine/stallWatchdog.ts). Armed per request, re-armed by every streamed event,
|
|
778
|
+
// off between requests. On a stall: say so on stderr, abort the turn, exit non-zero.
|
|
779
|
+
let stallCtx: any;
|
|
780
|
+
const stallMs = replyTimeoutMs();
|
|
781
|
+
const watchdog = new StallWatchdog(stallMs, () => {
|
|
782
|
+
const secs = Math.round(stallMs / 1000);
|
|
783
|
+
process.stderr.write(
|
|
784
|
+
`privateer: no reply from ${currentSpec || "the model"} for ${secs}s — stopped. The provider accepted the ` +
|
|
785
|
+
`request and then sent nothing.\n Re-run, or pick another model with --model. ` +
|
|
786
|
+
`${REPLY_TIMEOUT_ENV}=<seconds> changes the limit (0 turns it off).\n`,
|
|
787
|
+
);
|
|
788
|
+
process.exitCode = 1;
|
|
789
|
+
try {
|
|
790
|
+
stallCtx?.abort?.();
|
|
791
|
+
} catch {
|
|
792
|
+
/* the turn is already ending */
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
pi.on("before_provider_request", (_e: any, ctx: any) => {
|
|
796
|
+
if (!headlessRun) return;
|
|
797
|
+
stallCtx = ctx;
|
|
798
|
+
watchdog.arm();
|
|
799
|
+
});
|
|
800
|
+
pi.on("message_start", (ev: any) => {
|
|
801
|
+
if (headlessRun && ev?.message?.role === "assistant") watchdog.arm();
|
|
802
|
+
});
|
|
803
|
+
pi.on("message_update", () => {
|
|
804
|
+
if (headlessRun) watchdog.arm();
|
|
805
|
+
});
|
|
806
|
+
pi.on("message_end", () => watchdog.disarm());
|
|
807
|
+
|
|
775
808
|
// Print mode prints a failed turn's raw SDK text — "401 status code (no body)",
|
|
776
809
|
// "Connection error." — and exits. Follow it with what that means and what to do,
|
|
777
810
|
// on stderr so stdout stays the answer (or the JSON stream).
|
|
778
811
|
pi.on("agent_end", (ev: any) => {
|
|
779
|
-
|
|
812
|
+
watchdog.disarm();
|
|
813
|
+
if (!headlessRun || watchdog.stalled) return; // a stall already said what happened
|
|
780
814
|
const msgs: any[] = Array.isArray(ev?.messages) ? ev.messages : [];
|
|
781
815
|
const last = [...msgs].reverse().find((m) => m?.role === "assistant");
|
|
782
816
|
if (last?.stopReason !== "error" || typeof last.errorMessage !== "string") return;
|
|
@@ -21,11 +21,12 @@
|
|
|
21
21
|
// /hints on|off persists { hints: { enabled } } in ~/.privateer/config.json (ours;
|
|
22
22
|
// Pi never reads that file) — read-modify-write, preserving unrelated keys.
|
|
23
23
|
|
|
24
|
-
import { readFileSync,
|
|
24
|
+
import { readFileSync, mkdirSync } from "node:fs";
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import { configPath, globalDir } from "../src/config/paths.ts";
|
|
28
28
|
import { desktopAppPath } from "../src/config/desktopApp.ts";
|
|
29
|
+
import { writeFileAtomic } from "../src/util/atomicWrite.ts";
|
|
29
30
|
|
|
30
31
|
const FIRST_MS = 6_000; // a turn shorter than this never shows a tip
|
|
31
32
|
const EVERY_MS = 12_000;
|
|
@@ -120,7 +121,7 @@ function persistEnabled(enabled: boolean): void {
|
|
|
120
121
|
cfg.hints = { ...(cfg.hints ?? {}), enabled };
|
|
121
122
|
try {
|
|
122
123
|
mkdirSync(globalDir(), { recursive: true });
|
|
123
|
-
|
|
124
|
+
writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
124
125
|
} catch {
|
|
125
126
|
/* best effort — the toggle still holds for this session */
|
|
126
127
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/channels/run.ts
CHANGED
|
@@ -53,6 +53,8 @@ import { modelRegistryOf } from "../providers/piAuthStore.ts";
|
|
|
53
53
|
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
54
54
|
import { CRYPTO_TOOL_NAMES } from "../tools/crypto.ts";
|
|
55
55
|
import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
|
|
56
|
+
import { rotateLogIfLarge } from "../harbor/logRotate.ts";
|
|
57
|
+
import { logFilePath } from "../harbor/service.ts";
|
|
56
58
|
|
|
57
59
|
// Read-only default toolset — same rationale as the routines harbor's SAFE_TOOLS:
|
|
58
60
|
// a turn nobody is watching can't mutate the filesystem or shell out. Now that the
|
|
@@ -147,10 +149,19 @@ async function main() {
|
|
|
147
149
|
|
|
148
150
|
// ── config ──────────────────────────────────────────────────────────────────
|
|
149
151
|
let cfg: any = {};
|
|
152
|
+
let raw: string;
|
|
150
153
|
try {
|
|
151
|
-
|
|
154
|
+
raw = readFileSync(configPath(), "utf8");
|
|
152
155
|
} catch {
|
|
153
|
-
|
|
156
|
+
// Nothing configured yet is a clean exit (see the no-bridges case below for why).
|
|
157
|
+
log(`no config at ${configPath()} — add a channels block (see run.ts header), or set channels up from the app.`);
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
cfg = JSON.parse(raw);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// A config that exists but won't parse IS a failure: say so, and exit non-zero.
|
|
164
|
+
log(`can't parse ${configPath()}: ${e instanceof Error ? e.message : String(e)}`);
|
|
154
165
|
process.exit(1);
|
|
155
166
|
}
|
|
156
167
|
const ch = cfg.channels ?? {};
|
|
@@ -404,15 +415,23 @@ async function main() {
|
|
|
404
415
|
}
|
|
405
416
|
|
|
406
417
|
if (bridges.length === 0) {
|
|
407
|
-
|
|
408
|
-
|
|
418
|
+
// Exit 0, not 1: under the login service (`privateer channels install`) a failure
|
|
419
|
+
// exit is restarted, and "nothing configured yet" doesn't get better by retrying —
|
|
420
|
+
// it got restarted until systemd's start limit, filling the log with this line.
|
|
421
|
+
// Configure a channel (from the app or config.json), then start it again.
|
|
422
|
+
log("no channels started — configure a channels.<platform> block in config.json (or from the app), then run `privateer channels install` again.");
|
|
423
|
+
process.exit(0);
|
|
409
424
|
}
|
|
410
425
|
|
|
411
426
|
// Heartbeat: announce the live platforms now and refresh on a cadence so the app's
|
|
412
427
|
// channels manager can tell running from merely-configured. A stale/absent file
|
|
413
428
|
// reads as offline (see channels/status.ts).
|
|
414
429
|
writeChannelsStatus(startedPlatforms);
|
|
415
|
-
const heartbeat = setInterval(() =>
|
|
430
|
+
const heartbeat = setInterval(() => {
|
|
431
|
+
writeChannelsStatus(startedPlatforms);
|
|
432
|
+
// Bound the log launchd appends to (macOS service; a no-op elsewhere). See logRotate.ts.
|
|
433
|
+
if (rotateLogIfLarge(logFilePath("channels"))) log("rotated channels.log (previous kept as channels.log.1)");
|
|
434
|
+
}, HEARTBEAT_MS);
|
|
416
435
|
heartbeat.unref?.();
|
|
417
436
|
|
|
418
437
|
const shutdown = () => {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// `privateer channels [run|install|uninstall|status]` dispatcher.
|
|
2
|
+
//
|
|
3
|
+
// ORDERING CONTRACT: `run` dynamically imports channels/run.ts, which imports
|
|
4
|
+
// ./boot.ts first (env pin + attestation dispatcher) and starts on import. The service
|
|
5
|
+
// subcommands touch no Pi code (harbor/service.ts is import-safe), so they don't pay
|
|
6
|
+
// the session-stack import cost.
|
|
7
|
+
|
|
8
|
+
function usage(): string {
|
|
9
|
+
return [
|
|
10
|
+
"Usage: privateer channels [command]",
|
|
11
|
+
"",
|
|
12
|
+
" run Run the channels in the foreground (default). Bridges every",
|
|
13
|
+
" channels.<platform> block in ~/.privateer/config.json.",
|
|
14
|
+
" install Install it as a login service so it starts at login and stays up.",
|
|
15
|
+
" Re-run after changing channels from the app: it restarts them.",
|
|
16
|
+
" uninstall Remove the login service.",
|
|
17
|
+
" status Show whether the service is installed and which channels are live.",
|
|
18
|
+
].join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runChannelsCli(argv: string[]): Promise<void> {
|
|
22
|
+
const sub = argv[0] ?? "run";
|
|
23
|
+
switch (sub) {
|
|
24
|
+
case "run": {
|
|
25
|
+
await import("../channels/run.ts"); // starts on import, installs its own signal handlers
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
case "install": {
|
|
29
|
+
const { installService } = await import("../harbor/service.ts");
|
|
30
|
+
try {
|
|
31
|
+
const info = installService("channels");
|
|
32
|
+
process.stdout.write(
|
|
33
|
+
`Channels installed as a login service.\n unit: ${info.unitPath}\n logs: ${info.logPath}\n` +
|
|
34
|
+
"They start now and at every login. Manage with `privateer channels status|uninstall`.\n",
|
|
35
|
+
);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
case "uninstall": {
|
|
43
|
+
const { uninstallService } = await import("../harbor/service.ts");
|
|
44
|
+
try {
|
|
45
|
+
uninstallService("channels");
|
|
46
|
+
process.stdout.write("Channels login service removed.\n");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
case "status": {
|
|
54
|
+
const { channelsStatusReport } = await import("../harbor/service.ts");
|
|
55
|
+
process.stdout.write((await channelsStatusReport()) + "\n");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
case "-h":
|
|
59
|
+
case "--help":
|
|
60
|
+
case "help":
|
|
61
|
+
process.stdout.write(usage() + "\n");
|
|
62
|
+
return;
|
|
63
|
+
default:
|
|
64
|
+
process.stderr.write(`Unknown channels command: ${sub}\n\n${usage()}\n`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
* Missing file, unreadable file and malformed JSON all mean "no choice" — the account
|
|
19
19
|
* default — never a crash in the middle of a tool call.
|
|
20
20
|
*/
|
|
21
|
-
import { readFileSync,
|
|
21
|
+
import { readFileSync, mkdirSync } from "node:fs";
|
|
22
22
|
import { dirname } from "node:path";
|
|
23
23
|
import { configPath } from "./paths.ts";
|
|
24
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
24
25
|
|
|
25
26
|
export type MediaKind = "image" | "video";
|
|
26
27
|
|
|
@@ -55,7 +56,7 @@ export function setMediaModelPref(kind: MediaKind, id: string | null): void {
|
|
|
55
56
|
const next: Record<string, unknown> = { ...cfg, media };
|
|
56
57
|
if (Object.keys(media).length === 0) delete next.media;
|
|
57
58
|
mkdirSync(dirname(configPath()), { recursive: true });
|
|
58
|
-
|
|
59
|
+
writeFileAtomic(configPath(), JSON.stringify(next, null, 2) + "\n");
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/** The model a call should send: its own `model`, else this machine's choice, else none. */
|
package/src/config/piiAllow.ts
CHANGED
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
// made in an editor is picked up on the next scan, exactly like one made through the
|
|
29
29
|
// command — there is no "restart to apply" step to explain.
|
|
30
30
|
|
|
31
|
-
import { readFileSync, statSync
|
|
31
|
+
import { readFileSync, statSync } from "node:fs";
|
|
32
32
|
import { configPath } from "./paths.ts";
|
|
33
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
33
34
|
|
|
34
35
|
let cached: string[] = [];
|
|
35
36
|
let cachedStamp = "";
|
|
@@ -89,7 +90,7 @@ function write(entries: string[]): void {
|
|
|
89
90
|
/* new or unreadable — a fresh object, so one bad file doesn't lose the edit */
|
|
90
91
|
}
|
|
91
92
|
const privacy = { ...((cfg.privacy as Record<string, unknown>) ?? {}), piiAllow: entries };
|
|
92
|
-
|
|
93
|
+
writeFileAtomic(configPath(), JSON.stringify({ ...cfg, privacy }, null, 2) + "\n");
|
|
93
94
|
invalidate();
|
|
94
95
|
}
|
|
95
96
|
|
package/src/config/whatsNew.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
|
|
3
|
-
"reviewedFor": "0.14.
|
|
3
|
+
"reviewedFor": "0.14.1",
|
|
4
4
|
"items": [
|
|
5
|
+
{ "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" },
|
|
5
6
|
{ "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" },
|
|
6
7
|
{ "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" },
|
|
7
|
-
{ "text": "Voice follows the window you're in", "since": "0.13.0" }
|
|
8
|
-
{ "text": "Searchable model picker —", "cmd": "/models", "since": "0.12.53" }
|
|
8
|
+
{ "text": "Voice follows the window you're in", "since": "0.13.0" }
|
|
9
9
|
]
|
|
10
10
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A stall watchdog for headless runs.
|
|
2
|
+
//
|
|
3
|
+
// `privateer -p` waits on the model with no limit. A provider that accepts the request
|
|
4
|
+
// and then sends nothing (measured on the account channel: glm-5-2 stalled 5 of 14
|
|
5
|
+
// requests, see providers/defaultModel.ts) leaves the run hanging forever with nothing
|
|
6
|
+
// on stdout. That is what a 14-minute "hang" on 2026-10-02 looked like from the outside;
|
|
7
|
+
// the session file never appeared, because Pi writes it only once a first reply lands.
|
|
8
|
+
//
|
|
9
|
+
// So: armed when a request goes out, re-armed by every streamed event (text, thinking,
|
|
10
|
+
// tool-call deltas all count, so a slow but live stream never trips it), disarmed when
|
|
11
|
+
// the message ends. Between requests (tool execution) it is off — a long local command
|
|
12
|
+
// is not a provider stall. If it fires, the caller aborts the turn and says why.
|
|
13
|
+
//
|
|
14
|
+
// The limit: PRIVATEER_REPLY_TIMEOUT seconds, default 180, 0 turns it off. Silence, not
|
|
15
|
+
// total time, is what is measured, so a long answer is never cut off for being long.
|
|
16
|
+
|
|
17
|
+
export const REPLY_TIMEOUT_ENV = "PRIVATEER_REPLY_TIMEOUT";
|
|
18
|
+
export const DEFAULT_REPLY_TIMEOUT_MS = 180_000;
|
|
19
|
+
|
|
20
|
+
/** The stall limit in ms from the environment; 0 means off. Garbage falls back to the default. */
|
|
21
|
+
export function replyTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
22
|
+
const raw = (env[REPLY_TIMEOUT_ENV] ?? "").trim();
|
|
23
|
+
if (!raw) return DEFAULT_REPLY_TIMEOUT_MS;
|
|
24
|
+
const n = Number(raw);
|
|
25
|
+
if (!Number.isFinite(n) || n < 0) return DEFAULT_REPLY_TIMEOUT_MS;
|
|
26
|
+
return Math.round(n * 1000);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class StallWatchdog {
|
|
30
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
31
|
+
private fired = false;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly ms: number,
|
|
35
|
+
private readonly onStall: () => void,
|
|
36
|
+
private readonly timers: { set: typeof setTimeout; clear: typeof clearTimeout } = { set: setTimeout, clear: clearTimeout },
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
/** A request went out, or something streamed: (re)start the silence clock. */
|
|
40
|
+
arm(): void {
|
|
41
|
+
if (this.ms <= 0 || this.fired) return;
|
|
42
|
+
this.disarm();
|
|
43
|
+
this.timer = this.timers.set(() => {
|
|
44
|
+
this.timer = undefined;
|
|
45
|
+
this.fired = true;
|
|
46
|
+
this.onStall();
|
|
47
|
+
}, this.ms);
|
|
48
|
+
// Never the reason a finished run stays alive (see docs/one-shot-exit.md).
|
|
49
|
+
(this.timer as { unref?: () => void })?.unref?.();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The message ended, or the run did: nothing to wait on. */
|
|
53
|
+
disarm(): void {
|
|
54
|
+
if (this.timer !== undefined) this.timers.clear(this.timer);
|
|
55
|
+
this.timer = undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get stalled(): boolean {
|
|
59
|
+
return this.fired;
|
|
60
|
+
}
|
|
61
|
+
}
|
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
|
}
|
|
@@ -21,13 +21,14 @@
|
|
|
21
21
|
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
22
22
|
* frame plumbing and the running-presence read.
|
|
23
23
|
*/
|
|
24
|
-
import { readFileSync
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
25
|
import { configPath } from "../config/paths.ts";
|
|
26
26
|
// The platform roster, per-platform secret field names, and the "is this block
|
|
27
27
|
// startable" predicate all live in ONE place, shared with the channels runtime —
|
|
28
28
|
// see channels/platforms.ts. Re-exported here so existing importers of
|
|
29
29
|
// CHANNEL_PLATFORMS/ChannelPlatform from this module keep working unchanged.
|
|
30
30
|
import { CHANNEL_PLATFORMS, SECRET_FIELDS, isChannelPlatform, type ChannelPlatform } from "../channels/platforms.ts";
|
|
31
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
31
32
|
|
|
32
33
|
export { CHANNEL_PLATFORMS };
|
|
33
34
|
export type { ChannelPlatform };
|
|
@@ -161,7 +162,7 @@ export function makeChannelsControl(opts: {
|
|
|
161
162
|
|
|
162
163
|
cfg.channels[draft.platform] = block;
|
|
163
164
|
try {
|
|
164
|
-
|
|
165
|
+
writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
165
166
|
} catch (e) {
|
|
166
167
|
return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
|
|
167
168
|
}
|
|
@@ -174,7 +175,7 @@ export function makeChannelsControl(opts: {
|
|
|
174
175
|
if (!cfg.channels?.[platform]) return { ok: false, message: "Not configured." };
|
|
175
176
|
delete cfg.channels[platform];
|
|
176
177
|
try {
|
|
177
|
-
|
|
178
|
+
writeFileAtomic(configPath(), JSON.stringify(cfg, null, 2) + "\n");
|
|
178
179
|
} catch (e) {
|
|
179
180
|
return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
|
|
180
181
|
}
|
package/src/remote/mcpControl.ts
CHANGED
|
@@ -35,9 +35,10 @@
|
|
|
35
35
|
* Framework-agnostic: nothing here imports React or the relay. The caller owns the
|
|
36
36
|
* frame plumbing and the sealed-secret open.
|
|
37
37
|
*/
|
|
38
|
-
import { readFileSync,
|
|
38
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
39
39
|
import { join, dirname } from "node:path";
|
|
40
40
|
import { agentDir } from "../config/paths.ts";
|
|
41
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
41
42
|
|
|
42
43
|
export type McpTransport = "stdio" | "http";
|
|
43
44
|
|
|
@@ -200,7 +201,7 @@ export function makeMcpControl(opts?: {
|
|
|
200
201
|
|
|
201
202
|
function writeSource(src: SourceFile): void {
|
|
202
203
|
mkdirSync(dirname(sourcePath()), { recursive: true });
|
|
203
|
-
|
|
204
|
+
writeFileAtomic(sourcePath(), JSON.stringify(src, null, 2) + "\n");
|
|
204
205
|
project(src);
|
|
205
206
|
}
|
|
206
207
|
|
|
@@ -220,7 +221,7 @@ export function makeMcpControl(opts?: {
|
|
|
220
221
|
mcpServers[name] = toStandard(e);
|
|
221
222
|
}
|
|
222
223
|
mkdirSync(dirname(projectionPath()), { recursive: true });
|
|
223
|
-
|
|
224
|
+
writeFileAtomic(
|
|
224
225
|
projectionPath(),
|
|
225
226
|
JSON.stringify({ mcpServers, settings: { toolPrefix: "server" } }, null, 2) + "\n",
|
|
226
227
|
);
|
package/src/routines/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, readdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { writeFileAtomic } from "../util/atomicWrite.ts";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { globalDir } from "../config/paths.ts";
|
|
@@ -64,11 +65,9 @@ export function saveRoutines(routines: Routine[]): void {
|
|
|
64
65
|
mkdirSync(dir, { recursive: true });
|
|
65
66
|
tryChmod(dir, 0o700);
|
|
66
67
|
const payload: RoutineFile = { routines };
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
71
|
-
tryChmod(routinesFilePath(), 0o600);
|
|
68
|
+
// Atomic: a half-written routines.json reads as "no routines" (loadRoutines), and the
|
|
69
|
+
// next save would then persist only the one routine it was given.
|
|
70
|
+
writeFileAtomic(routinesFilePath(), JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
|
|
72
71
|
}
|
|
73
72
|
|
|
74
73
|
// Look up by id first, then by (case-insensitive) name for CLI convenience.
|
|
@@ -99,18 +98,48 @@ export function removeRoutine(idOrName: string): Routine | null {
|
|
|
99
98
|
return target;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
//
|
|
101
|
+
// Dated result files kept per routine. An hourly routine on an always-on box wrote one
|
|
102
|
+
// every hour, forever; latest.md is what anything reads, and this many is history enough.
|
|
103
|
+
export const ROUTINE_OUTPUT_KEEP = Number(process.env.PRIVATEER_ROUTINE_OUTPUT_KEEP) || 30;
|
|
104
|
+
|
|
105
|
+
// Write a run's result to the routine's output dir: a dated file plus latest.md, then
|
|
106
|
+
// prune the dated files to the newest ROUTINE_OUTPUT_KEEP. Owner-only, like the rest of
|
|
107
|
+
// the global dir: a result can quote whatever the routine read.
|
|
103
108
|
// Returns the absolute path of latest.md.
|
|
104
109
|
export function writeRoutineOutput(name: string, content: string): string {
|
|
105
110
|
const dir = routineOutputDir(name);
|
|
106
111
|
mkdirSync(dir, { recursive: true });
|
|
107
112
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
108
|
-
writeFileSync(join(dir, `${stamp}.md`), content, "utf8");
|
|
113
|
+
writeFileSync(join(dir, `${stamp}.md`), content, { encoding: "utf8", mode: 0o600 });
|
|
109
114
|
const latest = join(dir, "latest.md");
|
|
110
|
-
|
|
115
|
+
writeFileAtomic(latest, content, { mode: 0o600 });
|
|
116
|
+
pruneRoutineOutput(dir, ROUTINE_OUTPUT_KEEP);
|
|
111
117
|
return latest;
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
// Our dated names (ISO with ':' and '.' → '-') sort chronologically as strings, so the
|
|
121
|
+
// oldest are first. Only files of that shape are touched — anything else a user put in
|
|
122
|
+
// the folder is theirs. Best-effort: a prune failure never fails a delivery.
|
|
123
|
+
const DATED_OUTPUT = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.md$/;
|
|
124
|
+
|
|
125
|
+
export function pruneRoutineOutput(dir: string, keep: number): number {
|
|
126
|
+
let removed = 0;
|
|
127
|
+
try {
|
|
128
|
+
const dated = readdirSync(dir).filter((f) => DATED_OUTPUT.test(f)).sort();
|
|
129
|
+
for (const f of dated.slice(0, Math.max(0, dated.length - keep))) {
|
|
130
|
+
try {
|
|
131
|
+
unlinkSync(join(dir, f));
|
|
132
|
+
removed++;
|
|
133
|
+
} catch {
|
|
134
|
+
/* already gone */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
/* unreadable dir */
|
|
139
|
+
}
|
|
140
|
+
return removed;
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
// A pending routine result queued for the next interactive session ("notice"
|
|
115
144
|
// delivery). The TUI drains these on startup so results surface even when no
|
|
116
145
|
// terminal was attached at fire time.
|
|
@@ -144,7 +173,7 @@ export function addNotice(notice: RoutineNotice): void {
|
|
|
144
173
|
notices.push(notice);
|
|
145
174
|
// Keep the queue bounded so an offline stretch can't grow it without limit.
|
|
146
175
|
const trimmed = notices.slice(-50);
|
|
147
|
-
|
|
176
|
+
writeFileAtomic(noticesPath(), JSON.stringify(trimmed, null, 2) + "\n", { mode: 0o600 });
|
|
148
177
|
tryChmod(noticesPath(), 0o600);
|
|
149
178
|
}
|
|
150
179
|
|
|
@@ -189,7 +218,7 @@ export function addPendingRelay(entry: PendingRelay): void {
|
|
|
189
218
|
const queue = loadPendingRelay();
|
|
190
219
|
queue.push(entry);
|
|
191
220
|
const trimmed = queue.slice(-50); // bound the backlog
|
|
192
|
-
|
|
221
|
+
writeFileAtomic(pendingRelayPath(), JSON.stringify(trimmed, null, 2) + "\n", { mode: 0o600 });
|
|
193
222
|
tryChmod(pendingRelayPath(), 0o600);
|
|
194
223
|
}
|
|
195
224
|
|
|
@@ -271,6 +300,6 @@ export function addPendingCloud(entry: PendingCloud): void {
|
|
|
271
300
|
export function savePendingCloud(entries: PendingCloud[]): void {
|
|
272
301
|
const dir = join(globalDir(), "routines");
|
|
273
302
|
mkdirSync(dir, { recursive: true });
|
|
274
|
-
|
|
303
|
+
writeFileAtomic(pendingCloudPath(), JSON.stringify(entries, null, 2) + "\n", { mode: 0o600 });
|
|
275
304
|
tryChmod(pendingCloudPath(), 0o600);
|
|
276
305
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Replace a file all at once: write a sibling temp file, then rename it over the target.
|
|
2
|
+
//
|
|
3
|
+
// A plain writeFileSync truncates first and writes second, so a crash, a full disk or a
|
|
4
|
+
// kill in between leaves a short or empty file. For the files this is used on that is
|
|
5
|
+
// worse than losing the write: loadRoutines() reads a half-written routines.json as "no
|
|
6
|
+
// routines", and the next save from the app then writes just the one routine it holds —
|
|
7
|
+
// every other routine gone. rename() is atomic on one filesystem, so a reader sees the
|
|
8
|
+
// old file or the new one and never anything between.
|
|
9
|
+
//
|
|
10
|
+
// Permissions carry over from the file being replaced (a config.json someone chmod'ed
|
|
11
|
+
// 0600 stays 0600). A new file gets `mode`, default 0600: everything written through
|
|
12
|
+
// here is config that can hold tokens.
|
|
13
|
+
//
|
|
14
|
+
// IMPORT-SAFETY: node builtins only.
|
|
15
|
+
import { chmodSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
export function writeFileAtomic(path: string, data: string, opts: { mode?: number } = {}): void {
|
|
19
|
+
// Follow a symlinked config to the file it names, so we replace the file rather than
|
|
20
|
+
// the link (which would quietly detach a dotfiles-managed config).
|
|
21
|
+
let target = path;
|
|
22
|
+
let mode = opts.mode ?? 0o600;
|
|
23
|
+
try {
|
|
24
|
+
target = realpathSync(path);
|
|
25
|
+
if (opts.mode === undefined) mode = statSync(target).mode & 0o777;
|
|
26
|
+
} catch {
|
|
27
|
+
/* new file */
|
|
28
|
+
}
|
|
29
|
+
const tmp = `${target}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(tmp, data, { encoding: "utf8", mode });
|
|
32
|
+
try {
|
|
33
|
+
chmodSync(tmp, mode); // writeFileSync's mode is filtered by the umask
|
|
34
|
+
} catch {
|
|
35
|
+
/* non-POSIX filesystem */
|
|
36
|
+
}
|
|
37
|
+
renameSync(tmp, target);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
try {
|
|
40
|
+
unlinkSync(tmp);
|
|
41
|
+
} catch {
|
|
42
|
+
/* never created */
|
|
43
|
+
}
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|