privateer-agent 0.6.10 → 0.8.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 +20 -20
- package/SECURITY.md +1 -1
- package/bin/{privateer-daemon.mjs → privateer-harbor.mjs} +7 -7
- package/bin/privateer-launch.mjs +7 -6
- package/bin/privateer-subagent.mjs +1 -1
- package/extensions/privateer-brand.ts +1 -1
- package/extensions/privateer-connect.ts +2 -2
- package/extensions/privateer-tools.ts +1 -1
- package/package.json +4 -2
- package/src/auth/privateer.ts +2 -2
- package/src/channels/run.ts +16 -7
- package/src/channels/status.ts +7 -7
- package/src/cli/chat.ts +39 -5
- package/src/cli/{daemonCli.ts → harborCli.ts} +14 -14
- package/src/config/hosted.ts +5 -5
- package/src/crypto/accountTrust.ts +2 -2
- package/src/crypto/accountVerify.ts +1 -1
- package/src/{daemon → harbor}/index.ts +81 -44
- package/src/harbor/ipc.ts +175 -0
- package/src/{daemon → harbor}/service.ts +61 -28
- package/src/main.ts +1 -1
- package/src/mcp/catalog.ts +106 -0
- package/src/providers/account.ts +58 -11
- package/src/providers/catalog.ts +8 -6
- package/src/providers/defaultModel.ts +1 -1
- package/src/providers/phala/aci-verifier/VENDORED.md +23 -0
- package/src/providers/phala/aci-verifier/crypto.ts +95 -0
- package/src/providers/phala/aci-verifier/digest.ts +116 -0
- package/src/providers/phala/aci-verifier/e2ee-channel.ts +242 -0
- package/src/providers/phala/aci-verifier/e2ee.ts +73 -0
- package/src/providers/phala/aci-verifier/errors.ts +41 -0
- package/src/providers/phala/aci-verifier/index.ts +87 -0
- package/src/providers/phala/aci-verifier/jcs.ts +69 -0
- package/src/providers/phala/aci-verifier/receipt.ts +139 -0
- package/src/providers/phala/aci-verifier/report.ts +126 -0
- package/src/providers/phala/aci-verifier/types.ts +139 -0
- package/src/providers/phala/sse.ts +43 -0
- package/src/providers/phala/webcrypto-globals.d.ts +17 -0
- package/src/providers/phalaSeal.ts +200 -0
- package/src/providers/sealedShim.ts +295 -0
- package/src/remote/channelsControl.ts +8 -8
- package/src/remote/controlAuth.ts +1 -1
- package/src/remote/liveTaskSession.ts +15 -7
- package/src/remote/mcpControl.ts +2 -2
- package/src/remote/relayClient.ts +41 -21
- package/src/remote/remoteBridge.ts +17 -7
- package/src/remote/routinesControl.ts +7 -7
- package/src/remote/workflowsControl.ts +6 -6
- package/src/routines/delivery.ts +6 -6
- package/src/routines/schema.ts +3 -3
- package/src/routines/store.ts +3 -3
- package/src/routines/trigger.ts +1 -1
- package/src/tools/routine.ts +8 -8
- package/src/util/fileMentions.ts +232 -0
- package/src/workflows/expr.ts +1 -1
- package/src/workflows/runner.ts +3 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +1 -1
- package/src/daemon/ipc.ts +0 -127
|
@@ -2,7 +2,7 @@ import type { Server } from "node:net";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
// Pi session stack. The
|
|
5
|
+
// Pi session stack. The harbor MUST be launched after ./boot.ts (env +
|
|
6
6
|
// attestation dispatcher) — these are evaluated on import.
|
|
7
7
|
import {
|
|
8
8
|
createAgentSessionServices,
|
|
@@ -14,7 +14,7 @@ import { agentVersion } from "../config/version.ts";
|
|
|
14
14
|
import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
|
|
15
15
|
import { makePermissionGate, type GateController } from "../ext/permissionGate.ts";
|
|
16
16
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
17
|
-
import { makeAccountProvider } from "../providers/account.ts";
|
|
17
|
+
import { makeAccountProvider, privateerChannel } from "../providers/account.ts";
|
|
18
18
|
import { resolveDefaultModel } from "../providers/defaultModel.ts";
|
|
19
19
|
import { RelayClient, type TaskSpec } from "../remote/relayClient.ts";
|
|
20
20
|
import { createLiveTaskSession, type LiveTaskHandle } from "../remote/liveTaskSession.ts";
|
|
@@ -30,7 +30,7 @@ import { openJsonFromApp } from "../crypto/terminalUnseal.ts";
|
|
|
30
30
|
import { verifyChannelSave, verifyOutboxKey } from "../crypto/accountVerify.ts";
|
|
31
31
|
import { loadAccountSignKey, loadLastControlTs, saveLastControlTs } from "../crypto/accountTrust.ts";
|
|
32
32
|
import { authorizeControl } from "../remote/controlAuth.ts";
|
|
33
|
-
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke } from "../auth/privateer.ts";
|
|
33
|
+
import { hasCredentials, revokeLocalSessions, revokeAccountSession, apiRequest, acquireAccountCredential, handleServerRevoke, defaultDeviceLabel } from "../auth/privateer.ts";
|
|
34
34
|
import {
|
|
35
35
|
loadRoutines,
|
|
36
36
|
upsertRoutine,
|
|
@@ -50,7 +50,7 @@ import { splitRoutineTools } from "../routines/toolSelect.ts";
|
|
|
50
50
|
import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
|
|
51
51
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
52
52
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
53
|
-
import { startIpcServer, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
53
|
+
import { startIpcServer, HarborAlreadyRunningError, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
54
54
|
import { isHosted, publishRelayPub } from "../config/hosted.ts";
|
|
55
55
|
|
|
56
56
|
// The safe, read-only toolset for unattended runs — Pi builtins with no
|
|
@@ -71,15 +71,15 @@ const MAX_CLOUD_PLAINTEXT = 45_000;
|
|
|
71
71
|
// a stuck graph from pinning a `running` slot forever when the controller wanders off.
|
|
72
72
|
const GATE_TIMEOUT_MS = 5 * 60_000;
|
|
73
73
|
|
|
74
|
-
interface
|
|
74
|
+
interface HarborConfig {
|
|
75
75
|
defaultModel: string;
|
|
76
76
|
webhooks?: Record<string, { url: string; secret?: string; headers?: Record<string, string> }>;
|
|
77
77
|
providers?: Record<string, { apiKey?: string } | undefined>;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// Minimal config read (webhooks + providers-for-redaction + default model). The
|
|
81
|
-
// full config layer is a Phase-7 port; the
|
|
82
|
-
function
|
|
81
|
+
// full config layer is a Phase-7 port; the harbor only needs these fields.
|
|
82
|
+
function loadHarborConfig(): HarborConfig {
|
|
83
83
|
try {
|
|
84
84
|
const raw = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
85
85
|
return {
|
|
@@ -156,7 +156,7 @@ export function taskControlArgs(spec: TaskSpec): Record<string, unknown> {
|
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
export class
|
|
159
|
+
export class Harbor {
|
|
160
160
|
private server?: Server;
|
|
161
161
|
private timer?: ReturnType<typeof setInterval>;
|
|
162
162
|
private readonly startedAt = Date.now();
|
|
@@ -170,36 +170,36 @@ export class Daemon {
|
|
|
170
170
|
// socket stays open).
|
|
171
171
|
private lastActivityAt = Date.now();
|
|
172
172
|
// Live, app-drivable sessions spawned on demand (task_spawn). Each has its OWN relay
|
|
173
|
-
// terminal (task-<uuid>); the
|
|
173
|
+
// terminal (task-<uuid>); the harbor just keeps handles so it can reap them on shutdown.
|
|
174
174
|
private readonly liveTasks = new Map<string, LiveTaskHandle>();
|
|
175
175
|
|
|
176
|
-
// App-facing routine management (list/save/delete/pause/run) over the
|
|
177
|
-
// relay. Run-now is injected here since only the
|
|
176
|
+
// App-facing routine management (list/save/delete/pause/run) over the harbor's
|
|
177
|
+
// relay. Run-now is injected here since only the harbor can actually fire one;
|
|
178
178
|
// webhook validation reads config fresh so a just-declared endpoint is honored.
|
|
179
179
|
private readonly routines = makeRoutinesControl({
|
|
180
180
|
defaultCwd: () => process.cwd(),
|
|
181
|
-
webhookExists: (name) => !!
|
|
181
|
+
webhookExists: (name) => !!loadHarborConfig().webhooks?.[name],
|
|
182
182
|
runNow: (routine) => void this.runRoutine(routine),
|
|
183
183
|
});
|
|
184
184
|
|
|
185
|
-
// App-facing channel management (list/save/remove) over the
|
|
186
|
-
// channels
|
|
185
|
+
// App-facing channel management (list/save/remove) over the harbor's relay. The
|
|
186
|
+
// channels harbor (channels/run.ts) is a SEPARATE process that may be down, so
|
|
187
187
|
// this edits config.json directly; `runningPlatforms` is a best-effort heartbeat
|
|
188
188
|
// read for a live/offline badge, never a dependency. Edits apply on the channels
|
|
189
|
-
//
|
|
189
|
+
// harbor's next restart (its deliberate fail-safe posture).
|
|
190
190
|
private readonly channels = makeChannelsControl({
|
|
191
191
|
runningPlatforms: () => readRunningPlatforms(),
|
|
192
192
|
});
|
|
193
193
|
|
|
194
194
|
// App-facing MCP connector management (list/save/set_enabled/remove) over the
|
|
195
|
-
//
|
|
195
|
+
// harbor's relay — the harbor is the Node HOST that actually runs the adapter (a
|
|
196
196
|
// phone/web client can't). Edits the SHARED agent/mcp-desktop.json + mcp.json, so a
|
|
197
197
|
// machine has one MCP config whether it was set from the desktop (IPC) or the phone
|
|
198
198
|
// (relay). Tokens ride in a SEALED box (applyMcpSave) — the relay never sees them.
|
|
199
199
|
private readonly mcp = makeMcpControl();
|
|
200
200
|
|
|
201
|
-
// App-facing workflow management (list/get/save/remove/run) over the
|
|
202
|
-
// Run-now is injected here since only the
|
|
201
|
+
// App-facing workflow management (list/get/save/remove/run) over the harbor's relay.
|
|
202
|
+
// Run-now is injected here since only the harbor owns the runner + its seams. A
|
|
203
203
|
// workflow can carry a `script` step (RCE if forged), so every mutation is
|
|
204
204
|
// account-signed + verified (guardControl) before reaching this control.
|
|
205
205
|
private readonly workflows = makeWorkflowsControl({
|
|
@@ -226,17 +226,23 @@ export class Daemon {
|
|
|
226
226
|
return "queued";
|
|
227
227
|
};
|
|
228
228
|
|
|
229
|
-
start(): void {
|
|
229
|
+
async start(): Promise<void> {
|
|
230
|
+
// Single-instance lock FIRST, before any other side effect: binding the IPC
|
|
231
|
+
// socket is the machine's mutex. If a live harbor already holds it this throws
|
|
232
|
+
// HarborAlreadyRunningError — two harbors under one ~/.privateer share a single
|
|
233
|
+
// routineRelayId(), so a second would collide on the relay and double-fire
|
|
234
|
+
// routines. Doing this first means a rejected second instance never publishes a
|
|
235
|
+
// relay key, connects, or fires a tick.
|
|
236
|
+
this.server = await startIpcServer((req) => this.handleIpc(req));
|
|
230
237
|
// Hosted only: publish our relay pubkey for the host to bind into the SEV-SNP
|
|
231
238
|
// report. Before syncRelay() so the key exists by the time we're reachable.
|
|
232
239
|
publishRelayPub();
|
|
233
240
|
this.primeSchedule();
|
|
234
241
|
this.timer = setInterval(() => void this.tick(), TICK_MS);
|
|
235
|
-
this.server = startIpcServer((req) => this.handleIpc(req));
|
|
236
242
|
this.syncRelay();
|
|
237
243
|
void this.flushPendingCloud();
|
|
238
244
|
const count = loadRoutines().filter((r) => r.enabled).length;
|
|
239
|
-
log(`
|
|
245
|
+
log(`harbor started (pid ${process.pid}); ${count} enabled routine(s). Tick every ${TICK_MS / 1000}s.`);
|
|
240
246
|
void this.tick();
|
|
241
247
|
}
|
|
242
248
|
|
|
@@ -261,7 +267,7 @@ export class Daemon {
|
|
|
261
267
|
onInterrupt: () => {},
|
|
262
268
|
// A workflow human_gate / script-approval is surfaced as a select_request; the app
|
|
263
269
|
// answers with select_response (option name) or an approval_response (allow/deny).
|
|
264
|
-
// Both resolve the pending gate — otherwise the
|
|
270
|
+
// Both resolve the pending gate — otherwise the harbor relay ignores approvals.
|
|
265
271
|
onApprovalResponse: (id, decision) => this.resolveGate(id, decision === "deny" ? "deny" : "approve"),
|
|
266
272
|
onSelectResponse: (id, value) => this.resolveGate(id, value),
|
|
267
273
|
onControllerAttached: () => this.onControllerAttached(),
|
|
@@ -321,20 +327,20 @@ export class Daemon {
|
|
|
321
327
|
this.controllerAttached = false;
|
|
322
328
|
this.relay?.stop();
|
|
323
329
|
this.relay = undefined;
|
|
324
|
-
log("relay terminated from the app; staying offline until the
|
|
330
|
+
log("relay terminated from the app; staying offline until the harbor restarts");
|
|
325
331
|
},
|
|
326
|
-
// The account signed this
|
|
332
|
+
// The account signed this harbor out server-side (revoked from the app's Linked
|
|
327
333
|
// Devices). Beyond ending remote access (onTerminate), this wipes the machine
|
|
328
334
|
// login: drop the relay and clear credentials, so routines/tasks stop cleanly
|
|
329
335
|
// instead of dead-ending on a 401 each run. Stays idle until you /login on this
|
|
330
|
-
// machine and restart the
|
|
336
|
+
// machine and restart the harbor (the relayTerminated guard, as with onTerminate).
|
|
331
337
|
onRevoked: () => {
|
|
332
338
|
this.relayTerminated = true;
|
|
333
339
|
this.controllerAttached = false;
|
|
334
340
|
this.relay?.stop();
|
|
335
341
|
this.relay = undefined;
|
|
336
342
|
handleServerRevoke();
|
|
337
|
-
log("account signed out from the app (session revoked) — cleared credentials; idle until you run /login on this machine and restart the
|
|
343
|
+
log("account signed out from the app (session revoked) — cleared credentials; idle until you run /login on this machine and restart the harbor");
|
|
338
344
|
},
|
|
339
345
|
onStatus: (text) => log(`relay: ${text}`),
|
|
340
346
|
onDisconnected: () => {
|
|
@@ -448,7 +454,7 @@ export class Daemon {
|
|
|
448
454
|
return this.channels.save(withSecrets as any).message;
|
|
449
455
|
}
|
|
450
456
|
|
|
451
|
-
// Verify an account-signed mutating control frame (H2) against this
|
|
457
|
+
// Verify an account-signed mutating control frame (H2) against this harbor's termId,
|
|
452
458
|
// then run the mutation. Fail-closed: an unsigned/forged/stale frame returns the
|
|
453
459
|
// refusal message and the mutation NEVER runs. `routines_*` and `channels_remove`
|
|
454
460
|
// route through here; `channels_save` has its own verify (sealed secrets) above.
|
|
@@ -517,11 +523,22 @@ export class Daemon {
|
|
|
517
523
|
}
|
|
518
524
|
}
|
|
519
525
|
|
|
526
|
+
// This machine's origin tag, embedded (E2EE) in every sealed result so the app can
|
|
527
|
+
// show WHICH box/environment produced it — the outbox record itself is account-only,
|
|
528
|
+
// so attribution can only live inside the sealed blob (where hostnames are allowed;
|
|
529
|
+
// the server never sees it). `id` is this install's stable relay id; `label` is the
|
|
530
|
+
// hostname-based device name. Cached — it never changes for the process lifetime.
|
|
531
|
+
private originCache?: { id: string; label: string };
|
|
532
|
+
private machineOrigin(): { id: string; label: string } {
|
|
533
|
+
if (!this.originCache) this.originCache = { id: routineRelayId(), label: defaultDeviceLabel() };
|
|
534
|
+
return this.originCache;
|
|
535
|
+
}
|
|
536
|
+
|
|
520
537
|
private async postOutbox(name: string, at: string, status: "ok" | "error", content: string, kind: "routine" | "task" = "routine"): Promise<boolean> {
|
|
521
538
|
const pub = await this.ensureOutboxPub();
|
|
522
539
|
if (!pub) return false;
|
|
523
540
|
const body = content.length > MAX_CLOUD_PLAINTEXT ? content.slice(0, MAX_CLOUD_PLAINTEXT) + "\n…truncated" : content;
|
|
524
|
-
const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body });
|
|
541
|
+
const sealed = sealJson(pub, { v: 1, kind, name, status, at, content: body, origin: this.machineOrigin() });
|
|
525
542
|
try {
|
|
526
543
|
const res = await apiRequest("/api/outbox", {
|
|
527
544
|
method: "POST",
|
|
@@ -577,7 +594,7 @@ export class Daemon {
|
|
|
577
594
|
}
|
|
578
595
|
|
|
579
596
|
// ── Harbor hosted mode ──────────────────────────────────────────────────────
|
|
580
|
-
// A hosted
|
|
597
|
+
// A hosted harbor runs on-demand: it reports its earliest upcoming fire time so
|
|
581
598
|
// the server can wake it while suspended, and idle-suspends when there's no work.
|
|
582
599
|
// No-op on a user's own machine (isHosted() === false).
|
|
583
600
|
|
|
@@ -652,7 +669,7 @@ export class Daemon {
|
|
|
652
669
|
this.markActivity(); // hosted: work in progress — don't idle-suspend under it
|
|
653
670
|
log(`running routine "${routine.name}"`);
|
|
654
671
|
|
|
655
|
-
const config =
|
|
672
|
+
const config = loadHarborConfig();
|
|
656
673
|
const modelSpec = routine.model ?? config.defaultModel;
|
|
657
674
|
const split = splitRoutineTools(routine.tools);
|
|
658
675
|
// MCP tools (server__tool) join the allow-list: the mcpAdapter loaded in runSession
|
|
@@ -727,7 +744,18 @@ export class Daemon {
|
|
|
727
744
|
cwd: spec.cwd,
|
|
728
745
|
agentDir: agentDir(),
|
|
729
746
|
resourceLoaderOptions: {
|
|
730
|
-
extensionFactories: [
|
|
747
|
+
extensionFactories: [
|
|
748
|
+
makePermissionGate(gate),
|
|
749
|
+
// Per-model verified-TEE capability for pi-privacy's /models picker: show
|
|
750
|
+
// Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
|
|
751
|
+
// when logged in; ZDR-channel models stay at their honest floor. The live
|
|
752
|
+
// verdict still comes from accountPosture on select — this only lifts the label.
|
|
753
|
+
makePiPrivacyExtension({
|
|
754
|
+
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
755
|
+
}),
|
|
756
|
+
makeAccountProvider(),
|
|
757
|
+
mcpAdapter,
|
|
758
|
+
] as any,
|
|
731
759
|
},
|
|
732
760
|
});
|
|
733
761
|
servicesRef = services as any;
|
|
@@ -770,7 +798,7 @@ export class Daemon {
|
|
|
770
798
|
status = "error";
|
|
771
799
|
error = err instanceof Error ? err.message : String(err);
|
|
772
800
|
} finally {
|
|
773
|
-
// Revoke ONLY this run's account inference session (the
|
|
801
|
+
// Revoke ONLY this run's account inference session (the harbor's own child API
|
|
774
802
|
// session — relay/outbox — stays alive until shutdown). Drop Pi's persisted copy
|
|
775
803
|
// too so a later run's fallback never reuses a revoked token. Best-effort.
|
|
776
804
|
if (spawnedAccount) {
|
|
@@ -788,7 +816,7 @@ export class Daemon {
|
|
|
788
816
|
// just executes. Concurrency-guarded by a `task:<title>` key in `this.running` (never
|
|
789
817
|
// collides with routine ids, which are uuids).
|
|
790
818
|
async runTask(spec: TaskSpec): Promise<void> {
|
|
791
|
-
const config =
|
|
819
|
+
const config = loadHarborConfig();
|
|
792
820
|
const cwd = spec.cwd && spec.cwd.trim() ? spec.cwd : process.cwd();
|
|
793
821
|
const modelSpec = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
|
|
794
822
|
const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
|
|
@@ -825,7 +853,7 @@ export class Daemon {
|
|
|
825
853
|
void (async () => {
|
|
826
854
|
try {
|
|
827
855
|
const handle = await createLiveTaskSession(spec, {
|
|
828
|
-
defaultModel:
|
|
856
|
+
defaultModel: loadHarborConfig().defaultModel,
|
|
829
857
|
parseSpec,
|
|
830
858
|
log,
|
|
831
859
|
onClosed: (id) => this.liveTasks.delete(id),
|
|
@@ -845,7 +873,7 @@ export class Daemon {
|
|
|
845
873
|
|
|
846
874
|
// Run a saved workflow graph to completion (workflows_run / the injected runNow). The
|
|
847
875
|
// signed-frame gate (guardControl, STRICT) already ran before this is reached. Wires the
|
|
848
|
-
// runner's injected seams to the
|
|
876
|
+
// runner's injected seams to the harbor's real capabilities: agent steps → runSession
|
|
849
877
|
// (SAFE_TOOLS gate), gates → relay approvals, scripts → a gated child process (only when
|
|
850
878
|
// attended + approved; the runner fail-closes an unattended script itself), and the
|
|
851
879
|
// result is sealed to the outbox + mirrored live, exactly like an ad-hoc task.
|
|
@@ -863,9 +891,9 @@ export class Daemon {
|
|
|
863
891
|
runScript: (step, cwd) => this.runScript(step, cwd),
|
|
864
892
|
askGate: (step, promptText) => this.askGate(step.options.map((o) => ({ name: o.name, description: o.description })), promptText),
|
|
865
893
|
attended: () => this.controllerAttached,
|
|
866
|
-
// Preserve the
|
|
894
|
+
// Preserve the harbor's one-at-a-time discipline: fan-out (parallel/for_each) runs
|
|
867
895
|
// sequentially here, so a workflow never spawns concurrent headless sessions on the
|
|
868
|
-
// resident
|
|
896
|
+
// resident harbor. (The standalone runner defaults to 4; a UI host can raise it.)
|
|
869
897
|
concurrency: 1,
|
|
870
898
|
// An effectful step reached while unattended: seal a "needs approval" notice so the
|
|
871
899
|
// user catches up, and (if a controller is somehow attached) surface it live.
|
|
@@ -878,7 +906,7 @@ export class Daemon {
|
|
|
878
906
|
},
|
|
879
907
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
880
908
|
log: (m) => log(` [wf ${wf.workflow.name}] ${m}`),
|
|
881
|
-
// Live progress: announce each step start in the
|
|
909
|
+
// Live progress: announce each step start in the harbor terminal's feed.
|
|
882
910
|
onEvent: (ev) => {
|
|
883
911
|
if (ev.type === "step_start") this.relay?.sendNotice(`▶ ${ev.name}`);
|
|
884
912
|
},
|
|
@@ -969,7 +997,7 @@ export class Daemon {
|
|
|
969
997
|
// (so a later step can route on `{{ step.output.field }}`). Non-JSON output leaves
|
|
970
998
|
// `output` empty and lives in `text` — the raw/display path.
|
|
971
999
|
private async runWorkflowAgent(spec: AgentRunSpec): Promise<AgentRunResult> {
|
|
972
|
-
const config =
|
|
1000
|
+
const config = loadHarborConfig();
|
|
973
1001
|
const model = spec.model && spec.model.trim() ? spec.model : config.defaultModel;
|
|
974
1002
|
const split = spec.tools && spec.tools.length ? splitRoutineTools(spec.tools) : undefined;
|
|
975
1003
|
const tools = [...(split && split.builtin.length > 0 ? split.builtin : SAFE_TOOLS), ...(split?.mcp ?? [])];
|
|
@@ -1031,15 +1059,24 @@ export class Daemon {
|
|
|
1031
1059
|
}
|
|
1032
1060
|
}
|
|
1033
1061
|
|
|
1034
|
-
// Entry point for `privateer
|
|
1035
|
-
export function
|
|
1036
|
-
const
|
|
1037
|
-
daemon.start();
|
|
1062
|
+
// Entry point for `privateer harbor`. Caller must have imported ./boot.ts first.
|
|
1063
|
+
export function runHarbor(): void {
|
|
1064
|
+
const harbor = new Harbor();
|
|
1038
1065
|
const shutdown = () => {
|
|
1039
1066
|
log("shutting down");
|
|
1040
|
-
|
|
1067
|
+
harbor.stop();
|
|
1041
1068
|
void revokeLocalSessions().finally(() => process.exit(0));
|
|
1042
1069
|
};
|
|
1043
1070
|
process.on("SIGINT", shutdown);
|
|
1044
1071
|
process.on("SIGTERM", shutdown);
|
|
1072
|
+
harbor.start().catch((err) => {
|
|
1073
|
+
if (err instanceof HarborAlreadyRunningError) {
|
|
1074
|
+
// A resident harbor already owns this machine — leave it in charge. Exit 0 so a
|
|
1075
|
+
// manual `privateer harbor run` beside the installed login service isn't an error.
|
|
1076
|
+
process.stderr.write("A Harbor is already running on this machine — leaving the existing one in charge.\n");
|
|
1077
|
+
process.exit(0);
|
|
1078
|
+
}
|
|
1079
|
+
process.stderr.write(`Harbor failed to start: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1080
|
+
process.exit(1);
|
|
1081
|
+
});
|
|
1045
1082
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createServer, createConnection, type Socket, type Server } from "node:net";
|
|
2
|
+
import { existsSync, unlinkSync, chmodSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { globalDir } from "../config/paths.ts";
|
|
5
|
+
import type { Routine } from "../routines/schema.ts";
|
|
6
|
+
|
|
7
|
+
// The CLI/TUI talks to the resident harbor over a unix domain socket. The protocol
|
|
8
|
+
// is one JSON request per connection, answered with one JSON response, both
|
|
9
|
+
// newline-terminated. Kept tiny and local — nothing crosses the machine boundary.
|
|
10
|
+
|
|
11
|
+
export function harborSocketPath(): string {
|
|
12
|
+
return join(globalDir(), "harbor.sock");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type IpcRequest =
|
|
16
|
+
| { cmd: "status" }
|
|
17
|
+
| { cmd: "list" }
|
|
18
|
+
| { cmd: "add"; routine: Routine }
|
|
19
|
+
| { cmd: "remove"; idOrName: string }
|
|
20
|
+
| { cmd: "pause"; idOrName: string }
|
|
21
|
+
| { cmd: "resume"; idOrName: string }
|
|
22
|
+
| { cmd: "run-now"; idOrName: string }
|
|
23
|
+
| { cmd: "reload" };
|
|
24
|
+
|
|
25
|
+
export interface IpcResponse {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
message?: string;
|
|
28
|
+
routines?: Routine[];
|
|
29
|
+
// Harbor liveness/uptime for `status`.
|
|
30
|
+
pid?: number;
|
|
31
|
+
uptimeSec?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
|
|
35
|
+
|
|
36
|
+
// Probe whether a LIVE process is listening on the socket at `path`. Used as the
|
|
37
|
+
// single-instance test: a successful connect (or a slow-to-answer one) means a real
|
|
38
|
+
// harbor holds the lock; ECONNREFUSED/ENOENT means the socket file is stale (no
|
|
39
|
+
// listener behind it) and is safe to reclaim. Conservative — any ambiguous error
|
|
40
|
+
// resolves `true` so we never steal a path that might still be owned.
|
|
41
|
+
function probeExistingListener(path: string, timeoutMs = 1000): Promise<boolean> {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const sock = createConnection(path);
|
|
44
|
+
const done = (live: boolean) => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
try { sock.destroy(); } catch { /* already gone */ }
|
|
47
|
+
resolve(live);
|
|
48
|
+
};
|
|
49
|
+
const timer = setTimeout(() => done(true), timeoutMs); // slow to answer ⇒ assume live
|
|
50
|
+
sock.on("connect", () => done(true));
|
|
51
|
+
sock.on("error", (err: NodeJS.ErrnoException) => {
|
|
52
|
+
done(!(err.code === "ECONNREFUSED" || err.code === "ENOENT"));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Start the harbor-side socket server. Resolves with the Server (so the caller can
|
|
58
|
+
// close it), or REJECTS with HarborAlreadyRunningError if a live harbor already owns
|
|
59
|
+
// the socket — the bind is the machine's single-instance lock. Two harbors under one
|
|
60
|
+
// ~/.privateer share a single routineRelayId(), so a second instance would collide on
|
|
61
|
+
// the relay and double-fire routines; refusing to start is the fix. A stale socket
|
|
62
|
+
// file (crash with no live listener) is detected and reclaimed, so recovery still works.
|
|
63
|
+
export function startIpcServer(handler: IpcHandler): Promise<Server> {
|
|
64
|
+
const path = harborSocketPath();
|
|
65
|
+
const build = (): Server =>
|
|
66
|
+
createServer((sock: Socket) => {
|
|
67
|
+
let buf = "";
|
|
68
|
+
sock.on("data", (chunk) => {
|
|
69
|
+
buf += chunk.toString("utf8");
|
|
70
|
+
const nl = buf.indexOf("\n");
|
|
71
|
+
if (nl < 0) return; // wait for the full line
|
|
72
|
+
const line = buf.slice(0, nl);
|
|
73
|
+
void (async () => {
|
|
74
|
+
let res: IpcResponse;
|
|
75
|
+
try {
|
|
76
|
+
res = await handler(JSON.parse(line) as IpcRequest);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
res = { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
79
|
+
}
|
|
80
|
+
sock.end(JSON.stringify(res) + "\n");
|
|
81
|
+
})();
|
|
82
|
+
});
|
|
83
|
+
sock.on("error", () => sock.destroy());
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return new Promise<Server>((resolve, reject) => {
|
|
87
|
+
// `reclaimed` guards a single stale-socket reclaim so a persistent bind failure
|
|
88
|
+
// can't loop. On EADDRINUSE we probe for a live listener rather than unlinking
|
|
89
|
+
// blindly (the old behavior, which let a second harbor silently steal the path).
|
|
90
|
+
const attempt = (reclaimed: boolean) => {
|
|
91
|
+
const server = build();
|
|
92
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
93
|
+
if (err.code !== "EADDRINUSE") { reject(err); return; }
|
|
94
|
+
void probeExistingListener(path).then((live) => {
|
|
95
|
+
if (live) { reject(new HarborAlreadyRunningError()); return; }
|
|
96
|
+
if (reclaimed) { reject(err); return; } // already reclaimed once — give up
|
|
97
|
+
try { unlinkSync(path); } catch { /* ignore — retry surfaces a clearer error */ }
|
|
98
|
+
attempt(true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
server.listen(path, () => {
|
|
102
|
+
try {
|
|
103
|
+
chmodSync(path, 0o600); // owner-only IPC endpoint
|
|
104
|
+
} catch {
|
|
105
|
+
/* non-POSIX — best effort */
|
|
106
|
+
}
|
|
107
|
+
resolve(server);
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
attempt(false);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Client side: send one request, resolve with the response. Rejects if the harbor
|
|
115
|
+
// isn't running (no socket / connection refused) so callers can offer to start it.
|
|
116
|
+
export function sendToHarbor(req: IpcRequest, timeoutMs = 5_000): Promise<IpcResponse> {
|
|
117
|
+
const path = harborSocketPath();
|
|
118
|
+
return new Promise<IpcResponse>((resolve, reject) => {
|
|
119
|
+
if (!existsSync(path)) {
|
|
120
|
+
reject(new HarborNotRunningError());
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const sock = createConnection(path);
|
|
124
|
+
let buf = "";
|
|
125
|
+
const timer = setTimeout(() => {
|
|
126
|
+
sock.destroy();
|
|
127
|
+
reject(new Error("harbor did not respond in time"));
|
|
128
|
+
}, timeoutMs);
|
|
129
|
+
sock.on("connect", () => sock.end(JSON.stringify(req) + "\n"));
|
|
130
|
+
sock.on("data", (chunk) => {
|
|
131
|
+
buf += chunk.toString("utf8");
|
|
132
|
+
});
|
|
133
|
+
sock.on("end", () => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
try {
|
|
136
|
+
resolve(JSON.parse(buf.trim()) as IpcResponse);
|
|
137
|
+
} catch {
|
|
138
|
+
reject(new Error("malformed response from harbor"));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
sock.on("error", (err: NodeJS.ErrnoException) => {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
// ECONNREFUSED means a stale socket file with no listener behind it.
|
|
144
|
+
if (err.code === "ENOENT" || err.code === "ECONNREFUSED") reject(new HarborNotRunningError());
|
|
145
|
+
else reject(err);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class HarborNotRunningError extends Error {
|
|
151
|
+
constructor() {
|
|
152
|
+
super("Harbor is not running. Start it with `privateer harbor`.");
|
|
153
|
+
this.name = "HarborNotRunningError";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Thrown by startIpcServer when a live harbor already holds this machine's socket —
|
|
158
|
+
// i.e. a second instance is trying to start under the same ~/.privateer. The caller
|
|
159
|
+
// (runHarbor) treats this as a clean no-op exit, not a crash.
|
|
160
|
+
export class HarborAlreadyRunningError extends Error {
|
|
161
|
+
constructor() {
|
|
162
|
+
super("A Harbor is already running on this machine.");
|
|
163
|
+
this.name = "HarborAlreadyRunningError";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Convenience: is the harbor reachable right now?
|
|
168
|
+
export async function harborIsRunning(): Promise<boolean> {
|
|
169
|
+
try {
|
|
170
|
+
const res = await sendToHarbor({ cmd: "status" }, 2_000);
|
|
171
|
+
return res.ok;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|