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
package/src/channels/bridge.ts
CHANGED
|
@@ -2,15 +2,25 @@
|
|
|
2
2
|
// RemoteBridge for the relay. It owns the policy that must be identical on every
|
|
3
3
|
// platform:
|
|
4
4
|
// - allowlist (who may drive the agent; fail-closed + silent to strangers)
|
|
5
|
+
// - mention gate (in a shared room, only act when addressed)
|
|
5
6
|
// - serialization (one turn per conversation at a time; extra messages queue)
|
|
6
7
|
// - redaction (chat platforms are external egress — scrub before send)
|
|
7
|
-
// - chunking (respect the platform's per-message
|
|
8
|
+
// - chunking (respect the platform's per-message byte cap)
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ SECURITY — DO NOT RESOLVE QUOTED PARENTS. An inbound message may carry a
|
|
11
|
+
// `replyToId`, and it is tempting to fetch that parent and inline its text into the
|
|
12
|
+
// prompt for context. Don't. The allowlist gates the SENDER, not the room: on a
|
|
13
|
+
// shared platform (Buzz channels, a Discord guild, a Slack channel) anyone can post,
|
|
14
|
+
// and resolving quoted parents would let a non-allowlisted participant inject
|
|
15
|
+
// arbitrary text into the agent's context simply by getting an admin to reply to
|
|
16
|
+
// them. Threading ids are for ADDRESSING replies outward, never for pulling content
|
|
17
|
+
// inward.
|
|
8
18
|
//
|
|
9
19
|
// The agent itself is injected as `runTurn`, so this file stays Pi-free and
|
|
10
20
|
// unit-testable against a fake adapter + fake runner (see tests/channels.test.ts).
|
|
11
21
|
// The Pi-backed runner lives in ./run.ts.
|
|
12
22
|
|
|
13
|
-
import type { ChannelAdapter, InboundMessage } from "./types.ts";
|
|
23
|
+
import type { ChannelAdapter, InboundMessage, SendOptions } from "./types.ts";
|
|
14
24
|
|
|
15
25
|
// A minimal view of the permission request the gate hands us (see
|
|
16
26
|
// src/permissions/gate.ts PermissionRequest). Kept local so the bridge doesn't
|
|
@@ -48,8 +58,19 @@ export interface AuditEvent {
|
|
|
48
58
|
userId?: string;
|
|
49
59
|
role?: "admin" | "member";
|
|
50
60
|
detail?: string;
|
|
61
|
+
/** The platform id of the triggering message, when the platform has one — makes an
|
|
62
|
+
* audit line traceable back to the exact message in the channel. Note we
|
|
63
|
+
* deliberately do NOT log `mentions`: other participants' ids are third-party PII
|
|
64
|
+
* and don't belong in an append-only file. */
|
|
65
|
+
messageId?: string;
|
|
51
66
|
}
|
|
52
67
|
|
|
68
|
+
/** Whether the agent answers everything in a conversation, or only when addressed.
|
|
69
|
+
* off — every allowlisted message runs a turn (the original behavior)
|
|
70
|
+
* mention — only when the message @-mentions this agent
|
|
71
|
+
* mention-or-dm — as above, plus any 1:1 conversation */
|
|
72
|
+
export type MentionGate = "off" | "mention" | "mention-or-dm";
|
|
73
|
+
|
|
53
74
|
export interface MessagingBridgeConfig {
|
|
54
75
|
adapter: ChannelAdapter;
|
|
55
76
|
runTurn: TurnRunner;
|
|
@@ -62,15 +83,19 @@ export interface MessagingBridgeConfig {
|
|
|
62
83
|
isAdmin: (msg: InboundMessage) => boolean;
|
|
63
84
|
// Scrub secrets from every outbound message. Wired to redactText in ./run.ts.
|
|
64
85
|
redact?: (text: string) => string;
|
|
86
|
+
// Should the agent answer everything, or only when addressed? Defaults to "off",
|
|
87
|
+
// preserving the original behavior for every existing platform.
|
|
88
|
+
mentionGate?: MentionGate;
|
|
65
89
|
onLog?: (msg: string) => void;
|
|
66
90
|
// Optional append-only security audit sink.
|
|
67
91
|
onAudit?: (event: AuditEvent) => void;
|
|
68
92
|
}
|
|
69
93
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
94
|
+
// Fallback per-message ceiling in BYTES, used only when an adapter doesn't declare
|
|
95
|
+
// its own `maxMessageBytes`. It is the tightest common bound (Discord's 2000-char
|
|
96
|
+
// cap, with headroom), so it is safe everywhere and generous nowhere — adapters
|
|
97
|
+
// should declare the real figure. See channels/platforms.ts for the per-platform table.
|
|
98
|
+
const DEFAULT_MAX_BYTES = 1900;
|
|
74
99
|
|
|
75
100
|
// How long to wait for a yes/no approval reply before failing closed (deny).
|
|
76
101
|
const APPROVAL_TIMEOUT_MS = 120_000;
|
|
@@ -94,21 +119,80 @@ export function approvalPrompt(req: ApprovalRequest): string {
|
|
|
94
119
|
return `⚠️ Approval needed — ${req.title} (${req.kind})\n\n${detail}\n\nReply "yes" to allow or "no" to deny (times out in 2 min).`;
|
|
95
120
|
}
|
|
96
121
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
122
|
+
// The longest prefix of `s` that fits in `maxBytes` of UTF-8, cut only on code-point
|
|
123
|
+
// boundaries. Iterating the string yields whole code points, so a surrogate pair is
|
|
124
|
+
// never split down the middle into two replacement characters.
|
|
125
|
+
function sliceByBytes(s: string, maxBytes: number): string {
|
|
126
|
+
if (Buffer.byteLength(s) <= maxBytes) return s;
|
|
127
|
+
let bytes = 0;
|
|
128
|
+
let out = "";
|
|
129
|
+
for (const ch of s) {
|
|
130
|
+
const b = Buffer.byteLength(ch);
|
|
131
|
+
if (bytes + b > maxBytes) break;
|
|
132
|
+
bytes += b;
|
|
133
|
+
out += ch;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Split text into chunks that each fit in `maxBytes`, preferring newline boundaries
|
|
139
|
+
// so code/paragraphs aren't cut mid-line when possible.
|
|
140
|
+
//
|
|
141
|
+
// The cap is in BYTES, not characters, because every platform's limit ultimately is:
|
|
142
|
+
// a reply of emoji or CJK is up to 4x longer on the wire than its `.length` suggests,
|
|
143
|
+
// and measuring in characters silently overshoots and gets the message rejected.
|
|
144
|
+
export function chunkText(text: string, maxBytes = DEFAULT_MAX_BYTES): string[] {
|
|
100
145
|
const out: string[] = [];
|
|
101
146
|
let rest = text;
|
|
102
|
-
while (rest
|
|
103
|
-
let
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
147
|
+
while (Buffer.byteLength(rest) > maxBytes) {
|
|
148
|
+
let head = sliceByBytes(rest, maxBytes);
|
|
149
|
+
// A single code point wider than the cap would otherwise loop forever.
|
|
150
|
+
if (!head) head = [...rest][0] ?? "";
|
|
151
|
+
if (!head) break;
|
|
152
|
+
const nl = head.lastIndexOf("\n");
|
|
153
|
+
if (nl > 0 && nl >= head.length * 0.5) head = head.slice(0, nl); // usable newline in the back half
|
|
154
|
+
out.push(head);
|
|
155
|
+
rest = rest.slice(head.length).replace(/^\n/, "");
|
|
107
156
|
}
|
|
108
157
|
if (rest) out.push(rest);
|
|
109
158
|
return out;
|
|
110
159
|
}
|
|
111
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Should this message run a turn at all?
|
|
163
|
+
*
|
|
164
|
+
* In a 1:1 chat "answer everything" is right. In a shared room with other humans it
|
|
165
|
+
* is unusable noise — and worse, it means every passing remark becomes a prompt. So
|
|
166
|
+
* a channel can require being addressed.
|
|
167
|
+
*
|
|
168
|
+
* Fails CLOSED on adapters that can't report mentions: if the gate is on and
|
|
169
|
+
* `mentionsMe` is absent, the agent stays quiet rather than answering everything.
|
|
170
|
+
* Enabling a gate a platform can't honour should silence the bot, not defeat the gate.
|
|
171
|
+
*/
|
|
172
|
+
export function passesMentionGate(m: InboundMessage, mode: MentionGate | undefined): boolean {
|
|
173
|
+
if (!mode || mode === "off") return true;
|
|
174
|
+
if (m.mentionsMe) return true;
|
|
175
|
+
return mode === "mention-or-dm" && m.isDirect === true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Render attachments into the prompt text.
|
|
180
|
+
*
|
|
181
|
+
* v1 is a text footer: `TurnRunner` carries only a string, so real multimodal means
|
|
182
|
+
* widening that signature and routing bytes through src/util/attachmentStore.ts.
|
|
183
|
+
* This is the seam where that would go. Deliberately out of scope for now — the URL
|
|
184
|
+
* is enough for an agent with web/read tools to act on.
|
|
185
|
+
*/
|
|
186
|
+
export function promptWithAttachments(m: InboundMessage): string {
|
|
187
|
+
const text = m.text.trim();
|
|
188
|
+
if (!m.attachments?.length) return text;
|
|
189
|
+
const lines = m.attachments.map((a) => {
|
|
190
|
+
const bits = [a.mediaType ?? "file", a.name, a.url ?? a.id].filter(Boolean);
|
|
191
|
+
return `[attached: ${bits.join(" — ")}]`;
|
|
192
|
+
});
|
|
193
|
+
return text ? `${text}\n\n${lines.join("\n")}` : lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
112
196
|
export class MessagingBridge {
|
|
113
197
|
// Per-chat promise tail: each new turn chains onto the previous so turns in the
|
|
114
198
|
// same conversation never interleave (they share one agent session downstream).
|
|
@@ -118,6 +202,15 @@ export class MessagingBridge {
|
|
|
118
202
|
// Per-chat pending tool approval awaiting a yes/no reply. At most one at a time
|
|
119
203
|
// per chat (turns are serialized and a turn's tool calls are sequential).
|
|
120
204
|
private readonly approvals = new Map<string, (decision: "allow" | "deny") => void>();
|
|
205
|
+
// Where to attach outbound messages in each chat, so replies, approval prompts and
|
|
206
|
+
// error lines all land in the thread that triggered them rather than at the bottom
|
|
207
|
+
// of the room. Empty for platforms without threading — `sendReply` then degrades to
|
|
208
|
+
// a plain send.
|
|
209
|
+
private readonly lastInbound = new Map<string, { messageId?: string; threadRootId?: string }>();
|
|
210
|
+
// Set by stop(). Aborting a turn signals the GATE, not Pi's session.prompt() — so
|
|
211
|
+
// an in-flight turn can still resolve after teardown and try to send its answer
|
|
212
|
+
// through a closed adapter. Every send path checks this first.
|
|
213
|
+
private stopped = false;
|
|
121
214
|
|
|
122
215
|
constructor(private readonly cfg: MessagingBridgeConfig) {}
|
|
123
216
|
|
|
@@ -126,13 +219,16 @@ export class MessagingBridge {
|
|
|
126
219
|
this.log(`channel "${this.cfg.adapter.name}" listening`);
|
|
127
220
|
}
|
|
128
221
|
|
|
129
|
-
stop(): void {
|
|
130
|
-
this.
|
|
222
|
+
async stop(): Promise<void> {
|
|
223
|
+
this.stopped = true;
|
|
131
224
|
for (const a of this.aborts.values()) a.abort();
|
|
132
225
|
this.aborts.clear();
|
|
133
226
|
// Fail any pending approvals closed so no turn hangs on shutdown.
|
|
134
227
|
for (const resolve of this.approvals.values()) resolve("deny");
|
|
135
228
|
this.approvals.clear();
|
|
229
|
+
// Last: an adapter owning a listening socket resolves only once the port is
|
|
230
|
+
// genuinely free, which a targeted platform restart depends on.
|
|
231
|
+
await this.cfg.adapter.stop();
|
|
136
232
|
}
|
|
137
233
|
|
|
138
234
|
// Ask the user in `chatId` to approve a gated tool action, and await their yes/no
|
|
@@ -141,6 +237,9 @@ export class MessagingBridge {
|
|
|
141
237
|
// or shutdown all resolve to "deny". Public because the gate calls it directly
|
|
142
238
|
// (via an AsyncLocalStorage handle to this bridge + the current chat id).
|
|
143
239
|
requestApproval(chatId: string, req: ApprovalRequest, signal?: AbortSignal): Promise<"allow" | "deny"> {
|
|
240
|
+
// Torn down: nobody is listening for the reply, so fail closed immediately rather
|
|
241
|
+
// than prompting into a dead channel and waiting out the 2-minute timeout.
|
|
242
|
+
if (this.stopped) return Promise.resolve("deny");
|
|
144
243
|
// Only one outstanding approval per chat; deny any stale one first.
|
|
145
244
|
this.approvals.get(chatId)?.("deny");
|
|
146
245
|
|
|
@@ -153,7 +252,10 @@ export class MessagingBridge {
|
|
|
153
252
|
role: "admin", // approvals only arise from admin turns (members are read-only)
|
|
154
253
|
detail: this.cfg.redact ? this.cfg.redact(detail) : detail,
|
|
155
254
|
});
|
|
156
|
-
|
|
255
|
+
// Threaded alongside the turn that raised it, so a busy room doesn't scatter the
|
|
256
|
+
// question away from the request that prompted it.
|
|
257
|
+
const opts = this.sendOptions(chatId);
|
|
258
|
+
void this.cfg.adapter.sendText(chatId, this.cfg.redact ? this.cfg.redact(prompt) : prompt, opts);
|
|
157
259
|
|
|
158
260
|
return new Promise<"allow" | "deny">((resolve) => {
|
|
159
261
|
let timer: ReturnType<typeof setTimeout>;
|
|
@@ -165,11 +267,11 @@ export class MessagingBridge {
|
|
|
165
267
|
resolve(decision);
|
|
166
268
|
};
|
|
167
269
|
const onAbort = () => {
|
|
168
|
-
void this.cfg.adapter.sendText(chatId, "🚫 request interrupted — denied.");
|
|
270
|
+
void this.cfg.adapter.sendText(chatId, "🚫 request interrupted — denied.", opts);
|
|
169
271
|
settle("deny");
|
|
170
272
|
};
|
|
171
273
|
timer = setTimeout(() => {
|
|
172
|
-
void this.cfg.adapter.sendText(chatId, "⌛ approval timed out — denied.");
|
|
274
|
+
void this.cfg.adapter.sendText(chatId, "⌛ approval timed out — denied.", opts);
|
|
173
275
|
settle("deny");
|
|
174
276
|
}, APPROVAL_TIMEOUT_MS);
|
|
175
277
|
this.approvals.set(chatId, settle);
|
|
@@ -184,6 +286,14 @@ export class MessagingBridge {
|
|
|
184
286
|
this.cfg.onLog?.(msg);
|
|
185
287
|
}
|
|
186
288
|
|
|
289
|
+
// Where an outbound message for this chat should attach. Undefined on platforms
|
|
290
|
+
// that don't carry message ids, which every adapter accepts as "just send it".
|
|
291
|
+
private sendOptions(chatId: string): SendOptions | undefined {
|
|
292
|
+
const last = this.lastInbound.get(chatId);
|
|
293
|
+
if (!last?.messageId) return undefined;
|
|
294
|
+
return { replyTo: last.messageId, threadRoot: last.threadRootId ?? last.messageId };
|
|
295
|
+
}
|
|
296
|
+
|
|
187
297
|
private audit(m: InboundMessage, event: AuditEvent["event"], detail?: string): void {
|
|
188
298
|
if (!this.cfg.onAudit) return;
|
|
189
299
|
const red = detail && this.cfg.redact ? this.cfg.redact(detail) : detail;
|
|
@@ -194,10 +304,12 @@ export class MessagingBridge {
|
|
|
194
304
|
userId: m.userId,
|
|
195
305
|
role: this.cfg.isAdmin(m) ? "admin" : "member",
|
|
196
306
|
detail: red,
|
|
307
|
+
messageId: m.messageId,
|
|
197
308
|
});
|
|
198
309
|
}
|
|
199
310
|
|
|
200
311
|
private onMessage(m: InboundMessage): void {
|
|
312
|
+
if (this.stopped) return; // torn down; an adapter may still flush a queued frame
|
|
201
313
|
const text = m.text?.trim();
|
|
202
314
|
if (!text) return;
|
|
203
315
|
|
|
@@ -240,6 +352,17 @@ export class MessagingBridge {
|
|
|
240
352
|
return;
|
|
241
353
|
}
|
|
242
354
|
|
|
355
|
+
// The mention gate sits HERE and the position is load-bearing. It must come
|
|
356
|
+
// after the approval interception and after "/stop", because neither of those
|
|
357
|
+
// carries an @mention: gating an approval reply would leave the waiting turn
|
|
358
|
+
// hanging until its 2-minute fail-closed timeout, and gating "/stop" would make
|
|
359
|
+
// the channel uninterruptible.
|
|
360
|
+
if (!passesMentionGate(m, this.cfg.mentionGate)) return;
|
|
361
|
+
|
|
362
|
+
// Remember where to attach the reply. Recorded only for messages that actually
|
|
363
|
+
// run a turn, so an approval answer can't retarget the thread mid-conversation.
|
|
364
|
+
this.lastInbound.set(m.chatId, { messageId: m.messageId, threadRootId: m.threadRootId });
|
|
365
|
+
|
|
243
366
|
// Serialize per conversation: chain onto this chat's tail.
|
|
244
367
|
const prev = this.tails.get(m.chatId) ?? Promise.resolve();
|
|
245
368
|
const next = prev
|
|
@@ -248,7 +371,10 @@ export class MessagingBridge {
|
|
|
248
371
|
this.tails.set(m.chatId, next);
|
|
249
372
|
// Drop the tail once this was the last queued turn, so the map doesn't grow.
|
|
250
373
|
void next.finally(() => {
|
|
251
|
-
if (this.tails.get(m.chatId) === next)
|
|
374
|
+
if (this.tails.get(m.chatId) === next) {
|
|
375
|
+
this.tails.delete(m.chatId);
|
|
376
|
+
this.lastInbound.delete(m.chatId); // the reply is out; nothing left to attach to
|
|
377
|
+
}
|
|
252
378
|
});
|
|
253
379
|
}
|
|
254
380
|
|
|
@@ -267,7 +393,7 @@ export class MessagingBridge {
|
|
|
267
393
|
let buf = "";
|
|
268
394
|
let result: { ok: boolean; error?: string };
|
|
269
395
|
try {
|
|
270
|
-
result = await this.cfg.runTurn(chatId, m
|
|
396
|
+
result = await this.cfg.runTurn(chatId, promptWithAttachments(m), (d) => (buf += d), ac.signal, {
|
|
271
397
|
userId: m.userId,
|
|
272
398
|
isAdmin,
|
|
273
399
|
});
|
|
@@ -279,15 +405,35 @@ export class MessagingBridge {
|
|
|
279
405
|
|
|
280
406
|
const body = this.cfg.redact ? this.cfg.redact(buf) : buf;
|
|
281
407
|
|
|
408
|
+
// The turn may have outlived stop() (aborting reaches the gate, not Pi). Drop the
|
|
409
|
+
// answer rather than pushing it through a closed adapter — on a targeted restart
|
|
410
|
+
// the replacement bridge is already serving this chat.
|
|
411
|
+
if (this.stopped) {
|
|
412
|
+
this.log(`dropped a reply for chat ${chatId} — the channel was stopped mid-turn`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Send in the thread the request came from, chaining each chunk beneath the
|
|
417
|
+
// previous one when the adapter tells us the id it just wrote — so a long,
|
|
418
|
+
// multi-chunk answer reads as one conversation rather than N siblings.
|
|
419
|
+
const opts = this.sendOptions(chatId);
|
|
420
|
+
const root = opts?.threadRoot;
|
|
421
|
+
let parent = opts?.replyTo;
|
|
422
|
+
const send = async (t: string) => {
|
|
423
|
+
const id = await this.cfg.adapter.sendText(chatId, t, parent ? { replyTo: parent, threadRoot: root } : undefined);
|
|
424
|
+
if (typeof id === "string" && id) parent = id;
|
|
425
|
+
};
|
|
426
|
+
|
|
282
427
|
// Deliver any text the turn produced (even on error — a partial answer is
|
|
283
428
|
// useful), then an error line if it failed.
|
|
284
429
|
if (body.trim()) {
|
|
285
|
-
|
|
430
|
+
const maxBytes = this.cfg.adapter.maxMessageBytes ?? DEFAULT_MAX_BYTES;
|
|
431
|
+
for (const chunk of chunkText(body, maxBytes)) await send(chunk);
|
|
286
432
|
} else if (result.ok) {
|
|
287
|
-
await
|
|
433
|
+
await send("✓ done (no text output).");
|
|
288
434
|
}
|
|
289
435
|
if (!result.ok) {
|
|
290
|
-
await
|
|
436
|
+
await send(`⚠️ ${result.error ?? "the agent hit an error"}`);
|
|
291
437
|
}
|
|
292
438
|
}
|
|
293
439
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The per-platform facts that BOTH the channels runtime and the app-facing config
|
|
2
|
+
// control need to agree on: which platforms exist, which of their fields are
|
|
3
|
+
// secrets, how big a single message may be, and whether a given config block has
|
|
4
|
+
// enough in it to start.
|
|
5
|
+
//
|
|
6
|
+
// This file is deliberately the ONLY place those four things are declared. Before
|
|
7
|
+
// it existed they were duplicated between the `startChannel` if-chain in run.ts and
|
|
8
|
+
// the CHANNEL_PLATFORMS/SECRET_FIELDS constants in remote/channelsControl.ts, with
|
|
9
|
+
// a "keep in sync" comment as the only thing holding them together. Adding a
|
|
10
|
+
// platform now means editing this file plus wiring one adapter.
|
|
11
|
+
//
|
|
12
|
+
// Pi-free and dependency-free on purpose: channelsControl.ts runs inside the harbor
|
|
13
|
+
// (which must be able to configure a channel that has never run), while the runtime
|
|
14
|
+
// runs wherever the bots live. Both import this; neither imports the other.
|
|
15
|
+
|
|
16
|
+
export const CHANNEL_PLATFORMS = ["telegram", "slack", "discord", "whatsapp"] as const;
|
|
17
|
+
export type ChannelPlatform = (typeof CHANNEL_PLATFORMS)[number];
|
|
18
|
+
|
|
19
|
+
export function isChannelPlatform(v: unknown): v is ChannelPlatform {
|
|
20
|
+
return typeof v === "string" && (CHANNEL_PLATFORMS as readonly string[]).includes(v);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The secret (never-echoed) fields per platform — the union of the credentials each
|
|
24
|
+
// platform needs to START. `secretsSet` in the app reports the PRESENCE of these by
|
|
25
|
+
// name; their values never cross the wire back to the app.
|
|
26
|
+
export const SECRET_FIELDS: Record<ChannelPlatform, readonly string[]> = {
|
|
27
|
+
telegram: ["botToken"],
|
|
28
|
+
slack: ["appToken", "botToken"],
|
|
29
|
+
discord: ["botToken"],
|
|
30
|
+
whatsapp: ["phoneNumberId", "accessToken", "verifyToken", "appSecret"],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Per-message size ceiling, in BYTES of UTF-8 content, sitting just under each
|
|
34
|
+
// platform's documented hard cap so a multi-byte reply can't overshoot:
|
|
35
|
+
// telegram 4096 chars · slack ~40k · discord 2000 chars · whatsapp 4096 chars
|
|
36
|
+
// The bridge reads the adapter's own `maxMessageBytes` at send time; this table is
|
|
37
|
+
// what the adapters (and outbound delivery, which has no adapter instance in hand)
|
|
38
|
+
// are configured from.
|
|
39
|
+
export const MAX_MESSAGE_BYTES: Record<ChannelPlatform, number> = {
|
|
40
|
+
telegram: 4000,
|
|
41
|
+
slack: 39_000,
|
|
42
|
+
discord: 1900,
|
|
43
|
+
whatsapp: 3900,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Does this config block carry everything the platform needs to start? Mirrors the
|
|
47
|
+
// conditions the runtime uses to decide whether to construct an adapter at all.
|
|
48
|
+
//
|
|
49
|
+
// NOTE this checks CREDENTIALS only. The other fail-closed requirement — at least
|
|
50
|
+
// one admin or member — is deliberately NOT here: it's shared across every platform
|
|
51
|
+
// and is enforced once, by the runtime and by channelsControl.save().
|
|
52
|
+
export function startableFrom(platform: ChannelPlatform, block: any): boolean {
|
|
53
|
+
if (!block) return false;
|
|
54
|
+
switch (platform) {
|
|
55
|
+
case "telegram":
|
|
56
|
+
return !!block.botToken;
|
|
57
|
+
case "slack":
|
|
58
|
+
return !!block.appToken && !!block.botToken;
|
|
59
|
+
case "discord":
|
|
60
|
+
return !!block.botToken;
|
|
61
|
+
case "whatsapp":
|
|
62
|
+
return !!block.phoneNumberId && !!block.accessToken && !!block.verifyToken;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/channels/run.ts
CHANGED
|
@@ -106,6 +106,13 @@ function normalizePosture(v: unknown): Posture | undefined {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
async function main() {
|
|
109
|
+
// Mark the process before anything can create a session. This runner builds its
|
|
110
|
+
// session with its own makePermissionGate (the in-chat approver below), but the
|
|
111
|
+
// shipped TUI gate extension is still auto-discovered from the shared agent dir —
|
|
112
|
+
// and with no UI bound here its local asker fails CLOSED, denying every gated tool
|
|
113
|
+
// before our approver is ever consulted. See config/inlineMoat.ts.
|
|
114
|
+
const { markInlineMoat } = await import("../config/inlineMoat.ts");
|
|
115
|
+
markInlineMoat();
|
|
109
116
|
const { readFileSync, appendFileSync } = await import("node:fs");
|
|
110
117
|
const { join } = await import("node:path");
|
|
111
118
|
const {
|
|
@@ -131,6 +138,8 @@ async function main() {
|
|
|
131
138
|
const { DiscordAdapter } = await import("./discord.ts");
|
|
132
139
|
const { WhatsAppAdapter } = await import("./whatsapp.ts");
|
|
133
140
|
const { writeChannelsStatus, HEARTBEAT_MS } = await import("./status.ts");
|
|
141
|
+
const { startableFrom } = await import("./platforms.ts");
|
|
142
|
+
const { buzzRedactionSecrets } = await import("../nostr/keys.ts");
|
|
134
143
|
type ChannelAdapter = import("./types.ts").ChannelAdapter;
|
|
135
144
|
|
|
136
145
|
// ── config ──────────────────────────────────────────────────────────────────
|
|
@@ -149,7 +158,10 @@ async function main() {
|
|
|
149
158
|
: (web ? [...SAFE_TOOLS, ...WEB_TOOLS] : [...SAFE_TOOLS]);
|
|
150
159
|
const defaultPosture: Posture = normalizePosture(ch.posture) ?? "approve";
|
|
151
160
|
const cwd: string = ch.cwd ?? process.cwd();
|
|
152
|
-
|
|
161
|
+
// Provider API keys, plus this machine's Nostr secret if one has been minted.
|
|
162
|
+
// The agent can READ its own key file, so without this it could quote its own
|
|
163
|
+
// permanent identity into a public channel. Non-minting: absent → nothing added.
|
|
164
|
+
const secrets = [...collectSecrets(cfg.providers), ...buzzRedactionSecrets()];
|
|
153
165
|
const redact = (t: string) => redactText(t, secrets);
|
|
154
166
|
|
|
155
167
|
// Append-only security audit log — every prompt, approval request/decision, and
|
|
@@ -279,7 +291,7 @@ async function main() {
|
|
|
279
291
|
sweep.unref?.();
|
|
280
292
|
|
|
281
293
|
// ── build a bridge per configured platform ───────────────────────────────────
|
|
282
|
-
const bridges: { stop(): void }[] = [];
|
|
294
|
+
const bridges: { stop(): Promise<void> }[] = [];
|
|
283
295
|
// Platforms with a live bridge — written to the heartbeat file so the app's
|
|
284
296
|
// channels manager (running on the harbor's relay, a separate process) can show
|
|
285
297
|
// a live/offline badge without talking to this process.
|
|
@@ -343,28 +355,30 @@ async function main() {
|
|
|
343
355
|
);
|
|
344
356
|
}
|
|
345
357
|
|
|
346
|
-
|
|
358
|
+
// Which credentials each platform needs to start is declared once, in
|
|
359
|
+
// channels/platforms.ts, so this list and the app's config validator can't drift.
|
|
360
|
+
if (startableFrom("telegram", ch.telegram)) {
|
|
347
361
|
await startChannel(
|
|
348
362
|
"telegram",
|
|
349
363
|
new TelegramAdapter({ botToken: ch.telegram.botToken, onLog: log }),
|
|
350
364
|
ch.telegram,
|
|
351
365
|
);
|
|
352
366
|
}
|
|
353
|
-
if (
|
|
367
|
+
if (startableFrom("slack", ch.slack)) {
|
|
354
368
|
await startChannel(
|
|
355
369
|
"slack",
|
|
356
370
|
new SlackAdapter({ appToken: ch.slack.appToken, botToken: ch.slack.botToken, onLog: log }),
|
|
357
371
|
ch.slack,
|
|
358
372
|
);
|
|
359
373
|
}
|
|
360
|
-
if (ch.discord
|
|
374
|
+
if (startableFrom("discord", ch.discord)) {
|
|
361
375
|
await startChannel(
|
|
362
376
|
"discord",
|
|
363
377
|
new DiscordAdapter({ botToken: ch.discord.botToken, intents: ch.discord.intents, onLog: log }),
|
|
364
378
|
ch.discord,
|
|
365
379
|
);
|
|
366
380
|
}
|
|
367
|
-
if (
|
|
381
|
+
if (startableFrom("whatsapp", ch.whatsapp)) {
|
|
368
382
|
await startChannel(
|
|
369
383
|
"whatsapp",
|
|
370
384
|
new WhatsAppAdapter({
|
|
@@ -397,7 +411,10 @@ async function main() {
|
|
|
397
411
|
clearInterval(sweep);
|
|
398
412
|
clearInterval(heartbeat);
|
|
399
413
|
writeChannelsStatus([]); // clear presence immediately, don't wait for staleness
|
|
400
|
-
|
|
414
|
+
// Fire-and-forget: process exit releases every socket and port anyway, so there's
|
|
415
|
+
// nothing to wait for here. Awaiting matters for a TARGETED restart (one platform
|
|
416
|
+
// rebinding its port while the process lives on), not for shutdown.
|
|
417
|
+
for (const b of bridges) void b.stop();
|
|
401
418
|
process.exit(0);
|
|
402
419
|
};
|
|
403
420
|
process.on("SIGINT", shutdown);
|
package/src/channels/types.ts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
// Messaging-channel plumbing — the inbound/conversational counterpart to the relay.
|
|
2
2
|
//
|
|
3
3
|
// The relay (src/remote/*) lets the Privateer app drive this terminal. A messaging
|
|
4
|
-
// channel (Telegram/Slack/Discord/
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// and is shared across every platform.
|
|
4
|
+
// channel (Telegram/Slack/Discord/Buzz) is the SAME idea with a different transport:
|
|
5
|
+
// a user's message becomes a prompt, the agent's reply goes back to the channel.
|
|
6
|
+
// `ChannelAdapter` is the one platform-specific seam; everything above it (allowlist,
|
|
7
|
+
// mention gating, per-chat serialization, redaction, chunking) lives in
|
|
8
|
+
// MessagingBridge and is shared across every platform.
|
|
9
|
+
//
|
|
10
|
+
// EVERY field beyond the original four is OPTIONAL, and that is a load-bearing
|
|
11
|
+
// design choice rather than politeness: an adapter that cannot thread, cannot see
|
|
12
|
+
// mentions, or carries no message ids simply omits them, and the bridge reads
|
|
13
|
+
// "absent" as "this platform doesn't support it". That is what lets a rich platform
|
|
14
|
+
// (Buzz: message ids, NIP-10 threads, relay-indexed mentions, Blossom attachments)
|
|
15
|
+
// share one contract with a plain one, with no per-adapter branching in the bridge.
|
|
16
|
+
|
|
17
|
+
/** A file or image referenced by an inbound message. */
|
|
18
|
+
export interface Attachment {
|
|
19
|
+
/** The platform's content id — Buzz: the Blossom sha256. */
|
|
20
|
+
id?: string;
|
|
21
|
+
/** Fetchable by this machine. May require adapter-supplied auth. */
|
|
22
|
+
url?: string;
|
|
23
|
+
mediaType?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
bytes?: number;
|
|
26
|
+
}
|
|
9
27
|
|
|
10
28
|
// A normalized inbound message from any platform. `chatId` scopes the conversation
|
|
11
29
|
// (so each thread keeps its own agent session); `userId` is who sent it (allowlist
|
|
@@ -16,21 +34,74 @@ export interface InboundMessage {
|
|
|
16
34
|
userId: string;
|
|
17
35
|
userName?: string;
|
|
18
36
|
text: string;
|
|
37
|
+
|
|
38
|
+
/** This message's own platform id — Buzz: the nostr event id. Needed to reply
|
|
39
|
+
* in-thread or react to it. */
|
|
40
|
+
messageId?: string;
|
|
41
|
+
/** The message this one directly answers — Buzz: NIP-10 "e" tag marked "reply". */
|
|
42
|
+
replyToId?: string;
|
|
43
|
+
/** The root of the thread this belongs to — Buzz: "e" marked "root". Equal to
|
|
44
|
+
* `replyToId` at depth one. */
|
|
45
|
+
threadRootId?: string;
|
|
46
|
+
/** Everyone this message @-mentions, in platform-native id form. */
|
|
47
|
+
mentions?: string[];
|
|
48
|
+
/** Does it mention THIS agent? Only the adapter knows its own identity on the
|
|
49
|
+
* platform, so the adapter reports the FACT — the bridge owns the POLICY of what
|
|
50
|
+
* to do about it (see `mentionGate`). */
|
|
51
|
+
mentionsMe?: boolean;
|
|
52
|
+
/** True when this is a 1:1 conversation rather than a shared room — Buzz: a
|
|
53
|
+
* channel of type "Dm". A DM is inherently addressed to the agent, so the
|
|
54
|
+
* mention gate can let it through without an explicit @. */
|
|
55
|
+
isDirect?: boolean;
|
|
56
|
+
attachments?: Attachment[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How an outbound message relates to the conversation. An adapter that can't
|
|
60
|
+
* thread ignores this entirely — passing it is always safe. */
|
|
61
|
+
export interface SendOptions {
|
|
62
|
+
replyTo?: string;
|
|
63
|
+
threadRoot?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface OutboundMedia {
|
|
67
|
+
bytes: Uint8Array;
|
|
68
|
+
mediaType: string;
|
|
69
|
+
name?: string;
|
|
70
|
+
caption?: string;
|
|
19
71
|
}
|
|
20
72
|
|
|
21
73
|
// The per-platform transport. Implementations own the connection (long-poll,
|
|
22
|
-
// gateway socket, or inbound webhook) and the wire format; they
|
|
23
|
-
// messages and accept plain text back. Keep them DUMB: no
|
|
24
|
-
// no chunking — the bridge does all of that so
|
|
74
|
+
// gateway socket, relay websocket, or inbound webhook) and the wire format; they
|
|
75
|
+
// surface normalized messages and accept plain text back. Keep them DUMB: no
|
|
76
|
+
// allowlist, no redaction, no chunking, no gating — the bridge does all of that so
|
|
77
|
+
// it's written once and tested once.
|
|
25
78
|
export interface ChannelAdapter {
|
|
26
79
|
readonly name: string;
|
|
80
|
+
/** This agent's own id on the platform — Buzz: its pubkey hex. Used to recognize
|
|
81
|
+
* self-authored events and to resolve `mentionsMe`. Absent when the platform has
|
|
82
|
+
* no stable identity for the bot. */
|
|
83
|
+
readonly selfId?: string;
|
|
84
|
+
/** Per-message ceiling in BYTES of UTF-8 content. The bridge chunks to this;
|
|
85
|
+
* absent falls back to a conservative shared default. Before this existed the
|
|
86
|
+
* bridge hardcoded Discord's limit for every platform. */
|
|
87
|
+
readonly maxMessageBytes?: number;
|
|
88
|
+
|
|
27
89
|
// Begin receiving. Call `onMessage` for every inbound user message.
|
|
28
90
|
start(onMessage: (m: InboundMessage) => void): Promise<void>;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
91
|
+
/** Send a reply. The bridge guarantees `text` is already redacted and within
|
|
92
|
+
* `maxMessageBytes`. Returns the sent message's platform id when the adapter
|
|
93
|
+
* knows it, so the bridge can chain later chunks beneath it — returning nothing
|
|
94
|
+
* is fine and simply means later chunks hang off the same parent. */
|
|
95
|
+
sendText(chatId: string, text: string, opts?: SendOptions): Promise<string | void>;
|
|
32
96
|
// Optional "the agent is working" affordance (typing indicator). Best-effort.
|
|
33
97
|
sendTyping?(chatId: string): void;
|
|
34
|
-
|
|
35
|
-
|
|
98
|
+
/** React to a message — Buzz: NIP-25 kind 7. Best-effort; never awaited on a
|
|
99
|
+
* path that must not fail. */
|
|
100
|
+
sendReaction?(chatId: string, messageId: string, emoji: string): Promise<void>;
|
|
101
|
+
sendMedia?(chatId: string, media: OutboundMedia, opts?: SendOptions): Promise<void>;
|
|
102
|
+
// Stop receiving and release the connection. May be async: an adapter that owns a
|
|
103
|
+
// LISTENING SOCKET (whatsapp) has to await the port actually being free, or the
|
|
104
|
+
// next start() races its own teardown and fails with EADDRINUSE. Callers must
|
|
105
|
+
// await this before restarting the same platform.
|
|
106
|
+
stop(): void | Promise<void>;
|
|
36
107
|
}
|