privateer-agent 0.3.5 → 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/README.md +19 -0
- package/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +35 -5
- package/extensions/privateer-brand.ts +213 -34
- package/extensions/privateer-context.ts +59 -0
- package/extensions/privateer-gate.ts +351 -6
- package/extensions/privateer-posture.ts +18 -2
- package/extensions/privateer-privacy.ts +50 -1
- package/package.json +4 -1
- package/src/auth/privateer.ts +151 -19
- 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 +414 -28
- package/src/cli/daemonCli.ts +67 -0
- package/src/config/version.ts +16 -0
- package/src/context.ts +171 -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 +522 -34
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/providers/account.ts +20 -12
- 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 +524 -0
- 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
package/src/auth/privateer.ts
CHANGED
|
@@ -16,6 +16,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync }
|
|
|
16
16
|
import { hostname, userInfo } from "node:os";
|
|
17
17
|
import { globalDir, credentialsPath } from "../config/paths.ts";
|
|
18
18
|
import { isAccountCapCode } from "../engine/errors.ts";
|
|
19
|
+
import { terminalPublicKeyBase64 } from "../crypto/terminalKey.ts";
|
|
20
|
+
import { pinAccountSignKey, clearAccountSignKey } from "../crypto/accountTrust.ts";
|
|
19
21
|
|
|
20
22
|
// Default Privateer API host. NOTE: this is still the legacy "helix" Render
|
|
21
23
|
// hostname the mobile/web client also points at (client/config/environment.ts);
|
|
@@ -97,14 +99,42 @@ let _child: ChildSession | null = null;
|
|
|
97
99
|
let _spawnInFlight: Promise<ChildSession> | null = null;
|
|
98
100
|
let _refreshInFlight: Promise<ChildSession> | null = null;
|
|
99
101
|
|
|
102
|
+
// The most recent ACCOUNT-provider session (spawnAccountCredentials /
|
|
103
|
+
// refreshAccountCredentials). Pi owns this credential's lifecycle — it drives the
|
|
104
|
+
// account inference channel in the TUI — so it's a distinct server-side session
|
|
105
|
+
// (device row) from _child. We record only its latest access token here so exit
|
|
106
|
+
// cleanup / an explicit sign-out (revokeAccountSession) can kill it. Rotations
|
|
107
|
+
// overwrite it; the previous token is already dead server-side, so tracking only
|
|
108
|
+
// the latest is right.
|
|
109
|
+
//
|
|
110
|
+
// LIFECYCLE HAZARD: Pi PERSISTS this session to auth.json (with a ~24h `expires`) and
|
|
111
|
+
// reuses it on the next launch, refreshing only when `Date.now() >= expires` — it does
|
|
112
|
+
// NOT refresh reactively on a 401. So revoking it at exit while leaving the persisted
|
|
113
|
+
// copy in place would let the next run send a token that still looks valid but is dead
|
|
114
|
+
// server-side → inference fails with a dead-end `401 {code: SESSION_REVOKED}`.
|
|
115
|
+
// The fix is to revoke it AND drop the persisted credential together: the caller must
|
|
116
|
+
// remove the "privateer" entry from Pi's authStorage (authStorage.remove("privateer"))
|
|
117
|
+
// right after revokeLocalSessions() so the next launch spawns a fresh session instead
|
|
118
|
+
// of reusing the revoked one. Doing both is safe; doing only one is not. See
|
|
119
|
+
// revokeLocalSessions and its callers (cli/chat.ts, daemon/index.ts).
|
|
120
|
+
let _account: { accessToken: string } | null = null;
|
|
121
|
+
|
|
100
122
|
export function loadCredentials(): Credentials | null {
|
|
101
|
-
|
|
123
|
+
// Only a successfully-read credential is memoized. A NEGATIVE result (file absent
|
|
124
|
+
// or unreadable) is deliberately NOT cached: the credential can appear mid-session
|
|
125
|
+
// — the account /login writes credentials.json AFTER the extensions have already
|
|
126
|
+
// booted, and under jiti each extension gets its OWN module instance of this file,
|
|
127
|
+
// so a login's saveCredentials() never reaches another extension's `_cache`. If we
|
|
128
|
+
// memoized the pre-login "absent" as null, that instance would report "not signed
|
|
129
|
+
// in" forever (e.g. /remote-access refusing after a successful sign-in). Re-reading
|
|
130
|
+
// disk on each miss lets a later call see what a sign-in just wrote.
|
|
131
|
+
if (_cache) return _cache;
|
|
102
132
|
const path = credentialsPath();
|
|
103
|
-
if (!existsSync(path)) return
|
|
133
|
+
if (!existsSync(path)) return null;
|
|
104
134
|
try {
|
|
105
135
|
_cache = JSON.parse(readFileSync(path, "utf8")) as Credentials;
|
|
106
136
|
} catch {
|
|
107
|
-
|
|
137
|
+
return null;
|
|
108
138
|
}
|
|
109
139
|
return _cache;
|
|
110
140
|
}
|
|
@@ -125,8 +155,12 @@ export function clearCredentials(): void {
|
|
|
125
155
|
} catch {
|
|
126
156
|
/* nothing to remove */
|
|
127
157
|
}
|
|
158
|
+
// Drop the pinned account signing key too — it belongs to the account that just
|
|
159
|
+
// signed out; a different account must re-pin its own at link.
|
|
160
|
+
clearAccountSignKey();
|
|
128
161
|
_cache = null;
|
|
129
162
|
_child = null;
|
|
163
|
+
_account = null;
|
|
130
164
|
}
|
|
131
165
|
|
|
132
166
|
export function hasCredentials(): boolean {
|
|
@@ -171,6 +205,52 @@ function notifySessionExpired(): void {
|
|
|
171
205
|
}
|
|
172
206
|
}
|
|
173
207
|
|
|
208
|
+
// A server-pushed `session_revoked` relay frame arrived: the account signed this
|
|
209
|
+
// terminal out (from the app's Linked Devices). Treat it exactly like a lazily-
|
|
210
|
+
// detected expiry — wipe the local machine login and announce it via
|
|
211
|
+
// onSessionExpired — but PROMPTLY, while the process is live, instead of waiting
|
|
212
|
+
// for the next authedFetch/launch to hit a 401. The relay owner additionally
|
|
213
|
+
// drops remote access. No-op if we're already signed out, so a duplicate frame
|
|
214
|
+
// (or a frame racing an in-flight /signout) doesn't fire a spurious notice.
|
|
215
|
+
export function handleServerRevoke(): void {
|
|
216
|
+
if (!hasCredentials()) return;
|
|
217
|
+
clearCredentials();
|
|
218
|
+
notifySessionExpired();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Sign-in notification ─────────────────────────────────────────────────────
|
|
222
|
+
// Fired when a Privateer login completes on this terminal — the cue for the UI to
|
|
223
|
+
// re-render its header/badge to the signed-in state. It reaches every sign-in entry
|
|
224
|
+
// point:
|
|
225
|
+
// - our dedicated /signin command and a FRESH /login → "Use a subscription" OAuth
|
|
226
|
+
// login both run the device-code flow, which fires this from pollForToken once
|
|
227
|
+
// credentials are written; and
|
|
228
|
+
// - an ALREADY-LINKED machine selecting the subscription runs no device code (it
|
|
229
|
+
// just spawns an account session), so privateerOAuthProvider.login() fires this
|
|
230
|
+
// itself — otherwise that path had no hook back to the header and it kept showing
|
|
231
|
+
// the stale "not signed in" banner until the next launch.
|
|
232
|
+
// A listener here refreshes the UI regardless of which path the user took.
|
|
233
|
+
|
|
234
|
+
type SignedInListener = () => void;
|
|
235
|
+
const _signedInListeners = new Set<SignedInListener>();
|
|
236
|
+
|
|
237
|
+
export function onSignedIn(listener: SignedInListener): () => void {
|
|
238
|
+
_signedInListeners.add(listener);
|
|
239
|
+
return () => _signedInListeners.delete(listener);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Emit the sign-in signal. Exported so the account OAuth provider can announce a
|
|
243
|
+
// completed subscription login on the already-linked path (see the note above).
|
|
244
|
+
export function notifySignedIn(): void {
|
|
245
|
+
for (const listener of _signedInListeners) {
|
|
246
|
+
try {
|
|
247
|
+
listener();
|
|
248
|
+
} catch {
|
|
249
|
+
/* a failing listener must not break the auth path */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
174
254
|
// ── Device authorization flow ────────────────────────────────────────────────
|
|
175
255
|
|
|
176
256
|
export interface DeviceCode {
|
|
@@ -197,7 +277,17 @@ async function postJson(base: string, path: string, body: unknown, init: Request
|
|
|
197
277
|
// Step 1: ask the server for a device + user code the human will approve in-app.
|
|
198
278
|
export async function requestDeviceCode(deviceLabel = defaultDeviceLabel()): Promise<DeviceCode> {
|
|
199
279
|
const base = serverBaseUrl();
|
|
200
|
-
|
|
280
|
+
// This terminal's public key rides the grant so the app can PIN it on approval
|
|
281
|
+
// (TOFU) — the trust anchor for later sealing secrets (channel tokens) that only
|
|
282
|
+
// this machine can open. Best-effort: if keygen fails we just omit it and the app
|
|
283
|
+
// falls back to terminal-only secret entry rather than blocking login.
|
|
284
|
+
let terminalPub: string | undefined;
|
|
285
|
+
try {
|
|
286
|
+
terminalPub = terminalPublicKeyBase64();
|
|
287
|
+
} catch {
|
|
288
|
+
/* no key → no app-sealed secrets for this terminal; login still proceeds */
|
|
289
|
+
}
|
|
290
|
+
const res = await postJson(base, "/auth/device/code", { deviceLabel, terminalPub });
|
|
201
291
|
if (!res.ok) {
|
|
202
292
|
throw new Error(`Couldn't start login (${res.status}). Check your connection or PRIVATEER_SERVER_URL.`);
|
|
203
293
|
}
|
|
@@ -226,9 +316,13 @@ export async function pollForToken(
|
|
|
226
316
|
const res = await postJson(base, "/auth/device/token", { device_code: code.device_code });
|
|
227
317
|
|
|
228
318
|
if (res.ok) {
|
|
229
|
-
const data = (await res.json()) as Omit<Credentials, "serverBaseUrl"
|
|
230
|
-
const creds: Credentials = {
|
|
319
|
+
const data = (await res.json()) as Omit<Credentials, "serverBaseUrl"> & { accountSignPub?: string };
|
|
320
|
+
const creds: Credentials = { accessToken: data.accessToken, refreshToken: data.refreshToken, user: data.user, serverBaseUrl: base };
|
|
231
321
|
saveCredentials(creds);
|
|
322
|
+
// Pin the account's signing public key (TOFU) so channel-config from the app can
|
|
323
|
+
// be verified as genuinely coming from the account, not a forging relay (F7/F8).
|
|
324
|
+
pinAccountSignKey(data.accountSignPub);
|
|
325
|
+
notifySignedIn();
|
|
232
326
|
return creds;
|
|
233
327
|
}
|
|
234
328
|
|
|
@@ -409,23 +503,17 @@ export async function apiRequest(path: string, init: RequestInit = {}): Promise<
|
|
|
409
503
|
}
|
|
410
504
|
|
|
411
505
|
/**
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
* it. If no child was ever spawned (e.g. BYO-key run), there's nothing to do.
|
|
418
|
-
* Bounded by a short timeout — exit must never hang on a slow network — and all
|
|
419
|
-
* failures are swallowed; the server's TTL remains the fallback.
|
|
506
|
+
* DELETE the server-side session identified by `accessToken` (RFC-style bearer
|
|
507
|
+
* possession proof). Deliberately a raw fetch, NOT authedFetch — authedFetch would
|
|
508
|
+
* spawn/refresh a brand-new session just to kill this one. Bounded by a short
|
|
509
|
+
* timeout so exit never hangs on a slow network, and all failures are swallowed;
|
|
510
|
+
* the server's TTL is the fallback.
|
|
420
511
|
*/
|
|
421
|
-
|
|
422
|
-
const child = _child;
|
|
423
|
-
if (!child) return;
|
|
424
|
-
_child = null; // never reuse a session we've asked the server to revoke
|
|
512
|
+
async function deleteSession(accessToken: string, timeoutMs: number): Promise<void> {
|
|
425
513
|
try {
|
|
426
514
|
await fetch(`${serverBaseUrl()}/auth/session/current`, {
|
|
427
515
|
method: "DELETE",
|
|
428
|
-
headers: { Authorization: `Bearer ${
|
|
516
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
429
517
|
signal: AbortSignal.timeout(timeoutMs),
|
|
430
518
|
});
|
|
431
519
|
} catch {
|
|
@@ -433,6 +521,48 @@ export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
|
|
|
433
521
|
}
|
|
434
522
|
}
|
|
435
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Best-effort revoke of THIS terminal's child session (from authedFetch/apiRequest).
|
|
526
|
+
* If no child was ever spawned (e.g. BYO-key run), there's nothing to do.
|
|
527
|
+
*/
|
|
528
|
+
export async function revokeChildSession(timeoutMs = 1500): Promise<void> {
|
|
529
|
+
const child = _child;
|
|
530
|
+
if (!child) return;
|
|
531
|
+
_child = null; // never reuse a session we've asked the server to revoke
|
|
532
|
+
await deleteSession(child.accessToken, timeoutMs);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Best-effort revoke of the account-provider session (the one Pi drives for account
|
|
537
|
+
* inference). Called both on EXPLICIT sign-out AND as part of exit cleanup (via
|
|
538
|
+
* revokeLocalSessions) — safe in the exit path ONLY because the caller also drops Pi's
|
|
539
|
+
* persisted copy (authStorage.remove("privateer")) so the next launch spawns fresh
|
|
540
|
+
* rather than reusing this now-dead token. See the _account note and revokeLocalSessions.
|
|
541
|
+
*/
|
|
542
|
+
export async function revokeAccountSession(timeoutMs = 1500): Promise<void> {
|
|
543
|
+
const account = _account;
|
|
544
|
+
if (!account) return;
|
|
545
|
+
_account = null;
|
|
546
|
+
await deleteSession(account.accessToken, timeoutMs);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Revoke ALL server-side sessions this terminal created — the in-memory child session
|
|
551
|
+
* (authedFetch/apiRequest) AND the account-provider inference session — so the terminal
|
|
552
|
+
* drops off the app's Linked Devices list the moment it exits (Ctrl+C, /quit, SIGTERM …)
|
|
553
|
+
* instead of lingering until its token TTL (~24h). Best-effort, time-bounded, and the
|
|
554
|
+
* two revokes run in parallel so a slow network can't double the exit delay.
|
|
555
|
+
*
|
|
556
|
+
* IMPORTANT: the account session is persisted by Pi (auth.json) and reused on the next
|
|
557
|
+
* launch without a reactive-on-401 refresh, so the caller MUST also drop the persisted
|
|
558
|
+
* copy right after this resolves — `authStorage.remove("privateer")` — or the next run
|
|
559
|
+
* will reuse the token we just revoked and dead-end on a 401 (see the _account note).
|
|
560
|
+
* Callers: cli/chat.ts cleanup() and daemon/index.ts shutdown().
|
|
561
|
+
*/
|
|
562
|
+
export async function revokeLocalSessions(timeoutMs = 1500): Promise<void> {
|
|
563
|
+
await Promise.all([revokeChildSession(timeoutMs), revokeAccountSession(timeoutMs)]);
|
|
564
|
+
}
|
|
565
|
+
|
|
436
566
|
// ── Logout ───────────────────────────────────────────────────────────────────
|
|
437
567
|
|
|
438
568
|
/**
|
|
@@ -493,6 +623,7 @@ export async function spawnAccountCredentials(): Promise<AccountCredential> {
|
|
|
493
623
|
throw new Error("Your Privateer session expired. Run /login to sign in again.");
|
|
494
624
|
}
|
|
495
625
|
const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
|
|
626
|
+
_account = { accessToken }; // track for explicit sign-out revoke (revokeAccountSession)
|
|
496
627
|
return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
|
|
497
628
|
}
|
|
498
629
|
|
|
@@ -502,6 +633,7 @@ export async function refreshAccountCredentials(refresh: string): Promise<Accoun
|
|
|
502
633
|
const res = await postJson(serverBaseUrl(), "/auth/refresh", { refreshToken: refresh });
|
|
503
634
|
if (!res.ok) throw new Error(`account refresh failed (${res.status})`);
|
|
504
635
|
const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
|
|
636
|
+
_account = { accessToken }; // the rotated session is the one an explicit sign-out revokes
|
|
505
637
|
return { access: accessToken, refresh: refreshToken, expires: jwtExpMs(accessToken) };
|
|
506
638
|
}
|
|
507
639
|
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// The transport-agnostic core of the messaging channels — the analog of
|
|
2
|
+
// RemoteBridge for the relay. It owns the policy that must be identical on every
|
|
3
|
+
// platform:
|
|
4
|
+
// - allowlist (who may drive the agent; fail-closed + silent to strangers)
|
|
5
|
+
// - serialization (one turn per conversation at a time; extra messages queue)
|
|
6
|
+
// - redaction (chat platforms are external egress — scrub before send)
|
|
7
|
+
// - chunking (respect the platform's per-message length cap)
|
|
8
|
+
//
|
|
9
|
+
// The agent itself is injected as `runTurn`, so this file stays Pi-free and
|
|
10
|
+
// unit-testable against a fake adapter + fake runner (see tests/channels.test.ts).
|
|
11
|
+
// The Pi-backed runner lives in ./run.ts.
|
|
12
|
+
|
|
13
|
+
import type { ChannelAdapter, InboundMessage } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
// A minimal view of the permission request the gate hands us (see
|
|
16
|
+
// src/permissions/gate.ts PermissionRequest). Kept local so the bridge doesn't
|
|
17
|
+
// depend on the gate module.
|
|
18
|
+
export interface ApprovalRequest {
|
|
19
|
+
kind: string;
|
|
20
|
+
title: string;
|
|
21
|
+
detail: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Run one agent turn for a conversation. `onText` receives streamed text deltas as
|
|
25
|
+
// they arrive; the bridge buffers/coalesces them. Resolves when the turn is done.
|
|
26
|
+
// `signal` aborts a queued/in-flight turn (e.g. the user sent "/stop").
|
|
27
|
+
export type TurnRunner = (
|
|
28
|
+
chatId: string,
|
|
29
|
+
text: string,
|
|
30
|
+
onText: (delta: string) => void,
|
|
31
|
+
signal: AbortSignal,
|
|
32
|
+
// The triggering user + their role, so the runner (and the gate it drives) can
|
|
33
|
+
// cap a member to read-only regardless of the channel's posture.
|
|
34
|
+
meta: TurnMeta,
|
|
35
|
+
) => Promise<{ ok: boolean; error?: string }>;
|
|
36
|
+
|
|
37
|
+
export interface TurnMeta {
|
|
38
|
+
userId: string;
|
|
39
|
+
isAdmin: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A security-audit event. The bridge emits these at authorization-relevant moments;
|
|
43
|
+
// run.ts appends them to an on-disk log. `detail` is redacted before it's emitted.
|
|
44
|
+
export interface AuditEvent {
|
|
45
|
+
at: string;
|
|
46
|
+
event: "prompt" | "approval_request" | "approval_decision" | "interrupt" | "denied";
|
|
47
|
+
chatId: string;
|
|
48
|
+
userId?: string;
|
|
49
|
+
role?: "admin" | "member";
|
|
50
|
+
detail?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface MessagingBridgeConfig {
|
|
54
|
+
adapter: ChannelAdapter;
|
|
55
|
+
runTurn: TurnRunner;
|
|
56
|
+
// Who may interact at all (admin OR member). False → ignored (fail-closed and
|
|
57
|
+
// SILENT: we don't confirm the bot exists to un-allowlisted senders).
|
|
58
|
+
isAllowed: (msg: InboundMessage) => boolean;
|
|
59
|
+
// Is this user an admin? Admins are governed by the channel posture and are the
|
|
60
|
+
// ONLY users whose yes/no resolves an approval. Members are read-only and can't
|
|
61
|
+
// approve.
|
|
62
|
+
isAdmin: (msg: InboundMessage) => boolean;
|
|
63
|
+
// Scrub secrets from every outbound message. Wired to redactText in ./run.ts.
|
|
64
|
+
redact?: (text: string) => string;
|
|
65
|
+
onLog?: (msg: string) => void;
|
|
66
|
+
// Optional append-only security audit sink.
|
|
67
|
+
onAudit?: (event: AuditEvent) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Stay comfortably under Telegram's 4096-char hard cap (Slack ~40k, Discord 2000 —
|
|
71
|
+
// pick the tightest common bound for the shared path; a platform with a smaller cap
|
|
72
|
+
// can override in its adapter later).
|
|
73
|
+
const MAX_MSG = 1900;
|
|
74
|
+
|
|
75
|
+
// How long to wait for a yes/no approval reply before failing closed (deny).
|
|
76
|
+
const APPROVAL_TIMEOUT_MS = 120_000;
|
|
77
|
+
|
|
78
|
+
const YES = new Set(["yes", "y", "allow", "ok", "okay", "approve", "approved", "👍", "✅"]);
|
|
79
|
+
const NO = new Set(["no", "n", "deny", "denied", "stop", "cancel", "reject", "👎", "❌"]);
|
|
80
|
+
|
|
81
|
+
// Interpret an approval reply. Returns null for anything that isn't a clear
|
|
82
|
+
// yes/no, so the bridge can re-prompt instead of guessing (fail-safe: never treat
|
|
83
|
+
// ambiguous text as allow).
|
|
84
|
+
export function approvalDecision(text: string): "allow" | "deny" | null {
|
|
85
|
+
const t = text.trim().toLowerCase();
|
|
86
|
+
if (YES.has(t)) return "allow";
|
|
87
|
+
if (NO.has(t)) return "deny";
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The message a user sees when the agent wants to run a gated action.
|
|
92
|
+
export function approvalPrompt(req: ApprovalRequest): string {
|
|
93
|
+
const detail = req.detail.length > 600 ? req.detail.slice(0, 600) + "\n…(truncated)" : req.detail;
|
|
94
|
+
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
|
+
}
|
|
96
|
+
|
|
97
|
+
// Split text into <=max chunks, preferring newline boundaries so code/paragraphs
|
|
98
|
+
// aren't cut mid-line when possible.
|
|
99
|
+
export function chunkText(text: string, max = MAX_MSG): string[] {
|
|
100
|
+
const out: string[] = [];
|
|
101
|
+
let rest = text;
|
|
102
|
+
while (rest.length > max) {
|
|
103
|
+
let cut = rest.lastIndexOf("\n", max);
|
|
104
|
+
if (cut < max * 0.5) cut = max; // no usable newline in the back half → hard cut
|
|
105
|
+
out.push(rest.slice(0, cut));
|
|
106
|
+
rest = rest.slice(cut).replace(/^\n/, "");
|
|
107
|
+
}
|
|
108
|
+
if (rest) out.push(rest);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class MessagingBridge {
|
|
113
|
+
// Per-chat promise tail: each new turn chains onto the previous so turns in the
|
|
114
|
+
// same conversation never interleave (they share one agent session downstream).
|
|
115
|
+
private readonly tails = new Map<string, Promise<void>>();
|
|
116
|
+
// Per-chat abort handle for the in-flight turn, so "/stop" can interrupt it.
|
|
117
|
+
private readonly aborts = new Map<string, AbortController>();
|
|
118
|
+
// Per-chat pending tool approval awaiting a yes/no reply. At most one at a time
|
|
119
|
+
// per chat (turns are serialized and a turn's tool calls are sequential).
|
|
120
|
+
private readonly approvals = new Map<string, (decision: "allow" | "deny") => void>();
|
|
121
|
+
|
|
122
|
+
constructor(private readonly cfg: MessagingBridgeConfig) {}
|
|
123
|
+
|
|
124
|
+
async start(): Promise<void> {
|
|
125
|
+
await this.cfg.adapter.start((m) => this.onMessage(m));
|
|
126
|
+
this.log(`channel "${this.cfg.adapter.name}" listening`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
stop(): void {
|
|
130
|
+
this.cfg.adapter.stop();
|
|
131
|
+
for (const a of this.aborts.values()) a.abort();
|
|
132
|
+
this.aborts.clear();
|
|
133
|
+
// Fail any pending approvals closed so no turn hangs on shutdown.
|
|
134
|
+
for (const resolve of this.approvals.values()) resolve("deny");
|
|
135
|
+
this.approvals.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Ask the user in `chatId` to approve a gated tool action, and await their yes/no
|
|
139
|
+
// reply. Wired to the permission gate's remote approver (see channels/run.ts): the
|
|
140
|
+
// gate suspends the tool until this resolves. Fail-closed — timeout, abort (/stop),
|
|
141
|
+
// or shutdown all resolve to "deny". Public because the gate calls it directly
|
|
142
|
+
// (via an AsyncLocalStorage handle to this bridge + the current chat id).
|
|
143
|
+
requestApproval(chatId: string, req: ApprovalRequest, signal?: AbortSignal): Promise<"allow" | "deny"> {
|
|
144
|
+
// Only one outstanding approval per chat; deny any stale one first.
|
|
145
|
+
this.approvals.get(chatId)?.("deny");
|
|
146
|
+
|
|
147
|
+
const prompt = approvalPrompt(req);
|
|
148
|
+
const detail = `${req.title}: ${req.detail}`;
|
|
149
|
+
this.cfg.onAudit?.({
|
|
150
|
+
at: new Date().toISOString(),
|
|
151
|
+
event: "approval_request",
|
|
152
|
+
chatId,
|
|
153
|
+
role: "admin", // approvals only arise from admin turns (members are read-only)
|
|
154
|
+
detail: this.cfg.redact ? this.cfg.redact(detail) : detail,
|
|
155
|
+
});
|
|
156
|
+
void this.cfg.adapter.sendText(chatId, this.cfg.redact ? this.cfg.redact(prompt) : prompt);
|
|
157
|
+
|
|
158
|
+
return new Promise<"allow" | "deny">((resolve) => {
|
|
159
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
160
|
+
const settle = (decision: "allow" | "deny") => {
|
|
161
|
+
if (this.approvals.get(chatId) !== settle) return; // already settled
|
|
162
|
+
this.approvals.delete(chatId);
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
signal?.removeEventListener("abort", onAbort);
|
|
165
|
+
resolve(decision);
|
|
166
|
+
};
|
|
167
|
+
const onAbort = () => {
|
|
168
|
+
void this.cfg.adapter.sendText(chatId, "🚫 request interrupted — denied.");
|
|
169
|
+
settle("deny");
|
|
170
|
+
};
|
|
171
|
+
timer = setTimeout(() => {
|
|
172
|
+
void this.cfg.adapter.sendText(chatId, "⌛ approval timed out — denied.");
|
|
173
|
+
settle("deny");
|
|
174
|
+
}, APPROVAL_TIMEOUT_MS);
|
|
175
|
+
this.approvals.set(chatId, settle);
|
|
176
|
+
if (signal) {
|
|
177
|
+
if (signal.aborted) return onAbort();
|
|
178
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private log(msg: string): void {
|
|
184
|
+
this.cfg.onLog?.(msg);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private audit(m: InboundMessage, event: AuditEvent["event"], detail?: string): void {
|
|
188
|
+
if (!this.cfg.onAudit) return;
|
|
189
|
+
const red = detail && this.cfg.redact ? this.cfg.redact(detail) : detail;
|
|
190
|
+
this.cfg.onAudit({
|
|
191
|
+
at: new Date().toISOString(),
|
|
192
|
+
event,
|
|
193
|
+
chatId: m.chatId,
|
|
194
|
+
userId: m.userId,
|
|
195
|
+
role: this.cfg.isAdmin(m) ? "admin" : "member",
|
|
196
|
+
detail: red,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private onMessage(m: InboundMessage): void {
|
|
201
|
+
const text = m.text?.trim();
|
|
202
|
+
if (!text) return;
|
|
203
|
+
|
|
204
|
+
if (!this.cfg.isAllowed(m)) {
|
|
205
|
+
// Fail closed and stay silent — replying would confirm the bot to strangers.
|
|
206
|
+
this.log(`ignored message from unauthorized user ${m.userId} in chat ${m.chatId}`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// A pending tool approval in this chat consumes the next message as its answer —
|
|
211
|
+
// BEFORE the per-chat queue, because the turn awaiting approval is itself holding
|
|
212
|
+
// that queue open (routing the reply through the queue would deadlock it).
|
|
213
|
+
const pending = this.approvals.get(m.chatId);
|
|
214
|
+
if (pending) {
|
|
215
|
+
// Only admins may answer an approval. A member's reply is refused (and audited)
|
|
216
|
+
// — never silently treated as a decision.
|
|
217
|
+
if (!this.cfg.isAdmin(m)) {
|
|
218
|
+
this.audit(m, "denied", "non-admin attempted to answer an approval");
|
|
219
|
+
void this.cfg.adapter.sendText(m.chatId, "Only an admin can approve the pending action.");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (text === "/stop") {
|
|
223
|
+
this.audit(m, "approval_decision", "deny (/stop)");
|
|
224
|
+
pending("deny"); // interrupt while awaiting approval → deny it
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const decision = approvalDecision(text);
|
|
228
|
+
if (decision === null) {
|
|
229
|
+
void this.cfg.adapter.sendText(m.chatId, 'Reply "yes" to allow or "no" to deny the pending action.');
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.audit(m, "approval_decision", decision);
|
|
233
|
+
pending(decision);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// "/stop" interrupts the running turn instead of queueing another.
|
|
238
|
+
if (text === "/stop") {
|
|
239
|
+
this.aborts.get(m.chatId)?.abort();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Serialize per conversation: chain onto this chat's tail.
|
|
244
|
+
const prev = this.tails.get(m.chatId) ?? Promise.resolve();
|
|
245
|
+
const next = prev
|
|
246
|
+
.then(() => this.handle(m))
|
|
247
|
+
.catch((e) => this.log(`turn error: ${e instanceof Error ? e.message : String(e)}`));
|
|
248
|
+
this.tails.set(m.chatId, next);
|
|
249
|
+
// Drop the tail once this was the last queued turn, so the map doesn't grow.
|
|
250
|
+
void next.finally(() => {
|
|
251
|
+
if (this.tails.get(m.chatId) === next) this.tails.delete(m.chatId);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async handle(m: InboundMessage): Promise<void> {
|
|
256
|
+
const { chatId } = m;
|
|
257
|
+
const isAdmin = this.cfg.isAdmin(m);
|
|
258
|
+
this.audit(m, "prompt", m.text.trim().slice(0, 200));
|
|
259
|
+
const ac = new AbortController();
|
|
260
|
+
this.aborts.set(chatId, ac);
|
|
261
|
+
this.cfg.adapter.sendTyping?.(chatId);
|
|
262
|
+
|
|
263
|
+
// Buffer the whole turn's text, then send once (coalesced) — the simplest
|
|
264
|
+
// correct choice. Streaming partial edits back to the channel is a future
|
|
265
|
+
// enhancement; buffering avoids a race between deltas and async sends and
|
|
266
|
+
// keeps this unit-testable without timers.
|
|
267
|
+
let buf = "";
|
|
268
|
+
let result: { ok: boolean; error?: string };
|
|
269
|
+
try {
|
|
270
|
+
result = await this.cfg.runTurn(chatId, m.text.trim(), (d) => (buf += d), ac.signal, {
|
|
271
|
+
userId: m.userId,
|
|
272
|
+
isAdmin,
|
|
273
|
+
});
|
|
274
|
+
} catch (e) {
|
|
275
|
+
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
276
|
+
} finally {
|
|
277
|
+
this.aborts.delete(chatId);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const body = this.cfg.redact ? this.cfg.redact(buf) : buf;
|
|
281
|
+
|
|
282
|
+
// Deliver any text the turn produced (even on error — a partial answer is
|
|
283
|
+
// useful), then an error line if it failed.
|
|
284
|
+
if (body.trim()) {
|
|
285
|
+
for (const chunk of chunkText(body)) await this.cfg.adapter.sendText(chatId, chunk);
|
|
286
|
+
} else if (result.ok) {
|
|
287
|
+
await this.cfg.adapter.sendText(chatId, "✓ done (no text output).");
|
|
288
|
+
}
|
|
289
|
+
if (!result.ok) {
|
|
290
|
+
await this.cfg.adapter.sendText(chatId, `⚠️ ${result.error ?? "the agent hit an error"}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|