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.
Files changed (59) hide show
  1. package/README.md +20 -20
  2. package/SECURITY.md +1 -1
  3. package/bin/{privateer-daemon.mjs → privateer-harbor.mjs} +7 -7
  4. package/bin/privateer-launch.mjs +7 -6
  5. package/bin/privateer-subagent.mjs +1 -1
  6. package/extensions/privateer-brand.ts +1 -1
  7. package/extensions/privateer-connect.ts +2 -2
  8. package/extensions/privateer-tools.ts +1 -1
  9. package/package.json +4 -2
  10. package/src/auth/privateer.ts +2 -2
  11. package/src/channels/run.ts +16 -7
  12. package/src/channels/status.ts +7 -7
  13. package/src/cli/chat.ts +39 -5
  14. package/src/cli/{daemonCli.ts → harborCli.ts} +14 -14
  15. package/src/config/hosted.ts +5 -5
  16. package/src/crypto/accountTrust.ts +2 -2
  17. package/src/crypto/accountVerify.ts +1 -1
  18. package/src/{daemon → harbor}/index.ts +81 -44
  19. package/src/harbor/ipc.ts +175 -0
  20. package/src/{daemon → harbor}/service.ts +61 -28
  21. package/src/main.ts +1 -1
  22. package/src/mcp/catalog.ts +106 -0
  23. package/src/providers/account.ts +58 -11
  24. package/src/providers/catalog.ts +8 -6
  25. package/src/providers/defaultModel.ts +1 -1
  26. package/src/providers/phala/aci-verifier/VENDORED.md +23 -0
  27. package/src/providers/phala/aci-verifier/crypto.ts +95 -0
  28. package/src/providers/phala/aci-verifier/digest.ts +116 -0
  29. package/src/providers/phala/aci-verifier/e2ee-channel.ts +242 -0
  30. package/src/providers/phala/aci-verifier/e2ee.ts +73 -0
  31. package/src/providers/phala/aci-verifier/errors.ts +41 -0
  32. package/src/providers/phala/aci-verifier/index.ts +87 -0
  33. package/src/providers/phala/aci-verifier/jcs.ts +69 -0
  34. package/src/providers/phala/aci-verifier/receipt.ts +139 -0
  35. package/src/providers/phala/aci-verifier/report.ts +126 -0
  36. package/src/providers/phala/aci-verifier/types.ts +139 -0
  37. package/src/providers/phala/sse.ts +43 -0
  38. package/src/providers/phala/webcrypto-globals.d.ts +17 -0
  39. package/src/providers/phalaSeal.ts +200 -0
  40. package/src/providers/sealedShim.ts +295 -0
  41. package/src/remote/channelsControl.ts +8 -8
  42. package/src/remote/controlAuth.ts +1 -1
  43. package/src/remote/liveTaskSession.ts +15 -7
  44. package/src/remote/mcpControl.ts +2 -2
  45. package/src/remote/relayClient.ts +41 -21
  46. package/src/remote/remoteBridge.ts +17 -7
  47. package/src/remote/routinesControl.ts +7 -7
  48. package/src/remote/workflowsControl.ts +6 -6
  49. package/src/routines/delivery.ts +6 -6
  50. package/src/routines/schema.ts +3 -3
  51. package/src/routines/store.ts +3 -3
  52. package/src/routines/trigger.ts +1 -1
  53. package/src/tools/routine.ts +8 -8
  54. package/src/util/fileMentions.ts +232 -0
  55. package/src/workflows/expr.ts +1 -1
  56. package/src/workflows/runner.ts +3 -3
  57. package/src/workflows/schema.ts +1 -1
  58. package/src/workflows/store.ts +1 -1
  59. 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 daemon MUST be launched after ./boot.ts (env +
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 DaemonConfig {
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 daemon only needs these fields.
82
- function loadDaemonConfig(): DaemonConfig {
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 Daemon {
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 daemon just keeps handles so it can reap them on shutdown.
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 daemon's
177
- // relay. Run-now is injected here since only the daemon can actually fire one;
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) => !!loadDaemonConfig().webhooks?.[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 daemon's relay. The
186
- // channels daemon (channels/run.ts) is a SEPARATE process that may be down, so
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
- // daemon's next restart (its deliberate fail-safe posture).
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
- // daemon's relay — the daemon is the Node HOST that actually runs the adapter (a
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 daemon's relay.
202
- // Run-now is injected here since only the daemon owns the runner + its seams. A
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(`daemon started (pid ${process.pid}); ${count} enabled routine(s). Tick every ${TICK_MS / 1000}s.`);
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 daemon relay ignores approvals.
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 daemon restarts");
330
+ log("relay terminated from the app; staying offline until the harbor restarts");
325
331
  },
326
- // The account signed this daemon out server-side (revoked from the app's Linked
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 daemon (the relayTerminated guard, as with onTerminate).
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 daemon");
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 daemon's termId,
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 daemon runs on-demand: it reports its earliest upcoming fire time so
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 = loadDaemonConfig();
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: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider(), mcpAdapter] as any,
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 daemon's own child API
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 = loadDaemonConfig();
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: loadDaemonConfig().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 daemon's real capabilities: agent steps → runSession
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 daemon's one-at-a-time discipline: fan-out (parallel/for_each) runs
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 daemon. (The standalone runner defaults to 4; a UI host can raise it.)
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 daemon terminal's feed.
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 = loadDaemonConfig();
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 daemon`. Caller must have imported ./boot.ts first.
1035
- export function runDaemon(): void {
1036
- const daemon = new Daemon();
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
- daemon.stop();
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
+ }