privateer-agent 0.9.2 → 0.10.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/src/acp/run.ts ADDED
@@ -0,0 +1,376 @@
1
+ // Headless ACP entry — `privateer acp`.
2
+ //
3
+ // An ACP host (Buzz's `buzz-acp`, Zed, …) spawns this process and drives it over
4
+ // newline-delimited JSON-RPC on stdio. This file is the Pi-side wiring; the protocol
5
+ // surface is ./server.ts and the pure mappings are ./protocol.ts.
6
+ //
7
+ // ⚠️ STDOUT IS THE PROTOCOL. Anything written to stdout that isn't a JSON-RPC frame
8
+ // corrupts the stream and the host drops the connection — usually with an opaque
9
+ // parse error. Every diagnostic here goes to STDERR, which hosts capture as the
10
+ // agent's log. This is the single easiest way to break an ACP agent, so the rule is
11
+ // absolute: no console.log, no process.stdout.write, anywhere under this entry.
12
+ //
13
+ // SECURITY POSTURE. The host renders approvals; it does not grant them. Privateer's
14
+ // permission gate still classifies every action, `localAsk` is a fail-closed deny,
15
+ // an unparseable or cancelled answer is a deny, and `tools` is a hard ceiling the
16
+ // host cannot widen. See src/acp/server.ts:askOverAcp.
17
+ //
18
+ // Config lives in ~/.privateer/config.json (the same file the harbor reads):
19
+ // {
20
+ // "acp": {
21
+ // "model": "openrouter/openai/gpt-4o-mini", // optional
22
+ // "tools": ["read","grep","find","ls"], // optional ceiling
23
+ // "posture": "approve", // readonly | approve | auto
24
+ // "cwd": "/path/to/project" // optional; else this process's cwd
25
+ // }
26
+ // }
27
+ //
28
+ // ⚠️ THE WORKING DIRECTORY IS PROCESS-WIDE, NOT PER SESSION. `baseCwd` is fixed at
29
+ // startup from `acp.cwd` (or, absent that, whatever cwd the host spawned us with),
30
+ // and it is what BOTH the tools and the permission gate use. The `cwd` a host sends
31
+ // in `session/new` is effectively ignored: we hand it to `SessionManager.inMemory`,
32
+ // but Pi's `createAgentSessionFromServices` passes `services.cwd` to
33
+ // `createAgentSession`, and `options.cwd` wins over `sessionManager.getCwd()` — so
34
+ // the session manager's copy never reaches a tool.
35
+ //
36
+ // This is currently FAIL-SAFE and must stay that way: gate and tools agree on one
37
+ // root, so there is no split-brain where the gate judges a path in one directory
38
+ // while a tool acts in another. Do NOT "fix" this by threading the host's cwd into
39
+ // `SessionManager` alone — that would move the tools without moving the gate, which
40
+ // is precisely the class of bug that made `~/…` paths bypass confinement. Honouring
41
+ // a per-session cwd properly means building services + gate per session, or scoping
42
+ // `gate.cwd` through an AsyncLocalStorage the way `turnCtx` scopes approvals.
43
+
44
+ import "../boot.ts"; // env + attestation dispatcher, before any Pi import
45
+ import { WEB_TOOL_NAMES } from "../tools/web.ts";
46
+
47
+ // Read-only by default, exactly as the channels runtime and the routines harbor do:
48
+ // a turn nobody is watching must not be able to mutate the filesystem or shell out
49
+ // until a human widens it in config.
50
+ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
51
+ const WEB_TOOLS: string[] = [...WEB_TOOL_NAMES];
52
+
53
+ type Posture = "readonly" | "approve" | "auto";
54
+ const POSTURES: Posture[] = ["readonly", "approve", "auto"];
55
+
56
+ function normalizePosture(v: unknown): Posture | undefined {
57
+ return typeof v === "string" && (POSTURES as string[]).includes(v) ? (v as Posture) : undefined;
58
+ }
59
+
60
+ // STDERR only — see the header.
61
+ function log(msg: string): void {
62
+ process.stderr.write(`[${new Date().toISOString()}] acp: ${msg}\n`);
63
+ }
64
+
65
+ function parseSpec(spec: string): { provider: string; modelId: string } {
66
+ const i = spec.indexOf(":");
67
+ const j = spec.indexOf("/");
68
+ const sep = i === -1 ? j : j === -1 ? i : Math.min(i, j);
69
+ if (sep <= 0) return { provider: spec, modelId: "" };
70
+ return { provider: spec.slice(0, sep), modelId: spec.slice(sep + 1) };
71
+ }
72
+
73
+ export async function runAcp(): Promise<void> {
74
+ const { readFileSync } = await import("node:fs");
75
+ // Aliased: `resolve` is already the model-spec resolver below.
76
+ const { resolve: resolveFsPath } = await import("node:path");
77
+ const { Readable, Writable } = await import("node:stream");
78
+ const { AgentSideConnection, ndJsonStream } = await import("@zed-industries/agent-client-protocol");
79
+ const { createAgentSessionServices, createAgentSessionFromServices, SessionManager } = await import(
80
+ "@earendil-works/pi-coding-agent"
81
+ );
82
+ const { createEngineEventAdapter } = await import("../bridge/engineAdapter.ts");
83
+ const { makePermissionGate } = await import("../ext/permissionGate.ts");
84
+ type GateController = import("../ext/permissionGate.ts").GateController;
85
+ const { makePiPrivacyExtension } = await import("pi-privacy");
86
+ const { makeAccountProvider, privateerChannel, rememberAccountCredential, dropPersistedAccountCredential } =
87
+ await import("../providers/account.ts");
88
+ const { hasCredentials, acquireAccountCredential, revokeAccountSession } = await import("../auth/privateer.ts");
89
+ const { makeWebTools } = await import("../tools/web.ts");
90
+ const { webEnabled } = await import("../config/hosted.ts");
91
+ const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
92
+ const { agentDir, configPath } = await import("../config/paths.ts");
93
+ const { PrivateerAcpAgent, askOverAcp } = await import("./server.ts");
94
+ type AcpSession = import("./server.ts").AcpSession;
95
+ type TurnEvents = import("./server.ts").TurnEvents;
96
+
97
+ let cfg: any = {};
98
+ try {
99
+ cfg = JSON.parse(readFileSync(configPath(), "utf8"));
100
+ } catch {
101
+ /* no config is fine — defaults below are safe */
102
+ }
103
+ const block = cfg.acp ?? {};
104
+ const defaultModel: string = resolveDefaultModel({ explicit: block.model ?? cfg.defaultModel });
105
+ const web = webEnabled();
106
+ const tools: string[] = Array.isArray(block.tools) && block.tools.length
107
+ ? (web ? block.tools : block.tools.filter((t: string) => !WEB_TOOLS.includes(t)))
108
+ : (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
109
+ const posture: Posture = normalizePosture(block.posture) ?? "approve";
110
+ const baseCwd: string = block.cwd ?? process.cwd();
111
+
112
+ // The gate. Identical posture semantics to the channels runtime: "readonly" maps to
113
+ // plan mode (hard-deny writes), "auto" relaxes non-dangerous actions, and every ask
114
+ // routes REMOTELY — to the ACP host — because there is no terminal here. localAsk
115
+ // stays a deny so a missing host can never mean "allowed".
116
+ const gate: GateController = {
117
+ getMode: () => (posture === "readonly" ? "plan" : "default"),
118
+ setMode: () => {},
119
+ allowlist: [],
120
+ allowedOutsideRoots: [],
121
+ cwd: baseCwd,
122
+ confineToCwd: true,
123
+ getRemote: () => true,
124
+ getNoQuarter: () => posture === "auto",
125
+ async localAsk() {
126
+ return "deny";
127
+ },
128
+ async remoteAsk(req, signal) {
129
+ if (posture === "readonly") return "deny"; // read-only: deny outright, don't prompt
130
+ return askOverAcp(req, signal);
131
+ },
132
+ };
133
+
134
+ const services = await createAgentSessionServices({
135
+ cwd: baseCwd,
136
+ agentDir: agentDir(),
137
+ resourceLoaderOptions: {
138
+ // ⚠️ LOAD THE MOAT ONCE. ~/.privateer/agent/extensions holds shims for the
139
+ // interactive TUI's extensions, which Pi auto-discovers into every session
140
+ // built against that agentDir. privateer-gate.ts is one of them, and it
141
+ // installs its OWN permission gate wired to the relay bridge that only
142
+ // `/remote-access` ever attaches. In this process that bridge is permanently
143
+ // unattached, so its gate fails closed and DENIES every action before ours is
144
+ // ever consulted — the host is never asked, and the model is told "denied by
145
+ // the permission gate" with no prompt shown anywhere.
146
+ //
147
+ // Verified live: without this, `bash` was denied and remoteAsk never fired.
148
+ //
149
+ // So: discover nothing, and load exactly the moat we want as in-code
150
+ // factories below. Same shape as bin/privateer-subagent.mjs's
151
+ // `--no-extensions -e …`, and for the same reason. This disables discovered
152
+ // MCP too; an ACP host supplies its own MCP servers in session/new, which is
153
+ // the more correct source anyway.
154
+ noExtensions: true,
155
+ extensionFactories: [
156
+ makePermissionGate(gate),
157
+ makePiPrivacyExtension({
158
+ privateerVerifiedTee: (m: any) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
159
+ }),
160
+ makeAccountProvider(),
161
+ ...(webEnabled() ? [makeWebTools()] : []),
162
+ ] as any,
163
+ },
164
+ });
165
+
166
+ const registry = services.modelRegistry as any;
167
+ const resolve = (spec: string) => {
168
+ const { provider, modelId } = parseSpec(spec);
169
+ return registry.find(provider, modelId) ?? null;
170
+ };
171
+
172
+ const model = resolve(defaultModel);
173
+ if (!model) {
174
+ log(`model "${defaultModel}" not found — check the spec and provider keys`);
175
+ process.exit(1);
176
+ }
177
+
178
+ // The id we hand the host must round-trip back through parseSpec, so it is the
179
+ // same "<provider>:<modelId>" spec the config file uses. Anything else and
180
+ // session/set_model would hand us back a string we can't resolve.
181
+ const specOf = (m: any): string => `${m.provider}:${m.id}`;
182
+
183
+ // Every model with credentials configured — the same set /models offers. Marking
184
+ // the confidential ones matters here: in a shared channel the humans reading the
185
+ // picker cannot otherwise tell which choices keep their prompts inside a TEE.
186
+ function listModels(): { available: any[]; currentModelId: string } | undefined {
187
+ try {
188
+ const byId = new Map<string, any>();
189
+ // Models with an API key configured in authStorage.
190
+ for (const m of (registry.getAvailable?.() ?? []) as any[]) byId.set(specOf(m), m);
191
+ // …but the ACCOUNT provider authenticates with a child token rather than a
192
+ // stored apiKey, so getAvailable() omits every privateer/* model — including,
193
+ // absurdly, the default one we are already running on. Add back everything
194
+ // from the current model's provider: if that provider works for the model in
195
+ // use, its siblings are reachable too. This is what puts the confidential
196
+ // TEE models in the host's picker at all.
197
+ for (const m of (registry.getAll?.() ?? []) as any[]) {
198
+ if (m.provider === model.provider) byId.set(specOf(m), m);
199
+ }
200
+ // Belt and braces: whatever else is true, the model we booted with is selectable.
201
+ byId.set(specOf(model), model);
202
+
203
+ const all = [...byId.values()];
204
+ if (all.length === 0) return undefined;
205
+ const available = all.map((m) => {
206
+ const confidential = m.provider === "privateer" && privateerChannel(m.id ?? "") === "tee";
207
+ return {
208
+ modelId: specOf(m),
209
+ name: m.name ?? m.id,
210
+ description: [m.provider, confidential ? "confidential (TEE)" : undefined]
211
+ .filter(Boolean)
212
+ .join(" · "),
213
+ };
214
+ });
215
+ return { available, currentModelId: specOf(model) };
216
+ } catch (e) {
217
+ log(`could not list models: ${e instanceof Error ? e.message : String(e)}`);
218
+ return undefined;
219
+ }
220
+ }
221
+
222
+ // ── arm the account channel ───────────────────────────────────────────────────
223
+ //
224
+ // A `privateer/*` model runs on the account subscription, not an API key. Nothing
225
+ // arms that automatically here: the TUI does it from an interactive hook and the
226
+ // harbor does it explicitly per run (harbor/index.ts:958). Without this the model
227
+ // call fails with Pi's "This terminal isn't signed in to Privateer" — which is
228
+ // misleading, because the login is fine; it's the SESSION that never armed.
229
+ //
230
+ // This bit us silently: early runs worked only because a `privateer` entry left in
231
+ // ~/.privateer/agent/auth.json by a previous TUI session happened to still be
232
+ // there. The moment anything revoked it, every ACP turn started failing.
233
+ let armedAccount = false;
234
+ async function ensureAccountArmed(providerName: string): Promise<void> {
235
+ if (armedAccount || providerName !== "privateer") return;
236
+ if (!hasCredentials()) {
237
+ log("not signed in to Privateer — run `privateer` and /login, or pick a BYO-key model");
238
+ return;
239
+ }
240
+ try {
241
+ const creds = await acquireAccountCredential();
242
+ (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
243
+ rememberAccountCredential(creds); // claim it, so teardown drops OUR entry only
244
+ armedAccount = true;
245
+ log("account channel armed");
246
+ } catch (e) {
247
+ log(`account channel unavailable: ${e instanceof Error ? e.message : String(e)}`);
248
+ }
249
+ }
250
+ await ensureAccountArmed(model.provider);
251
+
252
+ const modelCount = listModels()?.available.length ?? 0;
253
+ log(
254
+ `ready — model ${defaultModel}, ${modelCount} selectable, ` +
255
+ `ceiling [${tools.join(", ")}], posture ${posture}`,
256
+ );
257
+
258
+ // One Pi session per ACP session. A single subscription routes streamed text into a
259
+ // mutable holder, which the running turn owns — safe because a session runs at most
260
+ // one turn at a time (enforced in PrivateerAcpAgent.prompt).
261
+ // NOTE `cwd` is the host's per-session directory and is NOT authoritative — see the
262
+ // header. It is passed to SessionManager for bookkeeping only; tools and the gate
263
+ // both run at `baseCwd`. Logged on session creation so a mismatch is visible rather
264
+ // than silent.
265
+ async function createSession(cwd: string, mcpServers: unknown[] = []): Promise<AcpSession> {
266
+ if (cwd && resolveFsPath(cwd) !== resolveFsPath(baseCwd)) {
267
+ log(`host asked for cwd ${cwd} but this process is confined to ${baseCwd} — using ${baseCwd}`);
268
+ }
269
+ // NOT SUPPORTED YET, and said out loud rather than dropped in silence. A host
270
+ // may offer MCP servers in session/new; we can't connect them because
271
+ // `noExtensions: true` (the fix for the discovered-gate collision) also
272
+ // disables the MCP adapter. A user whose connector never appears deserves a
273
+ // reason in the log instead of a mystery.
274
+ if (Array.isArray(mcpServers) && mcpServers.length > 0) {
275
+ log(`ignoring ${mcpServers.length} MCP server(s) offered by the host — not supported on the ACP path yet`);
276
+ }
277
+ const { session } = await createAgentSessionFromServices({
278
+ services,
279
+ sessionManager: SessionManager.inMemory(cwd || baseCwd),
280
+ model,
281
+ tools,
282
+ } as any);
283
+ const adapter = createEngineEventAdapter();
284
+ const noop: TurnEvents = { onText: () => {}, onToolStart: () => {}, onToolEnd: () => {} };
285
+ const holder: { events: TurnEvents; error?: string } = { events: noop };
286
+ session.subscribe((ev: any) => {
287
+ for (const ee of adapter.toEngineEvents(ev)) {
288
+ if (ee.type === "text") holder.events.onText(ee.text);
289
+ else if (ee.type === "error") holder.error = ee.error;
290
+ // Tool activity goes BOTH to the host (rendered as live progress — without
291
+ // it a minute of tool work looks like a hang) and to stderr, where it is
292
+ // the only way to tell a gate denial apart from the model simply choosing
293
+ // not to call a tool. The model will happily narrate a refusal it invented.
294
+ else if (ee.type === "tool-call") {
295
+ log(`tool → ${ee.name}`);
296
+ holder.events.onToolStart({ id: ee.id, name: ee.name });
297
+ } else if (ee.type === "tool-error") {
298
+ const error = String(ee.error).slice(0, 200);
299
+ log(`tool ✗ ${ee.name}: ${error}`);
300
+ holder.events.onToolEnd({ id: ee.id, name: ee.name, error });
301
+ } else if (ee.type === "tool-result") {
302
+ log(`tool ✓ ${ee.name}`);
303
+ holder.events.onToolEnd({ id: ee.id, name: ee.name });
304
+ }
305
+ }
306
+ });
307
+
308
+ return {
309
+ async setModel(spec: string) {
310
+ const next = resolve(spec);
311
+ if (!next) throw new Error(`unknown model: ${spec}`);
312
+ // Switching INTO the account channel needs it armed too — a session that
313
+ // started on a BYO-key model and moved to privateer/* would otherwise fail
314
+ // with the same misleading "not signed in".
315
+ await ensureAccountArmed(next.provider);
316
+ await session.setModel(next);
317
+ },
318
+ async prompt(text, events, signal) {
319
+ holder.events = events;
320
+ holder.error = undefined;
321
+ try {
322
+ await session.prompt(text);
323
+ } catch (e) {
324
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
325
+ } finally {
326
+ holder.events = noop;
327
+ }
328
+ if (signal.aborted) return { ok: false, error: "cancelled" };
329
+ return holder.error ? { ok: false, error: holder.error } : { ok: true };
330
+ },
331
+ };
332
+ }
333
+
334
+ const input = Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>;
335
+ const output = Writable.toWeb(process.stdout) as unknown as WritableStream<Uint8Array>;
336
+ const stream = ndJsonStream(output, input);
337
+
338
+ let agent: InstanceType<typeof PrivateerAcpAgent> | undefined;
339
+ new AgentSideConnection((conn) => {
340
+ agent = new PrivateerAcpAgent(conn, { createSession, models: listModels, onLog: log });
341
+ return agent;
342
+ }, stream);
343
+
344
+ // The host closing stdin is the shutdown signal.
345
+ let shuttingDown = false;
346
+ const shutdown = async () => {
347
+ if (shuttingDown) return; // stdin emits both "end" and "close"
348
+ shuttingDown = true;
349
+ log("host disconnected — shutting down");
350
+ await agent?.shutdown();
351
+ // Release this process's account inference session. The drop is OWNERSHIP-CHECKED
352
+ // (providers/account.ts), which is what lets an interactive terminal — or another
353
+ // ACP process, since a host may run several in parallel — keep its own auth.json
354
+ // entry through our teardown instead of being signed out by it.
355
+ if (armedAccount) {
356
+ try {
357
+ await revokeAccountSession();
358
+ } catch {
359
+ /* best effort — the server-side TTL is the fallback */
360
+ }
361
+ try {
362
+ dropPersistedAccountCredential({ modelRegistry: { authStorage: services.authStorage } } as any);
363
+ } catch {
364
+ /* nothing persisted */
365
+ }
366
+ }
367
+ process.exit(0);
368
+ };
369
+ process.stdin.on("end", () => void shutdown());
370
+ process.stdin.on("close", () => void shutdown());
371
+ process.on("SIGINT", () => void shutdown());
372
+ process.on("SIGTERM", () => void shutdown());
373
+
374
+ // Hold the process open; the connection lives on the stdio streams.
375
+ await new Promise<void>(() => {});
376
+ }
Binary file