privateer-agent 0.9.3 → 0.11.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 +83 -5
- package/bin/privateer-acp.mjs +40 -0
- package/bin/privateer-launch.mjs +16 -1
- package/extensions/privateer-gate.ts +17 -1
- package/package.json +3 -1
- package/src/acp/protocol.ts +145 -0
- package/src/acp/run.ts +376 -0
- package/src/acp/server.ts +0 -0
- package/src/channels/bridge.ts +170 -24
- package/src/channels/platforms.ts +64 -0
- package/src/channels/run.ts +24 -7
- package/src/channels/types.ts +84 -13
- package/src/channels/whatsapp.ts +40 -4
- package/src/cli/chat.ts +12 -9
- package/src/config/inlineMoat.ts +67 -0
- package/src/ext/headlessUi.ts +116 -0
- package/src/harbor/buildLock.ts +19 -0
- package/src/harbor/index.ts +9 -12
- package/src/nostr/bech32.ts +117 -0
- package/src/nostr/event.ts +92 -0
- package/src/nostr/keys.ts +172 -0
- package/src/nostr/tags.ts +60 -0
- package/src/permissions/classify.ts +61 -1
- package/src/providers/account.ts +53 -15
- package/src/providers/phala/aci-verifier/VENDORED.md +16 -0
- package/src/providers/phala/reportBinding.ts +193 -0
- package/src/providers/phalaSeal.ts +7 -3
- package/src/providers/sealedShim.ts +35 -6
- package/src/remote/channelsControl.ts +8 -16
- package/src/remote/extensionsControl.ts +2 -0
- package/src/remote/liveTaskSession.ts +16 -3
- package/src/remote/relayClient.ts +88 -12
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// no polyfills, unlike the RN app.
|
|
14
14
|
//
|
|
15
15
|
// Two-layer attestation, fail-secure:
|
|
16
|
-
// (1)
|
|
16
|
+
// (1) verifyAciReportBinding — the report's crypto binding (keyset digest,
|
|
17
17
|
// report_data == statement(nonce), endorsement sig). Self-attesting alone.
|
|
18
18
|
// (2) verifyHardwareQuote — the hardware root: @phala/dcap-qvl verifies the TDX quote
|
|
19
19
|
// against Intel collateral and binds the quote's report_data to (1)'s statement
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
|
|
23
23
|
import type { Report } from "@phala/dcap-qvl";
|
|
24
24
|
import {
|
|
25
|
-
verifyReportBinding,
|
|
26
25
|
openE2eeChannel,
|
|
27
26
|
toHex,
|
|
28
27
|
fromHex,
|
|
@@ -30,6 +29,11 @@ import {
|
|
|
30
29
|
type ReportVerification,
|
|
31
30
|
type E2eeChannel,
|
|
32
31
|
} from "./phala/aci-verifier/index.ts";
|
|
32
|
+
// Not the vendored verifyReportBinding directly: the deployed gateway signs its
|
|
33
|
+
// keyset endorsement with ecdsa-secp256k1, which upstream's Web-Crypto-only verifier
|
|
34
|
+
// refuses. This wrapper delegates ed25519 to it unchanged and adds the secp256k1 arm
|
|
35
|
+
// the spec allows (§4.3), leaving aci-verifier/ pristine for re-pulls.
|
|
36
|
+
import { verifyAciReportBinding } from "./phala/reportBinding.ts";
|
|
33
37
|
import { serverBaseUrl } from "../auth/privateer.ts";
|
|
34
38
|
|
|
35
39
|
const DEFAULT_ACCEPTABLE_TCB = ["UpToDate"];
|
|
@@ -94,7 +98,7 @@ async function establishAttestation(): Promise<VerifiedAttestation> {
|
|
|
94
98
|
if (!res.ok) throw new Error(`phala attestation HTTP ${res.status}`);
|
|
95
99
|
const report = (await res.json()) as AttestationReport;
|
|
96
100
|
|
|
97
|
-
const verification = await
|
|
101
|
+
const verification = await verifyAciReportBinding(report, nonce);
|
|
98
102
|
if (!verification.ok) {
|
|
99
103
|
const failed = verification.checks.filter((c) => !c.ok).map((c) => c.name).join(", ");
|
|
100
104
|
throw new Error(`phala attestation binding failed: ${failed}`);
|
|
@@ -40,13 +40,22 @@ import { iterateSSE } from "./phala/sse.ts";
|
|
|
40
40
|
// (unsealed) path, not sealed. See docs/tee-verified-tinfoil-ehbp.md §12.
|
|
41
41
|
export type SealedProvider = "tinfoil" | "phala";
|
|
42
42
|
|
|
43
|
-
// Sealed mode is
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
43
|
+
// Sealed mode is ON by default as of 2026-07-31, when the live checklist in
|
|
44
|
+
// docs/tee-verified-tinfoil-ehbp.md passed end to end against the deployed relay:
|
|
45
|
+
// both enclaves attest client-side (Tinfoil HPKE-key match; Phala report binding +
|
|
46
|
+
// TDX quote), a sealed turn round-trips and streams incrementally, a bogus enclave is
|
|
47
|
+
// refused rather than silently greened, and the server bills the turn.
|
|
48
|
+
//
|
|
49
|
+
// What it buys: the prompt is sealed to the enclave the client itself attested, so the
|
|
50
|
+
// badge is a quote WE checked rather than the account's word — green instead of
|
|
51
|
+
// "Trusted Execution (unconfirmed)".
|
|
52
|
+
//
|
|
53
|
+
// PRIVATEER_SEALED=0 (or =false) drops back to the cleartext `/api/agent/v1` path and
|
|
54
|
+
// the honest yellow badge. Note that is a real downgrade for `phala/*`, which is
|
|
55
|
+
// sealed-only and simply disappears from the catalog (see isServableAccountModel).
|
|
47
56
|
export function sealedEnabled(): boolean {
|
|
48
57
|
const v = process.env.PRIVATEER_SEALED;
|
|
49
|
-
return v === "
|
|
58
|
+
return !(v === "0" || v === "false");
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
// The sealed provider a model id routes through, or null if it isn't a sealed
|
|
@@ -151,7 +160,12 @@ export function buildForward(
|
|
|
151
160
|
// Not JSON — forward unchanged (X-Sealed-Model stays "unknown"; relay logs it).
|
|
152
161
|
}
|
|
153
162
|
const headers: Record<string, string> = {
|
|
154
|
-
|
|
163
|
+
// MUST carry the charset. EHBP seals the body but headers travel in cleartext
|
|
164
|
+
// (tinfoil/dist/encrypted-body-fetch.js: "EHBP only seals the body"), so this
|
|
165
|
+
// Content-Type is what the enclave's router actually validates — and it rejects a
|
|
166
|
+
// bare `application/json` with "Unsupported Media Type: Only 'application/json' is
|
|
167
|
+
// allowed" (verified live 2026-07-31: bare → 400, any `charset=` variant → 200).
|
|
168
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
155
169
|
"X-Sealed-Model": sealedModel,
|
|
156
170
|
};
|
|
157
171
|
if (authHeader) headers.Authorization = authHeader;
|
|
@@ -165,6 +179,7 @@ const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
|
165
179
|
|
|
166
180
|
let shimBase: string | null = null;
|
|
167
181
|
let shimStarting: Promise<string> | null = null;
|
|
182
|
+
let shimServer: http.Server | null = null;
|
|
168
183
|
|
|
169
184
|
// The shim's base URL once listening, else null. account.ts reads this to decide
|
|
170
185
|
// whether a sealed model can point its baseUrl at the shim yet.
|
|
@@ -179,6 +194,19 @@ export function ensureSealedShim(): Promise<string> {
|
|
|
179
194
|
return shimStarting;
|
|
180
195
|
}
|
|
181
196
|
|
|
197
|
+
// Close the shim and forget it, so a later ensureSealedShim() starts a fresh one.
|
|
198
|
+
// The listener is `unref`'d and never blocks exit, so this is not needed for
|
|
199
|
+
// shutdown — it exists so a caller that stops sealing (or a test) can drop the
|
|
200
|
+
// socket deterministically rather than leaving a port open for the process lifetime.
|
|
201
|
+
export function stopSealedShim(): Promise<void> {
|
|
202
|
+
const server = shimServer;
|
|
203
|
+
shimServer = null;
|
|
204
|
+
shimBase = null;
|
|
205
|
+
shimStarting = null;
|
|
206
|
+
if (!server) return Promise.resolve();
|
|
207
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
208
|
+
}
|
|
209
|
+
|
|
182
210
|
function startShim(): Promise<string> {
|
|
183
211
|
return new Promise((resolve, reject) => {
|
|
184
212
|
const server = http.createServer((req, res) => {
|
|
@@ -192,6 +220,7 @@ function startShim(): Promise<string> {
|
|
|
192
220
|
server.listen(0, "127.0.0.1", () => {
|
|
193
221
|
const addr = server.address();
|
|
194
222
|
if (addr && typeof addr === "object") {
|
|
223
|
+
shimServer = server;
|
|
195
224
|
shimBase = `http://127.0.0.1:${addr.port}`;
|
|
196
225
|
resolve(shimBase);
|
|
197
226
|
} else {
|
|
@@ -23,24 +23,18 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { configPath } from "../config/paths.ts";
|
|
26
|
+
// The platform roster, per-platform secret field names, and the "is this block
|
|
27
|
+
// startable" predicate all live in ONE place, shared with the channels runtime —
|
|
28
|
+
// see channels/platforms.ts. Re-exported here so existing importers of
|
|
29
|
+
// CHANNEL_PLATFORMS/ChannelPlatform from this module keep working unchanged.
|
|
30
|
+
import { CHANNEL_PLATFORMS, SECRET_FIELDS, isChannelPlatform, type ChannelPlatform } from "../channels/platforms.ts";
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
export const CHANNEL_PLATFORMS = ["telegram", "slack", "discord", "whatsapp"] as const;
|
|
30
|
-
export type ChannelPlatform = (typeof CHANNEL_PLATFORMS)[number];
|
|
32
|
+
export { CHANNEL_PLATFORMS };
|
|
33
|
+
export type { ChannelPlatform };
|
|
31
34
|
|
|
32
35
|
const POSTURES = ["readonly", "approve", "auto"] as const;
|
|
33
36
|
export type ChannelPosture = (typeof POSTURES)[number];
|
|
34
37
|
|
|
35
|
-
// The secret (never-echoed) fields per platform — the union of the token blocks
|
|
36
|
-
// run.ts requires to START each platform. `secretsSet` reports presence of these.
|
|
37
|
-
const SECRET_FIELDS: Record<ChannelPlatform, string[]> = {
|
|
38
|
-
telegram: ["botToken"],
|
|
39
|
-
slack: ["appToken", "botToken"],
|
|
40
|
-
discord: ["botToken"],
|
|
41
|
-
whatsapp: ["phoneNumberId", "accessToken", "verifyToken", "appSecret"],
|
|
42
|
-
};
|
|
43
|
-
|
|
44
38
|
// Non-secret projection of one platform's config, sent to the app. No token
|
|
45
39
|
// values, ever — only which secret fields are already present (`secretsSet`).
|
|
46
40
|
export interface RemoteChannel {
|
|
@@ -79,9 +73,7 @@ export interface ChannelsControl {
|
|
|
79
73
|
remove(platform: ChannelPlatform): { ok: boolean; message?: string };
|
|
80
74
|
}
|
|
81
75
|
|
|
82
|
-
|
|
83
|
-
return typeof v === "string" && (CHANNEL_PLATFORMS as readonly string[]).includes(v);
|
|
84
|
-
}
|
|
76
|
+
const isPlatform = isChannelPlatform;
|
|
85
77
|
|
|
86
78
|
function normalizePosture(v: unknown): ChannelPosture | undefined {
|
|
87
79
|
return typeof v === "string" && (POSTURES as readonly string[]).includes(v) ? (v as ChannelPosture) : undefined;
|
|
@@ -31,6 +31,8 @@ import { RemoteBridge } from "./remoteBridge.ts";
|
|
|
31
31
|
import { makeRelayFileTools } from "../tools/relayFileTools.ts";
|
|
32
32
|
import { AttachmentStore, type StoredAttachment } from "../util/attachmentStore.ts";
|
|
33
33
|
import { spawnAccountCredentials, revokeAccountSession, hasCredentials } from "../auth/privateer.ts";
|
|
34
|
+
import { createUIContext } from "../ext/headlessUi.ts";
|
|
35
|
+
import { noQuarterActive } from "../permissions/noQuarter.ts";
|
|
34
36
|
|
|
35
37
|
export interface LiveTaskHandle {
|
|
36
38
|
termId: string;
|
|
@@ -161,7 +163,11 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
161
163
|
attached = true;
|
|
162
164
|
if (attachTimer) { clearTimeout(attachTimer); attachTimer = undefined; }
|
|
163
165
|
relay?.sendSnapshot([]);
|
|
164
|
-
|
|
166
|
+
// cwd rides along here (and nowhere else — see RelayClient.sendContext): this
|
|
167
|
+
// session's working directory was either named by the driver in the spawn form
|
|
168
|
+
// or defaulted to the harbor's own, and they have no other way to see which.
|
|
169
|
+
// Every file this agent touches is under it.
|
|
170
|
+
relay?.sendContext({ model: modelSpec, cwd, version: agentVersion() });
|
|
165
171
|
relay?.sendCommands([]);
|
|
166
172
|
// Deliver the spawn's initial prompt exactly once, THROUGH the bridge's own prompt
|
|
167
173
|
// path so it counts as a driven turn (remote=true → tools relay to the app).
|
|
@@ -189,6 +195,13 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
189
195
|
},
|
|
190
196
|
getRemote: bridge.getRemote,
|
|
191
197
|
getNoQuarter: bridge.getNoQuarter,
|
|
198
|
+
// Session-wide TOTAL bypass when the harbor itself was launched `--no-quarter`
|
|
199
|
+
// (env PRIVATEER_NO_QUARTER): every action auto-approves with no prompt, here as
|
|
200
|
+
// in the TUI. Without this the flag meant nothing on a spawn — the app's own
|
|
201
|
+
// no-quarter toggle (getNoQuarter above) was the only switch that reached this
|
|
202
|
+
// gate, so an operator who had lowered the moat harbor-wide still got prompted
|
|
203
|
+
// for everything. Off unless the flag is set, which launchd/systemd never does.
|
|
204
|
+
getSkipAllPermissions: noQuarterActive,
|
|
192
205
|
remoteAsk: bridge.remoteAsk,
|
|
193
206
|
blockedWhenRemote: isRemoteUnsafeTool,
|
|
194
207
|
onRemoteBlocked: (toolName) => bridge.sendNotice(`${toolName} is disabled while driving remotely — its prompts can't reach the app.`),
|
|
@@ -248,7 +261,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
248
261
|
// Relay the extension mid-turn UI (select/confirm/input) to the app when driven, so an
|
|
249
262
|
// extension asking a question doesn't silently cancel. Mirrors cli/chat.ts's uiContext.
|
|
250
263
|
const driven = (): boolean => bridge.getRemote() && bridge.isConnected();
|
|
251
|
-
const uiContext = {
|
|
264
|
+
const uiContext = createUIContext({
|
|
252
265
|
async select(t: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined> {
|
|
253
266
|
if (!options.length) return undefined;
|
|
254
267
|
if (!driven()) return undefined;
|
|
@@ -268,7 +281,7 @@ export async function createLiveTaskSession(spec: TaskSpec, deps: LiveTaskDeps):
|
|
|
268
281
|
notify(message: string): void {
|
|
269
282
|
if (driven()) bridge.sendNotice(message);
|
|
270
283
|
},
|
|
271
|
-
};
|
|
284
|
+
});
|
|
272
285
|
await (session as any).bindExtensions({ uiContext });
|
|
273
286
|
|
|
274
287
|
const adapter = createEngineEventAdapter();
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import WebSocket from "ws";
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { homedir } from "node:os";
|
|
20
21
|
import { apiRequest, serverBaseUrl } from "../auth/privateer.ts";
|
|
21
22
|
import type { EngineEvent } from "../engine/events.ts";
|
|
22
23
|
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
@@ -227,6 +228,15 @@ export interface RelayCallbacks {
|
|
|
227
228
|
}
|
|
228
229
|
|
|
229
230
|
const RECONNECT_MS = 3000;
|
|
231
|
+
// Ceiling for the reconnect backoff below. A blip should be invisible (first retry at
|
|
232
|
+
// RECONNECT_MS), but a relay that is genuinely down must not be hammered every 3s for
|
|
233
|
+
// hours by every harbor on the fleet — and when it comes back, they must not all
|
|
234
|
+
// stampede it in the same 3s window. So: grow the delay on consecutive failures, cap it
|
|
235
|
+
// here, jitter it, and reset the moment a socket opens.
|
|
236
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
237
|
+
const RECONNECT_GROWTH = 1.7;
|
|
238
|
+
// Fraction of the delay to randomize (±), so restarts don't resynchronize the fleet.
|
|
239
|
+
const RECONNECT_JITTER = 0.25;
|
|
230
240
|
// Retry cadence after the relay REFUSES us (4xx — in practice the plan's live-agent
|
|
231
241
|
// cap). Slow, because only an account change can clear it, but not never: the harbor
|
|
232
242
|
// should come up on its own once a slot frees. Kept well under the server's denial
|
|
@@ -241,12 +251,27 @@ const REFUSED_RECONNECT_MS = 60_000;
|
|
|
241
251
|
// the server prunes the terminal from its presence registry after ~60s, so the app
|
|
242
252
|
// shows the harbor as offline while the harbor's own log says "connected", forever.
|
|
243
253
|
//
|
|
244
|
-
// So don't wait to be told. The server pings every 25s, so an alive socket sees
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
254
|
+
// So don't wait to be told. The server pings every 25s, so an alive socket sees inbound
|
|
255
|
+
// traffic at least that often; we ping on our own timer too and a healthy peer pongs
|
|
256
|
+
// immediately, so in practice inbound arrives every ~20s. If nothing arrives for
|
|
257
|
+
// LIVENESS_TIMEOUT_MS the socket is dead: terminate it and take the normal reconnect path.
|
|
258
|
+
//
|
|
259
|
+
// The timeout is sized against the SERVER's presence TTL, not against comfort. The server
|
|
260
|
+
// refreshes a 60s presence key on its own 25s heartbeat tick (server.js relayHeartbeat →
|
|
261
|
+
// relayHub.markOnline, PRESENCE_TTL_SECS = 60), and the app's agent list reads exactly
|
|
262
|
+
// that key. So the budget to detect a dead socket AND finish reconnecting is 60s from the
|
|
263
|
+
// last tick that reached us — otherwise the key lapses and the harbor blinks out of the
|
|
264
|
+
// app even though recovery is already under way. At the old 75s this was guaranteed: a
|
|
265
|
+
// silent death always cost ~20-40s of visible "offline". 50s of silence is already
|
|
266
|
+
// conclusive (two-plus missed exchanges) and leaves ~10s for the reconnect to re-register.
|
|
267
|
+
//
|
|
268
|
+
// Detection granularity is its own timer: polling silence on the 20s PING cadence added
|
|
269
|
+
// up to a whole extra ping interval of latency, and after a laptop wake (timers frozen
|
|
270
|
+
// while asleep — the common cause of this whole failure mode) it decided how long the
|
|
271
|
+
// harbor stayed dark. Checking every 5s costs nothing and bounds that.
|
|
248
272
|
const HEARTBEAT_MS = 20_000;
|
|
249
|
-
const
|
|
273
|
+
const LIVENESS_CHECK_MS = 5_000;
|
|
274
|
+
const LIVENESS_TIMEOUT_MS = 50_000;
|
|
250
275
|
// Cap the opening handshake too. Without this a black-holed connect leaves `this.ws`
|
|
251
276
|
// set with no open/close/error ever firing, and connect()'s `if (this.ws) return`
|
|
252
277
|
// guard then blocks every future attempt — the same permanent silence by another route.
|
|
@@ -267,6 +292,20 @@ function clip(s: string, max: number): string {
|
|
|
267
292
|
return s.slice(0, max) + `\n… (${s.length - max} more chars)`;
|
|
268
293
|
}
|
|
269
294
|
|
|
295
|
+
// Rewrite a path under the home directory as `~/…` — the CLI's own display form.
|
|
296
|
+
// Used on the cwd we report in a context frame: the driver still sees which folder
|
|
297
|
+
// the agent is working in, without the account name in the absolute path crossing
|
|
298
|
+
// the relay. Only an exact home prefix is collapsed (`/Users/pat2` is left alone);
|
|
299
|
+
// a path outside home is returned unchanged.
|
|
300
|
+
function homeCollapsed(p: string): string {
|
|
301
|
+
let home = "";
|
|
302
|
+
try { home = homedir(); } catch { /* no home → nothing to collapse */ }
|
|
303
|
+
if (!home || !p.startsWith(home)) return p;
|
|
304
|
+
const rest = p.slice(home.length);
|
|
305
|
+
if (rest === "") return "~";
|
|
306
|
+
return rest.startsWith("/") || rest.startsWith("\\") ? `~${rest}` : p;
|
|
307
|
+
}
|
|
308
|
+
|
|
270
309
|
function asText(output: unknown): string {
|
|
271
310
|
return typeof output === "string" ? output : JSON.stringify(output);
|
|
272
311
|
}
|
|
@@ -314,6 +353,8 @@ export class RelayClient {
|
|
|
314
353
|
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
315
354
|
private lastInboundAt = 0;
|
|
316
355
|
private connectedAt = 0;
|
|
356
|
+
// Current backoff delay for the next unqualified scheduleReconnect(); reset on 'open'.
|
|
357
|
+
private reconnectDelay = RECONNECT_MS;
|
|
317
358
|
// Last refusal reason reported, so a 4xx is logged once instead of on every retry.
|
|
318
359
|
private refusal: string | null = null;
|
|
319
360
|
// Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
|
|
@@ -402,6 +443,7 @@ export class RelayClient {
|
|
|
402
443
|
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
|
|
403
444
|
this.stopHeartbeat();
|
|
404
445
|
this.connectedAt = 0;
|
|
446
|
+
this.reconnectDelay = RECONNECT_MS; // a restarted client shouldn't inherit old backoff
|
|
405
447
|
this.bufKind = null;
|
|
406
448
|
this.buf = "";
|
|
407
449
|
this.incoming.clear();
|
|
@@ -450,6 +492,7 @@ export class RelayClient {
|
|
|
450
492
|
ws.on("open", () => {
|
|
451
493
|
opened = true;
|
|
452
494
|
this.refusal = null; // a later refusal is news again
|
|
495
|
+
this.reconnectDelay = RECONNECT_MS; // reachable again — next blip retries fast
|
|
453
496
|
this.connectedAt = Date.now();
|
|
454
497
|
this.startHeartbeat(ws);
|
|
455
498
|
this.settleFirstConnect(); // terminal is live on the relay — awaitRegistered() resolves
|
|
@@ -525,11 +568,13 @@ export class RelayClient {
|
|
|
525
568
|
private startHeartbeat(ws: WebSocket): void {
|
|
526
569
|
this.stopHeartbeat();
|
|
527
570
|
this.lastInboundAt = Date.now();
|
|
571
|
+
let lastPingAt = Date.now();
|
|
528
572
|
this.heartbeatTimer = setInterval(() => {
|
|
529
573
|
// A socket we've since replaced or dropped isn't ours to police anymore.
|
|
530
574
|
if (this.ws !== ws) { this.stopHeartbeat(); return; }
|
|
531
575
|
if (ws.readyState !== WebSocket.OPEN) return; // closing — 'close' will clean up
|
|
532
|
-
const
|
|
576
|
+
const now = Date.now();
|
|
577
|
+
const quietMs = now - this.lastInboundAt;
|
|
533
578
|
if (quietMs > LIVENESS_TIMEOUT_MS) {
|
|
534
579
|
this.cb.onStatus?.(
|
|
535
580
|
`Remote access went silent for ${Math.round(quietMs / 1000)}s (the connection died without closing) — dropping it and reconnecting…`,
|
|
@@ -538,8 +583,13 @@ export class RelayClient {
|
|
|
538
583
|
try { ws.terminate(); } catch (_) { /* already gone — 'close' still fires */ }
|
|
539
584
|
return;
|
|
540
585
|
}
|
|
541
|
-
|
|
542
|
-
|
|
586
|
+
// The check runs on LIVENESS_CHECK_MS; the ping stays on its own slower cadence so
|
|
587
|
+
// tightening detection doesn't multiply the traffic we put on the wire.
|
|
588
|
+
if (now - lastPingAt >= HEARTBEAT_MS) {
|
|
589
|
+
lastPingAt = now;
|
|
590
|
+
try { ws.ping(); } catch (_) { /* socket dying — the next tick or 'close' handles it */ }
|
|
591
|
+
}
|
|
592
|
+
}, LIVENESS_CHECK_MS);
|
|
543
593
|
// Never hold the process open for a heartbeat alone.
|
|
544
594
|
this.heartbeatTimer.unref?.();
|
|
545
595
|
}
|
|
@@ -548,12 +598,28 @@ export class RelayClient {
|
|
|
548
598
|
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
|
|
549
599
|
}
|
|
550
600
|
|
|
551
|
-
|
|
601
|
+
// Schedule the next connect attempt. With no explicit delay this walks the backoff
|
|
602
|
+
// ladder (reset to RECONNECT_MS by a successful 'open'), so the first retry after a
|
|
603
|
+
// blip is still ~3s and only a genuinely unreachable relay backs off. An explicit
|
|
604
|
+
// delay — the REFUSED_RECONNECT_MS path — is a deliberate cadence for a decision that
|
|
605
|
+
// won't change on its own, so it bypasses the ladder rather than compounding with it.
|
|
606
|
+
private scheduleReconnect(delayMs?: number): void {
|
|
552
607
|
if (this.closed || this.reconnectTimer) return;
|
|
608
|
+
let wait: number;
|
|
609
|
+
if (typeof delayMs === "number") {
|
|
610
|
+
wait = delayMs;
|
|
611
|
+
} else {
|
|
612
|
+
wait = this.reconnectDelay;
|
|
613
|
+
this.reconnectDelay = Math.min(Math.round(this.reconnectDelay * RECONNECT_GROWTH), RECONNECT_MAX_MS);
|
|
614
|
+
}
|
|
615
|
+
// Jitter every wait (including the fixed ones): the point is to break up fleet-wide
|
|
616
|
+
// synchronization after a server restart, which is exactly when many clients are
|
|
617
|
+
// sitting on the same timer.
|
|
618
|
+
const jittered = Math.max(250, Math.round(wait * (1 + (Math.random() * 2 - 1) * RECONNECT_JITTER)));
|
|
553
619
|
this.reconnectTimer = setTimeout(() => {
|
|
554
620
|
this.reconnectTimer = undefined;
|
|
555
621
|
void this.connect();
|
|
556
|
-
},
|
|
622
|
+
}, jittered);
|
|
557
623
|
}
|
|
558
624
|
|
|
559
625
|
private handle(data: WebSocket.RawData): void {
|
|
@@ -926,13 +992,23 @@ export class RelayClient {
|
|
|
926
992
|
// Push this terminal's live context (selected model, agent version) to a
|
|
927
993
|
// controller so the app's session banner reflects reality instead of a stub.
|
|
928
994
|
// Sent on controller attach — like the snapshot/no_quarter resync. NON-PII ONLY
|
|
929
|
-
// by design: deliberately NO
|
|
995
|
+
// by design: deliberately NO hostname / username, matching terminalLabel's
|
|
930
996
|
// stance (the server/controller learns as little as possible about the machine).
|
|
931
997
|
// Empty/absent fields are omitted so the app renders less rather than blank.
|
|
932
|
-
|
|
998
|
+
//
|
|
999
|
+
// `cwd` is the one scoped exception, sent by BOTH session kinds: it is where
|
|
1000
|
+
// everything the agent reads, writes and `@`-mentions lives, so a driver who can't
|
|
1001
|
+
// see it is guessing at the blast radius of every prompt they send. (It used to be
|
|
1002
|
+
// harbor-spawned sessions only — on the theory that a human sits in an interactive
|
|
1003
|
+
// terminal and already knows the folder. They don't when they're driving it from a
|
|
1004
|
+
// phone, which is the entire point of this transport.) It is home-collapsed (`~/…`)
|
|
1005
|
+
// on the way out, so the banner reads like the CLI's own and the OS username still
|
|
1006
|
+
// never crosses the relay.
|
|
1007
|
+
sendContext(ctx: { model?: string; version?: string; cwd?: string; terminalPub?: string }): void {
|
|
933
1008
|
const frame: Record<string, unknown> = { type: "context" };
|
|
934
1009
|
if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;
|
|
935
1010
|
if (typeof ctx.version === "string" && ctx.version) frame.version = ctx.version;
|
|
1011
|
+
if (typeof ctx.cwd === "string" && ctx.cwd) frame.cwd = clip(homeCollapsed(ctx.cwd), 300);
|
|
936
1012
|
// The terminal's identity public key (base64). NOT PII — it's a public key, and
|
|
937
1013
|
// the app uses it to confirm this terminal is the one it PINNED at link time
|
|
938
1014
|
// before sealing any secret to it (channel tokens). A malicious relay can swap
|