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
|
@@ -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
|
|
7
|
-
* NOT the channels
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2
|
-
// mode:"live"). Unlike a headless task (
|
|
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
|
|
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
|
|
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: [
|
|
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;
|
package/src/remote/mcpControl.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
85
|
-
// envelope (client/services/accountSign.ts) and the
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|