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.
@@ -64,6 +64,10 @@ import { postOutbox as sealToOutbox, type OutboxSource } from "../outbox/cloudOu
64
64
  import { redactText, collectSecrets } from "../util/redact.ts";
65
65
  import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
66
66
  import { serializeBuild } from "./buildLock.ts";
67
+ import { createAccountLease } from "./accountLease.ts";
68
+ import { closeSession } from "./closeSession.ts";
69
+ import { rotateLogIfLarge } from "./logRotate.ts";
70
+ import { logFilePath } from "./service.ts";
67
71
  import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/hosted.ts";
68
72
  import { relayExposureAllowed } from "../config/relayExposure.ts";
69
73
  import { WEB_TOOL_NAMES } from "../tools/web.ts";
@@ -134,6 +138,11 @@ const GATE_TIMEOUT_MS = 5 * 60_000;
134
138
  // endpoint or an expired OAuth token from stalling a scheduled run.
135
139
  const WARM_TIMEOUT_MS = Number(process.env.PRIVATEER_MCP_WARM_MS) || 30_000;
136
140
  const WARM_POLL_MS = 250;
141
+ // Hard ceiling on one unattended run (a routine, a submitted task, a workflow agent
142
+ // step). Nothing else bounds a provider stream that stops sending without closing, and a
143
+ // run stuck there holds its routine's `running` slot until the harbor restarts — the
144
+ // routine silently never fires again. Live spawns have their own cap (liveTaskSession).
145
+ const RUN_TIMEOUT_MS = Number(process.env.PRIVATEER_RUN_TIMEOUT_MS) || 30 * 60_000;
137
146
 
138
147
  // Distinguishes concurrent runs in the child-spend registry. A counter rather than a
139
148
  // timestamp: two runs starting in the same millisecond would share a key, and the second
@@ -177,6 +186,12 @@ function log(msg: string): void {
177
186
  process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
178
187
  }
179
188
 
189
+ // Identity of a queued outbox item, for removing the ones a flush delivered from a
190
+ // queue that may have grown while it was posting.
191
+ function pendingKey(p: PendingCloud): string {
192
+ return JSON.stringify([p.routine, p.at, p.kind ?? "routine", p.status, p.content]);
193
+ }
194
+
180
195
  // Connector notes are things the RUN could not do — a selected connector that isn't
181
196
  // configured, or one that wouldn't hand over a tool list. They ride the result
182
197
  // because the alternative is the failure mode RoutineIssues exists to prevent: an
@@ -271,6 +286,25 @@ export class Harbor {
271
286
  // Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
272
287
  // terminal (task-<uuid>); the harbor just keeps handles so it can reap them on shutdown.
273
288
  private readonly liveTasks = new Map<string, LiveTaskHandle>();
289
+ // The one account inference session every concurrent run shares — see accountLease.ts.
290
+ private readonly account = createAccountLease({
291
+ mint: async () => {
292
+ const creds = await acquireAccountCredential();
293
+ await persistAccountCredential(creds);
294
+ rememberAccountCredential(creds); // claim it, so the teardown drops OUR entry only
295
+ },
296
+ revoke: async () => {
297
+ try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
298
+ // Ownership-checked: an interactive terminal on this machine shares auth.json,
299
+ // and its entry must survive a harbor run's teardown (see providers/account.ts).
300
+ try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
301
+ },
302
+ log,
303
+ });
304
+ // The cloud-outbox flush in progress, if any. Every tick starts one; without this two
305
+ // could post the same items (duplicates in the Inbox) and the slower one's save would
306
+ // overwrite what the faster one left.
307
+ private flushing?: Promise<void>;
274
308
 
275
309
  // App-facing routine management (list/save/delete/pause/run) over the harbor's
276
310
  // relay. Run-now is injected here since only the harbor can actually fire one;
@@ -633,22 +667,33 @@ export class Harbor {
633
667
  return sealToOutbox(name, at, status, content, kind, media, source);
634
668
  }
635
669
 
636
- private async flushPendingCloud(): Promise<void> {
670
+ private flushPendingCloud(): Promise<void> {
671
+ this.flushing ??= this.doFlushPendingCloud()
672
+ .catch((e) => log(`outbox flush failed: ${e instanceof Error ? e.message : String(e)}`))
673
+ .finally(() => {
674
+ this.flushing = undefined;
675
+ });
676
+ return this.flushing;
677
+ }
678
+
679
+ private async doFlushPendingCloud(): Promise<void> {
637
680
  if (!hasCredentials()) return;
638
681
  const queue = loadPendingCloud();
639
682
  if (queue.length === 0) return;
640
- const remaining: PendingCloud[] = [];
683
+ const sent = new Set<string>();
641
684
  for (const p of queue) {
642
- if (
643
- remaining.length === 0 &&
644
- (await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? [], p.source))
645
- ) continue;
646
- remaining.push(p);
647
- }
648
- if (remaining.length !== queue.length) {
649
- savePendingCloud(remaining);
650
- log(`flushed ${queue.length - remaining.length} pending cloud outbox item(s); ${remaining.length} remaining`);
685
+ // Stop at the first failure: order is preserved, and whatever broke this one
686
+ // (offline, no outbox key yet) will break the rest too.
687
+ if (!(await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? [], p.source))) break;
688
+ sent.add(pendingKey(p));
651
689
  }
690
+ if (sent.size === 0) return;
691
+ // Re-read rather than save what we loaded: a run that finished while we were
692
+ // posting queued its result in the meantime, and overwriting with our snapshot
693
+ // would delete it.
694
+ const remaining = loadPendingCloud().filter((p) => !sent.has(pendingKey(p)));
695
+ savePendingCloud(remaining);
696
+ log(`flushed ${sent.size} pending cloud outbox item(s); ${remaining.length} remaining`);
652
697
  }
653
698
 
654
699
  private primeSchedule(): void {
@@ -672,6 +717,8 @@ export class Harbor {
672
717
  // returns immediately once a client exists, or while remote access is off.
673
718
  this.syncRelay();
674
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)");
675
722
  const now = Date.now();
676
723
  for (const r of loadRoutines()) {
677
724
  if (!r.enabled || this.running.has(r.id)) continue;
@@ -778,32 +825,56 @@ export class Harbor {
778
825
  const toInbox = routine.delivery.includes("cloud");
779
826
  const media = toInbox ? new ResultMedia() : undefined;
780
827
 
781
- const { out, status, error, notes } = await this.runSession({
782
- prompt: withBrief(routine.prompt, { inbox: toInbox, canAttach: !!media }),
783
- cwd: routine.cwd,
784
- model: modelSpec,
785
- tools: routine.tools ?? [],
786
- media,
787
- });
788
-
789
- const content = formatResult(routine, out, status, error, notes);
790
- const report = await deliver(routine, content, status, {
791
- media: media?.list() ?? [],
792
- pushRelay: this.pushRelay,
793
- pushCloud: this.pushCloud,
794
- webhooks: config.webhooks,
795
- redact: (text) => redactText(text, collectSecrets(config.providers)),
796
- });
797
- log(` "${routine.name}" ${status}; delivered via ${report.delivered.join(", ") || "(none)"}`);
828
+ let status: "ok" | "error" = "error";
829
+ let error: string | undefined;
830
+ let delivered: string[] = [];
831
+ try {
832
+ const run = await this.runSession({
833
+ prompt: withBrief(routine.prompt, { inbox: toInbox, canAttach: !!media }),
834
+ cwd: routine.cwd,
835
+ model: modelSpec,
836
+ tools: routine.tools ?? [],
837
+ media,
838
+ });
839
+ ({ status, error } = run);
798
840
 
799
- this.persistRun(routine.id, {
800
- lastRun: new Date().toISOString(),
801
- lastStatus: status,
802
- lastError: error,
803
- ...advanceAfterRun(routine),
804
- });
805
- this.running.delete(routine.id);
806
- return { ok: status === "ok", message: report.delivered.join(", ") || undefined };
841
+ const content = formatResult(routine, run.out, status, error, run.notes);
842
+ try {
843
+ const report = await deliver(routine, content, status, {
844
+ media: media?.list() ?? [],
845
+ pushRelay: this.pushRelay,
846
+ pushCloud: this.pushCloud,
847
+ webhooks: config.webhooks,
848
+ redact: (text) => redactText(text, collectSecrets(config.providers)),
849
+ });
850
+ delivered = report.delivered;
851
+ } catch (e) {
852
+ // A full disk, an unwritable output dir. The run happened and was paid for; say
853
+ // so in the log and on the routine rather than throwing past the schedule
854
+ // advance below — that throw is what used to re-fire (and re-bill) the routine
855
+ // on every restart.
856
+ error = `delivery failed: ${e instanceof Error ? e.message : String(e)}`;
857
+ status = "error";
858
+ }
859
+ log(` "${routine.name}" ${status}; delivered via ${delivered.join(", ") || "(none)"}${error && delivered.length === 0 ? ` (${error})` : ""}`);
860
+ } finally {
861
+ // Always advance the schedule and free the slot, whatever threw above. Without the
862
+ // finally, one exception left the routine marked running forever (it never fired
863
+ // again) — or, as an unhandled rejection, crashed the harbor before nextRun moved,
864
+ // so the restart fired it again straight away.
865
+ try {
866
+ this.persistRun(routine.id, {
867
+ lastRun: new Date().toISOString(),
868
+ lastStatus: status,
869
+ lastError: error,
870
+ ...advanceAfterRun(routine),
871
+ });
872
+ } catch (e) {
873
+ log(` couldn't record the run of "${routine.name}": ${e instanceof Error ? e.message : String(e)}`);
874
+ }
875
+ this.running.delete(routine.id);
876
+ }
877
+ return { ok: status === "ok", message: delivered.join(", ") || undefined };
807
878
  }
808
879
 
809
880
  // Build the Pi services for one unattended run: the auto-approve gate, the account
@@ -831,6 +902,11 @@ export class Harbor {
831
902
  allowedOutsideRoots: [],
832
903
  cwd,
833
904
  confineToCwd: true,
905
+ // Bypass mode lets anything through that the allow-list registered — including
906
+ // reads outside cwd and of protected files, which bypass allows outright. A run
907
+ // nobody is watching, that may well have fetched a hostile web page, must not
908
+ // read ~/.ssh or our own auth.json and put it in the next URL: refuse both.
909
+ hardConfine: true,
834
910
  async localAsk() {
835
911
  return "deny";
836
912
  },
@@ -954,7 +1030,7 @@ export class Harbor {
954
1030
  await new Promise((r) => setTimeout(r, WARM_POLL_MS));
955
1031
  }
956
1032
  } finally {
957
- session.dispose?.();
1033
+ await closeSession(session);
958
1034
  }
959
1035
  } catch (e) {
960
1036
  log(` connector warm-up failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -977,7 +1053,8 @@ export class Harbor {
977
1053
  let out = "";
978
1054
  let status: "ok" | "error" = "ok";
979
1055
  let error: string | undefined;
980
- let spawnedAccount = false;
1056
+ let leasedAccount = false;
1057
+ let session: any;
981
1058
  const resolved = await this.resolveRunTools(spec.tools, spec.cwd, spec.model);
982
1059
  // attach_to_result rides with the staging area rather than the allow-list: it is
983
1060
  // how the answer is DELIVERED, not a capability the run gains. A routine that
@@ -997,14 +1074,10 @@ export class Harbor {
997
1074
 
998
1075
  const { provider, modelId } = parseSpec(spec.model);
999
1076
  if (provider === "privateer") {
1000
- try {
1001
- const creds = await acquireAccountCredential();
1002
- await persistAccountCredential(creds);
1003
- rememberAccountCredential(creds); // claim it, so the teardown drops OUR entry only
1004
- spawnedAccount = true;
1005
- } catch (e) {
1006
- log(` account channel unavailable: ${(e as Error).message}`);
1007
- }
1077
+ // Shared with every other run in flight — see accountLease.ts. A failed mint is
1078
+ // logged by the lease; the run goes ahead and fails on auth with that context.
1079
+ leasedAccount = true;
1080
+ await this.account.acquire();
1008
1081
  }
1009
1082
 
1010
1083
  const model = (modelRegistryOf(services) as any).find(provider, modelId);
@@ -1012,12 +1085,12 @@ export class Harbor {
1012
1085
  status = "error";
1013
1086
  error = `model ${provider}/${modelId} not found`;
1014
1087
  } else {
1015
- const { session } = await createAgentSessionFromServices({
1088
+ ({ session } = await createAgentSessionFromServices({
1016
1089
  services,
1017
1090
  sessionManager: SessionManager.inMemory(spec.cwd),
1018
1091
  model,
1019
1092
  tools: allowedTools,
1020
- } as any);
1093
+ } as any));
1021
1094
  const adapter = createEngineEventAdapter();
1022
1095
  session.subscribe((ev: any) => {
1023
1096
  for (const ee of adapter.toEngineEvents(ev)) {
@@ -1028,21 +1101,30 @@ export class Harbor {
1028
1101
  }
1029
1102
  }
1030
1103
  });
1031
- await session.prompt(spec.prompt);
1104
+ let timedOut = false;
1105
+ const deadline = setTimeout(() => {
1106
+ timedOut = true;
1107
+ void Promise.resolve(session.abort?.()).catch(() => {});
1108
+ }, RUN_TIMEOUT_MS);
1109
+ try {
1110
+ await session.prompt(spec.prompt);
1111
+ } finally {
1112
+ clearTimeout(deadline);
1113
+ }
1114
+ if (timedOut) {
1115
+ status = "error";
1116
+ error = `run stopped after ${Math.round(RUN_TIMEOUT_MS / 60_000)} minutes (PRIVATEER_RUN_TIMEOUT_MS)`;
1117
+ }
1032
1118
  }
1033
1119
  } catch (err) {
1034
1120
  status = "error";
1035
1121
  error = err instanceof Error ? err.message : String(err);
1036
1122
  } finally {
1037
- // Revoke ONLY this run's account inference session (the harbor's own child API
1038
- // session — relay/outbox — stays alive until shutdown). Drop Pi's persisted copy
1039
- // too so a later run's fallback never reuses a revoked token. Best-effort.
1040
- if (spawnedAccount) {
1041
- try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
1042
- // Ownership-checked: an interactive terminal on this machine shares auth.json,
1043
- // and its entry must survive a harbor run's teardown (see providers/account.ts).
1044
- try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
1045
- }
1123
+ if (session) await closeSession(session);
1124
+ // Drop this run's reference to the shared account session; the last run out
1125
+ // revokes it (and its persisted copy). The harbor's own child API session —
1126
+ // relay/outbox — is separate and stays alive until shutdown.
1127
+ if (leasedAccount) await this.account.release();
1046
1128
  // Drop this run's child grant. Unconditional and last: a grant left behind would
1047
1129
  // authorize the NEXT run's subagents for tools that run never named.
1048
1130
  releaseChildSpend();
@@ -1108,6 +1190,7 @@ export class Harbor {
1108
1190
  defaultModel: loadHarborConfig().defaultModel,
1109
1191
  parseSpec,
1110
1192
  log,
1193
+ account: this.account,
1111
1194
  onClosed: (id) => this.liveTasks.delete(id),
1112
1195
  // A live spawn's feed lives only in the attached app; when the session ends
1113
1196
  // (closed, reaped, or timed out) its answer would otherwise be gone. Seal the
@@ -1401,6 +1484,12 @@ export function runHarbor(): void {
1401
1484
  };
1402
1485
  process.on("SIGINT", shutdown);
1403
1486
  process.on("SIGTERM", shutdown);
1487
+ // A resident service: one stray rejection from a fire-and-forget promise (a relay
1488
+ // callback, a run started from IPC) must not take every routine down with it — and,
1489
+ // under launchd/systemd, restart into the same state and do it again. Log it instead.
1490
+ process.on("unhandledRejection", (reason) => {
1491
+ log(`unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
1492
+ });
1404
1493
  harbor.start().catch(async (err) => {
1405
1494
  if (err instanceof HarborAlreadyRunningError) {
1406
1495
  // A resident harbor already owns this machine — leave it in charge. Exit 0 so a
@@ -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
+ }