privateer-agent 0.3.6 → 0.4.1

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