privateer-agent 0.12.58 → 0.14.0
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 +77 -3
- package/bin/headless-flags.d.mts +7 -0
- package/bin/headless-flags.mjs +40 -0
- package/bin/privateer-launch.mjs +11 -1
- package/extensions/privateer-brand.ts +15 -10
- package/extensions/privateer-gate.ts +23 -1
- package/extensions/privateer-speak.ts +9 -1
- package/extensions/privateer-terminals.ts +73 -0
- package/package.json +3 -2
- package/src/acp/run.ts +29 -4
- package/src/acp/server.ts +0 -0
- package/src/channels/run.ts +6 -1
- package/src/config/moatManifest.json +1 -0
- package/src/config/whatsNew.json +10 -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 +143 -58
- package/src/permissions/classify.ts +34 -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/liveTaskSession.ts +18 -2
- package/src/terminals/backends.ts +376 -0
- package/src/terminals/index.ts +200 -0
- package/src/terminals/registry.ts +107 -0
- package/src/tools/terminal.ts +80 -0
|
@@ -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
|
+
}
|
package/src/harbor/index.ts
CHANGED
|
@@ -64,6 +64,8 @@ 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";
|
|
67
69
|
import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/hosted.ts";
|
|
68
70
|
import { relayExposureAllowed } from "../config/relayExposure.ts";
|
|
69
71
|
import { WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
@@ -134,6 +136,11 @@ const GATE_TIMEOUT_MS = 5 * 60_000;
|
|
|
134
136
|
// endpoint or an expired OAuth token from stalling a scheduled run.
|
|
135
137
|
const WARM_TIMEOUT_MS = Number(process.env.PRIVATEER_MCP_WARM_MS) || 30_000;
|
|
136
138
|
const WARM_POLL_MS = 250;
|
|
139
|
+
// Hard ceiling on one unattended run (a routine, a submitted task, a workflow agent
|
|
140
|
+
// step). Nothing else bounds a provider stream that stops sending without closing, and a
|
|
141
|
+
// run stuck there holds its routine's `running` slot until the harbor restarts — the
|
|
142
|
+
// routine silently never fires again. Live spawns have their own cap (liveTaskSession).
|
|
143
|
+
const RUN_TIMEOUT_MS = Number(process.env.PRIVATEER_RUN_TIMEOUT_MS) || 30 * 60_000;
|
|
137
144
|
|
|
138
145
|
// Distinguishes concurrent runs in the child-spend registry. A counter rather than a
|
|
139
146
|
// timestamp: two runs starting in the same millisecond would share a key, and the second
|
|
@@ -177,6 +184,12 @@ function log(msg: string): void {
|
|
|
177
184
|
process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
|
|
178
185
|
}
|
|
179
186
|
|
|
187
|
+
// Identity of a queued outbox item, for removing the ones a flush delivered from a
|
|
188
|
+
// queue that may have grown while it was posting.
|
|
189
|
+
function pendingKey(p: PendingCloud): string {
|
|
190
|
+
return JSON.stringify([p.routine, p.at, p.kind ?? "routine", p.status, p.content]);
|
|
191
|
+
}
|
|
192
|
+
|
|
180
193
|
// Connector notes are things the RUN could not do — a selected connector that isn't
|
|
181
194
|
// configured, or one that wouldn't hand over a tool list. They ride the result
|
|
182
195
|
// because the alternative is the failure mode RoutineIssues exists to prevent: an
|
|
@@ -271,6 +284,25 @@ export class Harbor {
|
|
|
271
284
|
// Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
|
|
272
285
|
// terminal (task-<uuid>); the harbor just keeps handles so it can reap them on shutdown.
|
|
273
286
|
private readonly liveTasks = new Map<string, LiveTaskHandle>();
|
|
287
|
+
// The one account inference session every concurrent run shares — see accountLease.ts.
|
|
288
|
+
private readonly account = createAccountLease({
|
|
289
|
+
mint: async () => {
|
|
290
|
+
const creds = await acquireAccountCredential();
|
|
291
|
+
await persistAccountCredential(creds);
|
|
292
|
+
rememberAccountCredential(creds); // claim it, so the teardown drops OUR entry only
|
|
293
|
+
},
|
|
294
|
+
revoke: async () => {
|
|
295
|
+
try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
|
|
296
|
+
// Ownership-checked: an interactive terminal on this machine shares auth.json,
|
|
297
|
+
// and its entry must survive a harbor run's teardown (see providers/account.ts).
|
|
298
|
+
try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
|
|
299
|
+
},
|
|
300
|
+
log,
|
|
301
|
+
});
|
|
302
|
+
// The cloud-outbox flush in progress, if any. Every tick starts one; without this two
|
|
303
|
+
// could post the same items (duplicates in the Inbox) and the slower one's save would
|
|
304
|
+
// overwrite what the faster one left.
|
|
305
|
+
private flushing?: Promise<void>;
|
|
274
306
|
|
|
275
307
|
// App-facing routine management (list/save/delete/pause/run) over the harbor's
|
|
276
308
|
// relay. Run-now is injected here since only the harbor can actually fire one;
|
|
@@ -633,22 +665,33 @@ export class Harbor {
|
|
|
633
665
|
return sealToOutbox(name, at, status, content, kind, media, source);
|
|
634
666
|
}
|
|
635
667
|
|
|
636
|
-
private
|
|
668
|
+
private flushPendingCloud(): Promise<void> {
|
|
669
|
+
this.flushing ??= this.doFlushPendingCloud()
|
|
670
|
+
.catch((e) => log(`outbox flush failed: ${e instanceof Error ? e.message : String(e)}`))
|
|
671
|
+
.finally(() => {
|
|
672
|
+
this.flushing = undefined;
|
|
673
|
+
});
|
|
674
|
+
return this.flushing;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private async doFlushPendingCloud(): Promise<void> {
|
|
637
678
|
if (!hasCredentials()) return;
|
|
638
679
|
const queue = loadPendingCloud();
|
|
639
680
|
if (queue.length === 0) return;
|
|
640
|
-
const
|
|
681
|
+
const sent = new Set<string>();
|
|
641
682
|
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`);
|
|
683
|
+
// Stop at the first failure: order is preserved, and whatever broke this one
|
|
684
|
+
// (offline, no outbox key yet) will break the rest too.
|
|
685
|
+
if (!(await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? [], p.source))) break;
|
|
686
|
+
sent.add(pendingKey(p));
|
|
651
687
|
}
|
|
688
|
+
if (sent.size === 0) return;
|
|
689
|
+
// Re-read rather than save what we loaded: a run that finished while we were
|
|
690
|
+
// posting queued its result in the meantime, and overwriting with our snapshot
|
|
691
|
+
// would delete it.
|
|
692
|
+
const remaining = loadPendingCloud().filter((p) => !sent.has(pendingKey(p)));
|
|
693
|
+
savePendingCloud(remaining);
|
|
694
|
+
log(`flushed ${sent.size} pending cloud outbox item(s); ${remaining.length} remaining`);
|
|
652
695
|
}
|
|
653
696
|
|
|
654
697
|
private primeSchedule(): void {
|
|
@@ -778,32 +821,56 @@ export class Harbor {
|
|
|
778
821
|
const toInbox = routine.delivery.includes("cloud");
|
|
779
822
|
const media = toInbox ? new ResultMedia() : undefined;
|
|
780
823
|
|
|
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)"}`);
|
|
824
|
+
let status: "ok" | "error" = "error";
|
|
825
|
+
let error: string | undefined;
|
|
826
|
+
let delivered: string[] = [];
|
|
827
|
+
try {
|
|
828
|
+
const run = await this.runSession({
|
|
829
|
+
prompt: withBrief(routine.prompt, { inbox: toInbox, canAttach: !!media }),
|
|
830
|
+
cwd: routine.cwd,
|
|
831
|
+
model: modelSpec,
|
|
832
|
+
tools: routine.tools ?? [],
|
|
833
|
+
media,
|
|
834
|
+
});
|
|
835
|
+
({ status, error } = run);
|
|
798
836
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
837
|
+
const content = formatResult(routine, run.out, status, error, run.notes);
|
|
838
|
+
try {
|
|
839
|
+
const report = await deliver(routine, content, status, {
|
|
840
|
+
media: media?.list() ?? [],
|
|
841
|
+
pushRelay: this.pushRelay,
|
|
842
|
+
pushCloud: this.pushCloud,
|
|
843
|
+
webhooks: config.webhooks,
|
|
844
|
+
redact: (text) => redactText(text, collectSecrets(config.providers)),
|
|
845
|
+
});
|
|
846
|
+
delivered = report.delivered;
|
|
847
|
+
} catch (e) {
|
|
848
|
+
// A full disk, an unwritable output dir. The run happened and was paid for; say
|
|
849
|
+
// so in the log and on the routine rather than throwing past the schedule
|
|
850
|
+
// advance below — that throw is what used to re-fire (and re-bill) the routine
|
|
851
|
+
// on every restart.
|
|
852
|
+
error = `delivery failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
853
|
+
status = "error";
|
|
854
|
+
}
|
|
855
|
+
log(` "${routine.name}" ${status}; delivered via ${delivered.join(", ") || "(none)"}${error && delivered.length === 0 ? ` (${error})` : ""}`);
|
|
856
|
+
} finally {
|
|
857
|
+
// Always advance the schedule and free the slot, whatever threw above. Without the
|
|
858
|
+
// finally, one exception left the routine marked running forever (it never fired
|
|
859
|
+
// again) — or, as an unhandled rejection, crashed the harbor before nextRun moved,
|
|
860
|
+
// so the restart fired it again straight away.
|
|
861
|
+
try {
|
|
862
|
+
this.persistRun(routine.id, {
|
|
863
|
+
lastRun: new Date().toISOString(),
|
|
864
|
+
lastStatus: status,
|
|
865
|
+
lastError: error,
|
|
866
|
+
...advanceAfterRun(routine),
|
|
867
|
+
});
|
|
868
|
+
} catch (e) {
|
|
869
|
+
log(` couldn't record the run of "${routine.name}": ${e instanceof Error ? e.message : String(e)}`);
|
|
870
|
+
}
|
|
871
|
+
this.running.delete(routine.id);
|
|
872
|
+
}
|
|
873
|
+
return { ok: status === "ok", message: delivered.join(", ") || undefined };
|
|
807
874
|
}
|
|
808
875
|
|
|
809
876
|
// Build the Pi services for one unattended run: the auto-approve gate, the account
|
|
@@ -831,6 +898,11 @@ export class Harbor {
|
|
|
831
898
|
allowedOutsideRoots: [],
|
|
832
899
|
cwd,
|
|
833
900
|
confineToCwd: true,
|
|
901
|
+
// Bypass mode lets anything through that the allow-list registered — including
|
|
902
|
+
// reads outside cwd and of protected files, which bypass allows outright. A run
|
|
903
|
+
// nobody is watching, that may well have fetched a hostile web page, must not
|
|
904
|
+
// read ~/.ssh or our own auth.json and put it in the next URL: refuse both.
|
|
905
|
+
hardConfine: true,
|
|
834
906
|
async localAsk() {
|
|
835
907
|
return "deny";
|
|
836
908
|
},
|
|
@@ -954,7 +1026,7 @@ export class Harbor {
|
|
|
954
1026
|
await new Promise((r) => setTimeout(r, WARM_POLL_MS));
|
|
955
1027
|
}
|
|
956
1028
|
} finally {
|
|
957
|
-
session
|
|
1029
|
+
await closeSession(session);
|
|
958
1030
|
}
|
|
959
1031
|
} catch (e) {
|
|
960
1032
|
log(` connector warm-up failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -977,7 +1049,8 @@ export class Harbor {
|
|
|
977
1049
|
let out = "";
|
|
978
1050
|
let status: "ok" | "error" = "ok";
|
|
979
1051
|
let error: string | undefined;
|
|
980
|
-
let
|
|
1052
|
+
let leasedAccount = false;
|
|
1053
|
+
let session: any;
|
|
981
1054
|
const resolved = await this.resolveRunTools(spec.tools, spec.cwd, spec.model);
|
|
982
1055
|
// attach_to_result rides with the staging area rather than the allow-list: it is
|
|
983
1056
|
// how the answer is DELIVERED, not a capability the run gains. A routine that
|
|
@@ -997,14 +1070,10 @@ export class Harbor {
|
|
|
997
1070
|
|
|
998
1071
|
const { provider, modelId } = parseSpec(spec.model);
|
|
999
1072
|
if (provider === "privateer") {
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
spawnedAccount = true;
|
|
1005
|
-
} catch (e) {
|
|
1006
|
-
log(` account channel unavailable: ${(e as Error).message}`);
|
|
1007
|
-
}
|
|
1073
|
+
// Shared with every other run in flight — see accountLease.ts. A failed mint is
|
|
1074
|
+
// logged by the lease; the run goes ahead and fails on auth with that context.
|
|
1075
|
+
leasedAccount = true;
|
|
1076
|
+
await this.account.acquire();
|
|
1008
1077
|
}
|
|
1009
1078
|
|
|
1010
1079
|
const model = (modelRegistryOf(services) as any).find(provider, modelId);
|
|
@@ -1012,12 +1081,12 @@ export class Harbor {
|
|
|
1012
1081
|
status = "error";
|
|
1013
1082
|
error = `model ${provider}/${modelId} not found`;
|
|
1014
1083
|
} else {
|
|
1015
|
-
|
|
1084
|
+
({ session } = await createAgentSessionFromServices({
|
|
1016
1085
|
services,
|
|
1017
1086
|
sessionManager: SessionManager.inMemory(spec.cwd),
|
|
1018
1087
|
model,
|
|
1019
1088
|
tools: allowedTools,
|
|
1020
|
-
} as any);
|
|
1089
|
+
} as any));
|
|
1021
1090
|
const adapter = createEngineEventAdapter();
|
|
1022
1091
|
session.subscribe((ev: any) => {
|
|
1023
1092
|
for (const ee of adapter.toEngineEvents(ev)) {
|
|
@@ -1028,21 +1097,30 @@ export class Harbor {
|
|
|
1028
1097
|
}
|
|
1029
1098
|
}
|
|
1030
1099
|
});
|
|
1031
|
-
|
|
1100
|
+
let timedOut = false;
|
|
1101
|
+
const deadline = setTimeout(() => {
|
|
1102
|
+
timedOut = true;
|
|
1103
|
+
void Promise.resolve(session.abort?.()).catch(() => {});
|
|
1104
|
+
}, RUN_TIMEOUT_MS);
|
|
1105
|
+
try {
|
|
1106
|
+
await session.prompt(spec.prompt);
|
|
1107
|
+
} finally {
|
|
1108
|
+
clearTimeout(deadline);
|
|
1109
|
+
}
|
|
1110
|
+
if (timedOut) {
|
|
1111
|
+
status = "error";
|
|
1112
|
+
error = `run stopped after ${Math.round(RUN_TIMEOUT_MS / 60_000)} minutes (PRIVATEER_RUN_TIMEOUT_MS)`;
|
|
1113
|
+
}
|
|
1032
1114
|
}
|
|
1033
1115
|
} catch (err) {
|
|
1034
1116
|
status = "error";
|
|
1035
1117
|
error = err instanceof Error ? err.message : String(err);
|
|
1036
1118
|
} 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
|
-
}
|
|
1119
|
+
if (session) await closeSession(session);
|
|
1120
|
+
// Drop this run's reference to the shared account session; the last run out
|
|
1121
|
+
// revokes it (and its persisted copy). The harbor's own child API session —
|
|
1122
|
+
// relay/outbox — is separate and stays alive until shutdown.
|
|
1123
|
+
if (leasedAccount) await this.account.release();
|
|
1046
1124
|
// Drop this run's child grant. Unconditional and last: a grant left behind would
|
|
1047
1125
|
// authorize the NEXT run's subagents for tools that run never named.
|
|
1048
1126
|
releaseChildSpend();
|
|
@@ -1108,6 +1186,7 @@ export class Harbor {
|
|
|
1108
1186
|
defaultModel: loadHarborConfig().defaultModel,
|
|
1109
1187
|
parseSpec,
|
|
1110
1188
|
log,
|
|
1189
|
+
account: this.account,
|
|
1111
1190
|
onClosed: (id) => this.liveTasks.delete(id),
|
|
1112
1191
|
// A live spawn's feed lives only in the attached app; when the session ends
|
|
1113
1192
|
// (closed, reaped, or timed out) its answer would otherwise be gone. Seal the
|
|
@@ -1401,6 +1480,12 @@ export function runHarbor(): void {
|
|
|
1401
1480
|
};
|
|
1402
1481
|
process.on("SIGINT", shutdown);
|
|
1403
1482
|
process.on("SIGTERM", shutdown);
|
|
1483
|
+
// A resident service: one stray rejection from a fire-and-forget promise (a relay
|
|
1484
|
+
// callback, a run started from IPC) must not take every routine down with it — and,
|
|
1485
|
+
// under launchd/systemd, restart into the same state and do it again. Log it instead.
|
|
1486
|
+
process.on("unhandledRejection", (reason) => {
|
|
1487
|
+
log(`unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
|
|
1488
|
+
});
|
|
1404
1489
|
harbor.start().catch(async (err) => {
|
|
1405
1490
|
if (err instanceof HarborAlreadyRunningError) {
|
|
1406
1491
|
// A resident harbor already owns this machine — leave it in charge. Exit 0 so a
|
|
@@ -2,7 +2,7 @@ import { resolve, isAbsolute, relative, dirname, join, basename } from "node:pat
|
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { isProtectedPath } from "./protected.ts";
|
|
5
|
+
import { isProtectedPath, isCredentialStorePath } from "./protected.ts";
|
|
6
6
|
import type { PermissionRequest } from "./gate.ts";
|
|
7
7
|
|
|
8
8
|
// NEW glue for the Pi rewrite. In 0.2 each tool built its own PermissionRequest
|
|
@@ -19,6 +19,11 @@ export interface ScopeOptions {
|
|
|
19
19
|
cwd: string;
|
|
20
20
|
confineToCwd?: boolean; // default true
|
|
21
21
|
allowedOutsideRoots?: string[];
|
|
22
|
+
// Gate a READ of a protected file or credential store (isCredentialStorePath) even
|
|
23
|
+
// inside cwd, flagged `protected`. Off by default — an interactive session reading
|
|
24
|
+
// its own project's .env is normal. The harbor's unattended runs turn it on and pair
|
|
25
|
+
// it with GateController.hardConfine, which refuses anything flagged.
|
|
26
|
+
guardSecretReads?: boolean;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
// Resolve symlinks so an in-cwd symlink can't smuggle a path outside scope past the
|
|
@@ -619,13 +624,16 @@ export function classifyToolCall(
|
|
|
619
624
|
const p = firstPath(obj);
|
|
620
625
|
if (!p) return null; // e.g. grep with no explicit path → in-cwd, no gate
|
|
621
626
|
const abs = resolveInCwd(scope.cwd, p);
|
|
622
|
-
|
|
627
|
+
const outside = isOutsideScope(scope, abs);
|
|
628
|
+
const secret = scope.guardSecretReads === true && (isProtectedPath(abs) || isCredentialStorePath(abs));
|
|
629
|
+
if (!outside && !secret) return null;
|
|
623
630
|
return {
|
|
624
631
|
tool: toolName,
|
|
625
632
|
kind: "read",
|
|
626
|
-
title: "Read outside working directory",
|
|
633
|
+
title: secret ? "Read a protected file" : "Read outside working directory",
|
|
627
634
|
detail: abs,
|
|
628
|
-
outside
|
|
635
|
+
outside,
|
|
636
|
+
...(secret ? { protected: true } : {}),
|
|
629
637
|
path: abs,
|
|
630
638
|
};
|
|
631
639
|
}
|
|
@@ -648,6 +656,28 @@ export function classifyToolCall(
|
|
|
648
656
|
};
|
|
649
657
|
}
|
|
650
658
|
|
|
659
|
+
// terminal (src/tools/terminal.ts) — named windows the user watches. Opening one
|
|
660
|
+
// with a command RUNS that command, so it is bash-kind with the command as the
|
|
661
|
+
// detail: the same danger list, allowlist and plan-mode refusal as typing it into
|
|
662
|
+
// bash. A bare shell is bash-kind too (it puts a new window on the user's screen),
|
|
663
|
+
// and so is close, which ends whatever runs in the window. Focus and list only move
|
|
664
|
+
// a window to the front or read names: no gate, in any mode.
|
|
665
|
+
if (name === "terminal") {
|
|
666
|
+
const action = str(obj.action) || "list";
|
|
667
|
+
if (action === "focus" || action === "list") return null;
|
|
668
|
+
const label = str(obj.name);
|
|
669
|
+
if (action === "close") {
|
|
670
|
+
return { tool: toolName, kind: "bash", title: `Close terminal "${label}"`, detail: `close terminal ${label}` };
|
|
671
|
+
}
|
|
672
|
+
const command = str(obj.command).trim();
|
|
673
|
+
return {
|
|
674
|
+
tool: toolName,
|
|
675
|
+
kind: "bash",
|
|
676
|
+
title: command ? `Open terminal "${label}" running a command` : `Open terminal "${label}"`,
|
|
677
|
+
detail: command || `open a shell${obj.cwd ? ` in ${str(obj.cwd)}` : ""}`,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
651
681
|
// Network reads (web fetch / search / http).
|
|
652
682
|
if (name.includes("fetch") || name.includes("web") || name.includes("http") || name.includes("url")) {
|
|
653
683
|
return {
|
|
@@ -37,6 +37,15 @@ export function looksLikeSecretExfil(command: string): boolean {
|
|
|
37
37
|
return SECRET_FILE.test(command) && NETWORK_SINK.test(command);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Does this command look like it can move bytes off the machine? Private mode refuses
|
|
41
|
+
// these outright (permissions/privateMode.ts). Same heuristic as the exfil check, plus
|
|
42
|
+
// the git and package-manager verbs that upload: a regex over the raw text, not a shell
|
|
43
|
+
// parser, so it is a net, not a proof.
|
|
44
|
+
const UPLOAD_VERB = /\bgit\s+push\b|\b(npm|pnpm|yarn)\s+publish\b/i;
|
|
45
|
+
export function reachesNetwork(command: string): boolean {
|
|
46
|
+
return NETWORK_SINK.test(command) || UPLOAD_VERB.test(command);
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
export function matchesDenylist(command: string, patterns: string[]): boolean {
|
|
41
50
|
return patterns.some((src) => {
|
|
42
51
|
if (!src.trim()) return false;
|
|
@@ -69,6 +69,12 @@ export interface ModeGateDeps {
|
|
|
69
69
|
// budget RECORDS what it allows — asking it about a call the mode would refuse anyway
|
|
70
70
|
// would spend budget on nothing.
|
|
71
71
|
isSpendPreauthorized?: (req: PermissionRequest) => boolean | Promise<boolean>;
|
|
72
|
+
// Refuse every request that leaves cwd or touches a protected file, whatever the mode.
|
|
73
|
+
// For the harbor's unattended runs, which run in `bypass` (their safety is the tool
|
|
74
|
+
// allow-list) — and bypass allows outside/protected outright (mode.ts), which left
|
|
75
|
+
// their `confineToCwd` meaning nothing. Sits below no-quarter only: an operator who
|
|
76
|
+
// launched with --no-quarter asked for no gate at all.
|
|
77
|
+
hardConfine?: boolean;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
// The permission gate used by the live TUI. It first applies the mode/allowlist
|
|
@@ -85,6 +91,8 @@ export class ModeGate implements PermissionGate {
|
|
|
85
91
|
// first check so nothing below can force an "ask" back on.
|
|
86
92
|
if (this.deps.getSkipAllPermissions?.()) return "allow";
|
|
87
93
|
|
|
94
|
+
if (this.deps.hardConfine && (req.outside || req.protected)) return "deny";
|
|
95
|
+
|
|
88
96
|
const denylist = this.deps.denylist ?? [];
|
|
89
97
|
const auto = decideAuto(req, this.deps.getMode(), this.deps.allowlist, denylist);
|
|
90
98
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Private mode — the contract another agent relies on when it hands Privateer data it
|
|
2
|
+
// must not see itself.
|
|
3
|
+
//
|
|
4
|
+
// The handoff: an agent running on a model that is NOT private (a hosted frontier model,
|
|
5
|
+
// say) has a task that touches sensitive files. It keeps them out of its own context by
|
|
6
|
+
// asking Privateer to do the reading: `privateer -p --private "…"`, or an ACP session
|
|
7
|
+
// with `acp.private`. That only works if Privateer refuses to put those files in front of
|
|
8
|
+
// a model that isn't private either. A config that quietly fell back to a BYO-key model,
|
|
9
|
+
// a sealed shim that never came up, an attestation that came back yellow: each would
|
|
10
|
+
// send the very data the caller was keeping out of its own provider to someone else's.
|
|
11
|
+
//
|
|
12
|
+
// So private mode is enforced where the data enters the model: at the TOOL CALL. Before
|
|
13
|
+
// any tool runs, the session's current model must resolve to a verified-private tier —
|
|
14
|
+
// `tee-verified` (an enclave quote we checked) or `local` (on this machine). Anything
|
|
15
|
+
// else, including a check that throws, blocks the tool. The caller's own prompt still
|
|
16
|
+
// reaches the model, by design: it is the caller's text, and the caller already holds it.
|
|
17
|
+
// What never reaches an unverified model is anything Privateer READ on the caller's
|
|
18
|
+
// behalf.
|
|
19
|
+
//
|
|
20
|
+
// The second half is egress. A private model does not make a tool call private: a
|
|
21
|
+
// web_fetch carrying a file's contents leaves the machine no matter where inference ran.
|
|
22
|
+
// Private mode therefore runs on an ALLOWLIST of tools that stay on this machine, and
|
|
23
|
+
// refuses shell commands that reach the network, before any mode or approval is
|
|
24
|
+
// consulted — so no-quarter, `acp.posture: "auto"` and an approval cannot lift it.
|
|
25
|
+
//
|
|
26
|
+
// Turned on by `--private` (the launcher sets PRIVATEER_PRIVATE=1) or `acp.private`.
|
|
27
|
+
// The env var is honoured from the ambient environment on purpose: unlike the spend
|
|
28
|
+
// grants it can only ever make a run STRICTER, so an orchestrator may export it once
|
|
29
|
+
// for every Privateer it spawns. Subagent children inherit it with the rest of the env.
|
|
30
|
+
//
|
|
31
|
+
// IMPORT-SAFETY: the gate imports this module statically, and the gate must stay
|
|
32
|
+
// import-order-safe, so the Pi-touching posture code is imported dynamically below.
|
|
33
|
+
|
|
34
|
+
import type { PrivacyTier } from "pi-privacy";
|
|
35
|
+
import { reachesNetwork } from "./danger.ts";
|
|
36
|
+
|
|
37
|
+
export const PRIVATE_ENV = "PRIVATEER_PRIVATE";
|
|
38
|
+
|
|
39
|
+
/** True when the environment asks for private mode. */
|
|
40
|
+
export function privateModeFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
41
|
+
const v = (env[PRIVATE_ENV] ?? "").trim().toLowerCase();
|
|
42
|
+
return v === "1" || v === "true" || v === "yes";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The tiers a sensitive file may be shown to. Nothing weaker, and nothing asserted. */
|
|
46
|
+
export const PRIVATE_TIERS: ReadonlySet<PrivacyTier> = new Set<PrivacyTier>(["tee-verified", "local"]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tools that keep their effects on this machine. Everything else (web, crypto lookups,
|
|
50
|
+
* media generation, MCP, app/library uploads, routines, screen control) is refused in
|
|
51
|
+
* private mode, because each one can carry what was read to a third party. `bash` and
|
|
52
|
+
* `terminal` are here but also pass through reachesNetwork().
|
|
53
|
+
*/
|
|
54
|
+
export const PRIVATE_TOOLS: ReadonlySet<string> = new Set([
|
|
55
|
+
"read",
|
|
56
|
+
"grep",
|
|
57
|
+
"find",
|
|
58
|
+
"ls",
|
|
59
|
+
"write",
|
|
60
|
+
"edit",
|
|
61
|
+
"bash",
|
|
62
|
+
"terminal",
|
|
63
|
+
// Children inherit PRIVATEER_PRIVATE and run this same check against their own model.
|
|
64
|
+
"subagent",
|
|
65
|
+
"contact_supervisor",
|
|
66
|
+
"intercom",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
export interface ModelRef {
|
|
70
|
+
provider?: string;
|
|
71
|
+
id?: string;
|
|
72
|
+
baseUrl?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TTL_MS = 60_000;
|
|
76
|
+
const tierCache = new Map<string, { tier: PrivacyTier; at: number }>();
|
|
77
|
+
|
|
78
|
+
/** Test seam: swap how a model's tier is resolved. */
|
|
79
|
+
let resolveTierImpl: (m: Required<Pick<ModelRef, "provider" | "id">> & ModelRef) => Promise<PrivacyTier> = defaultResolveTier;
|
|
80
|
+
export function setTierResolverForTests(fn: typeof resolveTierImpl | undefined): void {
|
|
81
|
+
resolveTierImpl = fn ?? defaultResolveTier;
|
|
82
|
+
tierCache.clear();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function defaultResolveTier(m: Required<Pick<ModelRef, "provider" | "id">> & ModelRef): Promise<PrivacyTier> {
|
|
86
|
+
// The account channel's verdict is ours to give (see config/privacyPolicy.ts
|
|
87
|
+
// resolveTier); every other provider is pi-privacy's, which attests TEE providers live
|
|
88
|
+
// and never returns tee-verified from a static claim.
|
|
89
|
+
if (m.provider === "privateer") {
|
|
90
|
+
const { accountPosture } = await import("../providers/account.ts");
|
|
91
|
+
return (await accountPosture(m.id)).tier;
|
|
92
|
+
}
|
|
93
|
+
const { verifyModelPosture } = await import("pi-privacy");
|
|
94
|
+
const apiKey = m.provider === "nearai" ? process.env.NEARAI_API_KEY : undefined;
|
|
95
|
+
return (await verifyModelPosture(m.provider, m.id, { baseUrl: m.baseUrl, apiKey })).tier;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The model's privacy tier, from a short cache. A failure is not a tier — it throws. */
|
|
99
|
+
export async function modelTier(model: ModelRef): Promise<PrivacyTier> {
|
|
100
|
+
if (!model.provider || !model.id) throw new Error("no model selected");
|
|
101
|
+
const key = `${model.provider}/${model.id}@${model.baseUrl ?? ""}`;
|
|
102
|
+
const hit = tierCache.get(key);
|
|
103
|
+
if (hit && Date.now() - hit.at < TTL_MS) return hit.tier;
|
|
104
|
+
const tier = await resolveTierImpl({ ...model, provider: model.provider, id: model.id });
|
|
105
|
+
tierCache.set(key, { tier, at: Date.now() });
|
|
106
|
+
return tier;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const label = (m: ModelRef | undefined) => (m?.provider && m?.id ? `${m.provider}/${m.id}` : "no model");
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Why this tool call may not run in private mode, or undefined when it may. Never
|
|
113
|
+
* throws: any failure to establish privacy is itself the refusal.
|
|
114
|
+
*/
|
|
115
|
+
export async function privateRefusal(toolName: string, input: unknown, model: ModelRef | undefined): Promise<string | undefined> {
|
|
116
|
+
if (!PRIVATE_TOOLS.has(toolName)) {
|
|
117
|
+
return `${toolName} is unavailable in private mode: it can carry what this session has read off this machine. Work with local files only.`;
|
|
118
|
+
}
|
|
119
|
+
if (toolName === "bash" || toolName === "terminal") {
|
|
120
|
+
const obj = (input ?? {}) as Record<string, unknown>;
|
|
121
|
+
const command = String(obj.command ?? obj.cmd ?? obj.script ?? "");
|
|
122
|
+
if (reachesNetwork(command)) {
|
|
123
|
+
return `This command reaches the network, which private mode refuses: it could carry what this session has read off this machine. Use local commands only.`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let tier: PrivacyTier;
|
|
127
|
+
try {
|
|
128
|
+
tier = await modelTier(model ?? {});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return `Private mode could not verify ${label(model)} (${e instanceof Error ? e.message : String(e)}), so no tool may run: its output would reach a model that isn't proven private. Stop and report this; do not retry.`;
|
|
131
|
+
}
|
|
132
|
+
if (!PRIVATE_TIERS.has(tier)) {
|
|
133
|
+
return `Private mode: ${label(model)} is "${tier}", not a verified-private model, so no tool may run: its output would reach a model that isn't proven private. Stop and report this; do not retry.`;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|