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