privateer-agent 0.3.5 → 0.4.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.
Files changed (48) hide show
  1. package/README.md +19 -0
  2. package/bin/privateer-daemon.mjs +30 -0
  3. package/bin/privateer-subagent.mjs +68 -0
  4. package/bin/privateer-tui +35 -5
  5. package/extensions/privateer-brand.ts +213 -34
  6. package/extensions/privateer-context.ts +59 -0
  7. package/extensions/privateer-gate.ts +351 -6
  8. package/extensions/privateer-posture.ts +18 -2
  9. package/extensions/privateer-privacy.ts +50 -1
  10. package/package.json +4 -1
  11. package/src/auth/privateer.ts +151 -19
  12. package/src/channels/bridge.ts +293 -0
  13. package/src/channels/discord.ts +210 -0
  14. package/src/channels/run.ts +383 -0
  15. package/src/channels/slack.ts +176 -0
  16. package/src/channels/status.ts +54 -0
  17. package/src/channels/telegram.ts +139 -0
  18. package/src/channels/types.ts +36 -0
  19. package/src/channels/whatsapp.ts +178 -0
  20. package/src/cli/chat.ts +414 -28
  21. package/src/cli/daemonCli.ts +67 -0
  22. package/src/config/version.ts +16 -0
  23. package/src/context.ts +171 -0
  24. package/src/crypto/accountTrust.ts +113 -0
  25. package/src/crypto/accountVerify.ts +138 -0
  26. package/src/crypto/terminalKey.ts +95 -0
  27. package/src/crypto/terminalUnseal.ts +62 -0
  28. package/src/daemon/index.ts +522 -34
  29. package/src/daemon/service.ts +232 -0
  30. package/src/ext/permissionGate.ts +38 -0
  31. package/src/permissions/classify.ts +49 -5
  32. package/src/providers/account.ts +20 -12
  33. package/src/remote/channelsControl.ts +192 -0
  34. package/src/remote/controlAuth.ts +67 -0
  35. package/src/remote/extensionsControl.ts +140 -0
  36. package/src/remote/liveTaskSession.ts +218 -0
  37. package/src/remote/relayClient.ts +524 -0
  38. package/src/remote/remoteBridge.ts +172 -0
  39. package/src/remote/routinesControl.ts +216 -0
  40. package/src/remote/skillsControl.ts +205 -0
  41. package/src/remote/subagentChannel.ts +261 -0
  42. package/src/remote/subagentRelay.ts +126 -0
  43. package/src/remote/workflowsControl.ts +132 -0
  44. package/src/routines/store.ts +5 -1
  45. package/src/workflows/expr.ts +4 -0
  46. package/src/workflows/runner.ts +8 -0
  47. package/src/workflows/schema.ts +5 -0
  48. package/src/workflows/store.ts +108 -0
@@ -1,5 +1,7 @@
1
1
  import type { Server } from "node:net";
2
2
  import { readFileSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
3
5
  // Pi session stack. The daemon MUST be launched after ./boot.ts (env +
4
6
  // attestation dispatcher) — these are evaluated on import.
5
7
  import {
@@ -8,12 +10,25 @@ import {
8
10
  SessionManager,
9
11
  } from "@earendil-works/pi-coding-agent";
10
12
  import { agentDir, configPath } from "../config/paths.ts";
13
+ import { agentVersion } from "../config/version.ts";
11
14
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
12
15
  import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
13
16
  import { makePiPrivacyExtension } from "pi-privacy";
14
17
  import { makeAccountProvider } from "../providers/account.ts";
15
- import { RelayClient } from "../remote/relayClient.ts";
16
- import { hasCredentials, revokeChildSession, apiRequest, spawnAccountCredentials } from "../auth/privateer.ts";
18
+ import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
19
+ import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
20
+ import { makeRoutinesControl } from "../remote/routinesControl.ts";
21
+ import { makeChannelsControl } from "../remote/channelsControl.ts";
22
+ import { makeWorkflowsControl } from "../remote/workflowsControl.ts";
23
+ import { runWorkflow as executeWorkflow, type RunnerDeps, type AgentRunSpec, type AgentRunResult, type ScriptRunResult } from "../workflows/runner.ts";
24
+ import type { Workflow, Step } from "../workflows/schema.ts";
25
+ import { readRunningPlatforms } from "../channels/status.ts";
26
+ import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
27
+ import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
28
+ import { verifyChannelSave, verifyOutboxKey } from "../crypto/accountVerify.ts";
29
+ import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
30
+ import { authorizeControl } from "../remote/controlAuth.ts";
31
+ import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, spawnAccountCredentials, handleServerRevoke } from "../auth/privateer.ts";
17
32
  import {
18
33
  loadRoutines,
19
34
  upsertRoutine,
@@ -44,6 +59,10 @@ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
44
59
 
45
60
  const TICK_MS = 60_000; // scan for due routines once a minute
46
61
  const MAX_CLOUD_PLAINTEXT = 45_000;
62
+ // How long a workflow `human_gate` (or a script-approval prompt) waits for the app to
63
+ // answer before it fail-closes to "no response" (the runner then defers the run). Bounds
64
+ // a stuck graph from pinning a `running` slot forever when the controller wanders off.
65
+ const GATE_TIMEOUT_MS = 5 * 60_000;
47
66
 
48
67
  interface DaemonConfig {
49
68
  defaultModel: string;
@@ -87,6 +106,47 @@ function formatResult(routine: Routine, body: string, status: "ok" | "error", er
87
106
  return `${head}${body.trim() || "(no output)"}\n`;
88
107
  }
89
108
 
109
+ // A short human title for an ad-hoc task: the app's explicit title, else the first
110
+ // non-empty line of the prompt, clipped. Exported for the signed-args round-trip test.
111
+ export function deriveTaskTitle(spec: TaskSpec): string {
112
+ const explicit = spec.title?.trim();
113
+ if (explicit) return explicit.slice(0, 80);
114
+ const firstLine = spec.prompt.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "task";
115
+ return firstLine.slice(0, 80);
116
+ }
117
+
118
+ function formatTaskResult(title: string, body: string, status: "ok" | "error", error?: string, model?: string): string {
119
+ const when = new Date().toISOString();
120
+ const head = `# ${title}\n\n_${when} · ${status}${model ? ` · ${model}` : ""}_\n\n`;
121
+ if (status === "error") return `${head}**Task failed:** ${error ?? "unknown error"}\n\n${body}`.trimEnd() + "\n";
122
+ return `${head}${body.trim() || "(no output)"}\n`;
123
+ }
124
+
125
+ // Render a finished workflow run into the delivery/outbox markdown: the terminal status,
126
+ // the workflow-level `output:` (if any), and the halt reason for a failed/deferred run.
127
+ function formatWorkflowResult(name: string, result: { status: string; output: Record<string, unknown>; reason?: string }): string {
128
+ const when = new Date().toISOString();
129
+ const head = `# Workflow: ${name}\n\n_${when} · ${result.status}_\n\n`;
130
+ const body = Object.keys(result.output).length > 0 ? "```json\n" + JSON.stringify(result.output, null, 2) + "\n```\n" : "";
131
+ const reason = result.reason ? `\n${result.status === "success" ? "" : "**"}${result.reason}${result.status === "success" ? "" : "**"}\n` : "";
132
+ return `${head}${body}${reason}`.trimEnd() + "\n";
133
+ }
134
+
135
+ // Canonical control-envelope args for a task_submit / task_spawn signature. MUST match
136
+ // the app's signer (client/services/accountSign.ts) byte-for-byte: the SAME key set with
137
+ // undefined → null, so the recursive-key-sorted JSON both sides sign is identical. A
138
+ // mismatch here fails the signature and the task is refused (fail-closed). Exported so
139
+ // the test pins the exact shape the app must sign.
140
+ export function taskControlArgs(spec: TaskSpec): Record<string, unknown> {
141
+ return {
142
+ prompt: spec.prompt,
143
+ cwd: spec.cwd ?? null,
144
+ model: spec.model ?? null,
145
+ tools: spec.tools ?? null,
146
+ title: spec.title ?? null,
147
+ };
148
+ }
149
+
90
150
  export class Daemon {
91
151
  private server?: Server;
92
152
  private timer?: ReturnType<typeof setInterval>;
@@ -95,6 +155,40 @@ export class Daemon {
95
155
  private relay?: RelayClient;
96
156
  private controllerAttached = false;
97
157
  private relayTerminated = false;
158
+ // Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
159
+ // terminal (task-<uuid>); the daemon just keeps handles so it can reap them on shutdown.
160
+ private readonly liveTasks = new Map<string, LiveTaskHandle>();
161
+
162
+ // App-facing routine management (list/save/delete/pause/run) over the daemon's
163
+ // relay. Run-now is injected here since only the daemon can actually fire one;
164
+ // webhook validation reads config fresh so a just-declared endpoint is honored.
165
+ private readonly routines = makeRoutinesControl({
166
+ defaultCwd: () => process.cwd(),
167
+ webhookExists: (name) => !!loadDaemonConfig().webhooks?.[name],
168
+ runNow: (routine) => void this.runRoutine(routine),
169
+ });
170
+
171
+ // App-facing channel management (list/save/remove) over the daemon's relay. The
172
+ // channels daemon (channels/run.ts) is a SEPARATE process that may be down, so
173
+ // this edits config.json directly; `runningPlatforms` is a best-effort heartbeat
174
+ // read for a live/offline badge, never a dependency. Edits apply on the channels
175
+ // daemon's next restart (its deliberate fail-safe posture).
176
+ private readonly channels = makeChannelsControl({
177
+ runningPlatforms: () => readRunningPlatforms(),
178
+ });
179
+
180
+ // App-facing workflow management (list/get/save/remove/run) over the daemon's relay.
181
+ // Run-now is injected here since only the daemon owns the runner + its seams. A
182
+ // workflow can carry a `script` step (RCE if forged), so every mutation is
183
+ // account-signed + verified (guardControl) before reaching this control.
184
+ private readonly workflows = makeWorkflowsControl({
185
+ runNow: (wf) => void this.runWorkflow(wf),
186
+ });
187
+
188
+ // Pending human_gate / script-approval prompts a running workflow is blocked on,
189
+ // keyed by the select/approval frame id. Resolved when the app answers (onSelectResponse
190
+ // / onApprovalResponse) or when GATE_TIMEOUT_MS elapses (fail-closed → null).
191
+ private readonly pendingGates = new Map<string, (value: string | null) => void>();
98
192
 
99
193
  private readonly pushRelay: RelayPusher = (routine, content) => {
100
194
  if (this.controllerAttached && this.relay?.sendRoutineResult(routine.name, content)) return "live";
@@ -126,20 +220,70 @@ export class Daemon {
126
220
  if (this.timer) clearInterval(this.timer);
127
221
  this.server?.close();
128
222
  this.relay?.stop();
223
+ // Tear down any live spawned sessions (each revokes its own account session).
224
+ for (const handle of this.liveTasks.values()) void handle.stop();
225
+ this.liveTasks.clear();
129
226
  }
130
227
 
131
228
  private syncRelay(): void {
132
229
  if (this.relay || this.relayTerminated) return;
230
+ // Connect whenever the account is signed in — not only when a routine wants
231
+ // `relay` delivery — so the "Privateer Routines" terminal is always reachable
232
+ // from the app for management (including creating the very first routine).
133
233
  if (!hasCredentials()) return;
134
- const wantsRelay = loadRoutines().some((r) => r.enabled && r.delivery.includes("relay"));
135
- if (!wantsRelay) return;
136
234
  this.relay = new RelayClient(
137
235
  {
138
236
  onPrompt: () => {},
139
237
  onInterrupt: () => {},
140
- onApprovalResponse: () => {},
238
+ // A workflow human_gate / script-approval is surfaced as a select_request; the app
239
+ // answers with select_response (option name) or an approval_response (allow/deny).
240
+ // Both resolve the pending gate — otherwise the daemon relay ignores approvals.
241
+ onApprovalResponse: (id, decision) => this.resolveGate(id, decision === "deny" ? "deny" : "approve"),
242
+ onSelectResponse: (id, value) => this.resolveGate(id, value),
141
243
  onControllerAttached: () => this.onControllerAttached(),
142
244
  onAttachment: () => {},
245
+ // Routine management from the app. Each MUTATION is account-signed (H2) — a
246
+ // forged routine would run a headless bypass-mode session (RCE) — so it's
247
+ // verified (authorizeControl, fail-closed) before routinesControl validates +
248
+ // persists + re-pushes the list with a one-line result. `list` is read-only.
249
+ onRoutinesList: () => this.pushRoutines(),
250
+ onRoutinesSave: (draft, sig, ts) => this.pushRoutines(this.guardControl("routines_save", { routine: draft }, sig, ts, () => this.routines.save(draft).message)),
251
+ onRoutinesDelete: (idOrName, sig, ts) => this.pushRoutines(this.guardControl("routines_delete", { idOrName }, sig, ts, () => this.routines.remove(idOrName).message)),
252
+ onRoutinesSetEnabled: (idOrName, enabled, sig, ts) => this.pushRoutines(this.guardControl("routines_set_enabled", { idOrName, enabled }, sig, ts, () => this.routines.setEnabled(idOrName, enabled).message)),
253
+ onRoutinesRun: (idOrName, sig, ts) => this.pushRoutines(this.guardControl("routines_run", { idOrName }, sig, ts, () => this.routines.run(idOrName).message)),
254
+ // Ad-hoc task spawns from the app. A forged task_submit/task_spawn runs an
255
+ // arbitrary headless session (RCE) — same blast radius as routines_run — so both
256
+ // are account-signed and verified here (guardControl, fail-closed) BEFORE any
257
+ // session starts. The canonical signed args come from taskControlArgs (undefined
258
+ // → null), matching the app's signer.
259
+ onTaskSubmit: (spec, sig, ts) => {
260
+ const msg = this.guardControl("task_submit", taskControlArgs(spec), sig, ts, () => {
261
+ void this.runTask(spec);
262
+ return `Task "${deriveTaskTitle(spec)}" accepted — running now; the result will appear in your app.`;
263
+ });
264
+ if (msg) this.relay?.sendNotice(msg);
265
+ },
266
+ onTaskSpawn: (spec, sig, ts) => {
267
+ const msg = this.guardControl("task_spawn", taskControlArgs(spec), sig, ts, () => this.spawnLiveTask(spec));
268
+ if (msg) this.relay?.sendNotice(msg);
269
+ },
270
+ // Channel management from the app. `save` has its own signed verify (it also
271
+ // carries sealed secrets — applyChannelSave); `remove` is account-signed here
272
+ // (H2 — a forged removal is a DoS). Then channelsControl writes config.json and
273
+ // re-pushes the list.
274
+ onChannelsList: () => this.pushChannels(),
275
+ onChannelsSave: (draft, sealedSecrets, sig, ts) => this.pushChannels(this.applyChannelSave(draft, sealedSecrets, sig, ts)),
276
+ onChannelsRemove: (platform, sig, ts) => this.pushChannels(this.guardControl("channels_remove", { platform }, sig, ts, () => this.channels.remove(platform as any).message)),
277
+ // Workflow management from the app. Each MUTATION is account-signed (H2) — a forged
278
+ // workflows_save plants a `script` step that bypasses the permission gate (RCE),
279
+ // and workflows_run executes the graph — so all three are verified (guardControl,
280
+ // fail-closed) before the control acts. workflows_run verifies in STRICT mode (it's
281
+ // effectful, like task_spawn). list/get are read-only.
282
+ onWorkflowsList: () => this.pushWorkflows(),
283
+ onWorkflowsGet: (idOrName) => this.relay?.sendWorkflow(this.workflows.get(idOrName) ?? null),
284
+ onWorkflowsSave: (draft, sig, ts) => this.pushWorkflows(this.guardControl("workflows_save", { workflow: draft }, sig, ts, () => this.workflows.save(draft).message)),
285
+ onWorkflowsRemove: (idOrName, sig, ts) => this.pushWorkflows(this.guardControl("workflows_remove", { idOrName }, sig, ts, () => this.workflows.remove(idOrName).message)),
286
+ onWorkflowsRun: (idOrName, sig, ts) => this.pushWorkflows(this.guardControl("workflows_run", { idOrName }, sig, ts, () => this.workflows.run(idOrName).message)),
143
287
  onTerminate: () => {
144
288
  this.relayTerminated = true;
145
289
  this.controllerAttached = false;
@@ -147,6 +291,19 @@ export class Daemon {
147
291
  this.relay = undefined;
148
292
  log("relay terminated from the app; staying offline until the daemon restarts");
149
293
  },
294
+ // The account signed this daemon out server-side (revoked from the app's Linked
295
+ // Devices). Beyond ending remote access (onTerminate), this wipes the machine
296
+ // login: drop the relay and clear credentials, so routines/tasks stop cleanly
297
+ // instead of dead-ending on a 401 each run. Stays idle until you /signin on this
298
+ // machine and restart the daemon (the relayTerminated guard, as with onTerminate).
299
+ onRevoked: () => {
300
+ this.relayTerminated = true;
301
+ this.controllerAttached = false;
302
+ this.relay?.stop();
303
+ this.relay = undefined;
304
+ handleServerRevoke();
305
+ log("account signed out from the app (session revoked) — cleared credentials; idle until you run /signin on this machine and restart the daemon");
306
+ },
150
307
  onStatus: (text) => log(`relay: ${text}`),
151
308
  onDisconnected: () => {
152
309
  this.controllerAttached = false;
@@ -155,12 +312,111 @@ export class Daemon {
155
312
  { termId: routineRelayId(), label: "Privateer Routines" },
156
313
  );
157
314
  void this.relay.start();
158
- log("relay connection starting (routine has relay delivery + account signed in)");
315
+ log("relay connection starting (account signed in routines terminal reachable from the app)");
316
+ }
317
+
318
+ // Push the current routines list to an attached controller (its routines
319
+ // manager). `message` is a one-line result from the last mutation, if any.
320
+ private pushRoutines(message?: string): void {
321
+ this.relay?.sendRoutines({ items: this.routines.list(), message });
322
+ }
323
+
324
+ // Push the current channel config to an attached controller (its channels
325
+ // manager). `message` is a one-line result from the last mutation, if any.
326
+ private pushChannels(message?: string): void {
327
+ this.relay?.sendChannels({ items: this.channels.list(), message });
328
+ }
329
+
330
+ // Push the current workflow summaries to an attached controller (its workflows
331
+ // manager). `message` is a one-line result from the last mutation, if any.
332
+ private pushWorkflows(message?: string): void {
333
+ this.relay?.sendWorkflows({ items: this.workflows.list(), message });
334
+ }
335
+
336
+ // Resolve a pending workflow gate with the app's answer (an option name, "allow"/
337
+ // "deny" mapped to approve/deny, or null when dismissed/timed out). No-op if the id is
338
+ // unknown (a stale/duplicate response), so a late frame can't crash the runner.
339
+ private resolveGate(id: string, value: string | null): void {
340
+ const resolve = this.pendingGates.get(id);
341
+ if (!resolve) return;
342
+ this.pendingGates.delete(id);
343
+ resolve(value);
344
+ }
345
+
346
+ // Apply an app channel-save. Defense in depth, all fail-closed:
347
+ // 1. AUTHENTICITY (F7/F8): the whole save is signed with the account key we pinned
348
+ // at link. Verify it — a hostile relay can't forge a token or inject an admin
349
+ // because it can't produce this signature. No pin yet ⇒ refuse (re-link needed).
350
+ // 2. FRESHNESS (F9): reject a ts below the last applied — no replay/rollback of an
351
+ // older signed envelope.
352
+ // 3. CONFIDENTIALITY: open any sealed bot credentials (the server never could) and
353
+ // re-check the embedded termId (belt-and-suspenders over the signature's binding).
354
+ private applyChannelSave(draft: Record<string, unknown>, sealedSecrets?: string, sig?: string, ts?: number): string | undefined {
355
+ const accountPub = loadAccountSignKey();
356
+ if (!accountPub) return "This terminal can't accept channel changes from the app yet — re-link it to establish trust.";
357
+ if (!sig || typeof ts !== "number") return "Refused an unsigned channel change.";
358
+ // Verify against OUR termId — a signature made for a different terminal won't match,
359
+ // which also subsumes the misroute check.
360
+ if (!verifyChannelSave(accountPub, { termId: routineRelayId(), ts, draft, sealedSecrets }, sig)) {
361
+ return "Couldn't verify this change came from your account.";
362
+ }
363
+ const lastTs = loadLastControlTs(routineRelayId());
364
+ if (ts < lastTs) return "Ignored an out-of-date channel change.";
365
+ saveLastControlTs(routineRelayId(), ts);
366
+
367
+ let withSecrets = draft;
368
+ if (sealedSecrets) {
369
+ let opened: { termId?: string; secrets?: Record<string, string> };
370
+ try {
371
+ opened = openJsonFromApp(sealedSecrets);
372
+ } catch {
373
+ return "Couldn't decrypt the credentials — they may have been sealed to a different terminal.";
374
+ }
375
+ if (opened.termId !== routineRelayId()) {
376
+ return "These credentials were addressed to a different terminal.";
377
+ }
378
+ withSecrets = { ...draft, secrets: opened.secrets ?? {} };
379
+ }
380
+ return this.channels.save(withSecrets as any).message;
381
+ }
382
+
383
+ // Verify an account-signed mutating control frame (H2) against this daemon's termId,
384
+ // then run the mutation. Fail-closed: an unsigned/forged/stale frame returns the
385
+ // refusal message and the mutation NEVER runs. `routines_*` and `channels_remove`
386
+ // route through here; `channels_save` has its own verify (sealed secrets) above.
387
+ private guardControl(
388
+ action: string,
389
+ args: Record<string, unknown>,
390
+ sig: string | undefined,
391
+ ts: number | undefined,
392
+ run: () => string | undefined,
393
+ ): string | undefined {
394
+ // task_submit/task_spawn are NON-idempotent (each runs a headless session), so they
395
+ // require a strictly-fresh ts — a replayed frame with an equal ts must NOT re-run.
396
+ // The idempotent config mutations (routines/channels save|delete) keep the default
397
+ // at-or-above acceptance. See authorizeControl's strict note.
398
+ const strict = action === "task_submit" || action === "task_spawn" || action === "workflows_run";
399
+ const auth = authorizeControl(routineRelayId(), action, args, sig, ts, { strict });
400
+ if (!auth.ok) return auth.message;
401
+ return run();
159
402
  }
160
403
 
161
404
  private onControllerAttached(): void {
162
405
  this.controllerAttached = true;
163
406
  this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
407
+ // Version + this terminal's identity public key (so the app can confirm this is
408
+ // the terminal it PINNED at link time before sealing channel tokens to it). No
409
+ // model/cwd — the routines terminal isn't a single-model session, and cwd is PII.
410
+ let terminalPub: string | undefined;
411
+ try { terminalPub = terminalPublicKeyBase64(); } catch { /* no key → app can't seal, falls back */ }
412
+ this.relay?.sendContext({ version: agentVersion(), terminalPub });
413
+ // Prime the app's routines manager so it has the list on open (it also asks
414
+ // explicitly via routines_list; this just avoids a first-frame wait).
415
+ this.pushRoutines();
416
+ // Same for the channels manager.
417
+ this.pushChannels();
418
+ // …and the workflows manager.
419
+ this.pushWorkflows();
164
420
  const pending = drainPendingRelay();
165
421
  if (pending.length === 0) return;
166
422
  log(`controller attached — flushing ${pending.length} pending routine result(s)`);
@@ -174,8 +430,16 @@ export class Daemon {
174
430
  try {
175
431
  const res = await apiRequest("/api/outbox/pubkey");
176
432
  if (!res.ok) return undefined;
177
- const data = (await res.json()) as { outboxPublicKey?: string | null };
178
- if (!data.outboxPublicKey) return undefined;
433
+ const data = (await res.json()) as { outboxPublicKey?: string | null; outboxPublicKeySig?: string | null };
434
+ if (!data.outboxPublicKey || !data.outboxPublicKeySig) return undefined;
435
+ // The key comes from the UNTRUSTED server. Verify the account's signature over it
436
+ // against the account signing key we pinned at link — otherwise a malicious server
437
+ // could substitute a key it controls and read every result we seal. Fail closed
438
+ // (no pin, missing sig, or bad sig ⇒ don't seal): the `cloud` channel then falls
439
+ // back to a local notice, so the result is deferred/kept, never leaked.
440
+ const accountPub = loadAccountSignKey();
441
+ if (!accountPub) return undefined;
442
+ if (!verifyOutboxKey(accountPub, data.outboxPublicKey, data.outboxPublicKeySig)) return undefined;
179
443
  this.outboxPub = decodeAccountPublicKey(data.outboxPublicKey);
180
444
  return this.outboxPub;
181
445
  } catch {
@@ -183,11 +447,11 @@ export class Daemon {
183
447
  }
184
448
  }
185
449
 
186
- private async postOutbox(routine: string, at: string, status: "ok" | "error", content: string): Promise<boolean> {
450
+ private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind: "routine" | "task" = "routine"): Promise<boolean> {
187
451
  const pub = await this.ensureOutboxPub();
188
452
  if (!pub) return false;
189
453
  const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
190
- const sealed = sealJson(pub, { v: 1, kind: "routine", name: routine, status, at, content: body });
454
+ const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body });
191
455
  try {
192
456
  const res = await apiRequest("/api/outbox", {
193
457
  method: "POST",
@@ -206,7 +470,7 @@ export class Daemon {
206
470
  if (queue.length === 0) return;
207
471
  const remaining: PendingCloud[] = [];
208
472
  for (const p of queue) {
209
- if (remaining.length === 0 && (await this.postOutbox(p.routine, p.at, p.status, p.content))) continue;
473
+ if (remaining.length === 0 && (await this.postOutbox(p.routine, p.at, p.status, p.content, p.kind ?? "routine"))) continue;
210
474
  remaining.push(p);
211
475
  }
212
476
  if (remaining.length !== queue.length) {
@@ -257,36 +521,71 @@ export class Daemon {
257
521
  log(" note: MCP tools + email delivery are not wired yet (Phase 5) — skipping those");
258
522
  }
259
523
 
524
+ const { out, status, error } = await this.runSession({
525
+ prompt: routine.prompt,
526
+ cwd: routine.cwd,
527
+ model: modelSpec,
528
+ tools: allowedTools,
529
+ });
530
+
531
+ const content = formatResult(routine, out, status, error);
532
+ const report = await deliver(routine, content, status, {
533
+ pushRelay: this.pushRelay,
534
+ pushCloud: this.pushCloud,
535
+ webhooks: config.webhooks,
536
+ redact: (text) => redactText(text, collectSecrets(config.providers)),
537
+ });
538
+ log(` "${routine.name}" ${status}; delivered via ${report.delivered.join(", ") || "(none)"}`);
539
+
540
+ this.persistRun(routine.id, {
541
+ lastRun: new Date().toISOString(),
542
+ lastStatus: status,
543
+ lastError: error,
544
+ ...advanceAfterRun(routine),
545
+ });
546
+ this.running.delete(routine.id);
547
+ return { ok: status === "ok", message: report.delivered.join(", ") || undefined };
548
+ }
549
+
550
+ // Drive one headless Pi turn to completion and return its collected text + status.
551
+ // The shared core of BOTH a scheduled routine and an app-submitted ad-hoc task: an
552
+ // auto-approve (bypass) gate whose safety is the restricted `tools` list — a dangerous
553
+ // shell command still fail-closes headlessly (localAsk denies) — plus per-run account
554
+ // credentials that are revoked in the finally so they never linger as an orphaned
555
+ // "device" in the app's Linked Devices.
556
+ private async runSession(spec: { prompt: string; cwd: string; model: string; tools: string[] }): Promise<{ out: string; status: "ok" | "error"; error?: string }> {
260
557
  let out = "";
261
558
  let status: "ok" | "error" = "ok";
262
559
  let error: string | undefined;
560
+ let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
561
+ let spawnedAccount = false;
263
562
  try {
264
- // Auto-approve (bypass) gate — safety is `tools: allowedTools`; a dangerous
265
- // shell command still fail-closes headlessly (localAsk denies).
266
563
  const gate: GateController = {
267
564
  getMode: () => "bypass",
268
565
  setMode: () => {},
269
566
  allowlist: [],
270
567
  allowedOutsideRoots: [],
271
- cwd: routine.cwd,
568
+ cwd: spec.cwd,
272
569
  confineToCwd: true,
273
570
  async localAsk() {
274
571
  return "deny";
275
572
  },
276
573
  };
277
574
  const services = await createAgentSessionServices({
278
- cwd: routine.cwd,
575
+ cwd: spec.cwd,
279
576
  agentDir: agentDir(),
280
577
  resourceLoaderOptions: {
281
578
  extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
282
579
  },
283
580
  });
581
+ servicesRef = services as any;
284
582
 
285
- const { provider, modelId } = parseSpec(modelSpec);
583
+ const { provider, modelId } = parseSpec(spec.model);
286
584
  if (provider === "privateer") {
287
585
  try {
288
586
  const creds = await spawnAccountCredentials();
289
587
  (services.authStorage as any).set("privateer", { type: "oauth", ...creds });
588
+ spawnedAccount = true;
290
589
  } catch (e) {
291
590
  log(` account channel unavailable: ${(e as Error).message}`);
292
591
  }
@@ -299,9 +598,9 @@ export class Daemon {
299
598
  } else {
300
599
  const { session } = await createAgentSessionFromServices({
301
600
  services,
302
- sessionManager: SessionManager.inMemory(routine.cwd),
601
+ sessionManager: SessionManager.inMemory(spec.cwd),
303
602
  model,
304
- tools: allowedTools,
603
+ tools: spec.tools,
305
604
  } as any);
306
605
  const adapter = createEngineEventAdapter();
307
606
  session.subscribe((ev: any) => {
@@ -313,30 +612,219 @@ export class Daemon {
313
612
  }
314
613
  }
315
614
  });
316
- await session.prompt(routine.prompt);
615
+ await session.prompt(spec.prompt);
317
616
  }
318
617
  } catch (err) {
319
618
  status = "error";
320
619
  error = err instanceof Error ? err.message : String(err);
620
+ } finally {
621
+ // Revoke ONLY this run's account inference session (the daemon's own child API
622
+ // session — relay/outbox — stays alive until shutdown). Drop Pi's persisted copy
623
+ // too so a later run's fallback never reuses a revoked token. Best-effort.
624
+ if (spawnedAccount) {
625
+ try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
626
+ try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
627
+ }
321
628
  }
629
+ return { out, status, error };
630
+ }
322
631
 
323
- const content = formatResult(routine, out, status, error);
324
- const report = await deliver(routine, content, status, {
325
- pushRelay: this.pushRelay,
326
- pushCloud: this.pushCloud,
327
- webhooks: config.webhooks,
328
- redact: (text) => redactText(text, collectSecrets(config.providers)),
632
+ // Run an app-submitted AD-HOC task (task_submit): one restricted headless turn whose
633
+ // result is sealed to the account outbox (durable, server-can't-read) and, if a
634
+ // controller is attached, mirrored live. NOT a stored routine — no schedule, no
635
+ // persistence beyond delivery. The signed-frame gate (guardControl) already ran; this
636
+ // just executes. Concurrency-guarded by a `task:<title>` key in `this.running` (never
637
+ // collides with routine ids, which are uuids).
638
+ async runTask(spec: TaskSpec): Promise<void> {
639
+ const config = loadDaemonConfig();
640
+ const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
641
+ const modelSpec = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
642
+ const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
643
+ const allowedTools = split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
644
+ const title = deriveTaskTitle(spec);
645
+ const key = `task:${title}`;
646
+ if (this.running.has(key)) {
647
+ this.relay?.sendNotice(`A task titled "${title}" is already running.`);
648
+ return;
649
+ }
650
+ this.running.add(key);
651
+ log(`running task "${title}"`);
652
+ try {
653
+ const { out, status, error } = await this.runSession({ prompt: spec.prompt, cwd, model: modelSpec, tools: allowedTools });
654
+ const content = redactText(formatTaskResult(title, out, status, error, modelSpec), collectSecrets(config.providers));
655
+ const at = new Date().toISOString();
656
+ // Durable delivery: seal to the outbox. If we can't seal yet (no verified pubkey /
657
+ // offline), queue it with kind:"task" so the flush re-seals it correctly later.
658
+ const sealed = await this.postOutbox(title, at, status, content, "task");
659
+ if (!sealed) addPendingCloud({ routine: title, at, status, content, kind: "task" });
660
+ // Live mirror if a controller is attached (the outbox copy is the source of truth).
661
+ if (this.controllerAttached) this.relay?.sendTaskResult(title, content);
662
+ log(` task "${title}" ${status}; ${sealed ? "sealed to outbox" : "queued for outbox"}`);
663
+ } finally {
664
+ this.running.delete(key);
665
+ }
666
+ }
667
+
668
+ // Stand up a live, app-drivable session (task_spawn). Async: the session + its own relay
669
+ // terminal are created in the background, then the app is told the new termId via
670
+ // sendTaskSpawned so it can attach and drive. Returns an immediate ack for the notice.
671
+ // The signed-frame gate (guardControl) already ran before this is called.
672
+ private spawnLiveTask(spec: TaskSpec): string {
673
+ void (async () => {
674
+ try {
675
+ const handle = await createLiveTaskSession(spec, {
676
+ defaultModel: loadDaemonConfig().defaultModel,
677
+ parseSpec,
678
+ log,
679
+ onClosed: (id) => this.liveTasks.delete(id),
680
+ });
681
+ this.liveTasks.set(handle.termId, handle);
682
+ this.relay?.sendTaskSpawned(handle.termId, handle.label);
683
+ log(`live task spawned: ${handle.termId} (${handle.label})`);
684
+ } catch (e) {
685
+ const msg = `Couldn't spawn a live session: ${(e as Error).message}`;
686
+ log(msg);
687
+ this.relay?.sendNotice(msg);
688
+ }
689
+ })();
690
+ const title = deriveTaskTitle(spec);
691
+ return `Spawning a live session "${title}" — it'll open in your app in a moment.`;
692
+ }
693
+
694
+ // Run a saved workflow graph to completion (workflows_run / the injected runNow). The
695
+ // signed-frame gate (guardControl, STRICT) already ran before this is reached. Wires the
696
+ // runner's injected seams to the daemon's real capabilities: agent steps → runSession
697
+ // (SAFE_TOOLS gate), gates → relay approvals, scripts → a gated child process (only when
698
+ // attended + approved; the runner fail-closes an unattended script itself), and the
699
+ // result is sealed to the outbox + mirrored live, exactly like an ad-hoc task.
700
+ async runWorkflow(wf: Workflow): Promise<void> {
701
+ const key = `wf:${wf.workflow.id}`;
702
+ if (this.running.has(key)) {
703
+ this.relay?.sendNotice(`Workflow "${wf.workflow.name}" is already running.`);
704
+ return;
705
+ }
706
+ this.running.add(key);
707
+ log(`running workflow "${wf.workflow.name}"`);
708
+ try {
709
+ const deps: RunnerDeps = {
710
+ runAgent: (spec) => this.runWorkflowAgent(spec),
711
+ runScript: (step, cwd) => this.runScript(step, cwd),
712
+ askGate: (step, promptText) => this.askGate(step.options.map((o) => ({ name: o.name, description: o.description })), promptText),
713
+ attended: () => this.controllerAttached,
714
+ // Preserve the daemon's one-at-a-time discipline: fan-out (parallel/for_each) runs
715
+ // sequentially here, so a workflow never spawns concurrent headless sessions on the
716
+ // resident daemon. (The standalone runner defaults to 4; a UI host can raise it.)
717
+ concurrency: 1,
718
+ // An effectful step reached while unattended: seal a "needs approval" notice so the
719
+ // user catches up, and (if a controller is somehow attached) surface it live.
720
+ deferForApproval: async (reason) => {
721
+ const at = new Date().toISOString();
722
+ if (!(await this.postOutbox(wf.workflow.name, at, "error", reason, "task"))) {
723
+ addPendingCloud({ routine: wf.workflow.name, at, status: "error", content: reason, kind: "task" });
724
+ }
725
+ this.relay?.sendNotice(reason);
726
+ },
727
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
728
+ log: (m) => log(` [wf ${wf.workflow.name}] ${m}`),
729
+ // Live progress: announce each step start in the daemon terminal's feed.
730
+ onEvent: (ev) => {
731
+ if (ev.type === "step_start") this.relay?.sendNotice(`▶ ${ev.name}`);
732
+ },
733
+ };
734
+
735
+ const result = await executeWorkflow(wf, {}, deps);
736
+ const status: "ok" | "error" = result.status === "success" ? "ok" : "error";
737
+ const content = formatWorkflowResult(wf.workflow.name, result);
738
+ const at = new Date().toISOString();
739
+ // Durable delivery: seal to the outbox (queue on failure to re-seal later).
740
+ if (!(await this.postOutbox(wf.workflow.name, at, status, content, "task"))) {
741
+ addPendingCloud({ routine: wf.workflow.name, at, status, content, kind: "task" });
742
+ }
743
+ if (this.controllerAttached) this.relay?.sendWorkflowResult(wf.workflow.name, content);
744
+ log(` workflow "${wf.workflow.name}" ${result.status}${result.reason ? `: ${result.reason}` : ""}`);
745
+ this.pushWorkflows();
746
+ } finally {
747
+ this.running.delete(key);
748
+ }
749
+ }
750
+
751
+ // Bridge a workflow human_gate to a relay selection prompt. Returns the chosen option
752
+ // name, or null when there's no controller / the app dismisses it / it times out — the
753
+ // runner treats null as fail-closed (defer the run). Only one gate is outstanding per
754
+ // running workflow (the runner awaits it), so a fresh id per call is sufficient.
755
+ private askGate(options: { name: string; description?: string }[], promptText: string): Promise<string | null> {
756
+ if (!this.controllerAttached || !this.relay) return Promise.resolve(null);
757
+ const id = randomUUID();
758
+ return new Promise((resolve) => {
759
+ const timer = setTimeout(() => {
760
+ this.pendingGates.delete(id);
761
+ resolve(null);
762
+ }, GATE_TIMEOUT_MS);
763
+ this.pendingGates.set(id, (value) => {
764
+ clearTimeout(timer);
765
+ resolve(value);
766
+ });
767
+ this.relay!.requestSelect(id, {
768
+ title: promptText,
769
+ options: options.map((o) => ({ value: o.name, label: o.description || o.name })),
770
+ });
329
771
  });
330
- log(` "${routine.name}" ${status}; delivered via ${report.delivered.join(", ") || "(none)"}`);
772
+ }
331
773
 
332
- this.persistRun(routine.id, {
333
- lastRun: new Date().toISOString(),
334
- lastStatus: status,
335
- lastError: error,
336
- ...advanceAfterRun(routine),
774
+ // Execute a workflow `script` step as a gated child process. The runner ONLY calls this
775
+ // after its fail-closed posture check (attended + approved), so reaching here means the
776
+ // account authorized this exact command. Args are passed as argv (no shell), stdout is
777
+ // parsed as JSON into `output` when it's an object, and the process is hard-killed at its
778
+ // timeout so a hung script can't pin the run.
779
+ private runScript(step: Extract<Step, { type: "script" }>, cwd: string): Promise<ScriptRunResult> {
780
+ return new Promise((resolve) => {
781
+ let out = "";
782
+ let err = "";
783
+ let done = false;
784
+ const finish = (r: ScriptRunResult) => {
785
+ if (done) return;
786
+ done = true;
787
+ resolve(r);
788
+ };
789
+ let child: ReturnType<typeof spawn>;
790
+ try {
791
+ child = spawn(step.command, step.args, {
792
+ cwd,
793
+ env: { ...process.env, ...(step.env ?? {}) },
794
+ stdio: ["ignore", "pipe", "pipe"],
795
+ });
796
+ } catch (e) {
797
+ return finish({ output: {}, status: "error", exitCode: -1, error: (e as Error).message });
798
+ }
799
+ const timer = setTimeout(() => {
800
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
801
+ finish({ output: {}, status: "error", exitCode: -1, error: `script timed out after ${step.timeout ?? 120}s` });
802
+ }, (step.timeout ?? 120) * 1000);
803
+ child.stdout?.on("data", (d) => { out += String(d); });
804
+ child.stderr?.on("data", (d) => { err += String(d); });
805
+ child.on("error", (e) => { clearTimeout(timer); finish({ output: {}, status: "error", exitCode: -1, error: e.message }); });
806
+ child.on("close", (code) => {
807
+ clearTimeout(timer);
808
+ let output: Record<string, unknown> = {};
809
+ try { const p = JSON.parse(out.trim()); if (p && typeof p === "object" && !Array.isArray(p)) output = p as Record<string, unknown>; } catch { /* non-JSON stdout → no structured output */ }
810
+ finish({ output, status: code === 0 ? "ok" : "error", exitCode: code ?? -1, error: code === 0 ? undefined : (err.trim().slice(0, 500) || `exited ${code}`) });
811
+ });
337
812
  });
338
- this.running.delete(routine.id);
339
- return { ok: status === "ok", message: report.delivered.join(", ") || undefined };
813
+ }
814
+
815
+ // Drive one workflow `agent` step through the shared headless runSession (SAFE_TOOLS
816
+ // gate), then best-effort parse its stdout as a JSON object into structured `output`
817
+ // (so a later step can route on `{{ step.output.field }}`). Non-JSON output leaves
818
+ // `output` empty and lives in `text` — the raw/display path.
819
+ private async runWorkflowAgent(spec: AgentRunSpec): Promise<AgentRunResult> {
820
+ const config = loadDaemonConfig();
821
+ const model = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
822
+ const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
823
+ const tools = split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS;
824
+ const { out, status, error } = await this.runSession({ prompt: spec.prompt, cwd: spec.cwd, model, tools });
825
+ let output: Record<string, unknown> = {};
826
+ try { const p = JSON.parse(out.trim()); if (p && typeof p === "object" && !Array.isArray(p)) output = p as Record<string, unknown>; } catch { /* non-JSON → raw text only */ }
827
+ return { text: out, output, status, error };
340
828
  }
341
829
 
342
830
  private persistRun(id: string, patch: Partial<Routine>): void {
@@ -398,7 +886,7 @@ export function runDaemon(): void {
398
886
  const shutdown = () => {
399
887
  log("shutting down");
400
888
  daemon.stop();
401
- void revokeChildSession().finally(() => process.exit(0));
889
+ void revokeLocalSessions().finally(() => process.exit(0));
402
890
  };
403
891
  process.on("SIGINT", shutdown);
404
892
  process.on("SIGTERM", shutdown);