privateer-agent 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- cfg = JSON.parse(readFileSync(configPath(), "utf8"));
154
+ raw = readFileSync(configPath(), "utf8");
152
155
  } catch {
153
- log(`no config at ${configPath()} — add a channels block (see run.ts header).`);
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 ?? {};
@@ -332,7 +343,12 @@ async function main() {
332
343
  // in providers/account.ts hangs off `before_agent_start`, which pi's prompt()
333
344
  // never reaches — it throws on its own `hasConfiguredAuth` precheck first.
334
345
  if (model?.provider === "privateer") await ensureAccountArmed(undefined);
335
- await approvalCtx.run({ bridge, chatId, posture: effectivePosture }, () => session.prompt(text));
346
+ // A chat message is never a command: with expansion on, Pi runs a leading
347
+ // `/name` as an extension command, so any chat member could send `/privacy off`
348
+ // or `/privacy allow *` (persisted to config.json). Same rule as ACP.
349
+ await approvalCtx.run({ bridge, chatId, posture: effectivePosture }, () =>
350
+ session.prompt(text, { expandPromptTemplates: false }),
351
+ );
336
352
  } catch (e) {
337
353
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
338
354
  } finally {
@@ -399,15 +415,23 @@ async function main() {
399
415
  }
400
416
 
401
417
  if (bridges.length === 0) {
402
- log("no channels started — configure a channels.<platform> block in config.json.");
403
- process.exit(1);
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);
404
424
  }
405
425
 
406
426
  // Heartbeat: announce the live platforms now and refresh on a cadence so the app's
407
427
  // channels manager can tell running from merely-configured. A stale/absent file
408
428
  // reads as offline (see channels/status.ts).
409
429
  writeChannelsStatus(startedPlatforms);
410
- const heartbeat = setInterval(() => writeChannelsStatus(startedPlatforms), HEARTBEAT_MS);
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);
411
435
  heartbeat.unref?.();
412
436
 
413
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, writeFileSync, mkdirSync } from "node:fs";
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
- writeFileSync(configPath(), JSON.stringify(next, null, 2) + "\n");
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. */
@@ -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, writeFileSync } from "node:fs";
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
- writeFileSync(configPath(), JSON.stringify({ ...cfg, privacy }, null, 2) + "\n");
93
+ writeFileAtomic(configPath(), JSON.stringify({ ...cfg, privacy }, null, 2) + "\n");
93
94
  invalidate();
94
95
  }
95
96
 
@@ -0,0 +1,10 @@
1
+ {
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.1",
4
+ "items": [
5
+ { "text": "Chat channels as a login service: privateer channels install", "since": "0.14.1" },
6
+ { "text": "Hand off sensitive files: privateer --private", "since": "0.14.0" },
7
+ { "text": "Open and switch named terminals —", "cmd": "/term", "since": "0.13.0" },
8
+ { "text": "Voice follows the window you're in", "since": "0.13.0" }
9
+ ]
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
+ }
@@ -18,6 +18,7 @@ import { classifyToolCall } from "../permissions/classify.ts";
18
18
  import { redactText } from "../util/redact.ts";
19
19
  import { coauthorGitCommits } from "../util/coauthor.ts";
20
20
  import { DEFAULT_DENYLIST } from "../permissions/danger.ts";
21
+ import { privateModeFromEnv, privateRefusal, type ModelRef } from "../permissions/privateMode.ts";
21
22
 
22
23
  // The per-tool_call hook context we read (structural subset of Pi's ctx — kept
23
24
  // local so the gate stays import-order-safe and unit-testable).
@@ -25,6 +26,8 @@ export interface ToolCallCtx {
25
26
  signal?: AbortSignal;
26
27
  hasUI?: boolean;
27
28
  mode?: "tui" | "rpc" | "json" | "print" | string;
29
+ // The session's current model — what private mode checks before any tool runs.
30
+ model?: ModelRef;
28
31
  // Pi's ExtensionUIContext shape (verified against pi-coding-agent 0.80):
29
32
  // select(title, options: string[]) → chosen string | undefined; confirm(title, message).
30
33
  ui?: {
@@ -43,6 +46,10 @@ export interface GateController {
43
46
  denylist?: string[];
44
47
  cwd: string;
45
48
  confineToCwd?: boolean;
49
+ // Unattended confinement: deny outside-cwd and protected access in every mode, and
50
+ // treat a read of a credential store as protected even inside cwd. See
51
+ // ModeGate.hardConfine and ScopeOptions.guardSecretReads.
52
+ hardConfine?: boolean;
46
53
  getRemote?(): boolean;
47
54
  // The controller raised the flag: total bypass for remote turns — see
48
55
  // ModeGate.getNoQuarter.
@@ -54,6 +61,11 @@ export interface GateController {
54
61
  // launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
55
62
  // action with no prompt.
56
63
  getSkipAllPermissions?(): boolean;
64
+ // Private mode (permissions/privateMode.ts): no tool runs unless the session's model is
65
+ // verified-private, and only tools that stay on this machine run at all. ORed with
66
+ // PRIVATEER_PRIVATE, which any controller honours, so a controller can switch it on but
67
+ // never off.
68
+ getPrivate?(): boolean;
57
69
  // Billing tools the operator authorized before this run started, so an unattended
58
70
  // session can spend what it was told it may spend instead of denying every media
59
71
  // call for want of a human. Lifts `alwaysAsk` and nothing else — see
@@ -138,10 +150,18 @@ export async function decideToolCall(
138
150
  };
139
151
  }
140
152
 
153
+ // Private mode sits above every mode, allowlist and approval: nothing a person or a
154
+ // host answers can show a file to an unverified model or send it off the machine.
155
+ if (ctrl.getPrivate?.() || privateModeFromEnv()) {
156
+ const refusal = await privateRefusal(toolName, input, ctx.model);
157
+ if (refusal) return { block: true, reason: refusal };
158
+ }
159
+
141
160
  const req = classifyToolCall(toolName, input, {
142
161
  cwd: ctrl.cwd,
143
162
  confineToCwd: ctrl.confineToCwd,
144
163
  allowedOutsideRoots: ctrl.allowedOutsideRoots,
164
+ guardSecretReads: ctrl.hardConfine,
145
165
  });
146
166
  if (!req) return undefined; // no gate needed — read-only/in-scope/meta
147
167
 
@@ -181,6 +201,7 @@ export async function decideToolCall(
181
201
  getNoQuarter: ctrl.getNoQuarter,
182
202
  getAutoApprove: ctrl.getAutoApprove,
183
203
  getSkipAllPermissions: ctrl.getSkipAllPermissions,
204
+ hardConfine: ctrl.hardConfine,
184
205
  isSpendPreauthorized: ctrl.isSpendPreauthorized
185
206
  ? (r: PermissionRequest) => ctrl.isSpendPreauthorized!(r, input, ctx.signal)
186
207
  : undefined,
@@ -0,0 +1,76 @@
1
+ // One account inference session for the whole harbor, shared by every run in flight.
2
+ //
3
+ // The account credential is a single slot per process — `_account` in auth/privateer.ts,
4
+ // one row per pid in accountSessions.ts, one `privateer` entry in auth.json — but the
5
+ // harbor runs several sessions at once: overlapping ticks, a submitted task, a workflow
6
+ // step and a live spawn can all be mid-turn together. Each used to mint its own session
7
+ // on start and revoke "the" session on finish, so the first run to finish revoked
8
+ // whichever session was minted LAST (another run's, mid-turn → 401) and its own leaked
9
+ // server-side until the TTL, with its registry row overwritten so no later launch could
10
+ // reclaim it.
11
+ //
12
+ // A lease fits what the slot can actually hold: the first run to need the account mints
13
+ // it, later runs share it, and the last one out revokes it. Pure — the mint and revoke
14
+ // are injected — so the ordering is testable without a server.
15
+
16
+ export interface AccountLeaseDeps {
17
+ /** Mint the account session and arm it (persist + remember). Throws on failure. */
18
+ mint: () => Promise<void>;
19
+ /** Revoke the session and drop its persisted copy. Best-effort; must not throw. */
20
+ revoke: () => Promise<void>;
21
+ log?: (msg: string) => void;
22
+ }
23
+
24
+ export interface AccountLease {
25
+ /** Take a reference. Resolves true when the account session is armed. ALWAYS pair with release(). */
26
+ acquire(): Promise<boolean>;
27
+ /** Drop a reference; the last one out revokes the session. */
28
+ release(): Promise<void>;
29
+ /** References held right now (tests, status). */
30
+ readonly refs: number;
31
+ }
32
+
33
+ export function createAccountLease(deps: AccountLeaseDeps): AccountLease {
34
+ let refs = 0;
35
+ // The session the current holders share: resolves true once armed, false if the mint
36
+ // failed. Undefined when nobody holds one.
37
+ let current: Promise<boolean> | undefined;
38
+ // The previous session's revoke. A mint waits on it, so a run that starts as the last
39
+ // one ends never has its fresh credential dropped by the old one's teardown.
40
+ let teardown: Promise<void> = Promise.resolve();
41
+
42
+ return {
43
+ get refs() {
44
+ return refs;
45
+ },
46
+ async acquire() {
47
+ refs++;
48
+ if (!current) {
49
+ current = teardown.then(deps.mint).then(
50
+ () => true,
51
+ (e) => {
52
+ deps.log?.(` account channel unavailable: ${e instanceof Error ? e.message : String(e)}`);
53
+ return false;
54
+ },
55
+ );
56
+ }
57
+ const mine = current;
58
+ const ok = await mine;
59
+ // A failed mint is not cached: the next run should try again rather than inherit it.
60
+ if (!ok && current === mine) current = undefined;
61
+ return ok;
62
+ },
63
+ async release() {
64
+ if (refs === 0) return;
65
+ refs--;
66
+ if (refs > 0) return;
67
+ const held = current;
68
+ current = undefined;
69
+ if (!held) return;
70
+ teardown = teardown.then(async () => {
71
+ if (await held) await deps.revoke().catch(() => {});
72
+ });
73
+ await teardown;
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,20 @@
1
+ // Tear a headless session all the way down. dispose() alone stops the agent but never
2
+ // emits session_shutdown — that is AgentSessionRuntime's job, and we build sessions
3
+ // without one — so every extension that cleans up on it (pi-mcp-adapter's connections,
4
+ // OAuth runtime and lifecycle state among them) leaked once per run, for the life of a
5
+ // resident process. Reason "new", not "quit": to our own extensions "quit" means the
6
+ // PROCESS is exiting (privateer-brand revokes the machine's sessions on it), and the
7
+ // harbor process lives on. Best-effort: teardown must never fail a finished run.
8
+ export async function closeSession(session: any): Promise<void> {
9
+ try {
10
+ const runner = session?.extensionRunner;
11
+ if (runner?.hasHandlers?.("session_shutdown")) await runner.emit({ type: "session_shutdown", reason: "new" });
12
+ } catch {
13
+ /* a handler threw — dispose anyway */
14
+ }
15
+ try {
16
+ session?.dispose?.();
17
+ } catch {
18
+ /* already disposed */
19
+ }
20
+ }