refactor: centralize channel ingress access

This commit is contained in:
Peter Steinberger
2026-05-10 05:06:03 +01:00
parent 1725eebe62
commit a0fb7fb045
250 changed files with 11410 additions and 8161 deletions

View File

@@ -0,0 +1,137 @@
---
summary: "Experimental channel ingress API for inbound message authorization"
read_when:
- Building or migrating a messaging channel plugin
- Changing DM or group allowlists, route gates, command auth, event auth, or mention activation
- Reviewing channel ingress redaction or SDK compatibility boundaries
title: "Channel ingress API"
sidebarTitle: "Channel Ingress"
---
# Channel ingress API
Channel ingress is the experimental access-control boundary for inbound channel
events. Use `openclaw/plugin-sdk/channel-ingress-runtime` for receive paths.
The older `openclaw/plugin-sdk/channel-ingress` subpath stays exported as a
deprecated compatibility facade for third-party plugins.
Plugins own platform facts and side effects. Core owns generic policy: DM/group
allowlists, pairing-store DM entries, route gates, command gates, event auth,
mention activation, redacted diagnostics, and admission.
## Runtime Resolver
```ts
import {
defineStableChannelIngressIdentity,
resolveChannelMessageIngress,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
const identity = defineStableChannelIngressIdentity({
key: "platform-user-id",
normalize: normalizePlatformUserId,
sensitivity: "pii",
});
const result = await resolveChannelMessageIngress({
channelId: "my-channel",
accountId,
identity,
subject: { stableId: platformUserId },
conversation: { kind: isGroup ? "group" : "direct", id: conversationId },
event: { kind: "message", authMode: "inbound", mayPair: !isGroup },
policy: {
dmPolicy: config.dmPolicy,
groupPolicy: config.groupPolicy,
groupAllowFromFallbackToAllowFrom: true,
},
allowFrom: config.allowFrom,
groupAllowFrom: config.groupAllowFrom,
accessGroups: cfg.accessGroups,
route,
readStoreAllowFrom,
command: hasControlCommand ? { allowTextCommands: true, hasControlCommand } : undefined,
});
```
Do not precompute effective allowlists, command owners, or command groups. The
resolver derives them from raw allowlists, store callbacks, route descriptors,
access groups, policy, and conversation kind.
## Result
Bundled plugins should consume modern projections directly:
- `ingress`: ordered gate decision and admission
- `senderAccess`: sender/conversation authorization only
- `routeAccess`: route and route-sender projection
- `commandAccess`: command authorization; false when no command gate ran
- `activationAccess`: mention/activation result
Event authorization remains available on the ordered `ingress.graph` and the
decisive `ingress.reasonCode`; no separate event projection is emitted.
Deprecated third-party SDK helpers may rebuild older shapes internally. New
bundled receive paths should not translate modern results back into local DTOs.
## Access Groups
`accessGroup:<name>` entries stay redacted. Core resolves static
`message.senders` groups itself and calls `resolveAccessGroupMembership` only
for dynamic groups that require a platform lookup. Missing, unsupported, and
failed groups fail closed.
## Event Modes
| `authMode` | Meaning |
| ---------------- | ------------------------------------------------ |
| `inbound` | normal inbound sender gates |
| `command` | command gates for callbacks or scoped buttons |
| `origin-subject` | actor must match the original message subject |
| `route-only` | route gates only for route-scoped trusted events |
| `none` | plugin-owned internal events bypass shared auth |
Use `mayPair: false` for reactions, buttons, callbacks, and native commands.
## Routes And Activation
Use route descriptors for room, topic, guild, thread, or nested route policy:
```ts
route: {
id: "room",
allowed: roomAllowed,
enabled: roomEnabled,
senderPolicy: "replace",
senderAllowFrom: roomAllowFrom,
blockReason: "room_sender_not_allowlisted",
}
```
Use `channelIngressRoutes(...)` when a plugin has several optional route
descriptors; it filters disabled branches while keeping route facts generic and
ordered by each descriptor's `precedence`.
Mention gating is an activation gate. A mention miss returns
`admission: "skip"` so the turn kernel does not process an observe-only turn.
Most channels should leave activation after sender and command gates. Public
chat surfaces that must quiet non-mentioned traffic before sender allowlist
noise can opt into `activation.order: "before-sender"` when text-command
bypass is disabled. Channels with implicit activation, such as replies in bot
threads, can pass `activation.allowedImplicitMentionKinds`; the projected
`activationAccess.shouldBypassMention` then reports when command or implicit
activation bypassed an explicit mention.
## Redaction
Raw sender values and raw allowlist entries are resolver input only. They must
not appear in resolved state, decisions, diagnostics, snapshots, or
compatibility facts. Use opaque subject ids, entry ids, route ids, and
diagnostic ids.
## Verification
```bash
pnpm test src/channels/message-access/message-access.test.ts src/plugin-sdk/channel-ingress-runtime.test.ts
pnpm plugin-sdk:api:check
```

View File

@@ -74,6 +74,16 @@ remain available for compatibility dispatchers. Do not use those names for new
channel code; new plugins should start with the `message` adapter, receipts, and
receive/send lifecycle helpers on `openclaw/plugin-sdk/channel-message`.
Channels migrating inbound authorization can use the experimental
`openclaw/plugin-sdk/channel-ingress-runtime` subpath from runtime receive
paths. The subpath keeps platform lookup and side effects in the plugin, while
sharing allowlist state resolution, route/sender/command/event/activation
decisions, redacted diagnostics, and turn-admission mapping. Keep plugin
identity normalization in the descriptor you pass to the resolver; do not
serialize raw match values from the resolved state or decision. See
[Channel ingress API](/plugins/sdk-channel-ingress) for the API design,
ownership boundary, and test expectations.
If your channel supports typing indicators outside inbound replies, expose
`heartbeat.sendTyping(...)` on the channel plugin. Core calls it with the
resolved heartbeat delivery target before the heartbeat model run starts and

View File

@@ -64,6 +64,7 @@ The runtime exposes three preferred entry points so adapters can opt in at the l
```typescript
runtime.channel.turn.run(...) // adapter-driven full pipeline
runtime.channel.turn.runAssembled(...) // already-built context + delivery adapter
runtime.channel.turn.runPrepared(...) // channel owns dispatch; kernel runs record + finalize
runtime.channel.turn.buildContext(...) // pure facts to FinalizedMsgContext mapping
```
@@ -72,7 +73,7 @@ Two older runtime helpers remain available for Plugin SDK compatibility:
```typescript
runtime.channel.turn.runResolved(...) // deprecated compatibility alias; prefer run
runtime.channel.turn.dispatchAssembled(...) // deprecated compatibility alias; prefer run or runPrepared
runtime.channel.turn.dispatchAssembled(...) // deprecated compatibility alias; prefer runAssembled
```
### run
@@ -114,6 +115,41 @@ await runtime.channel.turn.run({
`run` is the right shape when the channel has small adapter logic and benefits from owning the lifecycle through hooks.
### runAssembled
Use when the channel has already resolved routing, built a `FinalizedMsgContext`,
and only needs the shared record, reply-pipeline, dispatch, and finalize
ordering. This is the preferred shape for simple bundled inbound paths that
would otherwise repeat `createChannelMessageReplyPipeline(...)` and
`runPrepared(...)` boilerplate.
```typescript
await runtime.channel.turn.runAssembled({
cfg,
channel: "irc",
accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: runtime.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await sendPlatformReply(payload);
},
onError: (err, info) => {
runtime.error?.(`reply ${info.kind} failed: ${String(err)}`);
},
},
});
```
Choose `runAssembled` over `runPrepared` when the only channel-owned dispatch
behavior is final payload delivery plus optional typing, reply options, durable
delivery, or error logging.
### runPrepared
Use when the channel has a complex local dispatcher with previews, retries, edits, or thread bootstrap that must stay channel-owned. The kernel still records the inbound session before dispatch and surfaces a uniform `DispatchedChannelTurnResult`.

View File

@@ -56,6 +56,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/account-id` | `DEFAULT_ACCOUNT_ID`, account-id normalization helpers |
| `plugin-sdk/account-resolution` | Account lookup + default-fallback helpers |
| `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers |
| `plugin-sdk/access-groups` | Access-group allowlist parsing and redacted group diagnostics helpers |
| `plugin-sdk/channel-pairing` | `createChannelPairingController` |
| `plugin-sdk/channel-reply-pipeline` | Legacy reply pipeline helpers. New channel reply pipeline code should use `createChannelMessageReplyPipeline` and `resolveChannelMessageSourceReplyDeliveryMode` from `plugin-sdk/channel-message`. |
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` |
@@ -65,6 +66,8 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |
| `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-ingress` | Deprecated low-level channel ingress compatibility facade. New receive paths should use `plugin-sdk/channel-ingress-runtime`. |
| `plugin-sdk/channel-ingress-runtime` | Experimental high-level channel ingress runtime resolver and route fact builders for migrated channel receive paths. Prefer this over assembling effective allowlists, command allowlists, and legacy projections in each plugin. See [Channel ingress API](/plugins/sdk-channel-ingress). |
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink`, `createChannelRunQueue`, and legacy draft stream lifecycle helpers. New preview finalization code should use `plugin-sdk/channel-message`. |
| `plugin-sdk/channel-message` | Cheap message lifecycle contract helpers such as `defineChannelMessageAdapter`, `createChannelMessageAdapterFromOutbound`, `createChannelMessageReplyPipeline`, `createReplyPrefixContext`, `resolveChannelMessageSourceReplyDeliveryMode`, durable-final capability derivation, capability proof helpers for send/receipt/side-effect capabilities, `MessageReceiveContext`, receive ack policy proofs, `defineFinalizableLivePreviewAdapter`, `deliverWithFinalizableLivePreviewAdapter`, live-preview and live-finalizer capability proofs, durable recovery state, `RenderedMessageBatch`, message receipt types, and receipt id helpers. See [Channel message API](/plugins/sdk-channel-message). Legacy reply-dispatch facades are deprecated compatibility only. |
| `plugin-sdk/channel-message-runtime` | Runtime delivery helpers that may load outbound delivery, including `deliverInboundReplyWithMessageSendContext`, `sendDurableMessageBatch`, and `withDurableMessageSendContext`. Deprecated reply-dispatch bridges remain importable for compatibility dispatchers only. Use from monitor/send runtime modules, not hot plugin bootstrap files. |