privateer-agent 0.9.0 → 0.9.1
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/extensions/privateer-privacy.ts +20 -8
- package/package.json +1 -1
- package/src/harbor/index.ts +62 -3
- package/src/harbor/ipc.ts +50 -0
- package/src/harbor/service.ts +56 -8
- package/src/providers/account.ts +46 -19
- package/src/remote/relayClient.ts +81 -2
|
@@ -5,15 +5,25 @@
|
|
|
5
5
|
// over-warning), and a zdr account model as zdr-policy. Replaces loading pi-privacy's
|
|
6
6
|
// default entry directly.
|
|
7
7
|
//
|
|
8
|
-
// It also
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
8
|
+
// It also REPAIRS two provider registrations pi-privacy makes from its own catalog, each
|
|
9
|
+
// of which replaces (not merges) whatever model list that provider already had:
|
|
10
|
+
//
|
|
11
|
+
// - `tinfoil` gets a single seed model, so any other Tinfoil model — notably our
|
|
12
|
+
// default `tinfoil/glm-5-2` — resolves as a "custom model id" with a startup warning
|
|
13
|
+
// and never shows in the picker. We re-register it with the current chat catalog.
|
|
14
|
+
// - `privateer` gets pi-privacy's PUBLIC developer-key channel (api.privateer.pro/v1 +
|
|
15
|
+
// `${PRIVATEER_API_KEY}`, one seed model), which clobbers the ACCOUNT channel our own
|
|
16
|
+
// privateer-account extension registers. That is our default model's provider, so the
|
|
17
|
+
// same "not found for provider privateer" warning followed — and worse, the model Pi
|
|
18
|
+
// synthesized pointed at the public endpoint instead of `/api/agent/v1`. We re-assert
|
|
19
|
+
// the account registration (see registerAccountModels).
|
|
20
|
+
//
|
|
21
|
+
// Both repairs run AFTER pi-privacy inside this same extension, so ours land second and
|
|
22
|
+
// win regardless of the order pi discovers extensions in. This is purely a
|
|
23
|
+
// display/resolution + routing list — posture and attestation are dispatcher-bound and
|
|
24
|
+
// unaffected by the model set.
|
|
15
25
|
import { makePiPrivacyExtension } from "pi-privacy";
|
|
16
|
-
import { accountPosture } from "../src/providers/account.ts";
|
|
26
|
+
import { accountPosture, registerAccountModels } from "../src/providers/account.ts";
|
|
17
27
|
|
|
18
28
|
// Tinfoil's live chat models (inference.tinfoil.sh/v1/models), glm-5-2 first — the
|
|
19
29
|
// launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
|
|
@@ -82,4 +92,6 @@ export default function privateerPrivacy(pi: any): void {
|
|
|
82
92
|
authHeader: true,
|
|
83
93
|
models: TINFOIL_MODELS.map(tinfoilModel),
|
|
84
94
|
});
|
|
95
|
+
// Put the ACCOUNT channel back over pi-privacy's public developer-key `privateer`.
|
|
96
|
+
registerAccountModels(pi);
|
|
85
97
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/harbor/index.ts
CHANGED
|
@@ -57,7 +57,7 @@ import { resolveMcpSelection, readMcpInventory, type ResolvedMcpTools } from "..
|
|
|
57
57
|
import { deliver, type RelayPusher, type CloudPusher } from "../routines/delivery.ts";
|
|
58
58
|
import { sealJson, decodeAccountPublicKey } from "../crypto/outboxSeal.ts";
|
|
59
59
|
import { redactText, collectSecrets } from "../util/redact.ts";
|
|
60
|
-
import { startIpcServer, HarborAlreadyRunningError, type IpcRequest, type IpcResponse } from "./ipc.ts";
|
|
60
|
+
import { startIpcServer, sendToHarbor, describeRelay, formatDuration, HarborAlreadyRunningError, type IpcRequest, type IpcResponse, type RelayStatus } from "./ipc.ts";
|
|
61
61
|
import { isHosted, publishRelayPub, webEnabled } from "../config/hosted.ts";
|
|
62
62
|
import { makeWebTools, WEB_TOOL_NAMES } from "../tools/web.ts";
|
|
63
63
|
|
|
@@ -807,6 +807,16 @@ export class Harbor {
|
|
|
807
807
|
// Privateer's TEE-channel models (near/tinfoil/phala) as "◆ Verifiable TEE"
|
|
808
808
|
// when logged in; ZDR-channel models stay at their honest floor. The live
|
|
809
809
|
// verdict still comes from accountPosture on select — this only lifts the label.
|
|
810
|
+
//
|
|
811
|
+
// ORDER MATTERS: pi-privacy's own catalog registers a `privateer`
|
|
812
|
+
// provider (its PUBLIC developer-key channel, one seed model), and Pi's
|
|
813
|
+
// registerProvider REPLACES a provider's models and request config. It
|
|
814
|
+
// must stay ABOVE makeAccountProvider() so the ACCOUNT channel lands last
|
|
815
|
+
// — otherwise the default model stops resolving and requests go to
|
|
816
|
+
// api.privateer.pro/v1 instead of /api/agent/v1. (The TUI hits this
|
|
817
|
+
// through extension discovery, where the order isn't ours to choose;
|
|
818
|
+
// extensions/privateer-privacy.ts re-asserts the account registration
|
|
819
|
+
// there. See registerAccountModels.)
|
|
810
820
|
makePiPrivacyExtension({
|
|
811
821
|
privateerVerifiedTee: (m) => hasCredentials() && privateerChannel(m.id ?? "") === "tee",
|
|
812
822
|
}),
|
|
@@ -1192,6 +1202,31 @@ export class Harbor {
|
|
|
1192
1202
|
return { text: notes.length > 0 ? `${out}${formatNotes(notes)}` : out, output, status, error };
|
|
1193
1203
|
}
|
|
1194
1204
|
|
|
1205
|
+
// What the app can actually see. A harbor is only drivable while its relay socket
|
|
1206
|
+
// is up, and every way that can fail — signed out, turned off from the app, refused
|
|
1207
|
+
// by the plan's agent cap, a socket that died without closing — used to be visible
|
|
1208
|
+
// ONLY as a line in a log file nobody reads. Reported alongside pid/uptime so
|
|
1209
|
+
// `privateer harbor status` can never again say "running" about a harbor the app
|
|
1210
|
+
// shows as offline.
|
|
1211
|
+
private relayStatus(): RelayStatus {
|
|
1212
|
+
const termId = routineRelayId();
|
|
1213
|
+
if (this.relayTerminated) {
|
|
1214
|
+
return { termId, connected: false, detail: "remote access was turned off from the app — restart the harbor to re-enable it" };
|
|
1215
|
+
}
|
|
1216
|
+
if (!this.relay) {
|
|
1217
|
+
return {
|
|
1218
|
+
termId,
|
|
1219
|
+
connected: false,
|
|
1220
|
+
detail: hasCredentials()
|
|
1221
|
+
? "relay not started"
|
|
1222
|
+
: "no account signed in on this machine — run `privateer` and /login, then restart the harbor",
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
const conn = this.relay.connectionStatus();
|
|
1226
|
+
if (!conn.connected) return { termId, connected: false, detail: "connecting…" };
|
|
1227
|
+
return { termId, connected: true, upSec: conn.upSec, quietSec: conn.quietSec };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1195
1230
|
private persistRun(id: string, patch: Partial<Routine>): void {
|
|
1196
1231
|
const current = findRoutine(loadRoutines(), id);
|
|
1197
1232
|
if (!current) return;
|
|
@@ -1201,7 +1236,13 @@ export class Harbor {
|
|
|
1201
1236
|
private async handleIpc(req: IpcRequest): Promise<IpcResponse> {
|
|
1202
1237
|
switch (req.cmd) {
|
|
1203
1238
|
case "status":
|
|
1204
|
-
return {
|
|
1239
|
+
return {
|
|
1240
|
+
ok: true,
|
|
1241
|
+
pid: process.pid,
|
|
1242
|
+
uptimeSec: Math.round((Date.now() - this.startedAt) / 1000),
|
|
1243
|
+
relay: this.relayStatus(),
|
|
1244
|
+
routines: loadRoutines(),
|
|
1245
|
+
};
|
|
1205
1246
|
case "list":
|
|
1206
1247
|
return { ok: true, routines: loadRoutines() };
|
|
1207
1248
|
case "add": {
|
|
@@ -1254,11 +1295,29 @@ export function runHarbor(): void {
|
|
|
1254
1295
|
};
|
|
1255
1296
|
process.on("SIGINT", shutdown);
|
|
1256
1297
|
process.on("SIGTERM", shutdown);
|
|
1257
|
-
harbor.start().catch((err) => {
|
|
1298
|
+
harbor.start().catch(async (err) => {
|
|
1258
1299
|
if (err instanceof HarborAlreadyRunningError) {
|
|
1259
1300
|
// A resident harbor already owns this machine — leave it in charge. Exit 0 so a
|
|
1260
1301
|
// manual `privateer harbor run` beside the installed login service isn't an error.
|
|
1302
|
+
//
|
|
1303
|
+
// Say WHICH harbor, and whether it is actually reachable from the app: the
|
|
1304
|
+
// incumbent can be a stale process that still answers IPC while its relay socket
|
|
1305
|
+
// is long dead, and "leaving the existing one in charge" then reads as reassurance
|
|
1306
|
+
// for a harbor the app shows as offline. The incumbent knows — ask it.
|
|
1261
1307
|
process.stderr.write("A Harbor is already running on this machine — leaving the existing one in charge.\n");
|
|
1308
|
+
try {
|
|
1309
|
+
const status = await sendToHarbor({ cmd: "status" }, 3_000);
|
|
1310
|
+
const up = typeof status.uptimeSec === "number" ? `, up ${formatDuration(status.uptimeSec)}` : "";
|
|
1311
|
+
process.stderr.write(` incumbent: pid ${status.pid ?? "?"}${up}\n`);
|
|
1312
|
+
process.stderr.write(` relay: ${describeRelay(status.relay)}\n`);
|
|
1313
|
+
if (status.relay && !status.relay.connected) {
|
|
1314
|
+
process.stderr.write(" Stop that process (or `privateer harbor uninstall && privateer harbor install`) to hand the machine to a fresh Harbor.\n");
|
|
1315
|
+
}
|
|
1316
|
+
} catch {
|
|
1317
|
+
// It held the lock a moment ago but won't answer now — a wedged process is
|
|
1318
|
+
// worth naming too, since nothing else will start while it holds the socket.
|
|
1319
|
+
process.stderr.write(" incumbent: holds the lock but is not answering IPC — it may be wedged; stop it and start again.\n");
|
|
1320
|
+
}
|
|
1262
1321
|
process.exit(0);
|
|
1263
1322
|
}
|
|
1264
1323
|
process.stderr.write(`Harbor failed to start: ${err instanceof Error ? err.message : String(err)}\n`);
|
package/src/harbor/ipc.ts
CHANGED
|
@@ -22,6 +22,28 @@ export type IpcRequest =
|
|
|
22
22
|
| { cmd: "run-now"; idOrName: string }
|
|
23
23
|
| { cmd: "reload" };
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The harbor's view of its own relay connection, reported by `status`.
|
|
27
|
+
*
|
|
28
|
+
* A harbor answering on this socket is running; that is NOT the same as being
|
|
29
|
+
* reachable from the app, which needs the relay socket up (the server drops a
|
|
30
|
+
* terminal from its presence registry ~60s after it stops hearing from it). The two
|
|
31
|
+
* used to be conflated — "running (answering IPC)" while the app showed the same
|
|
32
|
+
* harbor as offline — so every liveness report carries both now.
|
|
33
|
+
*/
|
|
34
|
+
export interface RelayStatus {
|
|
35
|
+
/** The relay terminal id the app looks for ("routines-…"). */
|
|
36
|
+
termId: string;
|
|
37
|
+
/** Socket open right now, i.e. the app can see and drive this harbor. */
|
|
38
|
+
connected: boolean;
|
|
39
|
+
/** Seconds the current connection has been up. */
|
|
40
|
+
upSec?: number;
|
|
41
|
+
/** Seconds since the server last sent anything (frame, ping or pong). */
|
|
42
|
+
quietSec?: number;
|
|
43
|
+
/** Why it isn't connected, when we know: signed out, turned off from the app, … */
|
|
44
|
+
detail?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
export interface IpcResponse {
|
|
26
48
|
ok: boolean;
|
|
27
49
|
message?: string;
|
|
@@ -29,6 +51,8 @@ export interface IpcResponse {
|
|
|
29
51
|
// Harbor liveness/uptime for `status`.
|
|
30
52
|
pid?: number;
|
|
31
53
|
uptimeSec?: number;
|
|
54
|
+
// Relay reachability for `status` — see RelayStatus.
|
|
55
|
+
relay?: RelayStatus;
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
|
|
@@ -164,6 +188,32 @@ export class HarborAlreadyRunningError extends Error {
|
|
|
164
188
|
}
|
|
165
189
|
}
|
|
166
190
|
|
|
191
|
+
// One-line, human rendering of a status reply's relay block — shared by
|
|
192
|
+
// `privateer harbor status` and the second-instance notice so both tell the same
|
|
193
|
+
// story. `undefined` means the harbor answering us predates this field.
|
|
194
|
+
export function describeRelay(relay?: RelayStatus): string {
|
|
195
|
+
if (!relay) return "unknown (this harbor is an older build)";
|
|
196
|
+
if (!relay.connected) {
|
|
197
|
+
return `NOT connected — the app shows this Harbor as inactive${relay.detail ? ` (${relay.detail})` : ""}`;
|
|
198
|
+
}
|
|
199
|
+
const up = typeof relay.upSec === "number" ? `, up ${formatDuration(relay.upSec)}` : "";
|
|
200
|
+
// A connected socket the server hasn't spoken on in a while is the half-open shape;
|
|
201
|
+
// the watchdog drops it within ~75s, so say so rather than reporting a flat "connected".
|
|
202
|
+
const quiet = typeof relay.quietSec === "number" && relay.quietSec > 40 ? `, quiet for ${relay.quietSec}s — checking` : "";
|
|
203
|
+
return `connected — drivable from the Privateer app (${relay.termId}${up}${quiet})`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function formatDuration(totalSec: number): string {
|
|
207
|
+
const s = Math.max(0, Math.round(totalSec));
|
|
208
|
+
const d = Math.floor(s / 86400);
|
|
209
|
+
const h = Math.floor((s % 86400) / 3600);
|
|
210
|
+
const m = Math.floor((s % 3600) / 60);
|
|
211
|
+
if (d) return `${d}d ${h}h`;
|
|
212
|
+
if (h) return `${h}h ${m}m`;
|
|
213
|
+
if (m) return `${m}m`;
|
|
214
|
+
return `${s}s`;
|
|
215
|
+
}
|
|
216
|
+
|
|
167
217
|
// Convenience: is the harbor reachable right now?
|
|
168
218
|
export async function harborIsRunning(): Promise<boolean> {
|
|
169
219
|
try {
|
package/src/harbor/service.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { join, dirname, resolve } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { globalDir } from "../config/paths.ts";
|
|
15
|
-
import { harborIsRunning } from "./ipc.ts";
|
|
15
|
+
import { harborIsRunning, sendToHarbor, describeRelay, formatDuration, type IpcResponse } from "./ipc.ts";
|
|
16
16
|
|
|
17
17
|
const LABEL = "pro.privateer.harbor"; // launchd label / reverse-dns id
|
|
18
18
|
const UNIT = "privateer-harbor.service"; // systemd --user unit name
|
|
@@ -62,7 +62,13 @@ function xmlEscape(s: string): string {
|
|
|
62
62
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
// KeepAlive is `{ SuccessfulExit: false }`, NOT plain `true` — restart on a crash,
|
|
66
|
+
// but leave a CLEAN exit alone. A bare `true` restarts unconditionally, which turns
|
|
67
|
+
// the two clean-exit paths into loops: the harbor that finds another one already
|
|
68
|
+
// holding the machine lock (exit 0 every ~10s, appending the same line to harbor.log
|
|
69
|
+
// forever — this is what produced a 7 MB log of "already running"), and a deliberate
|
|
70
|
+
// shutdown, which launchd would undo. Matches the systemd unit's Restart=on-failure.
|
|
71
|
+
export function launchdPlist(): string {
|
|
66
72
|
const args = [nodeBinaryPath(), harborLauncherPath(), "run"];
|
|
67
73
|
const envVars = forwardedEnv();
|
|
68
74
|
const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
|
|
@@ -83,7 +89,10 @@ ${argXml}
|
|
|
83
89
|
${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVariables</key>\n <dict>\n${envXml}\n </dict>\n` : ""} <key>RunAtLoad</key>
|
|
84
90
|
<true/>
|
|
85
91
|
<key>KeepAlive</key>
|
|
86
|
-
<
|
|
92
|
+
<dict>
|
|
93
|
+
<key>SuccessfulExit</key>
|
|
94
|
+
<false/>
|
|
95
|
+
</dict>
|
|
87
96
|
<key>StandardOutPath</key>
|
|
88
97
|
<string>${log}</string>
|
|
89
98
|
<key>StandardErrorPath</key>
|
|
@@ -199,6 +208,24 @@ export interface ServiceInfo {
|
|
|
199
208
|
installed: boolean;
|
|
200
209
|
unitPath: string;
|
|
201
210
|
logPath: string;
|
|
211
|
+
/** Installed unit predates a fix and should be rewritten — see needsRefresh(). */
|
|
212
|
+
needsRefresh: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Does the INSTALLED unit need rewriting? Deliberately narrow: a full text compare
|
|
216
|
+
// against what we'd generate today would flag every service installed by a different
|
|
217
|
+
// copy of the CLI (a dev checkout resolves a different launcher path), which is not a
|
|
218
|
+
// problem and not something the user should be nagged about. The one thing worth
|
|
219
|
+
// flagging is the pre-fix launchd `KeepAlive: true`, which restarts the harbor even
|
|
220
|
+
// after a clean exit — the log-spam loop described above launchdPlist().
|
|
221
|
+
export function unitNeedsRefresh(platform: NodeJS.Platform, unitPath: string): boolean {
|
|
222
|
+
if (platform !== "darwin" || !existsSync(unitPath)) return false;
|
|
223
|
+
try {
|
|
224
|
+
const plist = readFileSync(unitPath, "utf8");
|
|
225
|
+
return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(plist);
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
202
229
|
}
|
|
203
230
|
|
|
204
231
|
function unitPathFor(platform: NodeJS.Platform): string {
|
|
@@ -216,6 +243,7 @@ export function serviceInfo(): ServiceInfo {
|
|
|
216
243
|
installed: !!unitPath && existsSync(unitPath),
|
|
217
244
|
unitPath,
|
|
218
245
|
logPath: harborLogPath(),
|
|
246
|
+
needsRefresh: unitNeedsRefresh(platform, unitPath),
|
|
219
247
|
};
|
|
220
248
|
}
|
|
221
249
|
|
|
@@ -236,20 +264,40 @@ export function uninstallService(): ServiceInfo {
|
|
|
236
264
|
return serviceInfo();
|
|
237
265
|
}
|
|
238
266
|
|
|
239
|
-
// Human-readable status
|
|
240
|
-
// installed
|
|
267
|
+
// Human-readable status for `privateer harbor status`: whether the service is
|
|
268
|
+
// installed, whether a harbor is answering on the IPC socket, and — the part that
|
|
269
|
+
// actually answers "why does the app say inactive?" — whether that harbor is
|
|
270
|
+
// connected to the relay. Answering IPC only proves a local process is alive; the
|
|
271
|
+
// app lists a harbor from the server's presence registry, which a dead relay socket
|
|
272
|
+
// drops within ~60s. Reporting the first as if it implied the second is what made a
|
|
273
|
+
// stale harbor look healthy from the terminal and offline from the phone.
|
|
241
274
|
export async function statusReport(): Promise<string> {
|
|
242
275
|
const info = serviceInfo();
|
|
243
|
-
|
|
276
|
+
let status: IpcResponse | null = null;
|
|
277
|
+
try {
|
|
278
|
+
status = await sendToHarbor({ cmd: "status" }, 3_000);
|
|
279
|
+
} catch {
|
|
280
|
+
status = null; // not running, or wedged — harborIsRunning() below tells them apart
|
|
281
|
+
}
|
|
282
|
+
const live = status ? status.ok : await harborIsRunning();
|
|
283
|
+
const up = status && typeof status.uptimeSec === "number" ? `, up ${formatDuration(status.uptimeSec)}` : "";
|
|
284
|
+
const pid = status?.pid ? `pid ${status.pid}${up}` : "answering IPC";
|
|
244
285
|
const lines = [
|
|
245
286
|
`platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer harbor` manually)"}`,
|
|
246
287
|
`service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
|
|
247
|
-
`harbor: ${live ?
|
|
248
|
-
`logs: ${info.logPath}`,
|
|
288
|
+
`harbor: ${live ? `running (${pid})` : "not reachable"}`,
|
|
249
289
|
];
|
|
290
|
+
if (live) lines.push(`relay: ${describeRelay(status?.relay)}`);
|
|
291
|
+
lines.push(`logs: ${info.logPath}`);
|
|
250
292
|
// Surface a stale-unit hint: file present but nothing answering usually means it
|
|
251
293
|
// failed to boot — the log path above is where to look.
|
|
252
294
|
if (info.installed && !live) lines.push("hint: service is installed but not answering — check the log for a boot error.");
|
|
295
|
+
if (live && status?.relay && !status.relay.connected) {
|
|
296
|
+
lines.push("hint: Harbor is running but not reachable from the app. Restart it (`privateer harbor uninstall && privateer harbor install`) once the cause above is resolved.");
|
|
297
|
+
}
|
|
298
|
+
if (info.needsRefresh) {
|
|
299
|
+
lines.push("hint: the installed login service restarts Harbor even after a clean exit (older install) — run `privateer harbor install` to refresh it.");
|
|
300
|
+
}
|
|
253
301
|
return lines.join("\n");
|
|
254
302
|
}
|
|
255
303
|
|
package/src/providers/account.ts
CHANGED
|
@@ -429,6 +429,51 @@ export async function accountPosture(modelId: string): Promise<AccountPosture> {
|
|
|
429
429
|
}
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
+
// A model entry, with a per-model baseUrl override once the EHBP shim is listening:
|
|
433
|
+
// `tinfoil/*` then route through the loopback shim (which seals to the blind relay)
|
|
434
|
+
// instead of the cleartext `/api/agent/v1` proxy. Everything else keeps the provider
|
|
435
|
+
// baseUrl. Until the shim is up (or when sealed mode is off) sealed models fall back to
|
|
436
|
+
// the cleartext path — and the badge stays honestly `tee-unverified` (see accountPosture).
|
|
437
|
+
function modelEntry(id: string) {
|
|
438
|
+
const base = seedModel(id);
|
|
439
|
+
const provider = sealedEnabled() ? sealedProviderFor(id) : null;
|
|
440
|
+
const shim = sealedShimBase();
|
|
441
|
+
return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// The Pi provider config for the account channel, over a given set of model ids.
|
|
445
|
+
export function accountProviderConfig(ids: string[]): Record<string, unknown> {
|
|
446
|
+
return {
|
|
447
|
+
name: "Privateer account",
|
|
448
|
+
baseUrl: `${serverBaseUrl()}/api/agent/v1`,
|
|
449
|
+
api: "openai-completions",
|
|
450
|
+
oauth: privateerOAuthProvider,
|
|
451
|
+
models: ids.map(modelEntry),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Re-assert the account channel's registration from ANOTHER extension.
|
|
456
|
+
//
|
|
457
|
+
// pi-privacy also ships a `privateer` provider (its PRIVACY_PROVIDERS catalog) — the
|
|
458
|
+
// PUBLIC developer-key channel: baseUrl api.privateer.pro/v1, `${PRIVATEER_API_KEY}`,
|
|
459
|
+
// and a single seed model (near/zai-org/GLM-5.1-FP8). Pi's registerProvider FULLY
|
|
460
|
+
// REPLACES a provider's model list and its request config, so whichever registration
|
|
461
|
+
// lands last wins — and pi extensions are discovered with an unsorted readdirSync, which
|
|
462
|
+
// on a typical box puts privateer-privacy after privateer-account. The account channel's
|
|
463
|
+
// whole catalog was then replaced by that one model, so the default `tinfoil/glm-5-2` no
|
|
464
|
+
// longer resolved ("not found for provider privateer. Using custom model id") and the
|
|
465
|
+
// synthesized model inherited the PUBLIC endpoint instead of `/api/agent/v1`.
|
|
466
|
+
//
|
|
467
|
+
// So privateer-privacy.ts calls this right after pi-privacy runs, exactly as it re-widens
|
|
468
|
+
// `tinfoil`. Idempotent and order-independent: if privacy happens to load first, the
|
|
469
|
+
// account extension's own registration lands afterwards with the same config, and the
|
|
470
|
+
// live-catalog fetch re-registers over both moments later either way.
|
|
471
|
+
export function registerAccountModels(pi: {
|
|
472
|
+
registerProvider?: (name: string, config: unknown) => void;
|
|
473
|
+
}): void {
|
|
474
|
+
pi.registerProvider?.("privateer", accountProviderConfig(seedCatalogIds()));
|
|
475
|
+
}
|
|
476
|
+
|
|
432
477
|
// Extension factory: registers the account provider so `/login` can offer it.
|
|
433
478
|
//
|
|
434
479
|
// We register UNCONDITIONALLY (not only when a machine login already exists). Pi's
|
|
@@ -454,31 +499,13 @@ export function makeAccountProvider() {
|
|
|
454
499
|
on?: (event: string, handler: (e: unknown, ctx: unknown) => void) => void;
|
|
455
500
|
}): void => {
|
|
456
501
|
if (typeof pi.registerProvider !== "function") return;
|
|
457
|
-
// A model entry, with a per-model baseUrl override for sealed models once the
|
|
458
|
-
// EHBP shim is listening: `tinfoil/*` then route through the loopback shim (which
|
|
459
|
-
// seals to the blind relay) instead of the cleartext `/api/agent/v1` proxy.
|
|
460
|
-
// Everything else keeps the provider baseUrl below. Until the shim is up (or when
|
|
461
|
-
// sealed mode is off) sealed models fall back to the cleartext path — and the
|
|
462
|
-
// badge stays honestly `tee-unverified` (see accountPosture).
|
|
463
|
-
const modelEntry = (id: string) => {
|
|
464
|
-
const base = seedModel(id);
|
|
465
|
-
const provider = sealedEnabled() ? sealedProviderFor(id) : null;
|
|
466
|
-
const shim = sealedShimBase();
|
|
467
|
-
return provider && shim ? { ...base, baseUrl: `${shim}/${provider}/v1` } : base;
|
|
468
|
-
};
|
|
469
502
|
// Seed with the last live catalog when we have one (see seedCatalogIds): this is the
|
|
470
503
|
// list Pi resolves a saved default / a restored session model against at launch,
|
|
471
504
|
// before the live re-registration can reach the registry.
|
|
472
505
|
let lastIds: string[] = seedCatalogIds();
|
|
473
506
|
const register = (ids: string[]): void => {
|
|
474
507
|
lastIds = ids;
|
|
475
|
-
pi.registerProvider!("privateer",
|
|
476
|
-
name: "Privateer account",
|
|
477
|
-
baseUrl: `${serverBaseUrl()}/api/agent/v1`,
|
|
478
|
-
api: "openai-completions",
|
|
479
|
-
oauth: privateerOAuthProvider,
|
|
480
|
-
models: ids.map(modelEntry),
|
|
481
|
-
});
|
|
508
|
+
pi.registerProvider!("privateer", accountProviderConfig(ids));
|
|
482
509
|
};
|
|
483
510
|
register(lastIds); // immediate: provider exists this tick, with a resolvable catalog
|
|
484
511
|
// Bring up the sealed shim, then re-register so sealed models pick up their shim
|
|
@@ -233,6 +233,24 @@ const RECONNECT_MS = 3000;
|
|
|
233
233
|
// record TTL so the app's "blocked" row stays warm between attempts rather than
|
|
234
234
|
// flickering in and out of the plan-limit state.
|
|
235
235
|
const REFUSED_RECONNECT_MS = 60_000;
|
|
236
|
+
// ── Liveness ────────────────────────────────────────────────────────────────────
|
|
237
|
+
// A TCP socket can die without either side being told: a server instance restarts,
|
|
238
|
+
// a NAT/idle timer drops the flow, a laptop sleeps. The kernel keeps reporting
|
|
239
|
+
// ESTABLISHED, `ws` never fires 'close', and the reconnect path above — which only
|
|
240
|
+
// runs on close/error — never runs. That failure mode is invisible AND permanent:
|
|
241
|
+
// the server prunes the terminal from its presence registry after ~60s, so the app
|
|
242
|
+
// shows the harbor as offline while the harbor's own log says "connected", forever.
|
|
243
|
+
//
|
|
244
|
+
// So don't wait to be told. The server pings every 25s, so an alive socket sees
|
|
245
|
+
// inbound traffic at least that often; we ping on our own timer too (the peer's pong
|
|
246
|
+
// counts as inbound). If nothing arrives for LIVENESS_TIMEOUT_MS — three missed
|
|
247
|
+
// server pings — the socket is dead: terminate it and take the normal reconnect path.
|
|
248
|
+
const HEARTBEAT_MS = 20_000;
|
|
249
|
+
const LIVENESS_TIMEOUT_MS = 75_000;
|
|
250
|
+
// Cap the opening handshake too. Without this a black-holed connect leaves `this.ws`
|
|
251
|
+
// set with no open/close/error ever firing, and connect()'s `if (this.ws) return`
|
|
252
|
+
// guard then blocks every future attempt — the same permanent silence by another route.
|
|
253
|
+
const HANDSHAKE_TIMEOUT_MS = 15_000;
|
|
236
254
|
// File-transfer ceilings for app→CLI attachments. The app enforces its own caps
|
|
237
255
|
// before sending; these are a defensive backstop so a controller can't exhaust
|
|
238
256
|
// memory with a lying `size` or a flood of concurrent transfers.
|
|
@@ -291,6 +309,11 @@ export class RelayClient {
|
|
|
291
309
|
private closed = false;
|
|
292
310
|
private connecting = false;
|
|
293
311
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
312
|
+
// Liveness watchdog for the open socket (see HEARTBEAT_MS): our own ping timer plus
|
|
313
|
+
// the epoch of the last thing we heard from the server — any frame, ping or pong.
|
|
314
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
315
|
+
private lastInboundAt = 0;
|
|
316
|
+
private connectedAt = 0;
|
|
294
317
|
// Last refusal reason reported, so a 4xx is logged once instead of on every retry.
|
|
295
318
|
private refusal: string | null = null;
|
|
296
319
|
// Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
|
|
@@ -377,6 +400,8 @@ export class RelayClient {
|
|
|
377
400
|
this.settleFirstConnect(new Error("relay stopped before registering"));
|
|
378
401
|
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
|
|
379
402
|
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
|
|
403
|
+
this.stopHeartbeat();
|
|
404
|
+
this.connectedAt = 0;
|
|
380
405
|
this.bufKind = null;
|
|
381
406
|
this.buf = "";
|
|
382
407
|
this.incoming.clear();
|
|
@@ -417,7 +442,7 @@ export class RelayClient {
|
|
|
417
442
|
const wsUrl =
|
|
418
443
|
serverBaseUrl().replace(/^http/, "ws") + `/relay?ticket=${encodeURIComponent(ticket)}`;
|
|
419
444
|
this.debug(`connecting → ${wsUrl}`);
|
|
420
|
-
const ws = new WebSocket(wsUrl);
|
|
445
|
+
const ws = new WebSocket(wsUrl, { handshakeTimeout: HANDSHAKE_TIMEOUT_MS });
|
|
421
446
|
this.ws = ws;
|
|
422
447
|
let opened = false;
|
|
423
448
|
let lastErr = "";
|
|
@@ -425,12 +450,20 @@ export class RelayClient {
|
|
|
425
450
|
ws.on("open", () => {
|
|
426
451
|
opened = true;
|
|
427
452
|
this.refusal = null; // a later refusal is news again
|
|
453
|
+
this.connectedAt = Date.now();
|
|
454
|
+
this.startHeartbeat(ws);
|
|
428
455
|
this.settleFirstConnect(); // terminal is live on the relay — awaitRegistered() resolves
|
|
429
456
|
this.cb.onStatus?.("Remote access connected — drive this terminal from the Privateer app.");
|
|
430
457
|
});
|
|
431
|
-
|
|
458
|
+
// Anything the server sends counts as proof of life for the watchdog. `ws`
|
|
459
|
+
// answers server pings with a pong for us, and answers our pings with 'pong'.
|
|
460
|
+
ws.on("message", (data) => { this.lastInboundAt = Date.now(); this.handle(data); });
|
|
461
|
+
ws.on("ping", () => { this.lastInboundAt = Date.now(); });
|
|
462
|
+
ws.on("pong", () => { this.lastInboundAt = Date.now(); });
|
|
432
463
|
ws.on("close", () => {
|
|
464
|
+
this.stopHeartbeat();
|
|
433
465
|
if (this.ws === ws) this.ws = null;
|
|
466
|
+
this.connectedAt = 0;
|
|
434
467
|
this.cb.onDisconnected?.();
|
|
435
468
|
if (!this.closed) {
|
|
436
469
|
this.cb.onStatus?.(
|
|
@@ -484,6 +517,37 @@ export class RelayClient {
|
|
|
484
517
|
if (process.env.PRIVATEER_RELAY_DEBUG) this.cb.onStatus?.(`relay: ${msg}`);
|
|
485
518
|
}
|
|
486
519
|
|
|
520
|
+
// Watch one open socket: ping on a timer, and terminate it if the server has gone
|
|
521
|
+
// quiet for longer than any healthy connection ever is (see LIVENESS_TIMEOUT_MS).
|
|
522
|
+
// `terminate()` (not close()) because the point is that the peer may be gone — a
|
|
523
|
+
// close handshake would wait for a reply that never comes. The 'close' it fires
|
|
524
|
+
// takes the ordinary reconnect path, so recovery needs no separate machinery.
|
|
525
|
+
private startHeartbeat(ws: WebSocket): void {
|
|
526
|
+
this.stopHeartbeat();
|
|
527
|
+
this.lastInboundAt = Date.now();
|
|
528
|
+
this.heartbeatTimer = setInterval(() => {
|
|
529
|
+
// A socket we've since replaced or dropped isn't ours to police anymore.
|
|
530
|
+
if (this.ws !== ws) { this.stopHeartbeat(); return; }
|
|
531
|
+
if (ws.readyState !== WebSocket.OPEN) return; // closing — 'close' will clean up
|
|
532
|
+
const quietMs = Date.now() - this.lastInboundAt;
|
|
533
|
+
if (quietMs > LIVENESS_TIMEOUT_MS) {
|
|
534
|
+
this.cb.onStatus?.(
|
|
535
|
+
`Remote access went silent for ${Math.round(quietMs / 1000)}s (the connection died without closing) — dropping it and reconnecting…`,
|
|
536
|
+
);
|
|
537
|
+
this.stopHeartbeat();
|
|
538
|
+
try { ws.terminate(); } catch (_) { /* already gone — 'close' still fires */ }
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
try { ws.ping(); } catch (_) { /* socket dying — the next tick or 'close' handles it */ }
|
|
542
|
+
}, HEARTBEAT_MS);
|
|
543
|
+
// Never hold the process open for a heartbeat alone.
|
|
544
|
+
this.heartbeatTimer.unref?.();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private stopHeartbeat(): void {
|
|
548
|
+
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
|
|
549
|
+
}
|
|
550
|
+
|
|
487
551
|
private scheduleReconnect(delayMs: number = RECONNECT_MS): void {
|
|
488
552
|
if (this.closed || this.reconnectTimer) return;
|
|
489
553
|
this.reconnectTimer = setTimeout(() => {
|
|
@@ -746,6 +810,21 @@ export class RelayClient {
|
|
|
746
810
|
return this.ws?.readyState === WebSocket.OPEN;
|
|
747
811
|
}
|
|
748
812
|
|
|
813
|
+
// Connection health, for `privateer harbor status` / the IPC status reply. `quietSec`
|
|
814
|
+
// is how long since the server last said anything: a connected socket that has been
|
|
815
|
+
// quiet for longer than the server's 25s ping cadence is the shape of the half-open
|
|
816
|
+
// failure the watchdog exists to catch, so it is worth showing rather than a bare
|
|
817
|
+
// "connected".
|
|
818
|
+
connectionStatus(): { connected: boolean; upSec?: number; quietSec?: number } {
|
|
819
|
+
if (!this.isConnected()) return { connected: false };
|
|
820
|
+
const now = Date.now();
|
|
821
|
+
return {
|
|
822
|
+
connected: true,
|
|
823
|
+
upSec: this.connectedAt ? Math.round((now - this.connectedAt) / 1000) : undefined,
|
|
824
|
+
quietSec: this.lastInboundAt ? Math.round((now - this.lastInboundAt) / 1000) : undefined,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
749
828
|
// Push a finished routine result to any attached controller as a text event, so
|
|
750
829
|
// it renders in the app's live feed. Returns whether the socket was open to send
|
|
751
830
|
// on; a durable channel (file/notice) still backs this up, since we can't know
|