privateer-agent 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -3
- package/bin/headless-flags.d.mts +7 -0
- package/bin/headless-flags.mjs +40 -0
- package/bin/model-args.d.mts +9 -0
- package/bin/model-args.mjs +43 -0
- package/bin/privateer-channels.mjs +30 -0
- package/bin/privateer-launch.mjs +31 -6
- package/extensions/privateer-brand.ts +15 -10
- package/extensions/privateer-gate.ts +58 -2
- package/extensions/privateer-hints.ts +3 -2
- package/package.json +2 -1
- package/src/acp/run.ts +29 -4
- package/src/acp/server.ts +0 -0
- package/src/channels/run.ts +30 -6
- package/src/cli/channelsCli.ts +67 -0
- package/src/config/mediaModels.ts +3 -2
- package/src/config/piiAllow.ts +3 -2
- package/src/config/whatsNew.json +10 -0
- package/src/engine/stallWatchdog.ts +61 -0
- package/src/ext/permissionGate.ts +21 -0
- package/src/harbor/accountLease.ts +76 -0
- package/src/harbor/closeSession.ts +20 -0
- package/src/harbor/index.ts +147 -58
- package/src/harbor/logRotate.ts +31 -0
- package/src/harbor/service.ts +202 -75
- package/src/permissions/classify.ts +12 -4
- package/src/permissions/danger.ts +9 -0
- package/src/permissions/modeGate.ts +8 -0
- package/src/permissions/privateMode.ts +136 -0
- package/src/permissions/protected.ts +36 -1
- package/src/remote/channelsControl.ts +4 -3
- package/src/remote/liveTaskSession.ts +18 -2
- package/src/remote/mcpControl.ts +4 -3
- package/src/routines/store.ts +41 -12
- package/src/util/atomicWrite.ts +46 -0
package/src/harbor/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
683
|
+
const sent = new Set<string>();
|
|
641
684
|
for (const p of queue) {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
)
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
+
}
|