feat: add channel pairing request hook (#97733)

* feat: add channel pairing request hook

* fix(plugin-sdk): keep pairing hook types internal

* docs: regenerate docs map

* docs: regenerate docs map

* docs: regenerate docs map

* fix(plugin-sdk): keep pairing hook types internal

* fix(plugin-sdk): keep pairing hook types internal

* docs: refresh plugin hook docs

* docs: refresh plugin hook docs

* docs: refresh plugin hook docs

---------

Co-authored-by: clawSean <260045960+clawSean@users.noreply.github.com>
Co-authored-by: Omar Shahine <omarshahine@users.noreply.github.com>
This commit is contained in:
clawSean
2026-07-05 19:11:58 -07:00
committed by GitHub
parent c064cae040
commit d84aabf642
18 changed files with 298 additions and 15 deletions

View File

@@ -1,2 +1,2 @@
ff9754cd53ed77f33d8b4509736286b690675945b90b9a923ee7d4270f504fdd plugin-sdk-api-baseline.json
25bc1c31b2f1b3215db392296f5e8b8b41495016428f15eb879fe1fb0df84836 plugin-sdk-api-baseline.jsonl
ad3c811b2b11ce21cbad35a1420892789ab2ea9bc3359b246caab7b465fef848 plugin-sdk-api-baseline.json
2f4f477911da2a08116b684113aa136ca1d1eebdeb5e2136e191d01cc1c4b07a plugin-sdk-api-baseline.jsonl

View File

@@ -5495,6 +5495,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings:
- H2: Quick start
- H2: Hook catalog
- H3: Channel pairing requests
- H2: Debug runtime hooks
- H2: Tool call policy
- H3: Exec environment hook

View File

@@ -128,15 +128,16 @@ observation-only.
**Messages and delivery**
| Hook | Purpose |
| --------------------------- | ----------------------------------------------------------------- |
| **`inbound_claim`** | Claim an inbound message before agent routing (synthetic replies) |
| `message_received` | Observe inbound content, sender, thread, and metadata |
| **`message_sending`** | Rewrite outbound content or cancel delivery |
| **`reply_payload_sending`** | Mutate or cancel normalized reply payloads before delivery |
| `message_sent` | Observe outbound delivery success or failure |
| **`before_dispatch`** | Inspect or rewrite an outbound dispatch before channel handoff |
| **`reply_dispatch`** | Participate in the final reply-dispatch pipeline |
| Hook | Purpose |
| ------------------------------- | ----------------------------------------------------------------- |
| **`inbound_claim`** | Claim an inbound message before agent routing (synthetic replies) |
| **`channel_pairing_requested`** | Observe newly created DM pairing requests |
| `message_received` | Observe inbound content, sender, thread, and metadata |
| **`message_sending`** | Rewrite outbound content or cancel delivery |
| **`reply_payload_sending`** | Mutate or cancel normalized reply payloads before delivery |
| `message_sent` | Observe outbound delivery success or failure |
| **`before_dispatch`** | Inspect or rewrite an outbound dispatch before channel handoff |
| **`reply_dispatch`** | Participate in the final reply-dispatch pipeline |
**Sessions and compaction**
@@ -163,6 +164,28 @@ observation-only.
| `cron_changed` | Observe Gateway-owned cron lifecycle changes (added, updated, removed, started, finished, scheduled) |
| **`before_install`** | Inspect staged skill or plugin install material from a loaded plugin runtime |
### Channel pairing requests
Use `channel_pairing_requested` when a plugin needs to notify an operator or
write an audit record after an unpaired DM sender creates a pending pairing
request. The hook is dispatched when the request is created; channel delivery of
the pairing reply is not delayed by slow or failing hook handlers.
```typescript
api.on("channel_pairing_requested", async (event) => {
await notifyOperator({
text: `New ${event.channel} pairing request from ${event.senderId}: ${event.code}`,
});
});
```
The hook is observation-only. It does not approve, reject, suppress, or rewrite
the pairing reply. The payload includes the channel, optional `accountId`,
channel-scoped `senderId`, pairing `code`, and channel metadata. Treat the
pairing code as a live single-use approval credential and deliver it only to a
trusted operator sink. Treat `metadata` as untrusted sender-supplied identity
text. The hook does not include the inbound message body or media.
## Debug runtime hooks
Use `before_model_resolve` to switch provider or model for an agent turn - it

View File

@@ -67,6 +67,7 @@ async function ensureDmComponentAuthorized(params: {
}
const pairingResult = await createChannelPairingChallengeIssuer({
channel: "discord",
accountId: ctx.accountId,
upsertPairingRequest: async ({ id, meta }) => {
return await upsertChannelPairingRequest({
channel: "discord",

View File

@@ -23,6 +23,7 @@ export async function handleDiscordDmCommandDecision(params: {
const upsertPairingRequest = params.upsertPairingRequest ?? upsertChannelPairingRequest;
const result = await createChannelPairingChallengeIssuer({
channel: "discord",
accountId: params.accountId,
upsertPairingRequest: async ({ id, meta }) =>
await upsertPairingRequest({
channel: "discord",

View File

@@ -1089,6 +1089,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
}
await createChannelPairingChallengeIssuer({
channel: "imessage",
accountId: accountInfo.accountId,
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "imessage",

View File

@@ -190,6 +190,7 @@ async function sendLinePairingReply(params: {
})();
await createChannelPairingChallengeIssuer({
channel: "line",
accountId: context.account.accountId,
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "line",

View File

@@ -178,6 +178,7 @@ export async function handleSignalDirectMessageAccess(params: {
if (params.dmPolicy === "pairing") {
await createChannelPairingChallengeIssuer({
channel: "signal",
accountId: params.accountId,
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "signal",

View File

@@ -42,6 +42,7 @@ export async function authorizeSlackDirectMessage(params: {
if (params.ctx.dmPolicy === "pairing") {
await createChannelPairingChallengeIssuer({
channel: "slack",
accountId: params.accountId,
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "slack",

View File

@@ -57,6 +57,7 @@ async function issueSmsPairingChallenge(params: {
}) {
const issueChallenge = createChannelPairingChallengeIssuer({
channel: CHANNEL_ID,
accountId: params.account.accountId,
upsertPairingRequest: async (input) =>
await params.channelRuntime.pairing.upsertPairingRequest({
channel: CHANNEL_ID,

View File

@@ -127,6 +127,7 @@ export async function enforceTelegramDmAccess(params: {
const telegramUserId = sender.userId ?? sender.candidateId;
await createChannelPairingChallengeIssuer({
channel: "telegram",
accountId,
upsertPairingRequest: async ({ id, meta }) =>
await (upsertPairingRequest ?? upsertChannelPairingRequest)({
channel: "telegram",

View File

@@ -141,6 +141,7 @@ export async function checkInboundAccessControl(params: {
} else {
await createChannelPairingChallengeIssuer({
channel: "whatsapp",
accountId: policy.account.accountId,
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "whatsapp",

View File

@@ -1,8 +1,17 @@
// Tests pairing challenge creation, validation, and reply formatting.
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "../plugins/hook-runner-global.js";
import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js";
import { issuePairingChallenge } from "./pairing-challenge.js";
describe("issuePairingChallenge", () => {
afterEach(() => {
resetGlobalHookRunner();
});
function createBaseChallengeParams() {
return {
channel: "forum",
@@ -156,4 +165,79 @@ describe("issuePairingChallenge", () => {
] as const)("$name", async ({ setup }) => {
await expectIssuedChallengeCase(setup());
});
it("fires channel_pairing_requested only for newly created requests", async () => {
const handler = vi.fn(async () => {});
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "channel_pairing_requested",
handler,
},
]),
);
await issuePairingChallenge({
...createBaseChallengeParams(),
accountId: "alerts",
meta: { username: "alice" },
upsertPairingRequest: async () => ({ code: "HOOK1234", created: true }),
sendPairingReply: async () => {},
});
await issuePairingChallenge({
...createBaseChallengeParams(),
accountId: "alerts",
upsertPairingRequest: async () => ({ code: "EXISTS12", created: false }),
sendPairingReply: async () => {},
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
{
channel: "forum",
accountId: "alerts",
senderId: "123",
code: "HOOK1234",
metadata: { username: "alice" },
},
{
channelId: "forum",
accountId: "alerts",
senderId: "123",
},
);
});
it("does not block pairing replies when pairing-request hooks fail or stall", async () => {
const throwingHook = vi.fn(() => {
throw new Error("notification failed");
});
const stallingHook = vi.fn(() => new Promise<void>(() => {}));
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "channel_pairing_requested",
handler: throwingHook,
pluginId: "throwing",
},
{
hookName: "channel_pairing_requested",
handler: stallingHook,
pluginId: "stalling",
},
]),
);
const sendPairingReply = vi.fn(async () => {});
const result = await issuePairingChallenge({
...createBaseChallengeParams(),
upsertPairingRequest: async () => ({ code: "FAST1234", created: true }),
sendPairingReply,
});
expect(result).toEqual({ created: true, code: "FAST1234" });
expect(throwingHook).toHaveBeenCalledTimes(1);
expect(stallingHook).toHaveBeenCalledTimes(1);
expect(sendPairingReply).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,10 +1,13 @@
// Builds and validates channel pairing challenges for first-time setup.
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { normalizeAccountId } from "../routing/account-id.js";
import { buildPairingReply } from "./pairing-messages.js";
type PairingMeta = Record<string, string | undefined>;
export type PairingChallengeParams = {
channel: string;
accountId?: string;
senderId: string;
senderIdLine: string;
meta?: PairingMeta;
@@ -18,6 +21,33 @@ export type PairingChallengeParams = {
onReplyError?: (err: unknown) => void;
};
async function runPairingRequestedHook(params: {
channel: string;
accountId?: string;
senderId: string;
code: string;
meta?: PairingMeta;
}): Promise<void> {
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("channel_pairing_requested")) {
return;
}
await hookRunner.runChannelPairingRequested(
{
channel: params.channel,
accountId: params.accountId,
senderId: params.senderId,
code: params.code,
metadata: params.meta,
},
{
channelId: params.channel,
accountId: params.accountId,
senderId: params.senderId,
},
);
}
/**
* Shared pairing challenge issuance for DM pairing policy pathways.
* Ensures every channel follows the same create-if-missing + reply flow.
@@ -33,6 +63,15 @@ export async function issuePairingChallenge(
return { created: false };
}
params.onCreated?.({ code });
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
// Notification/audit hooks must not delay the pairing-code reply.
void runPairingRequestedHook({
channel: params.channel,
accountId,
senderId: params.senderId,
code,
meta: params.meta,
}).catch(() => undefined);
const replyText =
params.buildReplyText?.({ code, senderIdLine: params.senderIdLine }) ??
buildPairingReply({

View File

@@ -1,7 +1,12 @@
/**
* Tests channel pairing helpers and pairing reply behavior.
*/
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "../plugins/hook-runner-global.js";
import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import {
createChannelPairingChallengeIssuer,
@@ -18,6 +23,10 @@ function createReplyCollector() {
};
}
afterEach(() => {
resetGlobalHookRunner();
});
describe("createChannelPairingController", () => {
it("scopes store access and issues pairing challenges through the scoped store", async () => {
const readAllowFromStore = vi.fn(async () => ["alice"]);
@@ -58,6 +67,47 @@ describe("createChannelPairingController", () => {
expect(sendPairingReply).toHaveBeenCalledTimes(1);
expect(replies[0]).toContain("123456");
});
it("passes the scoped account id to channel_pairing_requested hooks", async () => {
const handler = vi.fn(async () => {});
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "channel_pairing_requested", handler }]),
);
const runtime = {
channel: {
pairing: {
readAllowFromStore: vi.fn(async () => []),
upsertPairingRequest: vi.fn(async () => ({ code: "ACCT1234", created: true })),
},
},
} as unknown as PluginRuntime;
const pairing = createChannelPairingController({
core: runtime,
channel: "googlechat",
accountId: "Primary",
});
await pairing.issueChallenge({
senderId: "user-1",
senderIdLine: "Your id: user-1",
sendPairingReply: async () => {},
});
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
channel: "googlechat",
accountId: "primary",
senderId: "user-1",
code: "ACCT1234",
}),
expect.objectContaining({
channelId: "googlechat",
accountId: "primary",
senderId: "user-1",
}),
);
});
});
describe("createChannelPairingChallengeIssuer", () => {
@@ -81,4 +131,35 @@ describe("createChannelPairingChallengeIssuer", () => {
});
expect(replies[0]).toContain("654321");
});
it("normalizes account ids before sending channel_pairing_requested hooks", async () => {
const handler = vi.fn(async () => {});
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "channel_pairing_requested", handler }]),
);
const issueChallenge = createChannelPairingChallengeIssuer({
channel: "quietchat",
accountId: "Alerts",
upsertPairingRequest: vi.fn(async () => ({ code: "NORM1234", created: true })),
});
await issueChallenge({
senderId: "user-3",
senderIdLine: "Your id: user-3",
sendPairingReply: async () => {},
});
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
channel: "quietchat",
accountId: "alerts",
senderId: "user-3",
}),
expect.objectContaining({
channelId: "quietchat",
accountId: "alerts",
senderId: "user-3",
}),
);
});
});

View File

@@ -20,7 +20,10 @@ type ScopedPairingAccess = ReturnType<typeof createScopedPairingAccess>;
export type ChannelPairingController = ScopedPairingAccess & {
/** Issue a pairing challenge using the controller's channel and scoped store writer. */
issueChallenge: (
params: Omit<Parameters<typeof issuePairingChallenge>[0], "channel" | "upsertPairingRequest">,
params: Omit<
Parameters<typeof issuePairingChallenge>[0],
"channel" | "accountId" | "upsertPairingRequest"
>,
) => ReturnType<typeof issuePairingChallenge>;
};
@@ -28,6 +31,8 @@ export type ChannelPairingController = ScopedPairingAccess & {
export function createChannelPairingChallengeIssuer(params: {
/** Channel id attached to every challenge issued by the returned helper. */
channel: ChannelId;
/** Optional channel account id attached to pairing-request hook payloads. */
accountId?: string;
/** Store writer that persists pending pairing requests for the bound channel. */
upsertPairingRequest: Parameters<typeof issuePairingChallenge>[0]["upsertPairingRequest"];
}) {
@@ -35,11 +40,12 @@ export function createChannelPairingChallengeIssuer(params: {
/** Challenge details supplied at message handling time. */
challenge: Omit<
Parameters<typeof issuePairingChallenge>[0],
"channel" | "upsertPairingRequest"
"channel" | "accountId" | "upsertPairingRequest"
>,
) =>
issuePairingChallenge({
channel: params.channel,
accountId: params.accountId,
upsertPairingRequest: params.upsertPairingRequest,
...challenge,
});
@@ -59,6 +65,7 @@ export function createChannelPairingController(params: {
...access,
issueChallenge: createChannelPairingChallengeIssuer({
channel: params.channel,
accountId: access.accountId,
upsertPairingRequest: access.upsertPairingRequest,
}),
};

View File

@@ -94,6 +94,7 @@ export type PluginHookName =
| "after_compaction"
| "before_reset"
| "inbound_claim"
| "channel_pairing_requested"
| "message_received"
| "message_sending"
| "reply_payload_sending"
@@ -141,6 +142,7 @@ export const PLUGIN_HOOK_NAMES = [
"after_compaction",
"before_reset",
"inbound_claim",
"channel_pairing_requested",
"message_received",
"message_sending",
"reply_payload_sending",
@@ -180,6 +182,25 @@ export type PluginHookDeprecation = {
removeAfter?: string;
};
type PluginHookChannelPairingRequestedEvent = {
/** Channel that created the pending pairing request. */
channel: string;
/** Provider account ID for multi-account channel setups. */
accountId?: string;
/** Channel-scoped sender ID awaiting operator approval. */
senderId: string;
/** Short-lived code accepted by `openclaw pairing approve`. */
code: string;
/** Sender-supplied channel metadata for operator notification/audit. Treat as untrusted. */
metadata?: Record<string, string | undefined>;
};
type PluginHookChannelPairingContext = {
channelId: string;
accountId?: string;
senderId: string;
};
export const DEPRECATED_PLUGIN_HOOKS = {
subagent_spawning: {
replacement: "`subagent_spawned` for observation; core session bindings for routing",
@@ -1144,6 +1165,10 @@ export type PluginHookHandlerMap = {
event: PluginHookInboundClaimEvent,
ctx: PluginHookInboundClaimContext,
) => Promise<PluginHookInboundClaimResult | void> | PluginHookInboundClaimResult | void;
channel_pairing_requested: (
event: PluginHookChannelPairingRequestedEvent,
ctx: PluginHookChannelPairingContext,
) => Promise<void> | void;
before_dispatch: (
event: PluginHookBeforeDispatchEvent,
ctx: PluginHookBeforeDispatchContext,

View File

@@ -30,6 +30,7 @@ import type {
PluginHookBeforeDispatchContext,
PluginHookBeforeDispatchEvent,
PluginHookBeforeDispatchResult,
PluginHookHandlerMap,
PluginHookReplyPayloadSendingContext,
PluginHookReplyPayloadSendingEvent,
PluginHookReplyPayloadSendingResult,
@@ -206,6 +207,7 @@ export type HookRunnerOptions = {
const DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK: Partial<Record<PluginHookName, number>> = {
agent_end: 30_000,
channel_pairing_requested: 2_000,
// Defensive default for the compaction lifecycle hooks. Without a budget an
// unresponsive handler runs fully unbounded, and in the codex agent harness
// these hooks fire on the serialized notification queue
@@ -1064,6 +1066,17 @@ export function createHookRunner(
return runVoidHook("message_received", event, ctx);
}
/**
* Run channel_pairing_requested hook.
* Observation-only; slow/failing handlers must not block pairing flow.
*/
async function runChannelPairingRequested(
event: Parameters<PluginHookHandlerMap["channel_pairing_requested"]>[0],
ctx: Parameters<PluginHookHandlerMap["channel_pairing_requested"]>[1],
): Promise<void> {
return runVoidHook("channel_pairing_requested", event, ctx);
}
/**
* Run before_dispatch hook.
* Allows plugins to inspect or handle a message before model dispatch.
@@ -1643,6 +1656,7 @@ export function createHookRunner(
runInboundClaim,
runInboundClaimForPlugin,
runInboundClaimForPluginOutcome,
runChannelPairingRequested,
runMessageReceived,
runBeforeDispatch,
runReplyDispatch,