privateer-agent 0.9.2 → 0.10.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 +79 -5
- package/bin/privateer-acp.mjs +40 -0
- package/bin/privateer-launch.mjs +13 -0
- package/extensions/privateer-gate.ts +17 -1
- package/package.json +2 -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 +3 -2
- 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 +52 -1
- package/src/remote/channelsControl.ts +8 -16
- package/src/remote/liveTaskSession.ts +16 -3
- package/src/remote/relayClient.ts +87 -12
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent's Nostr identity.
|
|
3
|
+
*
|
|
4
|
+
* A Nostr secret key is not a bot token. It is a PERMANENT identity: it cannot be
|
|
5
|
+
* rotated without becoming a different participant, everything it ever signed stays
|
|
6
|
+
* attributable to it, and there is no issuer to revoke it. So it gets the same
|
|
7
|
+
* treatment as the terminal identity key — minted locally, written 0600, and never
|
|
8
|
+
* sent anywhere — rather than living in plaintext config.json beside the revocable
|
|
9
|
+
* platform bot tokens.
|
|
10
|
+
*
|
|
11
|
+
* DEFAULT PATH: the agent generates its own keypair and reports the npub, which the
|
|
12
|
+
* user pastes into their Buzz workspace so an Owner can add it as a Bot member. The
|
|
13
|
+
* secret never crosses a wire, not even the sealed app→terminal channel.
|
|
14
|
+
*
|
|
15
|
+
* IMPORT PATH: a user who already has a Buzz identity can send an nsec through the
|
|
16
|
+
* app's sealed-secret flow; importBuzzKey() moves it into the 0600 file so it stops
|
|
17
|
+
* living in config.json.
|
|
18
|
+
*
|
|
19
|
+
* Construction mirrors crypto/terminalKey.ts deliberately, including the 0600
|
|
20
|
+
* TOCTOU-avoiding write and the process-lifetime cache.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { schnorr } from "@noble/curves/secp256k1";
|
|
26
|
+
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
|
27
|
+
import { globalDir } from "../config/paths.ts";
|
|
28
|
+
import { npubEncode, npubDecode, nsecEncode, nsecDecode } from "./bech32.ts";
|
|
29
|
+
|
|
30
|
+
interface BuzzKeyFile {
|
|
31
|
+
v: 1;
|
|
32
|
+
secretKey: string; // base64, 32 raw bytes — never leaves this machine
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BuzzIdentity {
|
|
36
|
+
secretHex: string;
|
|
37
|
+
pubkeyHex: string;
|
|
38
|
+
npub: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function keyPath(): string {
|
|
42
|
+
return join(globalDir(), "buzz-key.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let cached: BuzzIdentity | undefined;
|
|
46
|
+
|
|
47
|
+
// ── pure ────────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** A fresh 32-byte secp256k1 secret key. */
|
|
50
|
+
export function generateSecretKey(): Uint8Array {
|
|
51
|
+
return schnorr.utils.randomSecretKey();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The 32-byte x-only public key for a secret, as lowercase hex — a Nostr pubkey. */
|
|
55
|
+
export function publicKeyHex(secret: Uint8Array | string): string {
|
|
56
|
+
return bytesToHex(schnorr.getPublicKey(typeof secret === "string" ? hexToBytes(secret) : secret));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Normalize a configured identity to lowercase hex.
|
|
61
|
+
*
|
|
62
|
+
* Allowlists are written by humans, who will paste whichever form Buzz showed them —
|
|
63
|
+
* so accept both npub and raw hex and store one canonical form. Returns undefined
|
|
64
|
+
* for anything that isn't a valid 32-byte key, so a typo'd entry fails closed
|
|
65
|
+
* (dropped from the allowlist) rather than silently matching nothing forever.
|
|
66
|
+
*/
|
|
67
|
+
export function toHexPubkey(npubOrHex: string): string | undefined {
|
|
68
|
+
const s = npubOrHex.trim();
|
|
69
|
+
if (/^[0-9a-fA-F]{64}$/.test(s)) return s.toLowerCase();
|
|
70
|
+
if (s.startsWith("npub1")) {
|
|
71
|
+
try {
|
|
72
|
+
return bytesToHex(npubDecode(s));
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Accept either an nsec or raw hex secret; throws on anything else. */
|
|
81
|
+
export function secretFromNsec(nsecOrHex: string): Uint8Array {
|
|
82
|
+
const s = nsecOrHex.trim();
|
|
83
|
+
if (/^[0-9a-fA-F]{64}$/.test(s)) return hexToBytes(s.toLowerCase());
|
|
84
|
+
if (s.startsWith("nsec1")) return nsecDecode(s);
|
|
85
|
+
throw new Error("expected an nsec1… key or 64 hex characters");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function identityFrom(secret: Uint8Array): BuzzIdentity {
|
|
89
|
+
const pubkeyHex = publicKeyHex(secret);
|
|
90
|
+
return { secretHex: bytesToHex(secret), pubkeyHex, npub: npubEncode(hexToBytes(pubkeyHex)) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── persisted ───────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function persist(secret: Uint8Array): BuzzIdentity {
|
|
96
|
+
const file: BuzzKeyFile = { v: 1, secretKey: Buffer.from(secret).toString("base64") };
|
|
97
|
+
// 0600 from creation — see terminalKey.ts for why `mode` plus a follow-up chmod.
|
|
98
|
+
writeFileSync(keyPath(), JSON.stringify(file), { mode: 0o600 });
|
|
99
|
+
try {
|
|
100
|
+
chmodSync(keyPath(), 0o600);
|
|
101
|
+
} catch {
|
|
102
|
+
/* best effort — e.g. non-POSIX FS */
|
|
103
|
+
}
|
|
104
|
+
cached = identityFrom(secret);
|
|
105
|
+
return cached;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Load the persisted identity, minting and persisting one on first use. */
|
|
109
|
+
export function loadOrCreateBuzzKey(): BuzzIdentity {
|
|
110
|
+
if (cached) return cached;
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(readFileSync(keyPath(), "utf8")) as BuzzKeyFile;
|
|
113
|
+
if (parsed?.v === 1 && parsed.secretKey) {
|
|
114
|
+
const buf = Buffer.from(parsed.secretKey, "base64");
|
|
115
|
+
if (buf.length === 32) {
|
|
116
|
+
cached = identityFrom(new Uint8Array(buf));
|
|
117
|
+
return cached;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
/* missing or malformed → mint a fresh keypair below */
|
|
122
|
+
}
|
|
123
|
+
return persist(generateSecretKey());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Adopt an existing identity, replacing any current one.
|
|
128
|
+
*
|
|
129
|
+
* Called when a user supplies an nsec through the app: the value arrives sealed,
|
|
130
|
+
* lands here, and the caller then deletes it from config.json so the only copy on
|
|
131
|
+
* disk is the 0600 file.
|
|
132
|
+
*/
|
|
133
|
+
export function importBuzzKey(nsecOrHex: string): BuzzIdentity {
|
|
134
|
+
return persist(secretFromNsec(nsecOrHex));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every textual form of the persisted secret, for the outbound redactor.
|
|
139
|
+
*
|
|
140
|
+
* The agent can read its own key file — it's a file on the machine it operates —
|
|
141
|
+
* so without this it could quote its permanent identity into a public channel.
|
|
142
|
+
* Both encodings are returned because either could plausibly appear in output.
|
|
143
|
+
* Non-minting: no key file means nothing to redact.
|
|
144
|
+
*/
|
|
145
|
+
export function buzzRedactionSecrets(): string[] {
|
|
146
|
+
const secretHex = cached?.secretHex ?? readPersistedSecretHex();
|
|
147
|
+
if (!secretHex) return [];
|
|
148
|
+
return [secretHex, nsecEncode(hexToBytes(secretHex))];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readPersistedSecretHex(): string | undefined {
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(readFileSync(keyPath(), "utf8")) as BuzzKeyFile;
|
|
154
|
+
const buf = Buffer.from(parsed?.secretKey ?? "", "base64");
|
|
155
|
+
if (parsed?.v === 1 && buf.length === 32) return bytesToHex(new Uint8Array(buf));
|
|
156
|
+
} catch {
|
|
157
|
+
/* not configured yet */
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read the persisted npub WITHOUT minting one.
|
|
164
|
+
*
|
|
165
|
+
* Side-effect-free by design: the app lists every platform, configured or not, and
|
|
166
|
+
* merely rendering an empty Buzz card must not conjure a permanent identity.
|
|
167
|
+
*/
|
|
168
|
+
export function peekBuzzNpub(): string | undefined {
|
|
169
|
+
if (cached) return cached.npub;
|
|
170
|
+
const secretHex = readPersistedSecretHex();
|
|
171
|
+
return secretHex ? identityFrom(hexToBytes(secretHex)).npub : undefined;
|
|
172
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// NIP-10 threading and NIP-27 mention helpers — reading meaning out of an event's
|
|
2
|
+
// positional tag arrays, and building the tags for a reply.
|
|
3
|
+
//
|
|
4
|
+
// Pure and adapter-agnostic: the Buzz adapter uses these, but nothing here is
|
|
5
|
+
// Buzz-specific.
|
|
6
|
+
|
|
7
|
+
import type { Tag } from "./event.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract a reply's thread position from its "e" tags.
|
|
11
|
+
*
|
|
12
|
+
* Two encodings exist in the wild and both must be handled:
|
|
13
|
+
* MARKERED (current) ["e", <id>, <relay>, "root"] / [… "reply"]
|
|
14
|
+
* POSITIONAL (legacy) the FIRST "e" tag is the root, the LAST is the direct
|
|
15
|
+
* parent; with exactly one, it is both.
|
|
16
|
+
* A markered tag anywhere wins — mixing the two is malformed, and trusting the
|
|
17
|
+
* explicit marker is the safer read.
|
|
18
|
+
*/
|
|
19
|
+
export function threadRefs(tags: Tag[]): { root?: string; reply?: string } {
|
|
20
|
+
const eTags = tags.filter((t) => t[0] === "e" && typeof t[1] === "string" && t[1].length > 0);
|
|
21
|
+
if (eTags.length === 0) return {};
|
|
22
|
+
|
|
23
|
+
const markered = eTags.filter((t) => t[3] === "root" || t[3] === "reply");
|
|
24
|
+
if (markered.length > 0) {
|
|
25
|
+
return {
|
|
26
|
+
root: markered.find((t) => t[3] === "root")?.[1],
|
|
27
|
+
reply: markered.find((t) => t[3] === "reply")?.[1],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Legacy positional form.
|
|
32
|
+
if (eTags.length === 1) return { root: eTags[0][1], reply: eTags[0][1] };
|
|
33
|
+
return { root: eTags[0][1], reply: eTags[eTags.length - 1][1] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every pubkey this event tags — i.e. everyone it @-mentions. */
|
|
37
|
+
export function pTags(tags: Tag[]): string[] {
|
|
38
|
+
return tags.filter((t) => t[0] === "p" && typeof t[1] === "string" && t[1].length > 0).map((t) => t[1]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Blossom content hashes attached to this event (["x", <sha256>]). */
|
|
42
|
+
export function xTags(tags: Tag[]): string[] {
|
|
43
|
+
return tags.filter((t) => t[0] === "x" && typeof t[1] === "string" && t[1].length > 0).map((t) => t[1]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the tags for a reply.
|
|
48
|
+
*
|
|
49
|
+
* `root` is the thread root and `parent` the message being answered; when only one
|
|
50
|
+
* is known, pass it as both — a reply that marks a root but no parent reads as a
|
|
51
|
+
* top-level post to most clients. Mentioned pubkeys are deduped, since duplicate "p"
|
|
52
|
+
* tags inflate relay-side mention indexes for no benefit.
|
|
53
|
+
*/
|
|
54
|
+
export function replyTags(root?: string, parent?: string, mentionPubkeys: string[] = []): Tag[] {
|
|
55
|
+
const out: Tag[] = [];
|
|
56
|
+
if (root) out.push(["e", root, "", "root"]);
|
|
57
|
+
if (parent && parent !== root) out.push(["e", parent, "", "reply"]);
|
|
58
|
+
for (const pk of [...new Set(mentionPubkeys)]) out.push(["p", pk]);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { resolve, isAbsolute, relative, dirname, join, basename } from "node:path";
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
3
5
|
import { isProtectedPath } from "./protected.ts";
|
|
4
6
|
import type { PermissionRequest } from "./gate.ts";
|
|
5
7
|
|
|
@@ -42,8 +44,57 @@ function realBase(abs: string): string {
|
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
// ⚠️ SECURITY-CRITICAL: this MUST resolve a path string to exactly the same place
|
|
48
|
+
// Pi's file tools do, or the gate judges one file and the tool touches another.
|
|
49
|
+
//
|
|
50
|
+
// Pi resolves every tool path through `resolveToCwd` → `resolvePath` →
|
|
51
|
+
// `normalizePath` (node_modules/@earendil-works/pi-coding-agent/dist/utils/paths.js
|
|
52
|
+
// and dist/core/tools/path-utils.js), which EXPANDS `~`, STRIPS a leading `@`, and
|
|
53
|
+
// converts `file://` URLs. `node:path` treats all three as RELATIVE, so a classifier
|
|
54
|
+
// using bare resolve() computed `<cwd>/~/.ssh/authorized_keys` — comfortably "inside"
|
|
55
|
+
// the working directory — while `write` truly targeted `$HOME/.ssh/authorized_keys`.
|
|
56
|
+
// That made `outside` false and `protected` false (isProtectedPath matches basenames,
|
|
57
|
+
// and `authorized_keys` isn't one), so:
|
|
58
|
+
// • reads returned null from classifyToolCall — NO gate at all, in every posture
|
|
59
|
+
// including plan/readonly, at the default read-only tool ceiling; and
|
|
60
|
+
// • writes auto-allowed under acceptEdits / bypass / no-quarter.
|
|
61
|
+
// The adversary here is the MODEL (prompt injection), not the user, so "the operator
|
|
62
|
+
// could read that file anyway" does not apply.
|
|
63
|
+
//
|
|
64
|
+
// Pi does not export normalizePath (package exports are "." and "./rpc-entry" only),
|
|
65
|
+
// so it is mirrored here. KEEP IN SYNC — tests/classifyPathParity.test.ts asserts this
|
|
66
|
+
// function agrees with Pi's own resolver, so a Pi upgrade that changes normalization
|
|
67
|
+
// fails the suite instead of silently reopening the hole.
|
|
68
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
69
|
+
|
|
70
|
+
function normalizeLikePi(input: string, opts: { unicodeSpaces?: boolean; stripAt?: boolean } = {}): string {
|
|
71
|
+
let s = input;
|
|
72
|
+
if (opts.unicodeSpaces) s = s.replace(UNICODE_SPACES, " ");
|
|
73
|
+
if (opts.stripAt && s.startsWith("@")) s = s.slice(1);
|
|
74
|
+
const home = homedir();
|
|
75
|
+
if (s === "~") return home;
|
|
76
|
+
if (s.startsWith("~/") || (process.platform === "win32" && s.startsWith("~\\"))) return join(home, s.slice(2));
|
|
77
|
+
if (/^file:\/\//.test(s)) {
|
|
78
|
+
// Pi lets fileURLToPath throw here, which fails the tool call. We must not throw
|
|
79
|
+
// (that would break the gate), so fall back to the raw string: it then resolves
|
|
80
|
+
// inside cwd, but the tool errors on the same input, so there is no divergence
|
|
81
|
+
// a caller can exploit.
|
|
82
|
+
try {
|
|
83
|
+
return fileURLToPath(s);
|
|
84
|
+
} catch {
|
|
85
|
+
return s;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return s;
|
|
89
|
+
}
|
|
90
|
+
|
|
45
91
|
function resolveInCwd(cwd: string, p: string): string {
|
|
46
|
-
|
|
92
|
+
// Mirrors resolvePath(): the TARGET gets the tools' options
|
|
93
|
+
// ({normalizeUnicodeSpaces, stripAtPrefix}); the BASE gets normalizePath's defaults
|
|
94
|
+
// (tilde/file:// only), because Pi normalizes baseDir with no options.
|
|
95
|
+
const target = normalizeLikePi(p, { unicodeSpaces: true, stripAt: true });
|
|
96
|
+
const base = normalizeLikePi(cwd);
|
|
97
|
+
return realBase(isAbsolute(target) ? resolve(target) : resolve(base, target));
|
|
47
98
|
}
|
|
48
99
|
|
|
49
100
|
function isInsideDir(root: string, abs: string): boolean {
|
|
@@ -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,22 @@ 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, and ONLY a harbor-spawned live session passes
|
|
1000
|
+
// it (see liveTaskSession): the driver chose that directory in the spawn form — or,
|
|
1001
|
+
// having left it blank, needs to see which one the harbor picked — because it is
|
|
1002
|
+
// where everything that session reads, writes and `@`-mentions lives, and unlike an
|
|
1003
|
+
// interactive terminal there is no human sitting in it to already know. It is
|
|
1004
|
+
// home-collapsed (`~/…`) on the way out, so the banner reads like the CLI's own and
|
|
1005
|
+
// the OS username still never crosses the relay.
|
|
1006
|
+
sendContext(ctx: { model?: string; version?: string; cwd?: string; terminalPub?: string }): void {
|
|
933
1007
|
const frame: Record<string, unknown> = { type: "context" };
|
|
934
1008
|
if (typeof ctx.model === "string" && ctx.model) frame.model = ctx.model;
|
|
935
1009
|
if (typeof ctx.version === "string" && ctx.version) frame.version = ctx.version;
|
|
1010
|
+
if (typeof ctx.cwd === "string" && ctx.cwd) frame.cwd = clip(homeCollapsed(ctx.cwd), 300);
|
|
936
1011
|
// The terminal's identity public key (base64). NOT PII — it's a public key, and
|
|
937
1012
|
// the app uses it to confirm this terminal is the one it PINNED at link time
|
|
938
1013
|
// before sealing any secret to it (channel tokens). A malicious relay can swap
|