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.
- package/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +19 -0
- package/extensions/privateer-brand.ts +61 -20
- package/extensions/privateer-gate.ts +290 -3
- package/package.json +4 -1
- package/src/auth/privateer.ts +45 -6
- package/src/channels/bridge.ts +293 -0
- package/src/channels/discord.ts +210 -0
- package/src/channels/run.ts +383 -0
- package/src/channels/slack.ts +176 -0
- package/src/channels/status.ts +54 -0
- package/src/channels/telegram.ts +139 -0
- package/src/channels/types.ts +36 -0
- package/src/channels/whatsapp.ts +178 -0
- package/src/cli/chat.ts +389 -30
- package/src/cli/daemonCli.ts +67 -0
- package/src/crypto/accountTrust.ts +113 -0
- package/src/crypto/accountVerify.ts +138 -0
- package/src/crypto/terminalKey.ts +95 -0
- package/src/crypto/terminalUnseal.ts +62 -0
- package/src/daemon/index.ts +511 -46
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/remote/channelsControl.ts +192 -0
- package/src/remote/controlAuth.ts +67 -0
- package/src/remote/extensionsControl.ts +140 -0
- package/src/remote/liveTaskSession.ts +218 -0
- package/src/remote/relayClient.ts +512 -1
- package/src/remote/remoteBridge.ts +172 -0
- package/src/remote/routinesControl.ts +216 -0
- package/src/remote/skillsControl.ts +205 -0
- package/src/remote/subagentChannel.ts +261 -0
- package/src/remote/subagentRelay.ts +126 -0
- package/src/remote/workflowsControl.ts +132 -0
- package/src/routines/store.ts +5 -1
- package/src/workflows/expr.ts +4 -0
- package/src/workflows/runner.ts +8 -0
- package/src/workflows/schema.ts +5 -0
- package/src/workflows/store.ts +108 -0
package/src/daemon/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 (
|
|
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
|
|
166
|
-
//
|
|
167
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
601
|
+
sessionManager: SessionManager.inMemory(spec.cwd),
|
|
315
602
|
model,
|
|
316
|
-
tools:
|
|
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(
|
|
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
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
772
|
+
}
|
|
354
773
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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 {
|