privateer-agent 0.3.6 → 0.4.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/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +19 -0
- package/extensions/privateer-brand.ts +61 -20
- package/extensions/privateer-gate.ts +290 -3
- package/package.json +4 -1
- package/src/auth/privateer.ts +45 -6
- package/src/channels/bridge.ts +293 -0
- package/src/channels/discord.ts +210 -0
- package/src/channels/run.ts +383 -0
- package/src/channels/slack.ts +176 -0
- package/src/channels/status.ts +54 -0
- package/src/channels/telegram.ts +139 -0
- package/src/channels/types.ts +36 -0
- package/src/channels/whatsapp.ts +178 -0
- package/src/cli/chat.ts +389 -30
- package/src/cli/daemonCli.ts +67 -0
- package/src/crypto/accountTrust.ts +113 -0
- package/src/crypto/accountVerify.ts +138 -0
- package/src/crypto/terminalKey.ts +95 -0
- package/src/crypto/terminalUnseal.ts +62 -0
- package/src/daemon/index.ts +511 -46
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/remote/channelsControl.ts +192 -0
- package/src/remote/controlAuth.ts +67 -0
- package/src/remote/extensionsControl.ts +140 -0
- package/src/remote/liveTaskSession.ts +218 -0
- package/src/remote/relayClient.ts +512 -1
- package/src/remote/remoteBridge.ts +172 -0
- package/src/remote/routinesControl.ts +216 -0
- package/src/remote/skillsControl.ts +205 -0
- package/src/remote/subagentChannel.ts +261 -0
- package/src/remote/subagentRelay.ts +126 -0
- package/src/remote/workflowsControl.ts +132 -0
- package/src/routines/store.ts +5 -1
- package/src/workflows/expr.ts +4 -0
- package/src/workflows/runner.ts +8 -0
- package/src/workflows/schema.ts +5 -0
- package/src/workflows/store.ts +108 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Telegram channel adapter — the least-friction platform to prototype: a bot token
|
|
2
|
+
// from @BotFather, plain HTTPS, long-poll `getUpdates` (no public endpoint, no
|
|
3
|
+
// gateway socket, no app review). Zero new dependencies — the Bot API is just JSON
|
|
4
|
+
// over fetch.
|
|
5
|
+
//
|
|
6
|
+
// This file is the ONLY platform-specific code. A Slack/Discord/WhatsApp adapter
|
|
7
|
+
// implements the same ChannelAdapter interface and reuses MessagingBridge verbatim.
|
|
8
|
+
|
|
9
|
+
import type { ChannelAdapter, InboundMessage } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const API = "https://api.telegram.org";
|
|
12
|
+
// Long-poll seconds we ask Telegram to hold the connection; the HTTP timeout below
|
|
13
|
+
// must exceed this or we'd cancel every idle poll.
|
|
14
|
+
const POLL_SECONDS = 50;
|
|
15
|
+
const POLL_HTTP_TIMEOUT_MS = (POLL_SECONDS + 10) * 1000;
|
|
16
|
+
const SEND_TIMEOUT_MS = 15_000;
|
|
17
|
+
const BACKOFF_MS = 3000;
|
|
18
|
+
|
|
19
|
+
// Shape of the bits of a Telegram Update we read.
|
|
20
|
+
interface TgUpdate {
|
|
21
|
+
update_id: number;
|
|
22
|
+
message?: {
|
|
23
|
+
text?: string;
|
|
24
|
+
chat?: { id?: number | string };
|
|
25
|
+
from?: { id?: number | string; username?: string; first_name?: string };
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pure mapping Update → normalized InboundMessage (null for non-text / malformed
|
|
30
|
+
// updates). Extracted so it's unit-testable without the polling loop.
|
|
31
|
+
export function messageFromUpdate(upd: TgUpdate): InboundMessage | null {
|
|
32
|
+
const msg = upd.message;
|
|
33
|
+
if (!msg || typeof msg.text !== "string") return null;
|
|
34
|
+
const chatId = msg.chat?.id;
|
|
35
|
+
if (chatId === undefined || chatId === null) return null;
|
|
36
|
+
const from = msg.from;
|
|
37
|
+
return {
|
|
38
|
+
chatId: String(chatId),
|
|
39
|
+
userId: String(from?.id ?? chatId),
|
|
40
|
+
userName: from?.username || from?.first_name,
|
|
41
|
+
text: msg.text,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TelegramOptions {
|
|
46
|
+
botToken: string;
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
onLog?: (msg: string) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class TelegramAdapter implements ChannelAdapter {
|
|
52
|
+
readonly name = "telegram";
|
|
53
|
+
private readonly token: string;
|
|
54
|
+
private readonly fetchImpl: typeof fetch;
|
|
55
|
+
private readonly onLog?: (msg: string) => void;
|
|
56
|
+
private offset = 0; // next update_id to fetch from
|
|
57
|
+
private running = false;
|
|
58
|
+
private poll?: AbortController;
|
|
59
|
+
|
|
60
|
+
constructor(opts: TelegramOptions) {
|
|
61
|
+
this.token = opts.botToken;
|
|
62
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
63
|
+
this.onLog = opts.onLog;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async start(onMessage: (m: InboundMessage) => void): Promise<void> {
|
|
67
|
+
if (this.running) return;
|
|
68
|
+
this.running = true;
|
|
69
|
+
void this.loop(onMessage);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stop(): void {
|
|
73
|
+
this.running = false;
|
|
74
|
+
this.poll?.abort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async sendText(chatId: string, text: string): Promise<void> {
|
|
78
|
+
if (!text) return;
|
|
79
|
+
await this.call("sendMessage", { chat_id: chatId, text }, SEND_TIMEOUT_MS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
sendTyping(chatId: string): void {
|
|
83
|
+
// Best-effort; failures are cosmetic.
|
|
84
|
+
void this.call("sendChatAction", { chat_id: chatId, action: "typing" }, 10_000).catch(() => {});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private log(msg: string): void {
|
|
88
|
+
this.onLog?.(`telegram: ${msg}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async loop(onMessage: (m: InboundMessage) => void): Promise<void> {
|
|
92
|
+
while (this.running) {
|
|
93
|
+
this.poll = new AbortController();
|
|
94
|
+
try {
|
|
95
|
+
const res = await this.call(
|
|
96
|
+
"getUpdates",
|
|
97
|
+
{ timeout: POLL_SECONDS, offset: this.offset, allowed_updates: ["message"] },
|
|
98
|
+
POLL_HTTP_TIMEOUT_MS,
|
|
99
|
+
this.poll.signal,
|
|
100
|
+
);
|
|
101
|
+
const data = (await res.json()) as { ok?: boolean; result?: TgUpdate[]; description?: string };
|
|
102
|
+
if (!data.ok) {
|
|
103
|
+
this.log(`getUpdates not ok: ${data.description ?? "unknown"}`);
|
|
104
|
+
await this.sleep(BACKOFF_MS);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
for (const upd of data.result ?? []) {
|
|
108
|
+
this.offset = Math.max(this.offset, upd.update_id + 1);
|
|
109
|
+
const m = messageFromUpdate(upd);
|
|
110
|
+
if (m) onMessage(m);
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (!this.running) break; // stop() aborted the poll — expected
|
|
114
|
+
this.log(`poll error: ${e instanceof Error ? e.message : String(e)} — retrying`);
|
|
115
|
+
await this.sleep(BACKOFF_MS);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async call(
|
|
121
|
+
method: string,
|
|
122
|
+
body: Record<string, unknown>,
|
|
123
|
+
timeoutMs: number,
|
|
124
|
+
extraSignal?: AbortSignal,
|
|
125
|
+
): Promise<Response> {
|
|
126
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
127
|
+
const signal = extraSignal ? AbortSignal.any([extraSignal, timeout]) : timeout;
|
|
128
|
+
return this.fetchImpl(`${API}/bot${this.token}/${method}`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "content-type": "application/json" },
|
|
131
|
+
body: JSON.stringify(body),
|
|
132
|
+
signal,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private sleep(ms: number): Promise<void> {
|
|
137
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Messaging-channel plumbing — the inbound/conversational counterpart to the relay.
|
|
2
|
+
//
|
|
3
|
+
// The relay (src/remote/*) lets the Privateer app drive this terminal. A messaging
|
|
4
|
+
// channel (Telegram/Slack/Discord/WhatsApp) is the SAME idea with a different
|
|
5
|
+
// transport: a user's message becomes a prompt, the agent's reply goes back to the
|
|
6
|
+
// channel. `ChannelAdapter` is the one platform-specific seam; everything above it
|
|
7
|
+
// (allowlist, per-chat serialization, redaction, chunking) lives in MessagingBridge
|
|
8
|
+
// and is shared across every platform.
|
|
9
|
+
|
|
10
|
+
// A normalized inbound message from any platform. `chatId` scopes the conversation
|
|
11
|
+
// (so each thread keeps its own agent session); `userId` is who sent it (allowlist
|
|
12
|
+
// key). Both are strings so platform-native numeric ids don't leak type differences
|
|
13
|
+
// upward.
|
|
14
|
+
export interface InboundMessage {
|
|
15
|
+
chatId: string;
|
|
16
|
+
userId: string;
|
|
17
|
+
userName?: string;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The per-platform transport. Implementations own the connection (long-poll,
|
|
22
|
+
// gateway socket, or inbound webhook) and the wire format; they surface normalized
|
|
23
|
+
// messages and accept plain text back. Keep them DUMB: no allowlist, no redaction,
|
|
24
|
+
// no chunking — the bridge does all of that so it's written once and tested once.
|
|
25
|
+
export interface ChannelAdapter {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
// Begin receiving. Call `onMessage` for every inbound user message.
|
|
28
|
+
start(onMessage: (m: InboundMessage) => void): Promise<void>;
|
|
29
|
+
// Send a reply to a conversation. The bridge guarantees `text` is already
|
|
30
|
+
// redacted and within the platform's per-message length cap.
|
|
31
|
+
sendText(chatId: string, text: string): Promise<void>;
|
|
32
|
+
// Optional "the agent is working" affordance (typing indicator). Best-effort.
|
|
33
|
+
sendTyping?(chatId: string): void;
|
|
34
|
+
// Stop receiving and release the connection.
|
|
35
|
+
stop(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// WhatsApp channel adapter — the official Meta Cloud API. Unlike the socket/
|
|
2
|
+
// long-poll adapters, Cloud API delivers inbound messages by POSTing to a webhook,
|
|
3
|
+
// so this adapter is the INBOUND-WEBHOOK shape: start() runs a small HTTP listener
|
|
4
|
+
// (Node's built-in `http`, zero new deps) and sends via the Graph API over fetch.
|
|
5
|
+
//
|
|
6
|
+
// TRADEOFF: a webhook needs a PUBLIC HTTPS URL for Meta to reach — so unlike the
|
|
7
|
+
// others this can't run purely behind NAT. Expose the port with a tunnel
|
|
8
|
+
// (cloudflared / ngrok) or host it, and register that URL + the verify token in the
|
|
9
|
+
// Meta app's webhook config. This is inherent to the Cloud API, not the adapter.
|
|
10
|
+
//
|
|
11
|
+
// Meta app setup (one-time):
|
|
12
|
+
// - WhatsApp product → note the phone number id → `phoneNumberId`, and a
|
|
13
|
+
// (system-user) access token → `accessToken`.
|
|
14
|
+
// - Webhooks → callback URL = https://<your-tunnel>/webhook, verify token =
|
|
15
|
+
// whatever you set as `verifyToken`; subscribe to the `messages` field.
|
|
16
|
+
// - Optional but recommended: set `appSecret` to verify Meta's X-Hub-Signature-256.
|
|
17
|
+
//
|
|
18
|
+
// Everything above the adapter (allowlist, serialization, redaction, chunking) is
|
|
19
|
+
// MessagingBridge, reused verbatim.
|
|
20
|
+
|
|
21
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
22
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
23
|
+
import type { ChannelAdapter, InboundMessage } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
const GRAPH = "https://graph.facebook.com/v21.0";
|
|
26
|
+
const SEND_TIMEOUT_MS = 15_000;
|
|
27
|
+
|
|
28
|
+
interface WaWebhookBody {
|
|
29
|
+
entry?: Array<{
|
|
30
|
+
changes?: Array<{
|
|
31
|
+
value?: {
|
|
32
|
+
// Inbound user messages. (Delivery/read receipts arrive under `statuses`,
|
|
33
|
+
// which we deliberately ignore.)
|
|
34
|
+
messages?: Array<{ from?: string; type?: string; text?: { body?: string } }>;
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Pure mapping: a webhook POST body → the text messages it carries. A single POST
|
|
41
|
+
// can batch several. Non-text messages and status callbacks yield nothing.
|
|
42
|
+
// Extracted so it's unit-testable without an HTTP server.
|
|
43
|
+
export function messagesFromWebhook(body: WaWebhookBody): InboundMessage[] {
|
|
44
|
+
const out: InboundMessage[] = [];
|
|
45
|
+
for (const entry of body.entry ?? []) {
|
|
46
|
+
for (const change of entry.changes ?? []) {
|
|
47
|
+
for (const msg of change.value?.messages ?? []) {
|
|
48
|
+
if (msg.type !== "text") continue;
|
|
49
|
+
const text = msg.text?.body;
|
|
50
|
+
if (!msg.from || typeof text !== "string") continue;
|
|
51
|
+
// The sender's phone number is both the conversation and the user id.
|
|
52
|
+
out.push({ chatId: msg.from, userId: msg.from, text });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface WhatsAppOptions {
|
|
60
|
+
phoneNumberId: string;
|
|
61
|
+
accessToken: string;
|
|
62
|
+
verifyToken: string;
|
|
63
|
+
appSecret?: string; // enables X-Hub-Signature-256 verification when set
|
|
64
|
+
port?: number;
|
|
65
|
+
path?: string;
|
|
66
|
+
fetchImpl?: typeof fetch;
|
|
67
|
+
onLog?: (msg: string) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class WhatsAppAdapter implements ChannelAdapter {
|
|
71
|
+
readonly name = "whatsapp";
|
|
72
|
+
private readonly opts: WhatsAppOptions;
|
|
73
|
+
private readonly fetchImpl: typeof fetch;
|
|
74
|
+
private readonly onLog?: (msg: string) => void;
|
|
75
|
+
private readonly port: number;
|
|
76
|
+
private readonly path: string;
|
|
77
|
+
private server?: Server;
|
|
78
|
+
private onMessage?: (m: InboundMessage) => void;
|
|
79
|
+
|
|
80
|
+
constructor(opts: WhatsAppOptions) {
|
|
81
|
+
this.opts = opts;
|
|
82
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
83
|
+
this.onLog = opts.onLog;
|
|
84
|
+
this.port = opts.port ?? 8787;
|
|
85
|
+
this.path = opts.path ?? "/webhook";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async start(onMessage: (m: InboundMessage) => void): Promise<void> {
|
|
89
|
+
this.onMessage = onMessage;
|
|
90
|
+
this.server = createServer((req, res) => this.handle(req, res));
|
|
91
|
+
await new Promise<void>((resolve) => this.server!.listen(this.port, () => resolve()));
|
|
92
|
+
this.log(`webhook listening on :${this.port}${this.path} — expose it publicly for Meta to reach.`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
stop(): void {
|
|
96
|
+
this.server?.close();
|
|
97
|
+
this.server = undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async sendText(chatId: string, text: string): Promise<void> {
|
|
101
|
+
if (!text) return;
|
|
102
|
+
const res = await this.fetchImpl(`${GRAPH}/${this.opts.phoneNumberId}/messages`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { authorization: `Bearer ${this.opts.accessToken}`, "content-type": "application/json" },
|
|
105
|
+
body: JSON.stringify({ messaging_product: "whatsapp", to: chatId, type: "text", text: { body: text } }),
|
|
106
|
+
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok) this.log(`send failed: HTTP ${res.status}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private log(msg: string): void {
|
|
112
|
+
this.onLog?.(`whatsapp: ${msg}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private handle(req: IncomingMessage, res: ServerResponse): void {
|
|
116
|
+
const url = new URL(req.url ?? "/", `http://localhost:${this.port}`);
|
|
117
|
+
if (url.pathname !== this.path) {
|
|
118
|
+
res.writeHead(404);
|
|
119
|
+
res.end();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// GET: Meta's one-time verification challenge.
|
|
124
|
+
if (req.method === "GET") {
|
|
125
|
+
const mode = url.searchParams.get("hub.mode");
|
|
126
|
+
const token = url.searchParams.get("hub.verify_token");
|
|
127
|
+
const challenge = url.searchParams.get("hub.challenge") ?? "";
|
|
128
|
+
if (mode === "subscribe" && token === this.opts.verifyToken) {
|
|
129
|
+
res.writeHead(200, { "content-type": "text/plain" });
|
|
130
|
+
res.end(challenge);
|
|
131
|
+
} else {
|
|
132
|
+
res.writeHead(403);
|
|
133
|
+
res.end();
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// POST: an inbound message batch.
|
|
139
|
+
if (req.method === "POST") {
|
|
140
|
+
let raw = "";
|
|
141
|
+
req.on("data", (c) => (raw += c));
|
|
142
|
+
req.on("end", () => {
|
|
143
|
+
if (!this.verifySignature(req, raw)) {
|
|
144
|
+
this.log("rejected webhook: bad or missing X-Hub-Signature-256");
|
|
145
|
+
res.writeHead(401);
|
|
146
|
+
res.end();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Ack fast — Meta retries the delivery on any non-200.
|
|
150
|
+
res.writeHead(200);
|
|
151
|
+
res.end();
|
|
152
|
+
let body: WaWebhookBody;
|
|
153
|
+
try {
|
|
154
|
+
body = JSON.parse(raw);
|
|
155
|
+
} catch {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
for (const m of messagesFromWebhook(body)) this.onMessage?.(m);
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
res.writeHead(405);
|
|
164
|
+
res.end();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Verify Meta's HMAC signature when an app secret is configured. Absent → skipped
|
|
168
|
+
// (documented tradeoff); the allowlist still gates who may drive the agent.
|
|
169
|
+
private verifySignature(req: IncomingMessage, raw: string): boolean {
|
|
170
|
+
if (!this.opts.appSecret) return true;
|
|
171
|
+
const header = req.headers["x-hub-signature-256"];
|
|
172
|
+
if (typeof header !== "string") return false;
|
|
173
|
+
const expected = "sha256=" + createHmac("sha256", this.opts.appSecret).update(raw).digest("hex");
|
|
174
|
+
const a = Buffer.from(header);
|
|
175
|
+
const b = Buffer.from(expected);
|
|
176
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
177
|
+
}
|
|
178
|
+
}
|