Skip to content

Commit 22addf1

Browse files
committed
bridge: adopt protocol-versioned inbox pull/ack signing
1 parent 5cf2201 commit 22addf1

6 files changed

Lines changed: 54 additions & 36 deletions

File tree

CONFIGURATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ Set by `sudo baudbot broker register` when using brokered Slack OAuth flow.
106106
| `SLACK_BROKER_SIGNING_PUBLIC_KEY` | Broker Ed25519 public signing key (base64) |
107107
| `SLACK_BROKER_POLL_INTERVAL_MS` | Inbox poll interval in milliseconds (default: `3000`) |
108108
| `SLACK_BROKER_MAX_MESSAGES` | Max leased messages per poll request (default: `10`) |
109-
| `SLACK_BROKER_WAIT_SECONDS` | Long-poll wait window for `/api/inbox/pull` (default: `20`, set `0` for legacy short-poll, max `25`) |
109+
| `SLACK_BROKER_WAIT_SECONDS` | Long-poll wait window for `/api/inbox/pull` (default: `20`, set `0` for immediate short-poll, max `25`) |
110110
| `SLACK_BROKER_DEDUPE_TTL_MS` | Dedupe cache TTL in milliseconds (default: `1200000`) |
111111

112112
### Kernel (Cloud Browsers)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ sudo baudbot broker register \
9898
--registration-token <token-from-dashboard-callback>
9999
```
100100

101-
Broker pull mode uses long-polling by default (`SLACK_BROKER_WAIT_SECONDS=20`, max `25`; set `0` for legacy short-poll).
101+
Broker pull mode uses long-polling by default (`SLACK_BROKER_WAIT_SECONDS=20`, max `25`; set `0` for immediate short-poll behavior).
102102

103103
Need to rotate/update a key later?
104104

slack-bridge/broker-bridge.mjs

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ import {
2424
} from "./security.mjs";
2525
import {
2626
canonicalizeEnvelope,
27-
canonicalizeOutbound,
28-
canonicalizeOutboundV2,
27+
canonicalizeProtocolRequest,
2928
canonicalizeSendRequest,
3029
} from "./crypto.mjs";
3130

@@ -50,6 +49,7 @@ const DEDUPE_TTL_MS = clampInt(
5049
20 * 60 * 1000,
5150
);
5251
const MAX_BACKOFF_MS = 30_000;
52+
const INBOX_PROTOCOL_VERSION = "2026-02-1";
5353
const BROKER_HEALTH_PATH = path.join(homedir(), ".pi", "agent", "broker-health.json");
5454

5555
function ts() {
@@ -366,23 +366,23 @@ function getThreadId(channel, threadTs) {
366366
return id;
367367
}
368368

369-
function signRequest(action, timestamp, payloadField) {
370-
const canonical = canonicalizeOutbound(workspaceId, action, timestamp, payloadField);
369+
function signProtocolRequest(action, timestamp, payload) {
370+
const canonical = canonicalizeProtocolRequest(
371+
workspaceId,
372+
INBOX_PROTOCOL_VERSION,
373+
action,
374+
timestamp,
375+
payload,
376+
);
371377
const sig = sodium.crypto_sign_detached(canonical, cryptoState.serverSignSecretKey);
372378
return toBase64(sig);
373379
}
374380

375381
function signPullRequest(timestamp, maxMessages, waitSeconds) {
376-
if (waitSeconds <= 0) {
377-
return signRequest("inbox.pull", timestamp, String(maxMessages));
378-
}
379-
380-
const canonical = canonicalizeOutboundV2(workspaceId, "inbox.pull.v2", timestamp, {
382+
return signProtocolRequest("inbox.pull", timestamp, {
381383
max_messages: maxMessages,
382384
wait_seconds: waitSeconds,
383385
});
384-
const sig = sodium.crypto_sign_detached(canonical, cryptoState.serverSignSecretKey);
385-
return toBase64(sig);
386386
}
387387

388388
async function brokerFetch(pathname, body) {
@@ -421,8 +421,9 @@ async function pullInbox() {
421421

422422
const body = {
423423
workspace_id: workspaceId,
424+
protocol_version: INBOX_PROTOCOL_VERSION,
424425
max_messages: MAX_MESSAGES,
425-
...(BROKER_WAIT_SECONDS > 0 ? { wait_seconds: BROKER_WAIT_SECONDS } : {}),
426+
wait_seconds: BROKER_WAIT_SECONDS,
426427
timestamp,
427428
signature,
428429
};
@@ -435,11 +436,11 @@ async function pullInbox() {
435436
async function ackInbox(messageIds) {
436437
if (messageIds.length === 0) return;
437438
const timestamp = Math.floor(Date.now() / 1000);
438-
const joined = messageIds.join(",");
439-
const signature = signRequest("inbox.ack", timestamp, joined);
439+
const signature = signProtocolRequest("inbox.ack", timestamp, { message_ids: messageIds });
440440

441441
await brokerFetch("/api/inbox/ack", {
442442
workspace_id: workspaceId,
443+
protocol_version: INBOX_PROTOCOL_VERSION,
443444
message_ids: messageIds,
444445
timestamp,
445446
signature,
@@ -993,6 +994,7 @@ async function startPollLoop() {
993994
logInfo(` outbound mode: ${outboundMode} ${outboundMode === "direct" ? "(using SLACK_BOT_TOKEN)" : "(via broker)"}`);
994995
logInfo(` broker: ${brokerBaseUrl}`);
995996
logInfo(` workspace: ${workspaceId}`);
997+
logInfo(` inbox protocol: ${INBOX_PROTOCOL_VERSION}`);
996998
logInfo(
997999
` poll mode: ${BROKER_WAIT_SECONDS > 0 ? `long-poll (${BROKER_WAIT_SECONDS}s)` : "short-poll"}, ` +
9981000
`interval: ${POLL_INTERVAL_MS}ms, max messages: ${MAX_MESSAGES}`,

slack-bridge/crypto.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,16 @@ export function canonicalizeOutbound(workspace, action, timestamp, encryptedBody
4747
}
4848

4949
/**
50-
* Construct canonical bytes for v2 outbound request signing.
50+
* Construct canonical bytes for protocol-versioned inbox pull/ack signing.
5151
*
5252
* Uses deterministic JSON serialization (sorted keys) to match broker
5353
* json-stable-stringify canonicalization.
5454
*/
55-
export function canonicalizeOutboundV2(workspace, action, timestamp, payload) {
55+
export function canonicalizeProtocolRequest(workspace, protocolVersion, action, timestamp, payload) {
5656
return utf8Bytes(
5757
stableStringify({
5858
workspace_id: workspace,
59+
protocol_version: protocolVersion,
5960
action,
6061
timestamp,
6162
payload,

slack-bridge/crypto.test.mjs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
stableStringify,
1111
canonicalizeEnvelope,
1212
canonicalizeOutbound,
13-
canonicalizeOutboundV2,
13+
canonicalizeProtocolRequest,
1414
canonicalizeSendRequest,
1515
} from "./crypto.mjs";
1616

@@ -152,26 +152,25 @@ describe("canonicalizeOutbound", () => {
152152
});
153153
});
154154

155-
// ── canonicalizeOutboundV2 ──────────────────────────────────────────────────
155+
// ── canonicalizeProtocolRequest ─────────────────────────────────────────────
156156

157-
describe("canonicalizeOutboundV2", () => {
158-
it("produces stable JSON payload for inbox.pull.v2", () => {
157+
describe("canonicalizeProtocolRequest", () => {
158+
it("produces stable JSON payload for protocol-versioned inbox.pull", () => {
159159
const result = decode(
160-
canonicalizeOutboundV2("T123", "inbox.pull.v2", 1700000000, {
160+
canonicalizeProtocolRequest("T123", "2026-02-1", "inbox.pull", 1700000000, {
161161
max_messages: 10,
162162
wait_seconds: 20,
163163
}),
164164
);
165165
assert.equal(
166166
result,
167-
'{"action":"inbox.pull.v2","payload":{"max_messages":10,"wait_seconds":20},"timestamp":1700000000,"workspace_id":"T123"}',
167+
'{"action":"inbox.pull","payload":{"max_messages":10,"wait_seconds":20},"protocol_version":"2026-02-1","timestamp":1700000000,"workspace_id":"T123"}',
168168
);
169169
});
170170

171171
it("returns Uint8Array", () => {
172-
const result = canonicalizeOutboundV2("T123", "inbox.pull.v2", 1700000000, {
173-
max_messages: 10,
174-
wait_seconds: 20,
172+
const result = canonicalizeProtocolRequest("T123", "2026-02-1", "inbox.ack", 1700000000, {
173+
message_ids: ["m1", "m2"],
175174
});
176175
assert.ok(result instanceof Uint8Array);
177176
});

test/broker-bridge.integration.test.mjs

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ import { tmpdir } from "node:os";
99
import sodium from "libsodium-wrappers-sumo";
1010
import {
1111
canonicalizeEnvelope,
12-
canonicalizeOutbound,
13-
canonicalizeOutboundV2,
12+
canonicalizeProtocolRequest,
1413
} from "../slack-bridge/crypto.mjs";
1514

1615
function b64(bytes = 32, fill = 1) {
@@ -54,6 +53,8 @@ describe("broker pull bridge semi-integration", () => {
5453
});
5554

5655
it("acks poison messages from broker to avoid infinite retry loops", async () => {
56+
await sodium.ready;
57+
5758
let pullCount = 0;
5859
let ackPayload = null;
5960

@@ -162,7 +163,16 @@ describe("broker pull bridge semi-integration", () => {
162163
await Promise.race([ackWait, bridgeExited]);
163164

164165
expect(ackPayload.workspace_id).toBe("T123BROKER");
166+
expect(ackPayload.protocol_version).toBe("2026-02-1");
165167
expect(ackPayload.message_ids).toContain("m-poison-1");
168+
169+
const signKeypair = sodium.crypto_sign_seed_keypair(new Uint8Array(Buffer.alloc(32, 13)));
170+
const canonical = canonicalizeProtocolRequest("T123BROKER", "2026-02-1", "inbox.ack", ackPayload.timestamp, {
171+
message_ids: ackPayload.message_ids,
172+
});
173+
const sigBytes = new Uint8Array(Buffer.from(ackPayload.signature, "base64"));
174+
const valid = sodium.crypto_sign_verify_detached(sigBytes, canonical, signKeypair.publicKey);
175+
expect(valid).toBe(true);
166176
});
167177

168178
it("forwards user messages to agent in fire-and-forget mode without get_message/turn_end RPCs", async () => {
@@ -352,7 +362,7 @@ describe("broker pull bridge semi-integration", () => {
352362
expect(sendPayloads.some((payload) => payload.action === "reactions.add")).toBe(false);
353363
});
354364

355-
it("uses inbox.pull.v2 signatures with wait_seconds by default", async () => {
365+
it("uses protocol-versioned inbox.pull signatures with wait_seconds by default", async () => {
356366
await sodium.ready;
357367

358368
const workspaceId = "T123BROKER";
@@ -423,10 +433,11 @@ describe("broker pull bridge semi-integration", () => {
423433
await waitFor(() => pullPayload !== null, 10_000, 50, "timeout waiting for inbox pull request");
424434

425435
expect(pullPayload.workspace_id).toBe(workspaceId);
436+
expect(pullPayload.protocol_version).toBe("2026-02-1");
426437
expect(pullPayload.max_messages).toBe(10);
427438
expect(pullPayload.wait_seconds).toBe(20);
428439

429-
const canonical = canonicalizeOutboundV2(workspaceId, "inbox.pull.v2", pullPayload.timestamp, {
440+
const canonical = canonicalizeProtocolRequest(workspaceId, "2026-02-1", "inbox.pull", pullPayload.timestamp, {
430441
max_messages: 10,
431442
wait_seconds: 20,
432443
});
@@ -437,7 +448,7 @@ describe("broker pull bridge semi-integration", () => {
437448
bridge.kill("SIGTERM");
438449
});
439450

440-
it("falls back to legacy inbox.pull signature when SLACK_BROKER_WAIT_SECONDS=0", async () => {
451+
it("uses protocol-versioned inbox.pull signature with wait_seconds=0", async () => {
441452
await sodium.ready;
442453

443454
const workspaceId = "T123BROKER";
@@ -506,13 +517,17 @@ describe("broker pull bridge semi-integration", () => {
506517
});
507518
children.push(bridge);
508519

509-
await waitFor(() => pullPayload !== null, 10_000, 50, "timeout waiting for legacy inbox pull request");
520+
await waitFor(() => pullPayload !== null, 10_000, 50, "timeout waiting for protocol inbox pull request");
510521

511522
expect(pullPayload.workspace_id).toBe(workspaceId);
523+
expect(pullPayload.protocol_version).toBe("2026-02-1");
512524
expect(pullPayload.max_messages).toBe(10);
513-
expect(Object.prototype.hasOwnProperty.call(pullPayload, "wait_seconds")).toBe(false);
525+
expect(pullPayload.wait_seconds).toBe(0);
514526

515-
const canonical = canonicalizeOutbound(workspaceId, "inbox.pull", pullPayload.timestamp, "10");
527+
const canonical = canonicalizeProtocolRequest(workspaceId, "2026-02-1", "inbox.pull", pullPayload.timestamp, {
528+
max_messages: 10,
529+
wait_seconds: 0,
530+
});
516531
const sigBytes = new Uint8Array(Buffer.from(pullPayload.signature, "base64"));
517532
const valid = sodium.crypto_sign_verify_detached(sigBytes, canonical, signKeypair.publicKey);
518533
expect(valid).toBe(true);
@@ -592,10 +607,11 @@ describe("broker pull bridge semi-integration", () => {
592607
await waitFor(() => pullPayload !== null, 10_000, 50, "timeout waiting for clamped inbox pull request");
593608

594609
expect(pullPayload.workspace_id).toBe(workspaceId);
610+
expect(pullPayload.protocol_version).toBe("2026-02-1");
595611
expect(pullPayload.max_messages).toBe(100);
596612
expect(pullPayload.wait_seconds).toBe(20);
597613

598-
const canonical = canonicalizeOutboundV2(workspaceId, "inbox.pull.v2", pullPayload.timestamp, {
614+
const canonical = canonicalizeProtocolRequest(workspaceId, "2026-02-1", "inbox.pull", pullPayload.timestamp, {
599615
max_messages: 100,
600616
wait_seconds: 20,
601617
});

0 commit comments

Comments
 (0)