@spinabot/brigade 1.1.0 → 1.2.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/convex/channels.d.ts +5 -5
- package/convex/schema.d.ts +2 -2
- package/dist/agents/agent-loop.d.ts.map +1 -1
- package/dist/agents/agent-loop.js +27 -4
- package/dist/agents/agent-loop.js.map +1 -1
- package/dist/agents/channels/approval-callback-codec.d.ts +107 -0
- package/dist/agents/channels/approval-callback-codec.d.ts.map +1 -0
- package/dist/agents/channels/approval-callback-codec.js +173 -0
- package/dist/agents/channels/approval-callback-codec.js.map +1 -0
- package/dist/agents/channels/approval-router.d.ts +77 -20
- package/dist/agents/channels/approval-router.d.ts.map +1 -1
- package/dist/agents/channels/approval-router.js +163 -37
- package/dist/agents/channels/approval-router.js.map +1 -1
- package/dist/agents/channels/backoff.d.ts +55 -0
- package/dist/agents/channels/backoff.d.ts.map +1 -0
- package/dist/agents/channels/backoff.js +47 -0
- package/dist/agents/channels/backoff.js.map +1 -0
- package/dist/agents/channels/channel-secrets.d.ts +45 -0
- package/dist/agents/channels/channel-secrets.d.ts.map +1 -0
- package/dist/agents/channels/channel-secrets.js +69 -0
- package/dist/agents/channels/channel-secrets.js.map +1 -0
- package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
- package/dist/agents/channels/inbound-pipeline.js +67 -3
- package/dist/agents/channels/inbound-pipeline.js.map +1 -1
- package/dist/agents/channels/last-sent-message.d.ts +46 -0
- package/dist/agents/channels/last-sent-message.d.ts.map +1 -0
- package/dist/agents/channels/last-sent-message.js +55 -0
- package/dist/agents/channels/last-sent-message.js.map +1 -0
- package/dist/agents/channels/manager.d.ts +52 -0
- package/dist/agents/channels/manager.d.ts.map +1 -1
- package/dist/agents/channels/manager.js +141 -31
- package/dist/agents/channels/manager.js.map +1 -1
- package/dist/agents/channels/plugin-channel-manager-facade.d.ts +13 -2
- package/dist/agents/channels/plugin-channel-manager-facade.d.ts.map +1 -1
- package/dist/agents/channels/plugin-channel-manager-facade.js +21 -0
- package/dist/agents/channels/plugin-channel-manager-facade.js.map +1 -1
- package/dist/agents/channels/sdk.d.ts +426 -0
- package/dist/agents/channels/sdk.d.ts.map +1 -0
- package/dist/agents/channels/sdk.js +274 -0
- package/dist/agents/channels/sdk.js.map +1 -0
- package/dist/agents/channels/telegram/account-config.d.ts +92 -0
- package/dist/agents/channels/telegram/account-config.d.ts.map +1 -0
- package/dist/agents/channels/telegram/account-config.js +192 -0
- package/dist/agents/channels/telegram/account-config.js.map +1 -0
- package/dist/agents/channels/telegram/adapter.d.ts +79 -0
- package/dist/agents/channels/telegram/adapter.d.ts.map +1 -0
- package/dist/agents/channels/telegram/adapter.js +475 -0
- package/dist/agents/channels/telegram/adapter.js.map +1 -0
- package/dist/agents/channels/telegram/allowed-updates.d.ts +44 -0
- package/dist/agents/channels/telegram/allowed-updates.d.ts.map +1 -0
- package/dist/agents/channels/telegram/allowed-updates.js +52 -0
- package/dist/agents/channels/telegram/allowed-updates.js.map +1 -0
- package/dist/agents/channels/telegram/approval-authorize.d.ts +41 -0
- package/dist/agents/channels/telegram/approval-authorize.d.ts.map +1 -0
- package/dist/agents/channels/telegram/approval-authorize.js +69 -0
- package/dist/agents/channels/telegram/approval-authorize.js.map +1 -0
- package/dist/agents/channels/telegram/approval-native.d.ts +68 -0
- package/dist/agents/channels/telegram/approval-native.d.ts.map +1 -0
- package/dist/agents/channels/telegram/approval-native.js +94 -0
- package/dist/agents/channels/telegram/approval-native.js.map +1 -0
- package/dist/agents/channels/telegram/command-menu.d.ts +35 -0
- package/dist/agents/channels/telegram/command-menu.d.ts.map +1 -0
- package/dist/agents/channels/telegram/command-menu.js +59 -0
- package/dist/agents/channels/telegram/command-menu.js.map +1 -0
- package/dist/agents/channels/telegram/connection.d.ts +359 -0
- package/dist/agents/channels/telegram/connection.d.ts.map +1 -0
- package/dist/agents/channels/telegram/connection.js +865 -0
- package/dist/agents/channels/telegram/connection.js.map +1 -0
- package/dist/agents/channels/telegram/format.d.ts +48 -0
- package/dist/agents/channels/telegram/format.d.ts.map +1 -0
- package/dist/agents/channels/telegram/format.js +256 -0
- package/dist/agents/channels/telegram/format.js.map +1 -0
- package/dist/agents/channels/telegram/inbound-extras.d.ts +73 -0
- package/dist/agents/channels/telegram/inbound-extras.d.ts.map +1 -0
- package/dist/agents/channels/telegram/inbound-extras.js +231 -0
- package/dist/agents/channels/telegram/inbound-extras.js.map +1 -0
- package/dist/agents/channels/telegram/index.d.ts +14 -0
- package/dist/agents/channels/telegram/index.d.ts.map +1 -0
- package/dist/agents/channels/telegram/index.js +14 -0
- package/dist/agents/channels/telegram/index.js.map +1 -0
- package/dist/agents/channels/telegram/media.d.ts +68 -0
- package/dist/agents/channels/telegram/media.d.ts.map +1 -0
- package/dist/agents/channels/telegram/media.js +143 -0
- package/dist/agents/channels/telegram/media.js.map +1 -0
- package/dist/agents/channels/telegram/module.d.ts +15 -0
- package/dist/agents/channels/telegram/module.d.ts.map +1 -0
- package/dist/agents/channels/telegram/module.js +36 -0
- package/dist/agents/channels/telegram/module.js.map +1 -0
- package/dist/agents/channels/telegram/plugin.d.ts +76 -0
- package/dist/agents/channels/telegram/plugin.d.ts.map +1 -0
- package/dist/agents/channels/telegram/plugin.js +314 -0
- package/dist/agents/channels/telegram/plugin.js.map +1 -0
- package/dist/agents/channels/telegram/probe.d.ts +54 -0
- package/dist/agents/channels/telegram/probe.d.ts.map +1 -0
- package/dist/agents/channels/telegram/probe.js +95 -0
- package/dist/agents/channels/telegram/probe.js.map +1 -0
- package/dist/agents/channels/telegram/webhook.d.ts +55 -0
- package/dist/agents/channels/telegram/webhook.d.ts.map +1 -0
- package/dist/agents/channels/telegram/webhook.js +141 -0
- package/dist/agents/channels/telegram/webhook.js.map +1 -0
- package/dist/agents/extensions/modules/index.d.ts.map +1 -1
- package/dist/agents/extensions/modules/index.js +4 -0
- package/dist/agents/extensions/modules/index.js.map +1 -1
- package/dist/agents/extensions/types.d.ts +72 -2
- package/dist/agents/extensions/types.d.ts.map +1 -1
- package/dist/agents/extensions/types.js.map +1 -1
- package/dist/agents/tools/connect-channel-tool.d.ts +86 -0
- package/dist/agents/tools/connect-channel-tool.d.ts.map +1 -0
- package/dist/agents/tools/connect-channel-tool.js +398 -0
- package/dist/agents/tools/connect-channel-tool.js.map +1 -0
- package/dist/agents/tools/message-action-tool.d.ts +67 -0
- package/dist/agents/tools/message-action-tool.d.ts.map +1 -0
- package/dist/agents/tools/message-action-tool.js +216 -0
- package/dist/agents/tools/message-action-tool.js.map +1 -0
- package/dist/agents/tools/registry.d.ts.map +1 -1
- package/dist/agents/tools/registry.js +19 -0
- package/dist/agents/tools/registry.js.map +1 -1
- package/dist/buildstamp.json +1 -1
- package/dist/cli/commands/channels.d.ts.map +1 -1
- package/dist/cli/commands/channels.js +27 -2
- package/dist/cli/commands/channels.js.map +1 -1
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +77 -27
- package/dist/core/server.js.map +1 -1
- package/dist/cron/service/state.d.ts +10 -0
- package/dist/cron/service/state.d.ts.map +1 -1
- package/dist/cron/service/state.js.map +1 -1
- package/dist/cron/service/timer.d.ts.map +1 -1
- package/dist/cron/service/timer.js +43 -14
- package/dist/cron/service/timer.js.map +1 -1
- package/dist/cron/session-reaper.d.ts +27 -0
- package/dist/cron/session-reaper.d.ts.map +1 -1
- package/dist/cron/session-reaper.js +81 -0
- package/dist/cron/session-reaper.js.map +1 -1
- package/dist/system-prompt/assembler.d.ts +14 -0
- package/dist/system-prompt/assembler.d.ts.map +1 -1
- package/dist/system-prompt/assembler.js +36 -14
- package/dist/system-prompt/assembler.js.map +1 -1
- package/package.json +16 -3
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval-callback codec — the wire format for inline-button approvals.
|
|
3
|
+
*
|
|
4
|
+
* Some channels (Telegram, Slack block-kit, Discord components) render an
|
|
5
|
+
* approval prompt as native buttons instead of a "reply yes/no" text card. When
|
|
6
|
+
* the operator taps a button the channel delivers a `callback_query`-style event
|
|
7
|
+
* carrying an opaque payload string the BUTTON declared at send time. This codec
|
|
8
|
+
* is the central, channel-neutral encode/decode for that payload: it packs the
|
|
9
|
+
* pending-approval id + the chosen decision into one short string, and unpacks
|
|
10
|
+
* it back on the way in.
|
|
11
|
+
*
|
|
12
|
+
* THE 64-BYTE BUDGET. Telegram's `callback_data` is capped at **64 bytes** (a
|
|
13
|
+
* hard Bot API limit — a longer value is rejected outright). That is the
|
|
14
|
+
* tightest channel constraint, so this codec treats 64 bytes as the universal
|
|
15
|
+
* ceiling: {@link encodeApprovalCallback} returns `undefined` when the payload
|
|
16
|
+
* would exceed it (the caller then omits / falls back to the text prompt rather
|
|
17
|
+
* than ship an oversized button). The budget is measured in UTF-8 BYTES, not
|
|
18
|
+
* `string.length`, so a multi-byte id can't sneak past the limit.
|
|
19
|
+
*
|
|
20
|
+
* WIRE FORMAT (channel-neutral, no brand tokens, printable ASCII only):
|
|
21
|
+
*
|
|
22
|
+
* <tag>:<base64url(approvalId)>:<decisionCode>
|
|
23
|
+
*
|
|
24
|
+
* - `tag` is the constant {@link APPROVAL_CALLBACK_TAG} = `"bv1"` (a versioned
|
|
25
|
+
* marker — `b`rigade callback `v1` — that lets the decoder reject foreign
|
|
26
|
+
* button payloads fast and lets a future format bump cleanly).
|
|
27
|
+
* - the approval id is base64url-encoded so it can carry the `:`/`-`/`_`
|
|
28
|
+
* characters real ids use without colliding with the field delimiter.
|
|
29
|
+
* - `decisionCode` is a single char: `o` = allow-once, `a` = allow-always,
|
|
30
|
+
* `d` = deny. Single chars (rather than the full `allow-always` words) keep
|
|
31
|
+
* the payload comfortably inside 64 bytes.
|
|
32
|
+
*
|
|
33
|
+
* No NUL / control bytes ever appear: base64url is `[A-Za-z0-9_-]`, the tag is
|
|
34
|
+
* lowercase ASCII, the delimiter is a printable colon, and the decision code is
|
|
35
|
+
* one of three ASCII letters.
|
|
36
|
+
*
|
|
37
|
+
* The decision vocabulary is the channel-approval subset of the bridge's
|
|
38
|
+
* `ApprovalDecisionKind` — exactly the three an operator can choose from a
|
|
39
|
+
* button (`allow-once` / `allow-always` / `deny`); the bridge's other kinds
|
|
40
|
+
* (`allow-pattern`, `allow-session`) are not button-reachable.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Versioned, brand-neutral marker prefixing every approval callback payload.
|
|
44
|
+
* `b`rigade callback `v1`. Bumping the version (`bv2`, …) lets a new wire
|
|
45
|
+
* format coexist with old in-flight buttons.
|
|
46
|
+
*/
|
|
47
|
+
export const APPROVAL_CALLBACK_TAG = "bv1";
|
|
48
|
+
/**
|
|
49
|
+
* Telegram's `callback_data` hard limit — the tightest channel constraint, used
|
|
50
|
+
* here as the universal ceiling. Measured in UTF-8 bytes.
|
|
51
|
+
*/
|
|
52
|
+
export const APPROVAL_CALLBACK_MAX_BYTES = 64;
|
|
53
|
+
const DELIMITER = ":";
|
|
54
|
+
/** Map a full decision kind to its single-char wire code. */
|
|
55
|
+
function decisionToCode(decision) {
|
|
56
|
+
switch (decision) {
|
|
57
|
+
case "allow-once":
|
|
58
|
+
return "o";
|
|
59
|
+
case "allow-always":
|
|
60
|
+
return "a";
|
|
61
|
+
case "deny":
|
|
62
|
+
return "d";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Map a single-char wire code back to a decision kind (or null if unknown). */
|
|
66
|
+
function codeToDecision(code) {
|
|
67
|
+
switch (code) {
|
|
68
|
+
case "o":
|
|
69
|
+
return "allow-once";
|
|
70
|
+
case "a":
|
|
71
|
+
return "allow-always";
|
|
72
|
+
case "d":
|
|
73
|
+
return "deny";
|
|
74
|
+
default:
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Base64url-encode (no padding) — `[A-Za-z0-9_-]`, never a NUL/control byte. */
|
|
79
|
+
function toBase64Url(value) {
|
|
80
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
81
|
+
}
|
|
82
|
+
/** Base64url-decode back to UTF-8. Returns "" on malformed input. */
|
|
83
|
+
function fromBase64Url(value) {
|
|
84
|
+
try {
|
|
85
|
+
return Buffer.from(value, "base64url").toString("utf8");
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** True iff `value` fits the universal callback-data byte budget. */
|
|
92
|
+
export function fitsApprovalCallback(value) {
|
|
93
|
+
return Buffer.byteLength(value, "utf8") <= APPROVAL_CALLBACK_MAX_BYTES;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Build the standard three approval buttons (Allow once / Allow always / Deny)
|
|
97
|
+
* for an approval id, each carrying its codec-encoded payload. A channel's
|
|
98
|
+
* `sendApprovalPrompt` calls this to get a ready-made, byte-safe button spec
|
|
99
|
+
* instead of re-deriving the encoding by hand, then maps each `{ label, data }`
|
|
100
|
+
* onto its own native button type.
|
|
101
|
+
*
|
|
102
|
+
* `allowAlways: false` drops the "Allow always" button (for approvals where
|
|
103
|
+
* persisting an allowlist entry doesn't apply). Any button whose payload would
|
|
104
|
+
* exceed the 64-byte budget is OMITTED (never shipped oversized) — in practice
|
|
105
|
+
* that only happens for pathologically long approval ids, and the caller should
|
|
106
|
+
* fall back to the text prompt when fewer than two buttons come back.
|
|
107
|
+
*/
|
|
108
|
+
export function buildApprovalCallbackButtons(args) {
|
|
109
|
+
const specs = [
|
|
110
|
+
{ label: "Allow once", decision: "allow-once" },
|
|
111
|
+
...(args.allowAlways === false
|
|
112
|
+
? []
|
|
113
|
+
: [{ label: "Allow always", decision: "allow-always" }]),
|
|
114
|
+
{ label: "Deny", decision: "deny" },
|
|
115
|
+
];
|
|
116
|
+
const buttons = [];
|
|
117
|
+
for (const spec of specs) {
|
|
118
|
+
const data = encodeApprovalCallback({ approvalId: args.approvalId, decision: spec.decision });
|
|
119
|
+
if (!data)
|
|
120
|
+
continue; // oversized payload → omit this button
|
|
121
|
+
buttons.push({ label: spec.label, decision: spec.decision, data });
|
|
122
|
+
}
|
|
123
|
+
return buttons;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Encode an approval id + decision into a callback payload string.
|
|
127
|
+
*
|
|
128
|
+
* Returns `undefined` when the result would exceed {@link APPROVAL_CALLBACK_MAX_BYTES}
|
|
129
|
+
* (64 UTF-8 bytes) OR when the approval id is empty — in either case the caller
|
|
130
|
+
* must NOT render that button (fall back to the text prompt). A non-undefined
|
|
131
|
+
* return is always a wire-safe, printable-ASCII string under the limit.
|
|
132
|
+
*/
|
|
133
|
+
export function encodeApprovalCallback(args) {
|
|
134
|
+
const id = args.approvalId.trim();
|
|
135
|
+
if (!id)
|
|
136
|
+
return undefined;
|
|
137
|
+
const payload = `${APPROVAL_CALLBACK_TAG}${DELIMITER}${toBase64Url(id)}${DELIMITER}${decisionToCode(args.decision)}`;
|
|
138
|
+
if (!fitsApprovalCallback(payload))
|
|
139
|
+
return undefined;
|
|
140
|
+
return payload;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Decode a callback payload string back into `{ approvalId, decision }`.
|
|
144
|
+
*
|
|
145
|
+
* Returns `null` for anything that isn't a well-formed approval callback — a
|
|
146
|
+
* foreign button payload, a truncated/garbled string, an unknown decision code,
|
|
147
|
+
* or an oversized value. A `null` return means "not an approval callback; let
|
|
148
|
+
* the caller fall through" exactly like the text decoder's `null`.
|
|
149
|
+
*/
|
|
150
|
+
export function decodeApprovalCallback(data) {
|
|
151
|
+
if (typeof data !== "string" || data.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
// Reject oversized payloads up front — anything over the budget could not
|
|
154
|
+
// have been minted by our encoder, so it isn't ours to decode.
|
|
155
|
+
if (!fitsApprovalCallback(data))
|
|
156
|
+
return null;
|
|
157
|
+
const parts = data.split(DELIMITER);
|
|
158
|
+
if (parts.length !== 3)
|
|
159
|
+
return null;
|
|
160
|
+
const [tag, idB64, code] = parts;
|
|
161
|
+
if (tag !== APPROVAL_CALLBACK_TAG)
|
|
162
|
+
return null;
|
|
163
|
+
if (!idB64)
|
|
164
|
+
return null;
|
|
165
|
+
const approvalId = fromBase64Url(idB64).trim();
|
|
166
|
+
if (!approvalId)
|
|
167
|
+
return null;
|
|
168
|
+
const decision = codeToDecision(code ?? "");
|
|
169
|
+
if (decision === null)
|
|
170
|
+
return null;
|
|
171
|
+
return { approvalId, decision };
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=approval-callback-codec.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"approval-callback-codec.js","sourceRoot":"","sources":["../../../src/agents/channels/approval-callback-codec.ts"],"names":[],"mappings":"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"}
|
|
@@ -40,7 +40,10 @@
|
|
|
40
40
|
* threading a registry through 8 layers of args is the same fight as
|
|
41
41
|
* the bridge itself (see `approval-bridge.ts` for the same rationale).
|
|
42
42
|
*/
|
|
43
|
+
import type { BrigadeConfig } from "../../config/io.js";
|
|
43
44
|
import type { ApprovalDecision, ApprovalDecisionKind, ApprovalRequest } from "../approval-bridge.js";
|
|
45
|
+
import type { ChannelApprovalCapability, ChannelApprovalKind } from "./types.adapters.js";
|
|
46
|
+
import type { RuntimeEnv } from "./types.core.js";
|
|
44
47
|
/** A pending approval the channel router is waiting on a yes/no for. */
|
|
45
48
|
export interface ChannelApprovalRoute {
|
|
46
49
|
channelId: string;
|
|
@@ -58,13 +61,38 @@ export interface ChannelApprovalRoute {
|
|
|
58
61
|
* `startChannels` boot from each adapter's outbound surface.
|
|
59
62
|
*/
|
|
60
63
|
export interface ChannelApprovalDispatcher {
|
|
61
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Send the approval prompt to the conversation. The return is intentionally
|
|
66
|
+
* widened to accept adapters whose `sendText` now yields `{ messageId? }`
|
|
67
|
+
* (the additive outbound-id surface) — the router ignores any returned id;
|
|
68
|
+
* it only needs the send to resolve.
|
|
69
|
+
*/
|
|
62
70
|
sendText: (conversationId: string, text: string, opts?: {
|
|
63
71
|
threadId?: string;
|
|
64
72
|
accountId?: string;
|
|
65
|
-
}) => Promise<
|
|
73
|
+
}) => Promise<{
|
|
74
|
+
messageId?: string;
|
|
75
|
+
} | void>;
|
|
66
76
|
/** Human-readable label for log lines + the prompt header (e.g. "WhatsApp"). */
|
|
67
77
|
prettyName: string;
|
|
78
|
+
/**
|
|
79
|
+
* Optional native-button approval capability. When the registering channel's
|
|
80
|
+
* adapter exposes `approvalCapability.sendApprovalPrompt`, the router renders
|
|
81
|
+
* the approval question via THAT (inline buttons carrying codec-encoded
|
|
82
|
+
* callback payloads) instead of the default text card. Absent → text path.
|
|
83
|
+
* Additive: dispatchers registered with only `{ sendText, prettyName }`
|
|
84
|
+
* (WhatsApp, the manager's default) keep using the text prompt unchanged.
|
|
85
|
+
*/
|
|
86
|
+
approvalCapability?: ChannelApprovalCapability;
|
|
87
|
+
/**
|
|
88
|
+
* Supplies the `runtime` + `cfg` a native `sendApprovalPrompt` call needs.
|
|
89
|
+
* Only consulted when `approvalCapability.sendApprovalPrompt` is present, so
|
|
90
|
+
* text-only dispatchers never need it.
|
|
91
|
+
*/
|
|
92
|
+
getApprovalContext?: () => {
|
|
93
|
+
runtime: RuntimeEnv;
|
|
94
|
+
cfg: BrigadeConfig;
|
|
95
|
+
};
|
|
68
96
|
}
|
|
69
97
|
/**
|
|
70
98
|
* Register an adapter's outbound surface so the bridge can route prompts
|
|
@@ -114,38 +142,67 @@ export declare function dispatchChannelApproval(args: {
|
|
|
114
142
|
route: ChannelApprovalRoute;
|
|
115
143
|
resolveOnBridge: (decision: ApprovalDecision) => void;
|
|
116
144
|
}): Promise<boolean>;
|
|
145
|
+
export declare function tryConsumeChannelApprovalReply(args: {
|
|
146
|
+
channelId: string;
|
|
147
|
+
conversationId: string;
|
|
148
|
+
text: string;
|
|
149
|
+
threadId?: string;
|
|
150
|
+
accountId?: string;
|
|
151
|
+
agentId?: string;
|
|
152
|
+
}): {
|
|
153
|
+
matched: true;
|
|
154
|
+
decision: ApprovalDecisionKind;
|
|
155
|
+
approvalId: string;
|
|
156
|
+
} | {
|
|
157
|
+
matched: false;
|
|
158
|
+
};
|
|
117
159
|
/**
|
|
118
|
-
* Try to consume
|
|
119
|
-
* peer.
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* dispatch a turn for this message.
|
|
123
|
-
* - `false` → no pending approval for this peer, OR text wasn't a
|
|
124
|
-
* yes/no shape. Caller proceeds with normal dispatch.
|
|
160
|
+
* Try to consume an inline-button press (Telegram `callback_query` etc.) as the
|
|
161
|
+
* answer to a pending approval for this peer. Mirrors `tryConsumeChannelApprovalReply`
|
|
162
|
+
* but decodes the button's codec payload instead of free text, and runs an
|
|
163
|
+
* optional central authorization gate before settling.
|
|
125
164
|
*
|
|
126
|
-
*
|
|
127
|
-
* -
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
165
|
+
* Returns:
|
|
166
|
+
* - `{ matched: true, decision, approvalId }` — the payload decoded to a
|
|
167
|
+
* pending approval for an AUTHORIZED presser and the bridge was resolved.
|
|
168
|
+
* The caller should acknowledge the press and NOT dispatch a turn.
|
|
169
|
+
* - `{ matched: false, refused: true, reason }` — the payload matched a
|
|
170
|
+
* pending approval but the presser was refused by `authorizeApprover`. The
|
|
171
|
+
* pending entry is left intact (a non-operator press must not consume the
|
|
172
|
+
* operator's approval). The caller should ack with the refusal reason.
|
|
173
|
+
* - `{ matched: false }` — not an approval callback for this peer (foreign /
|
|
174
|
+
* malformed payload, or no pending entry). Caller proceeds normally.
|
|
175
|
+
*
|
|
176
|
+
* MUST be called AFTER the channel access gate (only trusted peers reach here)
|
|
177
|
+
* and BEFORE the text-reply path. `authorizeApprover` is the channel's own
|
|
178
|
+
* predicate (from its `ChannelApprovalCapability`); when provided it is invoked
|
|
179
|
+
* centrally so a non-operator's button press is refused here, not in the
|
|
180
|
+
* adapter.
|
|
135
181
|
*/
|
|
136
|
-
export declare function
|
|
182
|
+
export declare function tryConsumeChannelApprovalCallback(args: {
|
|
137
183
|
channelId: string;
|
|
138
184
|
conversationId: string;
|
|
139
|
-
|
|
185
|
+
callbackData: string;
|
|
140
186
|
threadId?: string;
|
|
141
187
|
accountId?: string;
|
|
142
188
|
agentId?: string;
|
|
189
|
+
senderId?: string;
|
|
190
|
+
authorizeApprover?: (params: {
|
|
191
|
+
accountId?: string;
|
|
192
|
+
senderId?: string;
|
|
193
|
+
approvalKind: ChannelApprovalKind;
|
|
194
|
+
}) => {
|
|
195
|
+
authorized: boolean;
|
|
196
|
+
reason?: string;
|
|
197
|
+
};
|
|
143
198
|
}): {
|
|
144
199
|
matched: true;
|
|
145
200
|
decision: ApprovalDecisionKind;
|
|
146
201
|
approvalId: string;
|
|
147
202
|
} | {
|
|
148
203
|
matched: false;
|
|
204
|
+
refused?: true;
|
|
205
|
+
reason?: string;
|
|
149
206
|
};
|
|
150
207
|
/**
|
|
151
208
|
* Cancel a pending approval by request id (e.g. on session abort).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"approval-router.d.ts","sourceRoot":"","sources":["../../../src/agents/channels/approval-router.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;
|
|
1
|
+
{"version":3,"file":"approval-router.d.ts","sourceRoot":"","sources":["../../../src/agents/channels/approval-router.ts"],"names":[],"mappings":"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"}
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { createSubsystemLogger } from "../../logging/subsystem-logger.js";
|
|
44
44
|
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
|
|
45
|
+
import { decodeApprovalCallback, } from "./approval-callback-codec.js";
|
|
45
46
|
const log = createSubsystemLogger("brigade/channel-approvals");
|
|
46
47
|
/** All three approval-router maps are pinned via global-singleton so a hot-reload / dual-build run shares one routing state. */
|
|
47
48
|
const APPROVAL_ROUTER_DISPATCHERS_KEY = Symbol.for("brigade.approvalRouter.dispatchers");
|
|
@@ -181,6 +182,16 @@ function buildPromptText(args) {
|
|
|
181
182
|
"Times out in 5 minutes.",
|
|
182
183
|
].join("\n");
|
|
183
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Infer the approval KIND for a request so a native prompt can label itself
|
|
187
|
+
* ("approve this shell command" vs "approve this plugin action"). Brigade's
|
|
188
|
+
* exec-gate raises shell-command approvals (the overwhelming common case); a
|
|
189
|
+
* `plugin:`-prefixed id marks a plugin-capability approval. The distinction is
|
|
190
|
+
* cosmetic for the channel render — the bridge resolution is identical.
|
|
191
|
+
*/
|
|
192
|
+
function approvalKindForRequest(request) {
|
|
193
|
+
return request.id.startsWith("plugin:") ? "plugin" : "exec";
|
|
194
|
+
}
|
|
184
195
|
/**
|
|
185
196
|
* Decode an operator's text reply into a decision kind. Liberal in what
|
|
186
197
|
* it accepts so the operator can type the obvious shapes without thinking:
|
|
@@ -299,7 +310,33 @@ export async function dispatchChannelApproval(args) {
|
|
|
299
310
|
sendOpts.threadId = route.threadId;
|
|
300
311
|
if (route.accountId)
|
|
301
312
|
sendOpts.accountId = route.accountId;
|
|
302
|
-
|
|
313
|
+
// Native inline-button render when the channel opted in; otherwise the
|
|
314
|
+
// default text prompt. The button payloads carry codec-encoded
|
|
315
|
+
// `{ approvalId, decision }` and the press comes back as an
|
|
316
|
+
// `InboundMessage.callbackQuery` → `tryConsumeChannelApprovalCallback`.
|
|
317
|
+
const native = dispatcher.approvalCapability?.sendApprovalPrompt;
|
|
318
|
+
if (native) {
|
|
319
|
+
const promptCtx = dispatcher.getApprovalContext?.();
|
|
320
|
+
const promptParams = {
|
|
321
|
+
// `runtime`/`cfg` come from the registering channel; fall back to
|
|
322
|
+
// empty shapes so a capability that ignores them still works.
|
|
323
|
+
runtime: promptCtx?.runtime ?? {},
|
|
324
|
+
cfg: promptCtx?.cfg ?? {},
|
|
325
|
+
conversationId: route.conversationId,
|
|
326
|
+
...(route.accountId !== undefined ? { accountId: route.accountId } : {}),
|
|
327
|
+
...(route.threadId !== undefined ? { threadId: route.threadId } : {}),
|
|
328
|
+
approvalId: request.id,
|
|
329
|
+
approvalKind: approvalKindForRequest(request),
|
|
330
|
+
command: request.command,
|
|
331
|
+
...(request.toolName !== undefined ? { toolName: request.toolName } : {}),
|
|
332
|
+
...(request.cwd !== undefined ? { cwd: request.cwd } : {}),
|
|
333
|
+
timeoutMs: request.timeoutMs,
|
|
334
|
+
};
|
|
335
|
+
await native(promptParams);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
await dispatcher.sendText(route.conversationId, prompt, Object.keys(sendOpts).length > 0 ? sendOpts : undefined);
|
|
339
|
+
}
|
|
303
340
|
}
|
|
304
341
|
catch (err) {
|
|
305
342
|
// Release the reserved slot so the WS-fallback path can run cleanly.
|
|
@@ -345,6 +382,7 @@ export async function dispatchChannelApproval(args) {
|
|
|
345
382
|
conversationId: route.conversationId,
|
|
346
383
|
approvalId: request.id,
|
|
347
384
|
via: dispatcher.prettyName,
|
|
385
|
+
render: dispatcher.approvalCapability?.sendApprovalPrompt ? "buttons" : "text",
|
|
348
386
|
});
|
|
349
387
|
return true;
|
|
350
388
|
}
|
|
@@ -367,13 +405,14 @@ export async function dispatchChannelApproval(args) {
|
|
|
367
405
|
* This keeps the router test-friendly (no I/O side effects on the
|
|
368
406
|
* intercept path) and lets per-channel formatting differ.
|
|
369
407
|
*/
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
408
|
+
/**
|
|
409
|
+
* Locate the pending approval that a reply / callback from this peer should
|
|
410
|
+
* settle, returning the entry + its map key. Shared by BOTH the text-reply and
|
|
411
|
+
* the inline-button consumers so the peer-disambiguation rules (exact-route key
|
|
412
|
+
* first, then a per-channel+conversation fallback scan for under-pinned
|
|
413
|
+
* inbounds like WhatsApp flat-DMs) are identical on both paths.
|
|
414
|
+
*/
|
|
415
|
+
function findPendingEntry(args) {
|
|
377
416
|
const exactKey = peerKey({
|
|
378
417
|
channelId: args.channelId,
|
|
379
418
|
conversationId: args.conversationId,
|
|
@@ -381,45 +420,132 @@ export function tryConsumeChannelApprovalReply(args) {
|
|
|
381
420
|
...(args.accountId !== undefined ? { accountId: args.accountId } : {}),
|
|
382
421
|
...(args.agentId !== undefined ? { agentId: args.agentId } : {}),
|
|
383
422
|
});
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
entryKey = k;
|
|
403
|
-
break;
|
|
404
|
-
}
|
|
423
|
+
const exact = pendingByPeer.get(exactKey);
|
|
424
|
+
if (exact)
|
|
425
|
+
return { entry: exact, entryKey: exactKey };
|
|
426
|
+
// Fall back to a per-channel + per-conversation scan that matches when the
|
|
427
|
+
// caller didn't pin every dimension (e.g. WhatsApp inbound has no
|
|
428
|
+
// thread/account). Stops on the first agreeing route to preserve the
|
|
429
|
+
// "one prompt per peer at a time" invariant.
|
|
430
|
+
for (const [k, candidate] of pendingByPeer.entries()) {
|
|
431
|
+
const r = candidate.route;
|
|
432
|
+
if (r.channelId !== args.channelId || r.conversationId !== args.conversationId)
|
|
433
|
+
continue;
|
|
434
|
+
if (args.threadId !== undefined && r.threadId !== undefined && r.threadId !== args.threadId)
|
|
435
|
+
continue;
|
|
436
|
+
if (args.accountId !== undefined && r.accountId !== undefined && r.accountId !== args.accountId)
|
|
437
|
+
continue;
|
|
438
|
+
if (args.agentId !== undefined && r.agentId !== undefined && r.agentId !== args.agentId)
|
|
439
|
+
continue;
|
|
440
|
+
return { entry: candidate, entryKey: k };
|
|
405
441
|
}
|
|
406
|
-
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
/** Evict a resolved entry from both maps + cancel its watchdog, then settle the bridge. */
|
|
445
|
+
function settlePending(entry, entryKey, kind) {
|
|
446
|
+
pendingByPeer.delete(entryKey);
|
|
447
|
+
pendingById.delete(entry.request.id);
|
|
448
|
+
clearTimeout(entry.timer);
|
|
449
|
+
entry.resolveOnBridge({ kind });
|
|
450
|
+
}
|
|
451
|
+
export function tryConsumeChannelApprovalReply(args) {
|
|
452
|
+
const found = findPendingEntry(args);
|
|
453
|
+
if (!found)
|
|
407
454
|
return { matched: false };
|
|
408
455
|
const kind = decodeReply(args.text);
|
|
409
456
|
if (kind === null)
|
|
410
457
|
return { matched: false };
|
|
411
|
-
|
|
412
|
-
pendingById.delete(entry.request.id);
|
|
413
|
-
clearTimeout(entry.timer);
|
|
414
|
-
const decision = { kind };
|
|
415
|
-
entry.resolveOnBridge(decision);
|
|
458
|
+
settlePending(found.entry, found.entryKey, kind);
|
|
416
459
|
log.info("approval resolved via channel reply", {
|
|
417
460
|
channelId: args.channelId,
|
|
418
461
|
conversationId: args.conversationId,
|
|
419
|
-
approvalId: entry.request.id,
|
|
462
|
+
approvalId: found.entry.request.id,
|
|
420
463
|
decision: kind,
|
|
421
464
|
});
|
|
422
|
-
return { matched: true, decision: kind, approvalId: entry.request.id };
|
|
465
|
+
return { matched: true, decision: kind, approvalId: found.entry.request.id };
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Try to consume an inline-button press (Telegram `callback_query` etc.) as the
|
|
469
|
+
* answer to a pending approval for this peer. Mirrors `tryConsumeChannelApprovalReply`
|
|
470
|
+
* but decodes the button's codec payload instead of free text, and runs an
|
|
471
|
+
* optional central authorization gate before settling.
|
|
472
|
+
*
|
|
473
|
+
* Returns:
|
|
474
|
+
* - `{ matched: true, decision, approvalId }` — the payload decoded to a
|
|
475
|
+
* pending approval for an AUTHORIZED presser and the bridge was resolved.
|
|
476
|
+
* The caller should acknowledge the press and NOT dispatch a turn.
|
|
477
|
+
* - `{ matched: false, refused: true, reason }` — the payload matched a
|
|
478
|
+
* pending approval but the presser was refused by `authorizeApprover`. The
|
|
479
|
+
* pending entry is left intact (a non-operator press must not consume the
|
|
480
|
+
* operator's approval). The caller should ack with the refusal reason.
|
|
481
|
+
* - `{ matched: false }` — not an approval callback for this peer (foreign /
|
|
482
|
+
* malformed payload, or no pending entry). Caller proceeds normally.
|
|
483
|
+
*
|
|
484
|
+
* MUST be called AFTER the channel access gate (only trusted peers reach here)
|
|
485
|
+
* and BEFORE the text-reply path. `authorizeApprover` is the channel's own
|
|
486
|
+
* predicate (from its `ChannelApprovalCapability`); when provided it is invoked
|
|
487
|
+
* centrally so a non-operator's button press is refused here, not in the
|
|
488
|
+
* adapter.
|
|
489
|
+
*/
|
|
490
|
+
export function tryConsumeChannelApprovalCallback(args) {
|
|
491
|
+
// Decode FIRST — a foreign / malformed button payload is simply "not ours".
|
|
492
|
+
const decoded = decodeApprovalCallback(args.callbackData);
|
|
493
|
+
if (!decoded)
|
|
494
|
+
return { matched: false };
|
|
495
|
+
const found = findPendingEntry(args);
|
|
496
|
+
if (!found)
|
|
497
|
+
return { matched: false };
|
|
498
|
+
// The decoded payload must reference the SAME pending approval we matched by
|
|
499
|
+
// peer — otherwise a stale button from a previous (already-resolved-and-
|
|
500
|
+
// replaced) prompt could settle the wrong approval.
|
|
501
|
+
if (found.entry.request.id !== decoded.approvalId)
|
|
502
|
+
return { matched: false };
|
|
503
|
+
// Central approver authorization: refuse a non-operator press BEFORE
|
|
504
|
+
// settling. Leave the pending entry intact so the real operator can still
|
|
505
|
+
// answer.
|
|
506
|
+
if (args.authorizeApprover) {
|
|
507
|
+
const verdict = args.authorizeApprover({
|
|
508
|
+
...(args.accountId !== undefined ? { accountId: args.accountId } : {}),
|
|
509
|
+
...(args.senderId !== undefined ? { senderId: args.senderId } : {}),
|
|
510
|
+
approvalKind: approvalKindForRequest(found.entry.request),
|
|
511
|
+
});
|
|
512
|
+
if (!verdict.authorized) {
|
|
513
|
+
log.warn("approval callback refused — presser not authorized", {
|
|
514
|
+
channelId: args.channelId,
|
|
515
|
+
conversationId: args.conversationId,
|
|
516
|
+
approvalId: found.entry.request.id,
|
|
517
|
+
senderId: args.senderId,
|
|
518
|
+
});
|
|
519
|
+
return {
|
|
520
|
+
matched: false,
|
|
521
|
+
refused: true,
|
|
522
|
+
...(verdict.reason !== undefined ? { reason: verdict.reason } : {}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const kind = callbackDecisionToBridgeKind(decoded.decision);
|
|
527
|
+
settlePending(found.entry, found.entryKey, kind);
|
|
528
|
+
log.info("approval resolved via channel callback", {
|
|
529
|
+
channelId: args.channelId,
|
|
530
|
+
conversationId: args.conversationId,
|
|
531
|
+
approvalId: found.entry.request.id,
|
|
532
|
+
decision: kind,
|
|
533
|
+
});
|
|
534
|
+
return { matched: true, decision: kind, approvalId: found.entry.request.id };
|
|
535
|
+
}
|
|
536
|
+
/** Map a codec decision onto the bridge's decision-kind vocabulary. */
|
|
537
|
+
function callbackDecisionToBridgeKind(decision) {
|
|
538
|
+
// The codec's three values are a strict subset of ApprovalDecisionKind, so
|
|
539
|
+
// this is a widening pass — kept explicit so a future codec value can't
|
|
540
|
+
// silently leak an unhandled kind onto the bridge.
|
|
541
|
+
switch (decision) {
|
|
542
|
+
case "allow-once":
|
|
543
|
+
return "allow-once";
|
|
544
|
+
case "allow-always":
|
|
545
|
+
return "allow-always";
|
|
546
|
+
case "deny":
|
|
547
|
+
return "deny";
|
|
548
|
+
}
|
|
423
549
|
}
|
|
424
550
|
/**
|
|
425
551
|
* Cancel a pending approval by request id (e.g. on session abort).
|