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
@@ -0,0 +1,295 @@
1
+ // Sealed-mode (EHBP) transport for the account channel.
2
+ //
3
+ // Background: the `privateer` provider runs inference through the server at
4
+ // `${server}/api/agent/v1`, which reads the prompt in cleartext (it assembles/
5
+ // forwards the body). For `tinfoil/*` models that means the badge honestly reads
6
+ // "Trusted Execution (unconfirmed)": the enclave is real, but a quote fetched
7
+ // through the proxy can't be bound to THIS connection (see account.ts and
8
+ // docs/tee-privateer-tinfoil-ehbp.md).
9
+ //
10
+ // Sealed mode closes that. Tinfoil's EHBP (Encrypted HTTP Body Protocol) is HPKE
11
+ // applied to the HTTP body only, independent of TLS, and the enclave's attestation
12
+ // binds the HPKE key. The Privateer server already exposes a blind relay for it
13
+ // (`${server}/api/sealed/:provider`, treeview/server/routes/sealed.js): it forwards
14
+ // the ciphertext body + the `Ehbp-*` headers, injects the provider key, and meters
15
+ // on a cleartext usage header — it never sees the prompt. The treeview app already
16
+ // speaks this (client/services/pipeline/transport/tinfoilProvider.ts).
17
+ //
18
+ // Pi's `openai-completions` adapter does the HTTP itself and exposes no custom-fetch
19
+ // hook, so we can't seal from inside the provider config. Instead we run an
20
+ // in-process loopback HTTP server (this module): Pi POSTs a plain OpenAI request to
21
+ // it, the shim seals the body to the attested enclave via Tinfoil's `SecureClient`,
22
+ // forwards Pi's account bearer + the cleartext `X-Sealed-Model` billing header, and
23
+ // streams the decrypted response back. The provider points the `tinfoil/*` models'
24
+ // per-model `baseUrl` at this shim (account.ts).
25
+ //
26
+ // The one SecureClient per provider is shared by the data plane (the shim) and the
27
+ // posture check (attestSealed), so the green shield reflects the exact client that
28
+ // carries the tokens — the invariant: attest the key we actually seal to.
29
+
30
+ import http from "node:http";
31
+ import { Readable } from "node:stream";
32
+ import { SecureClient } from "tinfoil";
33
+ import { serverBaseUrl } from "../auth/privateer.ts";
34
+ import { attestPhala, phalaSealedFetch } from "./phalaSeal.ts";
35
+ import { iterateSSE } from "./phala/sse.ts";
36
+
37
+ // Providers whose enclave supports application-layer body encryption + client-verified
38
+ // attestation, and for which we have a Node client: Tinfoil (EHBP) and Phala (ACI
39
+ // E2EE). NEAR does NOT — it's attested TLS only, so it stays the confidential
40
+ // (unsealed) path, not sealed. See docs/tee-verified-tinfoil-ehbp.md §12.
41
+ export type SealedProvider = "tinfoil" | "phala";
42
+
43
+ // Sealed mode is OFF until verified end-to-end against a live relay (a real EHBP
44
+ // round-trip needs the deployed relay + TINFOIL_API_KEY; see the live checklist in
45
+ // docs/tee-privateer-tinfoil-ehbp.md). Off = the current plaintext path + honest
46
+ // yellow badge, untouched. Flip with PRIVATEER_SEALED=1.
47
+ export function sealedEnabled(): boolean {
48
+ const v = process.env.PRIVATEER_SEALED;
49
+ return v === "1" || v === "true";
50
+ }
51
+
52
+ // The sealed provider a model id routes through, or null if it isn't a sealed
53
+ // model (or its client isn't available here). Mirrors the server's prefix routing.
54
+ export function sealedProviderFor(modelId: string): SealedProvider | null {
55
+ if (modelId.startsWith("tinfoil/")) return "tinfoil";
56
+ if (modelId.startsWith("phala/")) return "phala";
57
+ return null;
58
+ }
59
+
60
+ // The blind-relay base for a provider — SecureClient fetches `${base}/attestation`
61
+ // and we POST `${base}/v1/chat/completions`.
62
+ export function relayBase(provider: SealedProvider): string {
63
+ return `${serverBaseUrl().replace(/\/+$/, "")}/api/sealed/${provider}`;
64
+ }
65
+
66
+ // ── One SecureClient per provider (shared: data plane + posture) ──────────────
67
+
68
+ const clients = new Map<SealedProvider, SecureClient>();
69
+ const readyPromises = new Map<SealedProvider, Promise<void>>();
70
+
71
+ function client(provider: SealedProvider): SecureClient {
72
+ let c = clients.get(provider);
73
+ if (!c) {
74
+ const base = relayBase(provider);
75
+ // attestationBundleURL == base: the SDK appends `/attestation`, which the relay
76
+ // proxies to Tinfoil's ATC. transport 'ehbp' = HPKE body sealing.
77
+ c = new SecureClient({ baseURL: base, attestationBundleURL: base, transport: "ehbp" });
78
+ clients.set(provider, c);
79
+ }
80
+ return c;
81
+ }
82
+
83
+ // Attest once and cache; on failure drop the memo so a later call re-attests
84
+ // rather than caching the error (mirrors the treeview provider).
85
+ export function ready(provider: SealedProvider): Promise<void> {
86
+ let p = readyPromises.get(provider);
87
+ if (!p) {
88
+ p = client(provider)
89
+ .ready()
90
+ .catch((err) => {
91
+ readyPromises.delete(provider);
92
+ throw err as Error;
93
+ });
94
+ readyPromises.set(provider, p);
95
+ }
96
+ return p;
97
+ }
98
+
99
+ export interface SealedAttestation {
100
+ ok: boolean;
101
+ enclave?: string;
102
+ error?: string;
103
+ }
104
+
105
+ // Drive the provider's client-side attestation (the SAME client/keyset the shim
106
+ // seals with). A green result is a quote we verified ourselves, bound to the key we
107
+ // encrypt to — that earns tee-verified. Tinfoil: SecureClient.ready() (HPKE-key
108
+ // match). Phala: verifyReportBinding + TDX quote (see phalaSeal.ts).
109
+ export async function attestSealed(provider: SealedProvider): Promise<SealedAttestation> {
110
+ if (provider === "phala") return attestPhala();
111
+ try {
112
+ await ready(provider);
113
+ return { ok: true, enclave: client(provider).getEnclaveURL() };
114
+ } catch (e) {
115
+ return { ok: false, error: (e as Error).message };
116
+ }
117
+ }
118
+
119
+ // ── Pure request shaping (unit-tested without an enclave) ─────────────────────
120
+
121
+ export interface ForwardPlan {
122
+ url: string;
123
+ headers: Record<string, string>;
124
+ body: string;
125
+ sealedModel: string;
126
+ }
127
+
128
+ // Turn Pi's plain OpenAI request into what we seal to the relay:
129
+ // - strip the `${provider}/` prefix from the body model (the enclave wants the
130
+ // bare id; the body is encrypted so the relay can't strip it — we must),
131
+ // - keep the full prefixed id on the cleartext X-Sealed-Model header (the relay
132
+ // prices billing off it — it never reads the body),
133
+ // - forward Pi's account bearer verbatim (the relay authenticates the JWT; on a
134
+ // 401 the relay's response propagates so Pi refreshes and retries).
135
+ export function buildForward(
136
+ provider: SealedProvider,
137
+ rawBody: string,
138
+ authHeader: string | undefined,
139
+ ): ForwardPlan {
140
+ let body = rawBody;
141
+ let sealedModel = "unknown";
142
+ try {
143
+ const parsed = JSON.parse(rawBody);
144
+ if (parsed && typeof parsed.model === "string") {
145
+ sealedModel = parsed.model;
146
+ const prefix = `${provider}/`;
147
+ if (parsed.model.startsWith(prefix)) parsed.model = parsed.model.slice(prefix.length);
148
+ body = JSON.stringify(parsed);
149
+ }
150
+ } catch {
151
+ // Not JSON — forward unchanged (X-Sealed-Model stays "unknown"; relay logs it).
152
+ }
153
+ const headers: Record<string, string> = {
154
+ "Content-Type": "application/json",
155
+ "X-Sealed-Model": sealedModel,
156
+ };
157
+ if (authHeader) headers.Authorization = authHeader;
158
+ return { url: `${relayBase(provider)}/v1/chat/completions`, headers, body, sealedModel };
159
+ }
160
+
161
+ // ── Loopback HTTP shim ────────────────────────────────────────────────────────
162
+
163
+ const PATH_RE = /^\/(tinfoil|phala)\/v1\/chat\/completions$/;
164
+ const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
165
+
166
+ let shimBase: string | null = null;
167
+ let shimStarting: Promise<string> | null = null;
168
+
169
+ // The shim's base URL once listening, else null. account.ts reads this to decide
170
+ // whether a sealed model can point its baseUrl at the shim yet.
171
+ export function sealedShimBase(): string | null {
172
+ return shimBase;
173
+ }
174
+
175
+ // Start the loopback shim once and return its base URL. Idempotent.
176
+ export function ensureSealedShim(): Promise<string> {
177
+ if (shimBase) return Promise.resolve(shimBase);
178
+ if (!shimStarting) shimStarting = startShim();
179
+ return shimStarting;
180
+ }
181
+
182
+ function startShim(): Promise<string> {
183
+ return new Promise((resolve, reject) => {
184
+ const server = http.createServer((req, res) => {
185
+ void handle(req, res).catch((e) => {
186
+ if (!res.headersSent) res.writeHead(502, { "Content-Type": "application/json" });
187
+ res.end(JSON.stringify({ error: { message: `sealed shim: ${(e as Error).message}` } }));
188
+ });
189
+ });
190
+ server.on("error", reject);
191
+ // Loopback only, ephemeral port. unref so the shim never keeps the process alive.
192
+ server.listen(0, "127.0.0.1", () => {
193
+ const addr = server.address();
194
+ if (addr && typeof addr === "object") {
195
+ shimBase = `http://127.0.0.1:${addr.port}`;
196
+ resolve(shimBase);
197
+ } else {
198
+ reject(new Error("sealed shim: could not determine listen port"));
199
+ }
200
+ });
201
+ server.unref();
202
+ });
203
+ }
204
+
205
+ async function handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
206
+ // Defense in depth: only serve loopback peers (the port is ephemeral + bound to
207
+ // 127.0.0.1 already, but reject anything else outright).
208
+ if (!LOOPBACK.has(req.socket.remoteAddress ?? "")) {
209
+ res.writeHead(403).end();
210
+ return;
211
+ }
212
+ const path = (req.url ?? "").split("?")[0];
213
+ const m = PATH_RE.exec(path);
214
+ if (req.method !== "POST" || !m) {
215
+ res.writeHead(404).end();
216
+ return;
217
+ }
218
+ const provider = m[1] as SealedProvider;
219
+ const raw = await readBody(req);
220
+ const authHeader = typeof req.headers["authorization"] === "string" ? req.headers["authorization"] : undefined;
221
+
222
+ if (provider === "phala") {
223
+ await handlePhala(raw.toString("utf8"), authHeader, res);
224
+ return;
225
+ }
226
+
227
+ // Tinfoil (EHBP): SecureClient.fetch seals/opens transparently, so we just pass the
228
+ // body through and pipe the decrypted response.
229
+ const plan = buildForward(provider, raw.toString("utf8"), authHeader);
230
+ await ready(provider);
231
+ const upstream = await client(provider).fetch(plan.url, {
232
+ method: "POST",
233
+ headers: plan.headers,
234
+ body: plan.body,
235
+ });
236
+
237
+ res.writeHead(upstream.status, {
238
+ "Content-Type": upstream.headers.get("content-type") ?? "application/json",
239
+ });
240
+ if (upstream.body) {
241
+ const nodeStream = Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]);
242
+ // If Pi hangs up mid-stream, stop pulling from the enclave.
243
+ res.on("close", () => nodeStream.destroy());
244
+ nodeStream.pipe(res);
245
+ nodeStream.on("error", () => {
246
+ if (!res.writableEnded) res.end();
247
+ });
248
+ } else {
249
+ res.end(await upstream.text());
250
+ }
251
+ }
252
+
253
+ // Phala (ACI E2EE): fields are encrypted individually, so unlike Tinfoil we must
254
+ // decrypt the response with the per-call channel — chunk by chunk for SSE, or the
255
+ // whole JSON when non-streaming — and re-emit cleartext OpenAI to Pi.
256
+ async function handlePhala(
257
+ rawBody: string,
258
+ authHeader: string | undefined,
259
+ res: http.ServerResponse,
260
+ ): Promise<void> {
261
+ const ac = new AbortController();
262
+ res.on("close", () => ac.abort());
263
+ const { res: up, channel, streaming } = await phalaSealedFetch(rawBody, authHeader, ac.signal);
264
+
265
+ if (!up.ok) {
266
+ res.writeHead(up.status, { "Content-Type": up.headers.get("content-type") ?? "application/json" });
267
+ res.end(await up.text());
268
+ return;
269
+ }
270
+
271
+ if (streaming && up.body) {
272
+ res.writeHead(up.status, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform" });
273
+ for await (const obj of iterateSSE(up.body as ReadableStream<Uint8Array>)) {
274
+ const opened = await channel.openChunk(obj as Record<string, unknown>);
275
+ res.write(`data: ${JSON.stringify(opened)}\n\n`);
276
+ }
277
+ res.write("data: [DONE]\n\n");
278
+ res.end();
279
+ return;
280
+ }
281
+
282
+ // Non-streaming: decrypt the single JSON body.
283
+ const opened = await channel.open((await up.json()) as Record<string, unknown>);
284
+ res.writeHead(up.status, { "Content-Type": "application/json" });
285
+ res.end(JSON.stringify(opened));
286
+ }
287
+
288
+ function readBody(req: http.IncomingMessage): Promise<Buffer> {
289
+ return new Promise((resolve, reject) => {
290
+ const chunks: Buffer[] = [];
291
+ req.on("data", (c: Buffer) => chunks.push(c));
292
+ req.on("end", () => resolve(Buffer.concat(chunks)));
293
+ req.on("error", reject);
294
+ });
295
+ }
@@ -3,13 +3,13 @@
3
3
  * messaging-channel config (Telegram/Slack/Discord/WhatsApp) rather than scheduled
4
4
  * routines.
5
5
  *
6
- * Like routines, this is owned by the ALWAYS-ON daemon relay (daemon/index.ts),
7
- * NOT the channels daemon (channels/run.ts) — which may be down, and which the app
6
+ * Like routines, this is owned by the ALWAYS-ON harbor relay (harbor/index.ts),
7
+ * NOT the channels harbor (channels/run.ts) — which may be down, and which the app
8
8
  * must be able to configure before it has ever run. So this control edits the
9
- * `channels` block of ~/.privateer/config.json; the channels daemon adopts changes
9
+ * `channels` block of ~/.privateer/config.json; the channels harbor adopts changes
10
10
  * on its next RESTART, matching run.ts's deliberate "no in-chat toggle, restart is
11
11
  * the fail-safe reset" posture. `running` is a best-effort read of the channels
12
- * daemon's heartbeat (channels/status.ts), never a dependency.
12
+ * harbor's heartbeat (channels/status.ts), never a dependency.
13
13
  *
14
14
  * SECRETS: bot tokens are WRITE-ONLY from the app's perspective. list() NEVER
15
15
  * returns a token value — it reports `configured`, `running`, and `secretsSet`
@@ -46,7 +46,7 @@ const SECRET_FIELDS: Record<ChannelPlatform, string[]> = {
46
46
  export interface RemoteChannel {
47
47
  platform: ChannelPlatform;
48
48
  configured: boolean; // a config block exists for this platform
49
- running: boolean; // the channels daemon is currently serving it
49
+ running: boolean; // the channels harbor is currently serving it
50
50
  adminCount: number;
51
51
  memberCount: number;
52
52
  posture: ChannelPosture;
@@ -94,7 +94,7 @@ function cleanStrList(v: unknown): string[] | undefined {
94
94
  }
95
95
 
96
96
  export function makeChannelsControl(opts: {
97
- // Which platforms the channels daemon is serving right now (heartbeat read).
97
+ // Which platforms the channels harbor is serving right now (heartbeat read).
98
98
  // Absent → everything reports not-running.
99
99
  runningPlatforms?: () => Set<string>;
100
100
  }): ChannelsControl {
@@ -173,7 +173,7 @@ export function makeChannelsControl(opts: {
173
173
  } catch (e) {
174
174
  return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
175
175
  }
176
- return { ok: true, message: `Saved ${draft.platform}. Restart the channels daemon to apply.` };
176
+ return { ok: true, message: `Saved ${draft.platform}. Restart the channels harbor to apply.` };
177
177
  },
178
178
 
179
179
  remove(platform: ChannelPlatform): { ok: boolean; message?: string } {
@@ -186,7 +186,7 @@ export function makeChannelsControl(opts: {
186
186
  } catch (e) {
187
187
  return { ok: false, message: `Couldn't write config: ${e instanceof Error ? e.message : String(e)}` };
188
188
  }
189
- return { ok: true, message: `Removed ${platform}. Restart the channels daemon to apply.` };
189
+ return { ok: true, message: `Removed ${platform}. Restart the channels harbor to apply.` };
190
190
  },
191
191
  };
192
192
  }
@@ -9,7 +9,7 @@
9
9
  // skill). channels_save has its own bespoke verify (it also carries sealed secrets);
10
10
  // everything else routes through here.
11
11
  //
12
- // Both loci call this: the daemon (routines_*/channels_remove, termId = routineRelayId)
12
+ // Both loci call this: the harbor (routines_*/channels_remove, termId = routineRelayId)
13
13
  // and each interactive terminal (extensions_*/skills_*, termId = the relay's id).
14
14
  //
15
15
  // Fail-closed: no pinned account key, a missing signature, a bad signature, or a stale
@@ -1,9 +1,9 @@
1
- // A live, app-drivable agent session spawned on demand by the daemon (task_spawn,
2
- // mode:"live"). Unlike a headless task (daemon/index.ts runTask → restricted tools +
1
+ // A live, app-drivable agent session spawned on demand by the harbor (task_spawn,
2
+ // mode:"live"). Unlike a headless task (harbor/index.ts runTask → restricted tools +
3
3
  // bypass gate + outbox), this stands up a FULL interactive session behind its own relay
4
4
  // terminal so the app can attach and drive it in real time: stream tokens, approve each
5
5
  // tool, interrupt. It is the same wiring cli/chat.ts uses for `/remote-access`, factored
6
- // so the daemon can create one without a TTY.
6
+ // so the harbor can create one without a TTY.
7
7
  //
8
8
  // SAFETY: the gate runs in "default" mode, so on a driven turn every gated tool relays to
9
9
  // the app for allow/deny (bridge.remoteAsk) and fail-closes if the controller is gone —
@@ -20,10 +20,10 @@ import { agentVersion } from "../config/version.ts";
20
20
  import { createEngineEventAdapter } from "../bridge/engineAdapter.ts";
21
21
  import { makePermissionGate, isRemoteUnsafeTool, type GateController } from "../ext/permissionGate.ts";
22
22
  import { makePiPrivacyExtension } from "pi-privacy";
23
- import { makeAccountProvider } from "../providers/account.ts";
23
+ import { makeAccountProvider, privateerChannel } from "../providers/account.ts";
24
24
  import { RelayClient, type TaskSpec } from "./relayClient.ts";
25
25
  import { RemoteBridge } from "./remoteBridge.ts";
26
- import { spawnAccountCredentials, revokeAccountSession } from "../auth/privateer.ts";
26
+ import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
27
27
 
28
28
  export interface LiveTaskHandle {
29
29
  termId: string;
@@ -70,7 +70,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
70
70
  if (lifeTimer) clearTimeout(lifeTimer);
71
71
  try { relay?.stop(); } catch { /* already stopped */ }
72
72
  // Revoke ONLY this session's account inference session so it doesn't linger in the
73
- // app's Linked Devices; the daemon's own child session stays alive. Best-effort.
73
+ // app's Linked Devices; the harbor's own child session stays alive. Best-effort.
74
74
  if (spawnedAccount) {
75
75
  try { await revokeAccountSession(); } catch { /* server TTL is the fallback */ }
76
76
  try { servicesRef?.authStorage?.remove?.("privateer"); } catch { /* nothing persisted */ }
@@ -138,7 +138,15 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
138
138
  cwd,
139
139
  agentDir: agentDir(),
140
140
  resourceLoaderOptions: {
141
- extensionFactories: [makePermissionGate(gate), makePiPrivacyExtension(), makeAccountProvider()] as any,
141
+ extensionFactories: [
142
+ makePermissionGate(gate),
143
+ // Per-model verified-TEE label for the /models picker (see harbor/index.ts):
144
+ // TEE-channel Privateer models verify on select when logged in; ZDR stays floored.
145
+ makePiPrivacyExtension({
146
+ privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
147
+ }),
148
+ makeAccountProvider(),
149
+ ] as any,
142
150
  },
143
151
  });
144
152
  servicesRef = services as any;
@@ -2,7 +2,7 @@
2
2
  * MCP connector management for the app — the sibling of channelsControl.ts, but for
3
3
  * MCP server config rather than messaging channels. It is what lets the phone/web
4
4
  * client add, toggle, and remove MCP connectors on a Node HOST it drives over the
5
- * relay (the daemon today; an interactive terminal by the same shape).
5
+ * relay (the harbor today; an interactive terminal by the same shape).
6
6
  *
7
7
  * The client itself can NEVER run MCP — a browser tab / RN runtime can't spawn a
8
8
  * stdio child or hold the adapter. So "serving MCP to phone/web" means MANAGING the
@@ -20,7 +20,7 @@
20
20
  * returns an env VALUE — only which env keys exist (`envKeys`) and which are non-empty
21
21
  * (`secretsSet`), by name. save() persists whatever env VALUES it is handed in
22
22
  * `draft.env`; the seal/open of those values in transit is the caller's job (the
23
- * daemon opens a sealed-box addressed to its terminal, mirroring applyChannelSave), so
23
+ * harbor opens a sealed-box addressed to its terminal, mirroring applyChannelSave), so
24
24
  * this module only ever deals in the plaintext files it already owns.
25
25
  *
26
26
  * Framework-agnostic: nothing here imports React or the relay. The caller owns the
@@ -45,7 +45,7 @@ function tsOf(frame: { ts?: unknown }): number | undefined {
45
45
  }
46
46
 
47
47
  // Parse a task_submit/task_spawn frame into a TaskSpec, keeping ONLY well-typed,
48
- // present fields (absent → left undefined). The daemon re-derives the canonical signed
48
+ // present fields (absent → left undefined). The harbor re-derives the canonical signed
49
49
  // args from this (taskControlArgs, undefined → null), so it must not invent fields.
50
50
  export function parseTaskSpec(frame: {
51
51
  prompt?: string;
@@ -79,10 +79,10 @@ function safe(s: string, max: number): string {
79
79
  return clip(redactSecrets(s), max);
80
80
  }
81
81
 
82
- // An ad-hoc task the app asks the daemon to run headlessly (task_submit) or to spawn
82
+ // An ad-hoc task the app asks the harbor to run headlessly (task_submit) or to spawn
83
83
  // as a live-drivable session (task_spawn). Only `prompt` is required; the rest fall back
84
- // to daemon defaults. The SAME field set is what the app canonical-signs into the control
85
- // envelope (client/services/accountSign.ts) and the daemon re-derives to verify (index.ts
84
+ // to harbor defaults. The SAME field set is what the app canonical-signs into the control
85
+ // envelope (client/services/accountSign.ts) and the harbor re-derives to verify (index.ts
86
86
  // taskControlArgs) — keep the two in sync, byte for byte, like the other signed frames.
87
87
  export interface TaskSpec {
88
88
  prompt: string;
@@ -99,7 +99,7 @@ export interface RelayCallbacks {
99
99
  onInterrupt: () => void;
100
100
  // The app asked to turn remote access OFF entirely (the in-app "End remote
101
101
  // access" action). The owner should disable /remote-access — i.e. stop this
102
- // client and not reconnect. Optional: the routines daemon handles it too, but
102
+ // client and not reconnect. Optional: the routines harbor handles it too, but
103
103
  // callbacks that predate it keep compiling.
104
104
  onTerminate?: () => void;
105
105
  // The account signed this terminal out server-side (revoked from the app's
@@ -127,6 +127,10 @@ export interface RelayCallbacks {
127
127
  // The app answered a CLI-initiated text-input prompt (the id from requestInput).
128
128
  // A null value means the app dismissed the prompt without submitting.
129
129
  onInputResponse?: (id: string, value: string | null) => void;
130
+ // The app's composer is autocompleting an `@file` mention — reply with the cwd
131
+ // files/dirs matching `query` (a sendFileMatches frame, keyed by the same id).
132
+ // Read-only; resolution of the picked path still happens on the prompt turn.
133
+ onFilesSearch?: (id: string, query: string) => void;
130
134
  // The app opened the extensions manager — reply with the current installed list
131
135
  // (a sendExtensions frame). Optional so pre-extensions callbacks keep compiling.
132
136
  onExtensionsList?: () => void;
@@ -147,10 +151,10 @@ export interface RelayCallbacks {
147
151
  // The app toggled a user skill's model-invocation availability.
148
152
  onSkillSetEnabled?: (name: string, enabled: boolean, sig?: string, ts?: number) => void;
149
153
  // The app opened the routines manager — reply with the current routines list
150
- // (a sendRoutines frame). Owned by the daemon, so these only fire on its relay.
154
+ // (a sendRoutines frame). Owned by the harbor, so these only fire on its relay.
151
155
  onRoutinesList?: () => void;
152
156
  // The app asked to create (no id) or edit (id) a routine. The raw draft object is
153
- // handed through untyped; the daemon's routinesControl validates it. Signed (H2) —
157
+ // handed through untyped; the harbor's routinesControl validates it. Signed (H2) —
154
158
  // a forged routine runs a headless bypass-mode session (RCE), so it MUST be verified.
155
159
  onRoutinesSave?: (draft: Record<string, unknown>, sig?: string, ts?: number) => void;
156
160
  // The app asked to delete a routine by id or name.
@@ -163,14 +167,14 @@ export interface RelayCallbacks {
163
167
  // right now — a fresh restricted-tool bypass session whose result is sealed to the
164
168
  // account outbox. Signed (H2) — a forged task_submit runs an arbitrary headless
165
169
  // session (RCE), identical in blast radius to a forged routines_run, so it MUST be
166
- // verified via controlAuth before it runs. Daemon-owned (fires only on its relay).
170
+ // verified via controlAuth before it runs. Harbor-owned (fires only on its relay).
167
171
  onTaskSubmit?: (spec: TaskSpec, sig?: string, ts?: number) => void;
168
172
  // The app asked to SPAWN a fresh interactive session it can drive live (mode:"live").
169
- // The daemon stands up a new RemoteBridge terminal and replies (sendTaskSpawned) with
173
+ // The harbor stands up a new RemoteBridge terminal and replies (sendTaskSpawned) with
170
174
  // its termId so the app can attach. Same signed-RCE gate as task_submit.
171
175
  onTaskSpawn?: (spec: TaskSpec, sig?: string, ts?: number) => void;
172
176
  // The app opened the channels manager — reply with the current channel config
173
- // (a sendChannels frame). Owned by the daemon, so these only fire on its relay.
177
+ // (a sendChannels frame). Owned by the harbor, so these only fire on its relay.
174
178
  onChannelsList?: () => void;
175
179
  // The app asked to create/edit a platform's channel config. `draft` carries only
176
180
  // NON-secret fields (roles/posture/tools/model). `sealedSecrets`, when present, is
@@ -183,7 +187,7 @@ export interface RelayCallbacks {
183
187
  // (H2) — a forged removal is a DoS (the bot stops until re-added).
184
188
  onChannelsRemove?: (platform: string, sig?: string, ts?: number) => void;
185
189
  // The app opened the MCP connectors manager — reply with the current MCP config
186
- // (a sendMcp frame). Owned by the daemon (the host that runs the adapter), so these
190
+ // (a sendMcp frame). Owned by the harbor (the host that runs the adapter), so these
187
191
  // only fire on its relay. Read-only, so unsigned.
188
192
  onMcpList?: () => void;
189
193
  // The app asked to create/edit an MCP connector. `draft` carries only NON-secret
@@ -199,12 +203,12 @@ export interface RelayCallbacks {
199
203
  // The app asked to delete a connector by name. Signed (H2) — a forged removal is a DoS.
200
204
  onMcpRemove?: (name: string, sig?: string, ts?: number) => void;
201
205
  // The app opened the workflows manager — reply with the current workflow summaries
202
- // (a sendWorkflows frame). Owned by the daemon, so these only fire on its relay.
206
+ // (a sendWorkflows frame). Owned by the harbor, so these only fire on its relay.
203
207
  onWorkflowsList?: () => void;
204
208
  // The app opened one workflow in its editor — reply with the full graph (sendWorkflow).
205
209
  onWorkflowsGet?: (idOrName: string) => void;
206
210
  // The app asked to create (no workflow.id) or edit (id) a workflow. The raw graph is
207
- // handed through untyped; the daemon's workflowsControl strict-validates it. Signed
211
+ // handed through untyped; the harbor's workflowsControl strict-validates it. Signed
208
212
  // (H2) — a forged save plants a `script` step that BYPASSES the permission gate (RCE),
209
213
  // exactly like a forged routine, so it MUST be verified before it persists.
210
214
  onWorkflowsSave?: (draft: Record<string, unknown>, sig?: string, ts?: number) => void;
@@ -286,7 +290,7 @@ export class RelayClient {
286
290
  private buf = "";
287
291
  private flushTimer: ReturnType<typeof setTimeout> | undefined;
288
292
  // Stable for this process so reconnects keep the same terminal identity. Callers
289
- // may pass a persisted id/label (e.g. the routines daemon, so it shows up as one
293
+ // may pass a persisted id/label (e.g. the routines harbor, so it shows up as one
290
294
  // recognizable "Privateer Routines" terminal across restarts instead of a fresh
291
295
  // random one each time).
292
296
  private readonly termId: string;
@@ -424,6 +428,7 @@ export class RelayClient {
424
428
  title?: string;
425
429
  tools?: unknown;
426
430
  mode?: string;
431
+ query?: string;
427
432
  sig?: string;
428
433
  ts?: number;
429
434
  };
@@ -467,6 +472,9 @@ export class RelayClient {
467
472
  case "input_response":
468
473
  if (frame.id) this.cb.onInputResponse?.(frame.id, typeof frame.value === "string" ? frame.value : null);
469
474
  break;
475
+ case "files_search":
476
+ if (typeof frame.id === "string") this.cb.onFilesSearch?.(frame.id, typeof frame.query === "string" ? frame.query : "");
477
+ break;
470
478
  case "extensions_list":
471
479
  this.cb.onExtensionsList?.();
472
480
  break;
@@ -540,7 +548,7 @@ export class RelayClient {
540
548
  break;
541
549
  case "mcp_save":
542
550
  // The connector rides in `draft` (same slot as channels_save), untyped — the
543
- // daemon strict-validates it via mcpControl.save after the signature check.
551
+ // harbor strict-validates it via mcpControl.save after the signature check.
544
552
  if (frame.draft && typeof frame.draft === "object") {
545
553
  this.cb.onMcpSave?.(
546
554
  frame.draft,
@@ -563,7 +571,7 @@ export class RelayClient {
563
571
  if (typeof frame.idOrName === "string") this.cb.onWorkflowsGet?.(frame.idOrName);
564
572
  break;
565
573
  case "workflows_save":
566
- // The graph rides in `draft` (same slot as channels_save), untyped — the daemon
574
+ // The graph rides in `draft` (same slot as channels_save), untyped — the harbor
567
575
  // strict-validates it via workflowsControl.save after the signature check.
568
576
  if (frame.draft && typeof frame.draft === "object") this.cb.onWorkflowsSave?.(frame.draft, sig(frame), tsOf(frame));
569
577
  break;
@@ -667,7 +675,7 @@ export class RelayClient {
667
675
  }
668
676
 
669
677
  // Push a workflow's live step text / final result to any attached controller as a text
670
- // event, so it renders in the daemon terminal's feed. Same durable-copy caveat as
678
+ // event, so it renders in the harbor terminal's feed. Same durable-copy caveat as
671
679
  // sendRoutineResult (the outbox is the source of truth).
672
680
  sendWorkflowResult(name: string, content: string): boolean {
673
681
  if (!this.isConnected()) return false;
@@ -678,7 +686,7 @@ export class RelayClient {
678
686
 
679
687
  // Tell the app that a live task session was stood up on `termId` (label for display),
680
688
  // so it can open a controller connection to that terminal and drive it. Fire-and-forget
681
- // over the daemon's management relay.
689
+ // over the harbor's management relay.
682
690
  sendTaskSpawned(termId: string, label: string): void {
683
691
  this.rawSend({ type: "task_spawned", termId, label });
684
692
  }
@@ -771,6 +779,18 @@ export class RelayClient {
771
779
  });
772
780
  }
773
781
 
782
+ // Reply to an `@file` autocomplete query with the matching cwd files/dirs. Keyed by
783
+ // the query's id so the app can match it to the in-flight request. Bounded; paths are
784
+ // cwd-relative (the caller — searchFiles — never emits a path outside the cwd subtree,
785
+ // so no machine location leaks beyond the project filenames the driver is browsing).
786
+ sendFileMatches(id: string, matches: { path: string; isDir: boolean }[]): void {
787
+ this.rawSend({
788
+ type: "file_matches",
789
+ id,
790
+ matches: matches.slice(0, 50).map((m) => ({ path: safe(m.path, 300), isDir: !!m.isDir })),
791
+ });
792
+ }
793
+
774
794
  // Push the terminal's installed Pi extensions (the user's own packages; the moat
775
795
  // is excluded upstream) to the app's extensions manager. Sent on request and after
776
796
  // each add/remove. `busy` drives a progress indicator; `needsRestart` tells the app
@@ -823,7 +843,7 @@ export class RelayClient {
823
843
  });
824
844
  }
825
845
 
826
- // Push the daemon's saved routines to the app's routines manager. Sent on request
846
+ // Push the harbor's saved routines to the app's routines manager. Sent on request
827
847
  // and after each save/delete/pause/run. `busy` drives a progress indicator;
828
848
  // `message` carries a one-line result/error. Unlike the feed/webhook paths this is
829
849
  // the user's OWN config echoed back to their OWN app, so fields are size-clipped
@@ -873,7 +893,7 @@ export class RelayClient {
873
893
  });
874
894
  }
875
895
 
876
- // Push the daemon's channel config to the app's channels manager. Sent on request
896
+ // Push the harbor's channel config to the app's channels manager. Sent on request
877
897
  // and after each save/remove. Like sendRoutines this is the user's OWN config
878
898
  // echoed to their OWN app — but a bot token NEVER crosses this wire: only
879
899
  // `secretsSet` (which secret fields are present, by NAME) is sent, so a relay /
@@ -953,7 +973,7 @@ export class RelayClient {
953
973
  });
954
974
  }
955
975
 
956
- // Push the daemon's saved workflows to the app's workflows manager as SUMMARIES (not
976
+ // Push the harbor's saved workflows to the app's workflows manager as SUMMARIES (not
957
977
  // the full graphs — the editor fetches one at a time via sendWorkflow). Sent on request
958
978
  // and after each save/remove/run. The user's OWN config echoed to their OWN app, so
959
979
  // fields are clipped (not redacted). List bounded like the other managers.