privateer-agent 0.3.6 → 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 (41) hide show
  1. package/bin/privateer-daemon.mjs +30 -0
  2. package/bin/privateer-subagent.mjs +68 -0
  3. package/bin/privateer-tui +19 -0
  4. package/extensions/privateer-brand.ts +61 -20
  5. package/extensions/privateer-gate.ts +290 -3
  6. package/package.json +4 -1
  7. package/src/auth/privateer.ts +45 -6
  8. package/src/channels/bridge.ts +293 -0
  9. package/src/channels/discord.ts +210 -0
  10. package/src/channels/run.ts +383 -0
  11. package/src/channels/slack.ts +176 -0
  12. package/src/channels/status.ts +54 -0
  13. package/src/channels/telegram.ts +139 -0
  14. package/src/channels/types.ts +36 -0
  15. package/src/channels/whatsapp.ts +178 -0
  16. package/src/cli/chat.ts +389 -30
  17. package/src/cli/daemonCli.ts +67 -0
  18. package/src/crypto/accountTrust.ts +113 -0
  19. package/src/crypto/accountVerify.ts +138 -0
  20. package/src/crypto/terminalKey.ts +95 -0
  21. package/src/crypto/terminalUnseal.ts +62 -0
  22. package/src/daemon/index.ts +511 -46
  23. package/src/daemon/service.ts +232 -0
  24. package/src/ext/permissionGate.ts +38 -0
  25. package/src/permissions/classify.ts +49 -5
  26. package/src/remote/channelsControl.ts +192 -0
  27. package/src/remote/controlAuth.ts +67 -0
  28. package/src/remote/extensionsControl.ts +140 -0
  29. package/src/remote/liveTaskSession.ts +218 -0
  30. package/src/remote/relayClient.ts +512 -1
  31. package/src/remote/remoteBridge.ts +172 -0
  32. package/src/remote/routinesControl.ts +216 -0
  33. package/src/remote/skillsControl.ts +205 -0
  34. package/src/remote/subagentChannel.ts +261 -0
  35. package/src/remote/subagentRelay.ts +126 -0
  36. package/src/remote/workflowsControl.ts +132 -0
  37. package/src/routines/store.ts +5 -1
  38. package/src/workflows/expr.ts +4 -0
  39. package/src/workflows/runner.ts +8 -0
  40. package/src/workflows/schema.ts +5 -0
  41. 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 {
@@ -13,8 +15,20 @@ import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
13
15
  import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
14
16
  import { makePiPrivacyExtension } from "pi-privacy";
15
17
  import { makeAccountProvider } from "../providers/account.ts";
16
- import { RelayClient } from "../remote/relayClient.ts";
17
- import { hasCredentials, revokeLocalSessions, revokeAccountSession, 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";
18
32
  import {
19
33
  loadRoutines,
20
34
  upsertRoutine,
@@ -45,6 +59,10 @@ const SAFE_TOOLS = ["read", "grep", "find", "ls"];
45
59
 
46
60
  const TICK_MS = 60_000; // scan for due routines once a minute
47
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;
48
66
 
49
67
  interface DaemonConfig {
50
68
  defaultModel: string;
@@ -88,6 +106,47 @@ function formatResult(routine: Routine, body: string, status: "ok" | "error", er
88
106
  return `${head}${body.trim() || "(no output)"}\n`;
89
107
  }
90
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
+
91
150
  export class Daemon {
92
151
  private server?: Server;
93
152
  private timer?: ReturnType<typeof setInterval>;
@@ -96,6 +155,40 @@ export class Daemon {
96
155
  private relay?: RelayClient;
97
156
  private controllerAttached = false;
98
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>();
99
192
 
100
193
  private readonly pushRelay: RelayPusher = (routine, content) => {
101
194
  if (this.controllerAttached && this.relay?.sendRoutineResult(routine.name, content)) return "live";
@@ -127,20 +220,70 @@ export class Daemon {
127
220
  if (this.timer) clearInterval(this.timer);
128
221
  this.server?.close();
129
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();
130
226
  }
131
227
 
132
228
  private syncRelay(): void {
133
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).
134
233
  if (!hasCredentials()) return;
135
- const wantsRelay = loadRoutines().some((r) => r.enabled && r.delivery.includes("relay"));
136
- if (!wantsRelay) return;
137
234
  this.relay = new RelayClient(
138
235
  {
139
236
  onPrompt: () => {},
140
237
  onInterrupt: () => {},
141
- 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),
142
243
  onControllerAttached: () => this.onControllerAttached(),
143
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)),
144
287
  onTerminate: () => {
145
288
  this.relayTerminated = true;
146
289
  this.controllerAttached = false;
@@ -148,6 +291,19 @@ export class Daemon {
148
291
  this.relay = undefined;
149
292
  log("relay terminated from the app; staying offline until the daemon restarts");
150
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
+ },
151
307
  onStatus: (text) => log(`relay: ${text}`),
152
308
  onDisconnected: () => {
153
309
  this.controllerAttached = false;
@@ -156,15 +312,111 @@ export class Daemon {
156
312
  { termId: routineRelayId(), label: "Privateer Routines" },
157
313
  );
158
314
  void this.relay.start();
159
- 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();
160
402
  }
161
403
 
162
404
  private onControllerAttached(): void {
163
405
  this.controllerAttached = true;
164
406
  this.relay?.sendSnapshot([{ kind: "notice", text: "Privateer routines — results will appear here as they run." }]);
165
- // Version only the routines terminal isn't a single-model session, so no
166
- // model field (and no cwd, per RelayClient.sendContext's non-PII stance).
167
- this.relay?.sendContext({ version: agentVersion() });
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();
168
420
  const pending = drainPendingRelay();
169
421
  if (pending.length === 0) return;
170
422
  log(`controller attached — flushing ${pending.length} pending routine result(s)`);
@@ -178,8 +430,16 @@ export class Daemon {
178
430
  try {
179
431
  const res = await apiRequest("/api/outbox/pubkey");
180
432
  if (!res.ok) return undefined;
181
- const data = (await res.json()) as { outboxPublicKey?: string | null };
182
- 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;
183
443
  this.outboxPub = decodeAccountPublicKey(data.outboxPublicKey);
184
444
  return this.outboxPub;
185
445
  } catch {
@@ -187,11 +447,11 @@ export class Daemon {
187
447
  }
188
448
  }
189
449
 
190
- 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> {
191
451
  const pub = await this.ensureOutboxPub();
192
452
  if (!pub) return false;
193
453
  const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
194
- 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 });
195
455
  try {
196
456
  const res = await apiRequest("/api/outbox", {
197
457
  method: "POST",
@@ -210,7 +470,7 @@ export class Daemon {
210
470
  if (queue.length === 0) return;
211
471
  const remaining: PendingCloud[] = [];
212
472
  for (const p of queue) {
213
- 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;
214
474
  remaining.push(p);
215
475
  }
216
476
  if (remaining.length !== queue.length) {
@@ -261,31 +521,58 @@ export class Daemon {
261
521
  log(" note: MCP tools + email delivery are not wired yet (Phase 5) — skipping those");
262
522
  }
263
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 }> {
264
557
  let out = "";
265
558
  let status: "ok" | "error" = "ok";
266
559
  let error: string | undefined;
267
- // Track this run's account inference session so it can be torn down when the
268
- // routine finishes — each run force-spawns a fresh one (below), so without this
269
- // a long-lived daemon would leave one orphaned account "device" per run lingering
270
- // in the app's Linked Devices until its token TTL.
271
560
  let servicesRef: { authStorage?: { remove?: (p: string) => void } } | null = null;
272
561
  let spawnedAccount = false;
273
562
  try {
274
- // Auto-approve (bypass) gate — safety is `tools: allowedTools`; a dangerous
275
- // shell command still fail-closes headlessly (localAsk denies).
276
563
  const gate: GateController = {
277
564
  getMode: () => "bypass",
278
565
  setMode: () => {},
279
566
  allowlist: [],
280
567
  allowedOutsideRoots: [],
281
- cwd: routine.cwd,
568
+ cwd: spec.cwd,
282
569
  confineToCwd: true,
283
570
  async localAsk() {
284
571
  return "deny";
285
572
  },
286
573
  };
287
574
  const services = await createAgentSessionServices({
288
- cwd: routine.cwd,
575
+ cwd: spec.cwd,
289
576
  agentDir: agentDir(),
290
577
  resourceLoaderOptions: {
291
578
  extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
@@ -293,7 +580,7 @@ export class Daemon {
293
580
  });
294
581
  servicesRef = services as any;
295
582
 
296
- const { provider, modelId } = parseSpec(modelSpec);
583
+ const { provider, modelId } = parseSpec(spec.model);
297
584
  if (provider === "privateer") {
298
585
  try {
299
586
  const creds = await spawnAccountCredentials();
@@ -311,9 +598,9 @@ export class Daemon {
311
598
  } else {
312
599
  const { session } = await createAgentSessionFromServices({
313
600
  services,
314
- sessionManager: SessionManager.inMemory(routine.cwd),
601
+ sessionManager: SessionManager.inMemory(spec.cwd),
315
602
  model,
316
- tools: allowedTools,
603
+ tools: spec.tools,
317
604
  } as any);
318
605
  const adapter = createEngineEventAdapter();
319
606
  session.subscribe((ev: any) => {
@@ -325,41 +612,219 @@ export class Daemon {
325
612
  }
326
613
  }
327
614
  });
328
- await session.prompt(routine.prompt);
615
+ await session.prompt(spec.prompt);
329
616
  }
330
617
  } catch (err) {
331
618
  status = "error";
332
619
  error = err instanceof Error ? err.message : String(err);
333
620
  } finally {
334
- // Tear down THIS run's account inference session so it doesn't linger in the
335
- // app's Linked Devices after the routine finishes. Revoke only the account
336
- // session the daemon's child API session (relay/outbox) must stay alive for
337
- // the daemon's lifetime and is revoked on shutdown. Also drop Pi's persisted
338
- // copy so a later run's fallback never reuses this revoked token. Best-effort;
339
- // the next run force-spawns a fresh account session.
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.
340
624
  if (spawnedAccount) {
341
625
  try { await revokeAccountSession(); } catch { /* best effort — server TTL is the fallback */ }
342
626
  try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
343
627
  }
344
628
  }
629
+ return { out, status, error };
630
+ }
345
631
 
346
- const content = formatResult(routine, out, status, error);
347
- const report = await deliver(routine, content, status, {
348
- pushRelay: this.pushRelay,
349
- pushCloud: this.pushCloud,
350
- webhooks: config.webhooks,
351
- 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
+ });
352
771
  });
353
- log(` "${routine.name}" ${status}; delivered via ${report.delivered.join(", ") || "(none)"}`);
772
+ }
354
773
 
355
- this.persistRun(routine.id, {
356
- lastRun: new Date().toISOString(),
357
- lastStatus: status,
358
- lastError: error,
359
- ...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
+ });
360
812
  });
361
- this.running.delete(routine.id);
362
- 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 };
363
828
  }
364
829
 
365
830
  private persistRun(id: string, patch: Partial<Routine>): void {