privateer-agent 0.12.13 → 0.12.14

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.
@@ -117,6 +117,99 @@ export function isAccountCapCode(code: string | null | undefined): boolean {
117
117
  return typeof code === "string" && CAP_CODE.test(code);
118
118
  }
119
119
 
120
+ // ── Oversized / non-API error bodies ─────────────────────────────────────────
121
+ //
122
+ // An inference endpoint does not always answer as an API. Put a WAF, a proxy or a
123
+ // captive portal in front of one and a rejected request comes back as an HTML page,
124
+ // which the provider SDK folds whole into `error.message` — status first, body after.
125
+ //
126
+ // The incident this exists for: the account channel's edge WAF answered a turn with a
127
+ // 403 block page carrying three inline base64 web fonts, so `errorMessage` was 221 KB.
128
+ // Pi printed it into the terminal in full, appended it to the session file on every
129
+ // attempt (a 1.8 MB session), and ran its transient-error regex over it — and a
130
+ // megabyte of base64 reliably contains "429", "500", "502", so a permanent 403 looked
131
+ // retryable and burned the whole retry budget before the user saw anything.
132
+ //
133
+ // Both halves are fixed where Pi reads the message (see the patch in
134
+ // patches/@earendil-works+pi-coding-agent+*.patch, which mirrors these two helpers):
135
+ // squeeze the page down to the line a person can act on, and let the STATUS decide
136
+ // retryability rather than a substring of the body.
137
+
138
+ /** Hard cap on an error message we display, persist, or classify. */
139
+ export const MAX_ERROR_CHARS = 2_000;
140
+
141
+ /** How much of an HTML page's visible text is worth keeping. */
142
+ const MAX_PAGE_TEXT_CHARS = 600;
143
+
144
+ const HTML_DOC = /<!doctype html|<html[\s>]/i;
145
+
146
+ /** Tags whose contents are never prose: markup, styling, or a logo. */
147
+ const NON_PROSE = /<(script|style|svg|head|noscript)\b[\s\S]*?<\/\1\s*>/gi;
148
+
149
+ const ENTITIES: Record<string, string> = {
150
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'", "#x27": "'",
151
+ };
152
+
153
+ function plainText(html: string): string {
154
+ return html
155
+ .replace(/<[^>]*>/g, " ")
156
+ .replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (m, code: string) => {
157
+ const key = code.toLowerCase();
158
+ if (ENTITIES[key] !== undefined) return ENTITIES[key];
159
+ if (key.startsWith("#x")) return String.fromCodePoint(parseInt(key.slice(2), 16) || 0) || m;
160
+ if (key.startsWith("#")) return String.fromCodePoint(parseInt(key.slice(1), 10) || 0) || m;
161
+ return m;
162
+ })
163
+ .replace(/\s+/g, " ")
164
+ .trim();
165
+ }
166
+
167
+ /**
168
+ * Reduce a provider error message to something worth showing, storing and classifying.
169
+ *
170
+ * An HTML page collapses to its status, its <title> and its visible text — which is
171
+ * where a WAF puts the one detail the user needs to report ("Request ID: …"). Anything
172
+ * else over the cap is truncated. Text already short and non-HTML is returned unchanged,
173
+ * so ordinary provider errors pass through untouched.
174
+ */
175
+ export function compactProviderError(raw: string): string {
176
+ const text = typeof raw === "string" ? raw : String(raw ?? "");
177
+ if (!HTML_DOC.test(text)) {
178
+ if (text.length <= MAX_ERROR_CHARS) return text;
179
+ return `${text.slice(0, MAX_ERROR_CHARS)}… [dropped ${text.length - MAX_ERROR_CHARS} chars]`;
180
+ }
181
+
182
+ const status = /^\s*(\d{3})\b/.exec(text)?.[1];
183
+ const title = plainText(/<title[^>]*>([\s\S]*?)<\/title>/i.exec(text)?.[1] ?? "");
184
+ const body = plainText(text.replace(NON_PROSE, " "));
185
+ const visible = [title, body].filter(Boolean).join(" — ").slice(0, MAX_PAGE_TEXT_CHARS);
186
+
187
+ return (
188
+ `${status ?? "HTTP error"} — an HTML page, not an API response (something in front of ` +
189
+ `the provider answered: a WAF, a proxy, or a captive portal): ` +
190
+ `${visible || "(no readable text)"} [dropped ${text.length} chars of HTML]`
191
+ );
192
+ }
193
+
194
+ // Client-error statuses that CAN clear on their own: a timeout, a lock conflict, an
195
+ // early-data replay, a throttle. Every other 4xx is the request itself being wrong.
196
+ const TRANSIENT_CLIENT_STATUS = new Set([408, 409, 425, 429]);
197
+
198
+ /**
199
+ * True when the message opens with an HTTP status that retrying cannot clear.
200
+ *
201
+ * Both provider paths put the status first — the OpenAI-shaped SDK builds
202
+ * `"403 <body>"`, pi-messages builds `"403 Forbidden: <body>"` — so the status is a
203
+ * fact we can read, where a substring of the body is only a guess. 5xx and messages
204
+ * with no leading status are left to the caller's own classifier.
205
+ */
206
+ export function isHardHttpFailure(text: string | null | undefined): boolean {
207
+ const m = /^\s*(\d{3})\b/.exec(typeof text === "string" ? text : "");
208
+ if (!m) return false;
209
+ const status = Number(m[1]);
210
+ return status >= 400 && status < 500 && !TRANSIENT_CLIENT_STATUS.has(status);
211
+ }
212
+
120
213
  function rawMessage(err: unknown): string {
121
214
  if (err instanceof Error) return err.message;
122
215
  if (typeof err === "string") return err;
@@ -243,5 +336,8 @@ export function describeError(err: unknown): DescribedError {
243
336
  });
244
337
  }
245
338
 
246
- return out({ message: text });
339
+ // Unrecognized: show the provider's own words rather than swallow them — but an
340
+ // unrecognized error is exactly where a WAF block page or a megabyte of markup
341
+ // arrives, so it goes through the compactor first.
342
+ return out({ message: compactProviderError(text) });
247
343
  }
@@ -53,6 +53,11 @@ export interface GateController {
53
53
  // launch flag (env PRIVATEER_NO_QUARTER); when true the gate auto-allows every
54
54
  // action with no prompt.
55
55
  getSkipAllPermissions?(): boolean;
56
+ // Billing tools the operator authorized before this run started, so an unattended
57
+ // session can spend what it was told it may spend instead of denying every media
58
+ // call for want of a human. Lifts `alwaysAsk` and nothing else — see
59
+ // ModeGate.isSpendPreauthorized for the guards.
60
+ isSpendPreauthorized?(req: PermissionRequest): boolean;
56
61
  // Block a tool outright while the turn is remote-driven (only consulted when
57
62
  // getRemote() is true). For tools whose own prompts render on the host terminal
58
63
  // rather than the relay — e.g. pi-subagents — so a driven turn can't wedge on an
@@ -144,6 +149,7 @@ export async function decideToolCall(
144
149
  getNoQuarter: ctrl.getNoQuarter,
145
150
  getAutoApprove: ctrl.getAutoApprove,
146
151
  getSkipAllPermissions: ctrl.getSkipAllPermissions,
152
+ isSpendPreauthorized: ctrl.isSpendPreauthorized,
147
153
  });
148
154
 
149
155
  let decision: "allow" | "deny";
@@ -60,7 +60,7 @@ import { deliver, type RelayPusher, type CloudPusher } from "../routines/deliver
60
60
  import { ResultMedia, type StagedMedia } from "../routines/resultMedia.ts";
61
61
  import { withBrief } from "../routines/resultBrief.ts";
62
62
  import { ATTACH_RESULT_TOOL } from "../tools/attachResult.ts";
63
- import { postOutbox as sealToOutbox } from "../outbox/cloudOutbox.ts";
63
+ import { postOutbox as sealToOutbox, type OutboxSource } from "../outbox/cloudOutbox.ts";
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";
@@ -68,6 +68,8 @@ import { isHosted, publishRelayPub, webEnabled, mediaEnabled } from "../config/h
68
68
  import { WEB_TOOL_NAMES } from "../tools/web.ts";
69
69
  import { MEDIA_TOOL_NAMES } from "../tools/media.ts";
70
70
  import { COMPOSE_TOOL_NAMES } from "../tools/videoCompose.ts";
71
+ import { BILLED_MEDIA_TOOLS } from "../permissions/classify.ts";
72
+ import { grantChildSpend } from "../permissions/childSpend.ts";
71
73
 
72
74
  // The safe, read-only toolset for unattended runs — Pi builtins with no
73
75
  // write/edit/bash, so a routine firing with nobody watching can't mutate the
@@ -130,6 +132,11 @@ const GATE_TIMEOUT_MS = 5 * 60_000;
130
132
  const WARM_TIMEOUT_MS = Number(process.env.PRIVATEER_MCP_WARM_MS) || 30_000;
131
133
  const WARM_POLL_MS = 250;
132
134
 
135
+ // Distinguishes concurrent runs in the child-spend registry. A counter rather than a
136
+ // timestamp: two runs starting in the same millisecond would share a key, and the second
137
+ // to finish would release a grant the first still holds.
138
+ let runSeq = 0;
139
+
133
140
  interface HarborConfig {
134
141
  defaultModel: string;
135
142
  webhooks?: Record<string, { url: string; secret?: string; headers?: Record<string, string> }>;
@@ -212,6 +219,22 @@ function formatWorkflowResult(name: string, result: { status: string; output: Re
212
219
  return `${head}${body}${reason}`.trimEnd() + "\n";
213
220
  }
214
221
 
222
+ // What a routine ASKED for, sealed into the outbox envelope beside what it answered.
223
+ //
224
+ // The app can then act on a finished run — "now do X with this" — with the routine's
225
+ // own standing instruction and working directory in hand, instead of guessing from a
226
+ // summary. The routine never leaves this machine except inside the sealed blob (the
227
+ // server holds ciphertext), and cloudOutbox clips every field.
228
+ function routineSource(routine: Routine): OutboxSource {
229
+ return {
230
+ routineId: routine.id,
231
+ prompt: routine.prompt,
232
+ cwd: routine.cwd,
233
+ model: routine.model,
234
+ schedule: routine.cron ?? routine.at,
235
+ };
236
+ }
237
+
215
238
  // Canonical control-envelope args for a task_submit / task_spawn signature. MUST match
216
239
  // the app's signer (client/services/accountSign.ts) byte-for-byte: the SAME key set with
217
240
  // undefined → null, so the recursive-key-sorted JSON both sides sign is identical. A
@@ -292,12 +315,13 @@ export class Harbor {
292
315
 
293
316
  private readonly pushCloud: CloudPusher = async (routine, content, status, media = []) => {
294
317
  const at = new Date().toISOString();
295
- if (await this.postOutbox(routine.name, at, status, content, "routine", media)) return "sent";
318
+ const source = routineSource(routine);
319
+ if (await this.postOutbox(routine.name, at, status, content, "routine", media, source)) return "sent";
296
320
  // The queued copy keeps the attachment RECORDS, not the bytes: the files are
297
321
  // still on this disk, so a flush hours later re-reads them (and says so in the
298
322
  // body for any that have since gone). Buffering megabytes into a JSON queue
299
323
  // would be the same content twice, on a box that already has it.
300
- addPendingCloud({ routine: routine.name, at, status, content, ...(media.length ? { media } : {}) });
324
+ addPendingCloud({ routine: routine.name, at, status, content, source, ...(media.length ? { media } : {}) });
301
325
  return "queued";
302
326
  };
303
327
 
@@ -594,8 +618,9 @@ export class Harbor {
594
618
  content: string,
595
619
  kind: OutboxKind = "routine",
596
620
  media: StagedMedia[] = [],
621
+ source?: OutboxSource,
597
622
  ): Promise<boolean> {
598
- return sealToOutbox(name, at, status, content, kind, media);
623
+ return sealToOutbox(name, at, status, content, kind, media, source);
599
624
  }
600
625
 
601
626
  private async flushPendingCloud(): Promise<void> {
@@ -606,7 +631,7 @@ export class Harbor {
606
631
  for (const p of queue) {
607
632
  if (
608
633
  remaining.length === 0 &&
609
- (await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? []))
634
+ (await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine", p.media ?? [], p.source))
610
635
  ) continue;
611
636
  remaining.push(p);
612
637
  }
@@ -771,8 +796,14 @@ export class Harbor {
771
796
  // activation, so the build is serialized (serializeBuild) and the previous value is
772
797
  // restored. "__none__" is its sentinel for "register none": an unattended run with
773
798
  // no connector selectors gets no direct tools, whatever a shared mcp.json says.
774
- private buildSessionServices(cwd: string, directTools: string[], media?: ResultMedia): Promise<any> {
799
+ private buildSessionServices(
800
+ cwd: string,
801
+ directTools: string[],
802
+ media?: ResultMedia,
803
+ spendGrant: readonly string[] = [],
804
+ ): Promise<any> {
775
805
  return serializeBuild(async () => {
806
+ const granted = new Set(spendGrant);
776
807
  const gate: GateController = {
777
808
  getMode: () => "bypass",
778
809
  setMode: () => {},
@@ -783,6 +814,14 @@ export class Harbor {
783
814
  async localAsk() {
784
815
  return "deny";
785
816
  },
817
+ // The billing media tools THIS run was granted. Without this the run below is a
818
+ // contradiction: a routine may name generate_video (naming it is the operator's
819
+ // decision — see MEDIA_GEN_TOOLS above), the tool registers, and then every call
820
+ // is denied, because `alwaysAsk` outranks bypass and localAsk has nobody to ask.
821
+ // Scoped to this session's gate rather than a process-wide switch, so two
822
+ // concurrent runs can hold different grants. The gate still refuses to let a
823
+ // grant cover a call that leaves cwd or reads a protected file.
824
+ isSpendPreauthorized: (req) => granted.has(req.tool),
786
825
  };
787
826
  // Every extension this session gets, in the one canonical order — including the MCP
788
827
  // adapter (Phase 5), the web/media capability shaping, and the filter that ignores
@@ -927,8 +966,14 @@ export class Harbor {
927
966
  // is going to the user's own mailbox, which `delivery: cloud` already chose.
928
967
  const allowedTools = spec.media ? [...resolved.tools, ATTACH_RESULT_TOOL] : resolved.tools;
929
968
  const { directToolsEnv, notes } = resolved;
969
+ // The billing tools this run may use: the ones its own allow-list names, and no
970
+ // others. Authorizes the run's own calls (the gate, below) and its subagents' —
971
+ // children are a separate process and read the grant from the environment, released
972
+ // in the finally so it can never outlive the run that holds it.
973
+ const spendGrant = allowedTools.filter((t) => BILLED_MEDIA_TOOLS.has(t));
974
+ const releaseChildSpend = grantChildSpend(`run:${++runSeq}`, spendGrant);
930
975
  try {
931
- const services = await this.buildSessionServices(spec.cwd, directToolsEnv, spec.media);
976
+ const services = await this.buildSessionServices(spec.cwd, directToolsEnv, spec.media, spendGrant);
932
977
 
933
978
  const { provider, modelId } = parseSpec(spec.model);
934
979
  if (provider === "privateer") {
@@ -978,6 +1023,9 @@ export class Harbor {
978
1023
  // and its entry must survive a harbor run's teardown (see providers/account.ts).
979
1024
  try { await dropPersistedAccountCredential(); } catch { /* nothing persisted */ }
980
1025
  }
1026
+ // Drop this run's child grant. Unconditional and last: a grant left behind would
1027
+ // authorize the NEXT run's subagents for tools that run never named.
1028
+ releaseChildSpend();
981
1029
  }
982
1030
  return { out, status, error, notes };
983
1031
  }
@@ -1014,10 +1062,13 @@ export class Harbor {
1014
1062
  const content = redactText(formatTaskResult(title, out, status, error, modelSpec, notes), collectSecrets(config.providers));
1015
1063
  const at = new Date().toISOString();
1016
1064
  const staged = media.list();
1065
+ // What was asked, carried with what came back — a submitted task is followed up
1066
+ // on exactly like a routine run (the app's Inbox is the only place either is read).
1067
+ const source: OutboxSource = { prompt: spec.prompt, cwd, model: modelSpec };
1017
1068
  // Durable delivery: seal to the outbox. If we can't seal yet (no verified pubkey /
1018
1069
  // offline), queue it with kind:"task" so the flush re-seals it correctly later.
1019
- const sealed = await this.postOutbox(title, at, status, content, "task", staged);
1020
- if (!sealed) addPendingCloud({ routine: title, at, status, content, kind: "task", ...(staged.length ? { media: staged } : {}) });
1070
+ const sealed = await this.postOutbox(title, at, status, content, "task", staged, source);
1071
+ if (!sealed) addPendingCloud({ routine: title, at, status, content, kind: "task", source, ...(staged.length ? { media: staged } : {}) });
1021
1072
  // Live mirror if a controller is attached (the outbox copy is the source of truth).
1022
1073
  if (this.controllerAttached) this.relay?.sendTaskResult(title, content);
1023
1074
  log(` task "${title}" ${status}; ${sealed ? "sealed to outbox" : "queued for outbox"}`);
@@ -1046,8 +1097,11 @@ export class Harbor {
1046
1097
  void (async () => {
1047
1098
  const at = new Date().toISOString();
1048
1099
  const body = redactText(content, collectSecrets(loadHarborConfig().providers));
1049
- if (!(await this.postOutbox(title, at, status, body, "task"))) {
1050
- addPendingCloud({ routine: title, at, status, content: body, kind: "task" });
1100
+ // An empty-prompt spawn (a bare agent to drive) packs to no source at all
1101
+ // cloudOutbox drops it rather than shipping an empty object.
1102
+ const source: OutboxSource = { prompt: spec.prompt, cwd: spec.cwd, model: spec.model };
1103
+ if (!(await this.postOutbox(title, at, status, body, "task", [], source))) {
1104
+ addPendingCloud({ routine: title, at, status, content: body, kind: "task", source });
1051
1105
  }
1052
1106
  })();
1053
1107
  },
@@ -68,6 +68,62 @@ export interface OutboxMedia {
68
68
  blobId?: string;
69
69
  }
70
70
 
71
+ /**
72
+ * What the run was ASKED to do, carried beside what it answered.
73
+ *
74
+ * A result on its own is a dead end: the app can show it, read it aloud and file
75
+ * it, but "now book the top one" needs the standing instruction the run was given,
76
+ * and the directory it ran in, or the follow-up starts from a summary with no idea
77
+ * what produced it. That context is the routine — it lives on this machine and the
78
+ * app has never seen it — so it rides inside the SEALED envelope (the server holds
79
+ * ciphertext either way, exactly like `origin`).
80
+ *
81
+ * Every field is optional and clipped: this travels on every result, and a routine
82
+ * prompt can be arbitrarily long. Absent → the app falls back to the result body
83
+ * alone, which is what results from older CLIs carry.
84
+ */
85
+ export interface OutboxSource {
86
+ /** The saved routine's id, when one produced this. Absent for ad-hoc tasks. */
87
+ routineId?: string;
88
+ /** The instruction the run was given (a routine's `prompt`, a task's spec). */
89
+ prompt?: string;
90
+ /** Where the run executed — a follow-up belongs in the same directory. */
91
+ cwd?: string;
92
+ /** The "provider:model" the run resolved to. */
93
+ model?: string;
94
+ /** The trigger verbatim: a cron expression, or a one-off ISO datetime. */
95
+ schedule?: string;
96
+ }
97
+
98
+ /** Clip bounds for `source`. The prompt is the only field that can be long. */
99
+ export const MAX_SOURCE_PROMPT = 2_000;
100
+ const SOURCE_FIELD_CAPS: Record<keyof OutboxSource, number> = {
101
+ routineId: 200,
102
+ prompt: MAX_SOURCE_PROMPT,
103
+ cwd: 500,
104
+ model: 200,
105
+ schedule: 100,
106
+ };
107
+
108
+ /**
109
+ * Normalize a source for the envelope: trim, clip, drop empties — and return
110
+ * undefined when nothing survives, so a workflow (or an empty-prompt live spawn)
111
+ * doesn't ship an empty object and bump the envelope version for nothing.
112
+ * Exported for the round-trip test.
113
+ */
114
+ export function packSource(source?: OutboxSource): OutboxSource | undefined {
115
+ if (!source) return undefined;
116
+ const out: OutboxSource = {};
117
+ for (const [key, cap] of Object.entries(SOURCE_FIELD_CAPS) as [keyof OutboxSource, number][]) {
118
+ const raw = source[key];
119
+ if (typeof raw !== "string") continue;
120
+ const value = raw.trim();
121
+ if (!value) continue;
122
+ out[key] = value.length > cap ? value.slice(0, cap) + "…" : value;
123
+ }
124
+ return Object.keys(out).length > 0 ? out : undefined;
125
+ }
126
+
71
127
  let outboxPub: Uint8Array | undefined;
72
128
  let originCache: { id: string; label: string } | undefined;
73
129
 
@@ -216,6 +272,9 @@ function undeliveredNote(names: string[]): string {
216
272
  * BEFORE the message, so a message that lands always references bytes that exist;
217
273
  * if the message then fails, the blobs are dropped again and the caller's queued
218
274
  * retry re-uploads from the same paths on disk.
275
+ *
276
+ * `source` is what the run was asked to do (see OutboxSource) — the context a
277
+ * follow-up needs, sealed alongside the answer.
219
278
  */
220
279
  export async function postOutbox(
221
280
  name: string,
@@ -224,20 +283,28 @@ export async function postOutbox(
224
283
  content: string,
225
284
  kind: OutboxKind = "routine",
226
285
  staged: StagedMedia[] = [],
286
+ source?: OutboxSource,
227
287
  ): Promise<boolean> {
228
288
  const pub = await ensureOutboxPub();
229
289
  if (!pub) return false;
230
290
  let body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
231
291
 
292
+ // Packed before the media budget is struck, because it spends the same envelope:
293
+ // attachments must not be sized against room the source has already taken.
294
+ const packedSource = packSource(source);
295
+ const sourceCost = packedSource ? JSON.stringify(packedSource).length : 0;
296
+
232
297
  const { media, blobIds, undelivered } = staged.length
233
- ? await packMedia(pub, staged, MAX_ENVELOPE_PLAINTEXT - body.length - 2_000)
298
+ ? await packMedia(pub, staged, MAX_ENVELOPE_PLAINTEXT - body.length - sourceCost - 2_000)
234
299
  : { media: [] as OutboxMedia[], blobIds: [] as string[], undelivered: [] as string[] };
235
300
  body += undeliveredNote(undelivered);
236
301
 
237
- // v2 adds `media`. Older apps ignore the field and still render the body, which is
238
- // why the note above names undelivered files in prose rather than only in metadata.
302
+ // v2 adds `media`, v3 `source`. Both are additive: an older app ignores the field
303
+ // and still renders the body, which is why the note above names undelivered files
304
+ // in prose rather than only in metadata, and why a follow-up degrades to "the
305
+ // result text alone" rather than failing when `source` is missing.
239
306
  const envelope: Record<string, unknown> = {
240
- v: media.length > 0 ? 2 : 1,
307
+ v: packedSource ? 3 : media.length > 0 ? 2 : 1,
241
308
  kind,
242
309
  name,
243
310
  status,
@@ -245,6 +312,7 @@ export async function postOutbox(
245
312
  content: body,
246
313
  origin: machineOrigin(),
247
314
  ...(media.length > 0 ? { media } : {}),
315
+ ...(packedSource ? { source: packedSource } : {}),
248
316
  };
249
317
 
250
318
  let sealed = sealJson(pub, envelope);
@@ -0,0 +1,105 @@
1
+ // Handing an unattended run's SPEND AUTHORIZATION down to its subagent children.
2
+ //
3
+ // THE PROBLEM. A subagent is a fresh headless `pi` subprocess (pi-subagents, spawned
4
+ // through bin/privateer-subagent.mjs), so it inherits nothing from its parent except the
5
+ // environment and the `-e` moat the wrapper injects. Its gate therefore runs in bypass
6
+ // with a fail-closed asker: ordinary writes go through, and every `alwaysAsk` tool — which
7
+ // is every billing media tool — is denied, because there is no human on the other end of
8
+ // a headless child's stdin. So "one subagent per shot" worked in a terminal, where the
9
+ // child's ask relays up to the parent's TUI, and quietly could not work at all in the one
10
+ // place it matters most: a routine that fires at 3am to build a film.
11
+ //
12
+ // WHAT IS HANDED DOWN, AND WHY THAT IS NOT A HOLE. Only the names of billing tools the
13
+ // operator already authorized for this run, by naming them when they saved the routine —
14
+ // media generation is deliberately absent from the harbor's default allow-list precisely
15
+ // so that granting it is a decision someone made. The child gets no more than its parent
16
+ // has, and the gate still refuses to let a pre-authorization cover a call that leaves the
17
+ // working directory or touches a protected file (see ModeGate.isSpendPreauthorized).
18
+ //
19
+ // HONOURED ONLY IN A CHILD. `PRIVATEER_CHILD_SPEND` sitting in a developer's shell must
20
+ // never turn a TERMINAL into a session that bills without asking, so the child side reads
21
+ // it only when pi-subagents has marked this process as a child. A top-level session always
22
+ // asks its human, however cheap the call.
23
+ //
24
+ // CONCURRENCY, AND WHY THE UNION WOULD BE WRONG. The harbor daemon can have two unattended
25
+ // runs in flight at once (a scheduled routine and an app-submitted task), and children read
26
+ // the environment when they spawn — one process-wide variable, two different grants. Taking
27
+ // the union would let run B's child spend on a tool only run A was granted. So the exported
28
+ // value is the INTERSECTION of every grant currently in flight: exact when one run holds a
29
+ // grant (the overwhelmingly common case), and narrowing — fail-closed, with the gate's
30
+ // ordinary denial message — when two overlap. A child denied that way reports it plainly;
31
+ // a child over-granted would bill silently.
32
+
33
+ import { isSubagentChild } from "../remote/subagentRelay.ts";
34
+
35
+ /** The env var carrying a run's spend grant to its (possibly nested) children. */
36
+ export const CHILD_SPEND_ENV = "PRIVATEER_CHILD_SPEND";
37
+
38
+ // Grants currently in flight, keyed by the run that holds one. Module-level because the
39
+ // value it projects is process-wide (the environment) — one registry per daemon.
40
+ const active = new Map<string, ReadonlySet<string>>();
41
+
42
+ function publish(): void {
43
+ if (active.size === 0) {
44
+ delete process.env[CHILD_SPEND_ENV];
45
+ return;
46
+ }
47
+ const sets = [...active.values()];
48
+ const shared = [...sets[0]].filter((tool) => sets.every((s) => s.has(tool)));
49
+ if (shared.length === 0) delete process.env[CHILD_SPEND_ENV];
50
+ else process.env[CHILD_SPEND_ENV] = [...new Set(shared)].sort().join(",");
51
+ }
52
+
53
+ /**
54
+ * Advertise `tools` to children spawned while this run is in flight, and return the
55
+ * release. ALWAYS call the release in a `finally`: a grant that outlives its run would
56
+ * authorize the next one's children, which is the whole thing this is careful about.
57
+ * An empty/absent `tools` registers nothing, so a run with no media grant neither widens
58
+ * nor narrows what a concurrent run advertises.
59
+ */
60
+ export function grantChildSpend(runKey: string, tools: Iterable<string> | undefined): () => void {
61
+ const set = new Set([...(tools ?? [])].filter((t) => typeof t === "string" && t.trim() !== ""));
62
+ if (set.size === 0) return () => {};
63
+ active.set(runKey, set);
64
+ publish();
65
+ return () => {
66
+ active.delete(runKey);
67
+ publish();
68
+ };
69
+ }
70
+
71
+ /** Test seam: forget every in-flight grant (and the env var it projects). */
72
+ export function resetChildSpend(): void {
73
+ active.clear();
74
+ delete process.env[CHILD_SPEND_ENV];
75
+ }
76
+
77
+ /**
78
+ * The grant this process inherited — empty unless we ARE a subagent child, so a stray
79
+ * env var can never lower a terminal's gate. Parsed defensively: unknown names simply
80
+ * never match a tool, and the gate's own guards bound what a match can authorize.
81
+ */
82
+ export function inheritedChildSpend(env: NodeJS.ProcessEnv = process.env): ReadonlySet<string> {
83
+ if (!isSubagentChild()) return new Set();
84
+ const raw = env[CHILD_SPEND_ENV];
85
+ if (!raw) return new Set();
86
+ return new Set(
87
+ raw
88
+ .split(",")
89
+ .map((t) => t.trim())
90
+ .filter(Boolean),
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Is `tool` pre-authorized for this process? False everywhere but a child that was
96
+ * granted it. Shape matches GateController.isSpendPreauthorized's needs.
97
+ */
98
+ export function childSpendAllows(tool: string): boolean {
99
+ return inheritedChildSpend().has(tool);
100
+ }
101
+
102
+ /** Does this child hold any spend grant at all — i.e. should billing tools exist here? */
103
+ export function childHoldsSpendGrant(): boolean {
104
+ return inheritedChildSpend().size > 0;
105
+ }
@@ -118,6 +118,14 @@ function str(v: unknown): string {
118
118
  return typeof v === "string" ? v : v == null ? "" : String(v);
119
119
  }
120
120
 
121
+ // A path from a list entry that may be a bare string or an object carrying one —
122
+ // video_compose's `tracks` and `images` take both, and the tools open whichever arrives,
123
+ // so the classifier has to resolve whichever arrives. Miss the object form and
124
+ // `tracks: [{path: "~/.ssh/id_rsa"}]` reads a key with `outside` left false.
125
+ function nestedPath(entry: unknown): string {
126
+ return entry && typeof entry === "object" ? str((entry as Record<string, unknown>).path) : str(entry);
127
+ }
128
+
121
129
  function firstPath(input: Record<string, unknown>): string {
122
130
  return str(input.path ?? input.file_path ?? input.file ?? input.filename ?? input.dir ?? input.directory);
123
131
  }
@@ -166,8 +174,10 @@ const BASH_TOOLS = new Set(["bash", "shell", "run", "exec", "sh"]);
166
174
  const MEDIA_TOOLS = new Set([
167
175
  "generate_image",
168
176
  "generate_video",
177
+ "generate_model",
169
178
  "generate_speech",
170
179
  "generate_music",
180
+ "generate_sfx",
171
181
  "media_capabilities",
172
182
  "video_compose",
173
183
  ]);
@@ -181,17 +191,30 @@ const MEDIA_TOOLS = new Set([
181
191
  // remembered, so every generation is a fresh human decision. video_compose is
182
192
  // excluded on purpose — it is local ffmpeg, no egress and no spend — and
183
193
  // media_capabilities is a read.
184
- const MEDIA_GEN_TOOLS = new Set([
194
+ // Exported because it is the definition of "this call bills, so a human decides": the
195
+ // harbor reads it to work out which of a routine's granted tools its pre-authorization
196
+ // has to cover (harbor/index.ts), and a second hand-written copy of this list there
197
+ // would be one that drifts.
198
+ export const BILLED_MEDIA_TOOLS: ReadonlySet<string> = new Set([
185
199
  "generate_image",
186
200
  "generate_video",
201
+ "generate_model",
187
202
  "generate_speech",
188
203
  "generate_music",
204
+ "generate_sfx",
189
205
  ]);
190
206
  const MEDIA_TITLES: Record<string, string> = {
191
207
  generate_image: "Generate an image (billed to your Privateer account)",
192
208
  generate_video: "Generate a video (billed to your Privateer account)",
209
+ // The dearest of these per call, and the one whose price moves with the
210
+ // options, so the title says so out loud rather than leaving the human to
211
+ // work it out from a JSON blob of flags.
212
+ generate_model: "Generate a 3D model (billed; $0.14-$2.41 a mesh depending on the model)",
193
213
  generate_speech: "Generate speech (billed to your Privateer account)",
194
214
  generate_music: "Generate music (billed; music prompts have no zero-retention option)",
215
+ // Cheap per call and therefore the one most likely to be called twenty times in a
216
+ // row for a single cut, which is the number the title should let a human weigh.
217
+ generate_sfx: "Generate a sound effect (billed ~$0.02; effect models are non-ZDR)",
195
218
  video_compose: "Compose video/audio locally",
196
219
  media_capabilities: "Read media capabilities",
197
220
  };
@@ -225,6 +248,28 @@ export function classifyToolCall(
225
248
  };
226
249
  }
227
250
 
251
+ // Reading a routine back (src/tools/routineResult.ts): its stored instruction and
252
+ // its latest result. A READ, and it must be classified as one — the unknown-tool
253
+ // branch at the bottom would call it bash-kind, which prompts with a JSON blob and
254
+ // denies outright in plan/readonly, the very posture where "what did last night's
255
+ // run find?" is the most reasonable question there is.
256
+ //
257
+ // It still asks in default mode rather than returning null: the files live in
258
+ // ~/.privateer (outside any cwd) and can hold whatever the run collected, so
259
+ // pulling one into a session is the user's call, exactly as reading the file by
260
+ // hand would be. It takes a NAME, not a path, so there is no target to resolve —
261
+ // `outside` is therefore left unset (setting it would force a prompt even under
262
+ // acceptEdits for a read the user just asked for).
263
+ if (name === "read_routine_result") {
264
+ const label = str(obj.name);
265
+ return {
266
+ tool: toolName,
267
+ kind: "read",
268
+ title: "Read a routine's saved result",
269
+ detail: label ? `routine "${label}" (instruction + latest result)` : "list saved routines",
270
+ };
271
+ }
272
+
228
273
  // Media generation (src/tools/media.ts) and local composition (videoCompose.ts).
229
274
  //
230
275
  // Left to the unknown-tool branch at the bottom these classify as bash-kind, which
@@ -243,9 +288,22 @@ export function classifyToolCall(
243
288
  ...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
244
289
  str(obj.input),
245
290
  str(obj.audio),
246
- ...(Array.isArray(obj.images) ? (obj.images as unknown[]).map(str) : []),
291
+ // `images` is a list of paths for generate_video's reference stills and a list of
292
+ // OBJECTS for video_compose overlay_image's layers — both shapes have to resolve, or
293
+ // the nested one is a path the gate never judges.
294
+ ...(Array.isArray(obj.images) ? (obj.images as unknown[]).map(nestedPath) : []),
247
295
  str(obj.firstFrame),
248
296
  str(obj.lastFrame),
297
+ // video_compose mix_audio carries its paths INSIDE objects — one per placed
298
+ // track — and a nested path is one the gate would never judge: `tracks:
299
+ // [{path: "~/.ssh/id_rsa"}]` would read a key outside scope with `outside`
300
+ // left false. Bare strings are accepted here too because the tool accepts
301
+ // them, and the classifier must see whatever the tool will open.
302
+ ...(Array.isArray(obj.tracks) ? (obj.tracks as unknown[]).map(nestedPath) : []),
303
+ // overlay_text renders a font file into the frame, and burn_subtitles reads a whole
304
+ // subtitle file into it, so an out-of-scope or protected one is a read like any other.
305
+ str(obj.fontFile),
306
+ str(obj.subtitles),
249
307
  ].filter(Boolean);
250
308
  // Resolve each input once, then flag the two ways an input is sensitive: it leaves
251
309
  // the working directory, or it is a guarded file (.env, keys, credentials, …). The
@@ -297,7 +355,7 @@ export function classifyToolCall(
297
355
  detail: `${outputOutside ? absOut : outPath}${inputNote}`,
298
356
  protected: isProtectedPath(absOut) || protectedInputs.length > 0,
299
357
  outside,
300
- alwaysAsk: MEDIA_GEN_TOOLS.has(name),
358
+ alwaysAsk: BILLED_MEDIA_TOOLS.has(name),
301
359
  path: absOut,
302
360
  };
303
361
  }