feat(ui): manage DM pairing requests in Channels (#112401)

* feat(ui): manage DM pairing requests

* fix(ui): clear pairing data across auth changes

* test(ui): tighten pairing page fixture type

* fix(gateway): complete pairing protocol contracts

* fix(ui): guard pairing mutations across epochs

* fix(ui): restore chat teardown gates

* fix(ui): isolate channel auth lifecycles

* fix(ui): remove stale chat view export
This commit is contained in:
Peter Steinberger
2026-07-22 04:54:20 -07:00
committed by GitHub
parent 0d7e870a2b
commit 0c99a4e362
40 changed files with 3014 additions and 141 deletions

View File

@@ -414,6 +414,9 @@ enum class GatewayMethod(
TerminalInput("terminal.input"),
TerminalResize("terminal.resize"),
TerminalClose("terminal.close"),
ChannelsPairingList("channels.pairing.list"),
ChannelsPairingApprove("channels.pairing.approve"),
ChannelsPairingDismiss("channels.pairing.dismiss"),
AssistantMediaGet("assistant.media.get"),
SessionsGet("sessions.get"),
SessionsResolve("sessions.resolve"),

View File

@@ -9898,6 +9898,146 @@ public struct ChannelsStatusResult: Codable, Sendable {
}
}
public struct ChannelsPairingListParams: Codable, Sendable {
public let channel: String?
public let accountid: String?
public init(
channel: String? = nil,
accountid: String? = nil)
{
self.channel = channel
self.accountid = accountid
}
private enum CodingKeys: String, CodingKey {
case channel
case accountid = "accountId"
}
}
public struct ChannelsPairingListResult: Codable, Sendable {
public let accounts: [[String: AnyCodable]]
public let requests: [[String: AnyCodable]]
public let commandownerconfigured: Bool
public let limits: [String: AnyCodable]
public init(
accounts: [[String: AnyCodable]],
requests: [[String: AnyCodable]],
commandownerconfigured: Bool,
limits: [String: AnyCodable])
{
self.accounts = accounts
self.requests = requests
self.commandownerconfigured = commandownerconfigured
self.limits = limits
}
private enum CodingKeys: String, CodingKey {
case accounts
case requests
case commandownerconfigured = "commandOwnerConfigured"
case limits
}
}
public struct ChannelsPairingApproveParams: Codable, Sendable {
public let channel: String
public let accountid: String
public let requestid: String
public let notify: Bool?
public let bootstrapcommandowner: Bool?
public init(
channel: String,
accountid: String,
requestid: String,
notify: Bool? = nil,
bootstrapcommandowner: Bool? = nil)
{
self.channel = channel
self.accountid = accountid
self.requestid = requestid
self.notify = notify
self.bootstrapcommandowner = bootstrapcommandowner
}
private enum CodingKeys: String, CodingKey {
case channel
case accountid = "accountId"
case requestid = "requestId"
case notify
case bootstrapcommandowner = "bootstrapCommandOwner"
}
}
public struct ChannelsPairingApproveResult: Codable, Sendable {
public let requestid: String
public let senderid: String
public let notification: String
public let commandownerbootstrap: String
public init(
requestid: String,
senderid: String,
notification: String,
commandownerbootstrap: String)
{
self.requestid = requestid
self.senderid = senderid
self.notification = notification
self.commandownerbootstrap = commandownerbootstrap
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
case senderid = "senderId"
case notification
case commandownerbootstrap = "commandOwnerBootstrap"
}
}
public struct ChannelsPairingDismissParams: Codable, Sendable {
public let channel: String
public let accountid: String
public let requestid: String
public init(
channel: String,
accountid: String,
requestid: String)
{
self.channel = channel
self.accountid = accountid
self.requestid = requestid
}
private enum CodingKeys: String, CodingKey {
case channel
case accountid = "accountId"
case requestid = "requestId"
}
}
public struct ChannelsPairingDismissResult: Codable, Sendable {
public let requestid: String
public let senderid: String
public init(
requestid: String,
senderid: String)
{
self.requestid = requestid
self.senderid = senderid
}
private enum CodingKeys: String, CodingKey {
case requestid = "requestId"
case senderid = "senderId"
}
}
public struct ChannelsStartParams: Codable, Sendable {
public let channel: String
public let accountid: String?

View File

@@ -32,20 +32,43 @@ Pairing codes:
- **Expire after 1 hour**. The bot only sends the pairing message when a new request is created (roughly once per hour per sender).
- Pending DM pairing requests are capped at **3 per channel account**; additional requests are ignored until one expires or is approved.
### Approve a sender
### Approve from the Control UI
Open **Settings → Channels → DM access requests**. The queue combines pending
requests from every configured channel account whose DM policy is `pairing`.
Filter by channel or account, review the sender ID and metadata, then choose
**Approve**.
Approval grants direct-message access only. It does not grant group access. The
approval dialog also offers these explicit options when supported:
- **Notify the requester after approval**
- **Also make this sender the first command owner**, shown only when no command
owner exists and the Control UI session has `operator.admin`
Choose **Dismiss** to remove a pending request without approving it. Dismissal is
not a permanent block; the sender can request access again later.
### Approve from the CLI
```bash
openclaw pairing list telegram
openclaw pairing approve telegram <CODE>
```
Add `--notify` to the approve command to tell the requester on the same channel. Multi-account channels take `--account <id>`.
Add `--notify` to tell the requester on the same channel. Multi-account channels
take `--account <id>`.
If no command owner is configured yet, approving a DM pairing code also bootstraps
`commands.ownerAllowFrom` to the approved sender, such as `telegram:123456789`.
That gives first-time setups an explicit owner for privileged commands and exec
approval prompts. After an owner exists, later pairing approvals only grant DM
access; they do not add more owners.
Unlike the Control UI's explicit checkbox, the CLI automatically bootstraps
`commands.ownerAllowFrom` when no command owner is configured, using an entry
such as `telegram:123456789`. This gives first-time setups an explicit owner for
privileged commands and exec approval prompts. After an owner exists, later
pairing approvals only grant DM access; they do not add more owners.
<Note>
WhatsApp's login QR links a WhatsApp account to OpenClaw. DM access requests
approve people who message that account. These are separate flows.
</Note>
Supported channels (any installed channel plugin that declares pairing; external plugins such as `openclaw-weixin` can add more): `discord`, `feishu`, `googlechat`, `imessage`, `irc`, `line`, `matrix`, `mattermost`, `msteams`, `nextcloud-talk`, `nostr`, `signal`, `slack`, `sms`, `synology-chat`, `telegram`, `twitch`, `whatsapp`, `zalo`, `zalouser`.

View File

@@ -80,14 +80,19 @@ openclaw gateway
</Step>
<Step title="Approve the first pairing request (pairing mode)">
<Step title="Approve the first DM access request (pairing mode)">
Open **Settings → Channels → DM access requests**, find the WhatsApp account,
and approve the sender. If you prefer the CLI:
```bash
openclaw pairing list whatsapp
openclaw pairing approve whatsapp <CODE>
```
Pairing requests expire after 1 hour; pending requests are capped at 3 per account.
DM access requests expire after 1 hour; pending requests are capped at 3 per
account. This approval is separate from the WhatsApp login QR used to link the
account itself.
</Step>
</Steps>

View File

@@ -11,6 +11,11 @@ Approve or inspect DM pairing requests for channels that support pairing (chat D
Related: [Pairing flow](/channels/pairing)
The same pending requests can be reviewed in the Control UI under **Settings →
Channels → DM access requests**. The Control UI supports approve, optional
requester notification, and dismiss. Dismiss removes the current request but does
not permanently block the sender.
## Commands
```bash
@@ -50,7 +55,7 @@ Options: `--channel <channel>`, `--account <accountId>`, `--notify` (send a conf
### Owner bootstrap
If `commands.ownerAllowFrom` is empty when you approve a pairing code, OpenClaw also records the approved sender as the command owner, using a channel-scoped entry such as `telegram:123456789`. This only bootstraps the first owner - later pairing approvals never replace or expand `commands.ownerAllowFrom`.
If `commands.ownerAllowFrom` is empty when you approve a pairing code, the CLI also records the approved sender as the command owner, using a channel-scoped entry such as `telegram:123456789`. This only bootstraps the first owner - later pairing approvals never replace or expand `commands.ownerAllowFrom`. The Control UI presents this elevation as a separate `operator.admin`-protected checkbox instead of applying it automatically.
The command owner is the human operator account allowed to run owner-only commands and approve dangerous actions such as `/diagnostics`, `/export-session`, `/export-trajectory`, `/config`, and exec approvals. Pairing only lets a sender talk to the agent; it does not by itself grant owner privileges beyond this one-time bootstrap.

View File

@@ -784,7 +784,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Route: /channels/pairing
- Headings:
- H2: 1) DM pairing (inbound chat access)
- H3: Approve a sender
- H3: Approve from the Control UI
- H3: Approve from the CLI
- H3: Reusable sender groups
- H3: Where the state lives
- H2: 2) Node device pairing (iOS/Android/macOS/headless nodes)

View File

@@ -103,6 +103,12 @@ import {
ConversationTurnParamsSchema,
ConversationTurnReplySchema,
ConversationTurnResultSchema,
ChannelsPairingApproveParamsSchema,
ChannelsPairingApproveResultSchema,
ChannelsPairingDismissParamsSchema,
ChannelsPairingDismissResultSchema,
ChannelsPairingListParamsSchema,
ChannelsPairingListResultSchema,
ChannelsStartParamsSchema,
ChannelsStopParamsSchema,
ChannelsLogoutParamsSchema,
@@ -877,6 +883,9 @@ export const validateTalkSessionCloseParams = lazyCompile(TalkSessionCloseParams
export const validateTalkSpeakParams = lazyCompile(TalkSpeakParamsSchema);
export const validateTtsSpeakParams = lazyCompile(TtsSpeakParamsSchema);
export const validateChannelsStatusParams = lazyCompile(ChannelsStatusParamsSchema);
export const validateChannelsPairingListParams = lazyCompile(ChannelsPairingListParamsSchema);
export const validateChannelsPairingApproveParams = lazyCompile(ChannelsPairingApproveParamsSchema);
export const validateChannelsPairingDismissParams = lazyCompile(ChannelsPairingDismissParamsSchema);
export const validateChannelsStartParams = lazyCompile(ChannelsStartParamsSchema);
export const validateChannelsStopParams = lazyCompile(ChannelsStopParamsSchema);
export const validateChannelsLogoutParams = lazyCompile(ChannelsLogoutParamsSchema);
@@ -1302,6 +1311,12 @@ export {
TtsSpeakResultSchema,
ChannelsStatusParamsSchema,
ChannelsStatusResultSchema,
ChannelsPairingListParamsSchema,
ChannelsPairingListResultSchema,
ChannelsPairingApproveParamsSchema,
ChannelsPairingApproveResultSchema,
ChannelsPairingDismissParamsSchema,
ChannelsPairingDismissResultSchema,
ChannelsStartParamsSchema,
ChannelsStopParamsSchema,
ChannelsLogoutParamsSchema,
@@ -1646,6 +1661,14 @@ export type {
TalkModeParams,
ChannelsStatusParams,
ChannelsStatusResult,
ChannelsPairingListParams,
ChannelsPairingListResult,
ChannelsPairingApproveParams,
ChannelsPairingApproveResult,
ChannelsPairingDismissParams,
ChannelsPairingDismissResult,
ChannelsPairingAccount,
ChannelsPairingRequest,
ChannelsStartParams,
ChannelsStopParams,
ChannelsLogoutParams,

View File

@@ -15,6 +15,7 @@ export * from "./schema/audit.js";
export * from "./schema/board.js";
export * from "./schema/users.js";
export * from "./schema/channels.js";
export * from "./schema/channel-pairing.js";
export * from "./schema/talk-marks.js";
export * from "./schema/commands.js";
export * from "./schema/config.js";

View File

@@ -0,0 +1,83 @@
// Gateway Protocol schemas for DM sender access requests.
import type { Static } from "typebox";
import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
const ChannelPairingAccountSchema = closedObject({
channel: NonEmptyString,
channelLabel: NonEmptyString,
accountId: NonEmptyString,
accountLabel: Type.Optional(NonEmptyString),
notifySupported: Type.Boolean(),
});
const ChannelPairingRequestSchema = closedObject({
requestId: NonEmptyString,
channel: NonEmptyString,
channelLabel: NonEmptyString,
accountId: NonEmptyString,
accountLabel: Type.Optional(NonEmptyString),
senderId: NonEmptyString,
senderLabel: NonEmptyString,
metadata: Type.Optional(Type.Record(NonEmptyString, Type.String())),
createdAt: NonEmptyString,
lastSeenAt: NonEmptyString,
expiresAt: NonEmptyString,
notifySupported: Type.Boolean(),
});
/** Lists pending DM sender access requests for pairing-policy channel accounts. */
export const ChannelsPairingListParamsSchema = closedObject({
channel: Type.Optional(NonEmptyString),
accountId: Type.Optional(NonEmptyString),
});
export const ChannelsPairingListResultSchema = closedObject({
accounts: Type.Array(ChannelPairingAccountSchema),
requests: Type.Array(ChannelPairingRequestSchema),
commandOwnerConfigured: Type.Boolean(),
limits: closedObject({
pendingPerAccount: Type.Integer({ minimum: 0 }),
ttlMs: Type.Integer({ minimum: 0 }),
}),
});
/** Approves one pending DM sender request. */
export const ChannelsPairingApproveParamsSchema = closedObject({
channel: NonEmptyString,
accountId: NonEmptyString,
requestId: NonEmptyString,
notify: Type.Optional(Type.Boolean()),
bootstrapCommandOwner: Type.Optional(Type.Boolean()),
});
export const ChannelsPairingApproveResultSchema = closedObject({
requestId: NonEmptyString,
senderId: NonEmptyString,
notification: Type.String({ enum: ["not-requested", "sent", "unsupported", "failed"] }),
commandOwnerBootstrap: Type.String({
enum: ["not-requested", "configured", "already-configured", "unavailable"],
}),
});
/** Dismisses one pending request without permanently blocking the sender. */
export const ChannelsPairingDismissParamsSchema = closedObject({
channel: NonEmptyString,
accountId: NonEmptyString,
requestId: NonEmptyString,
});
export const ChannelsPairingDismissResultSchema = closedObject({
requestId: NonEmptyString,
senderId: NonEmptyString,
});
export type ChannelsPairingListParams = Static<typeof ChannelsPairingListParamsSchema>;
export type ChannelsPairingListResult = Static<typeof ChannelsPairingListResultSchema>;
export type ChannelsPairingApproveParams = Static<typeof ChannelsPairingApproveParamsSchema>;
export type ChannelsPairingApproveResult = Static<typeof ChannelsPairingApproveResultSchema>;
export type ChannelsPairingDismissParams = Static<typeof ChannelsPairingDismissParamsSchema>;
export type ChannelsPairingDismissResult = Static<typeof ChannelsPairingDismissResultSchema>;
export type ChannelsPairingAccount = Static<typeof ChannelPairingAccountSchema>;
export type ChannelsPairingRequest = Static<typeof ChannelPairingRequestSchema>;

View File

@@ -191,6 +191,14 @@ import {
BoardWidgetDeclaredSchema,
BoardWidgetSchema,
} from "./board.js";
import {
ChannelsPairingApproveParamsSchema,
ChannelsPairingApproveResultSchema,
ChannelsPairingDismissParamsSchema,
ChannelsPairingDismissResultSchema,
ChannelsPairingListParamsSchema,
ChannelsPairingListResultSchema,
} from "./channel-pairing.js";
import {
ChannelsStartParamsSchema,
ChannelsStopParamsSchema,
@@ -973,6 +981,12 @@ export const ProtocolSchemas = {
TtsSpeakResult: TtsSpeakResultSchema,
ChannelsStatusParams: ChannelsStatusParamsSchema,
ChannelsStatusResult: ChannelsStatusResultSchema,
ChannelsPairingListParams: ChannelsPairingListParamsSchema,
ChannelsPairingListResult: ChannelsPairingListResultSchema,
ChannelsPairingApproveParams: ChannelsPairingApproveParamsSchema,
ChannelsPairingApproveResult: ChannelsPairingApproveResultSchema,
ChannelsPairingDismissParams: ChannelsPairingDismissParamsSchema,
ChannelsPairingDismissResult: ChannelsPairingDismissResultSchema,
ChannelsStartParams: ChannelsStartParamsSchema,
ChannelsStopParams: ChannelsStopParamsSchema,
ChannelsLogoutParams: ChannelsLogoutParamsSchema,

View File

@@ -9,15 +9,8 @@ import { getTerminalTableWidth, renderTable } from "../../packages/terminal-core
import { theme } from "../../packages/terminal-core/src/theme.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
import { listPairingChannels, notifyPairingApproved } from "../channels/plugins/pairing.js";
import {
formatCommandOwnerFromChannelSender,
hasConfiguredCommandOwners,
} from "../commands/doctor-command-owner.js";
import {
getRuntimeConfig,
readConfigFileSnapshotForWrite,
replaceConfigFile,
} from "../config/config.js";
import { getRuntimeConfig } from "../config/config.js";
import { bootstrapCommandOwnerFromPairing } from "../pairing/command-owner.js";
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
import { approveChannelPairingCode, listChannelPairingRequests } from "../pairing/pairing-store.js";
import type { PairingChannel } from "../pairing/pairing-store.types.js";
@@ -57,35 +50,6 @@ async function notifyApproved(channel: PairingChannel, id: string, accountId?: s
await notifyPairingApproved({ channelId: channel, id, cfg, ...(accountId ? { accountId } : {}) });
}
async function maybeBootstrapCommandOwnerFromPairing(params: {
channel: PairingChannel;
id: string;
}): Promise<{ ownerEntry: string | null; bootstrapped: boolean }> {
// First approved pairing can seed ownerAllowFrom so command access is not left open-ended.
const ownerEntry = formatCommandOwnerFromChannelSender(params);
if (!ownerEntry) {
return { ownerEntry: null, bootstrapped: false };
}
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
if (hasConfiguredCommandOwners(snapshot.sourceConfig)) {
return { ownerEntry, bootstrapped: false };
}
const nextConfig = structuredClone(snapshot.sourceConfig);
nextConfig.commands = {
...nextConfig.commands,
ownerAllowFrom: [ownerEntry],
};
await replaceConfigFile({
nextConfig,
snapshot,
writeOptions,
afterWrite: { mode: "auto" },
});
return { ownerEntry, bootstrapped: true };
}
export function registerPairingCli(program: Command) {
const channels = listPairingChannels();
// Avoid rendering a bare "()" enum when no channels are configured.
@@ -208,11 +172,11 @@ export function registerPairingCli(program: Command) {
defaultRuntime.log(
`${theme.success("Approved")} ${theme.muted(channel)} sender ${theme.command(approved.id)}.`,
);
const ownerBootstrap = await maybeBootstrapCommandOwnerFromPairing({
const ownerBootstrap = await bootstrapCommandOwnerFromPairing({
channel,
id: approved.id,
});
if (ownerBootstrap.bootstrapped && ownerBootstrap.ownerEntry) {
if (ownerBootstrap.status === "configured" && ownerBootstrap.ownerEntry) {
defaultRuntime.log(
`${theme.success("Command owner configured")} ${theme.command(ownerBootstrap.ownerEntry)} ${theme.muted("(commands.ownerAllowFrom was empty).")}`,
);

View File

@@ -180,6 +180,29 @@ describe("method scope resolution", () => {
).toEqual({ allowed: true });
});
it("requires admin only when DM pairing approval bootstraps a command owner", () => {
expect(resolveLeastPrivilegeOperatorScopesForMethod("channels.pairing.approve", {})).toEqual([
"operator.pairing",
]);
expect(
resolveLeastPrivilegeOperatorScopesForMethod("channels.pairing.approve", {
bootstrapCommandOwner: true,
}),
).toEqual(["operator.pairing", "operator.admin"]);
expect(
authorizeOperatorScopesForMethod("channels.pairing.approve", ["operator.pairing"], {
bootstrapCommandOwner: true,
}),
).toEqual({ allowed: false, missingScope: "operator.admin" });
expect(
authorizeOperatorScopesForMethod(
"channels.pairing.approve",
["operator.pairing", "operator.admin"],
{ bootstrapCommandOwner: true },
),
).toEqual({ allowed: true });
});
it("classifies plugin session actions with a CLI-safe default operator scope", () => {
expect(resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction")).toEqual([
"operator.write",

View File

@@ -192,6 +192,13 @@ function resolveDynamicLeastPrivilegeOperatorScopesForMethod(
: undefined;
return includeSecrets === true ? [READ_SCOPE, TALK_SECRETS_SCOPE] : [READ_SCOPE];
}
if (method === "channels.pairing.approve") {
const bootstrapCommandOwner =
params && typeof params === "object" && !Array.isArray(params)
? (params as { bootstrapCommandOwner?: unknown }).bootstrapCommandOwner
: undefined;
return bootstrapCommandOwner === true ? [PAIRING_SCOPE, ADMIN_SCOPE] : [PAIRING_SCOPE];
}
if (method === "sessions.patch") {
return resolveSessionsPatchRequiredScopes(params);
}

View File

@@ -55,6 +55,9 @@ const CURRENT_TRAIN_METHODS = [
"device.pair.rename",
"sessions.observer.ask",
"sessions.observer.visibility",
"channels.pairing.list",
"channels.pairing.approve",
"channels.pairing.dismiss",
] as const;
describe("core gateway method release trains", () => {

View File

@@ -314,6 +314,11 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "terminal.input", scope: "operator.admin", since: "2026.7" },
{ name: "terminal.resize", scope: "operator.admin", since: "2026.7" },
{ name: "terminal.close", scope: "operator.admin", since: "2026.7" },
// DM pairing is additive to the advertised method list. Keep it appended so
// older clients retain every pre-existing advertised method index.
{ name: "channels.pairing.list", scope: "operator.pairing", since: "2026.7" },
{ name: "channels.pairing.approve", scope: "dynamic", since: "2026.7" },
{ name: "channels.pairing.dismiss", scope: "operator.pairing", since: "2026.7" },
{ name: "assistant.media.get", scope: "operator.read", since: "<=2026.7", advertise: false },
{ name: "sessions.get", scope: "operator.read", since: "<=2026.7", advertise: false },
{ name: "sessions.resolve", scope: "operator.read", since: "<=2026.7", advertise: false },

View File

@@ -81,6 +81,10 @@ const loadChannelsHandlers = lazyHandlerModule(
() => import("./server-methods/channels.js"),
(module) => module.channelsHandlers,
);
const loadChannelPairingHandlers = lazyHandlerModule(
() => import("./server-methods/channel-pairing.js"),
(module) => module.channelPairingHandlers,
);
const loadChatHandlers = lazyHandlerModule(
() => import("./server-methods/chat.js"),
(module) => module.chatHandlers,
@@ -426,6 +430,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
methods: ["channels.status", "channels.start", "channels.stop", "channels.logout"],
loadHandlers: loadChannelsHandlers,
}),
...createLazyCoreHandlers({
methods: ["channels.pairing.list", "channels.pairing.approve", "channels.pairing.dismiss"],
loadHandlers: loadChannelPairingHandlers,
}),
...createLazyCoreHandlers({
methods: [
"chat.history",

View File

@@ -0,0 +1,251 @@
import { expectDefined } from "@openclaw/normalization-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
approve: vi.fn(),
bootstrapOwner: vi.fn(),
dismiss: vi.fn(),
hasOwners: vi.fn(),
listPlugins: vi.fn(),
listRequests: vi.fn(),
notify: vi.fn(),
}));
vi.mock("../../channels/plugins/index.js", () => ({
listChannelPlugins: mocks.listPlugins,
}));
vi.mock("../../channels/plugins/pairing.js", () => ({
notifyPairingApproved: mocks.notify,
}));
vi.mock("../../commands/doctor-command-owner.js", () => ({
hasConfiguredCommandOwners: mocks.hasOwners,
}));
vi.mock("../../pairing/command-owner.js", () => ({
bootstrapCommandOwnerFromPairing: mocks.bootstrapOwner,
}));
vi.mock("../../pairing/pairing-store.js", () => ({
approveChannelPairingRequest: mocks.approve,
CHANNEL_PAIRING_PENDING_MAX: 3,
CHANNEL_PAIRING_PENDING_TTL_MS: 3_600_000,
dismissChannelPairingRequest: mocks.dismiss,
listChannelPairingRequests: mocks.listRequests,
resolveChannelPairingRequestId: vi.fn(() => "opaque-request-id"),
}));
vi.mock("../runtime-plugin-config.js", () => ({
resolveGatewayPluginConfig: ({ config }: { config: unknown }) => config,
}));
import { channelPairingHandlers } from "./channel-pairing.js";
const notifyApproval = vi.fn(async () => undefined);
const pairingPlugin = {
id: "whatsapp",
meta: { label: "WhatsApp" },
pairing: { idLabel: "Phone number", notifyApproval },
config: {
listAccountIds: () => ["personal", "public", "unconfigured"],
resolveAccount: (_cfg: unknown, accountId: string) => ({
configured: accountId !== "unconfigured",
dmPolicy: accountId === "public" ? "open" : "pairing",
name: accountId === "personal" ? "Personal" : accountId,
}),
isConfigured: (account: { configured: boolean }) => account.configured,
describeAccount: (account: { name: string }) => ({
accountId: account.name,
name: account.name,
}),
},
security: {
resolveDmPolicy: ({ account }: { account: { dmPolicy: string } }) => ({
policy: account.dmPolicy,
allowFromPath: "channels.whatsapp.allowFrom",
approveHint: "approve",
}),
},
};
function createContext() {
return {
getRuntimeConfig: () => ({}),
logGateway: { warn: vi.fn() },
};
}
async function invoke(
method: keyof typeof channelPairingHandlers,
params: Record<string, unknown>,
) {
const respond = vi.fn();
const handler = expectDefined(channelPairingHandlers[method], `${method} test invariant`);
await handler({
params,
respond,
context: createContext(),
} as unknown as Parameters<typeof handler>[0]);
return respond;
}
beforeEach(() => {
vi.clearAllMocks();
mocks.listPlugins.mockReturnValue([pairingPlugin]);
mocks.hasOwners.mockReturnValue(false);
mocks.listRequests.mockResolvedValue([]);
mocks.bootstrapOwner.mockResolvedValue({ ownerEntry: "whatsapp:+1555", status: "configured" });
});
describe("channel DM pairing gateway handlers", () => {
it("lists only pairing-policy accounts without exposing the human code", async () => {
mocks.listRequests.mockResolvedValue([
{
id: "+15551234567",
code: "SECRET12",
createdAt: "2026-07-20T10:00:00.000Z",
lastSeenAt: "2026-07-20T10:05:00.000Z",
meta: { accountId: "personal", name: "Alice" },
},
]);
const respond = await invoke("channels.pairing.list", {});
expect(mocks.listRequests).toHaveBeenCalledTimes(1);
expect(mocks.listRequests).toHaveBeenCalledWith("whatsapp", process.env, "personal");
expect(respond).toHaveBeenCalledWith(
true,
{
accounts: [
{
channel: "whatsapp",
channelLabel: "WhatsApp",
accountId: "personal",
accountLabel: "Personal",
notifySupported: true,
},
],
requests: [
{
requestId: "opaque-request-id",
channel: "whatsapp",
channelLabel: "WhatsApp",
accountId: "personal",
accountLabel: "Personal",
senderId: "+15551234567",
senderLabel: "Phone number",
metadata: { name: "Alice" },
createdAt: "2026-07-20T10:00:00.000Z",
lastSeenAt: "2026-07-20T10:05:00.000Z",
expiresAt: "2026-07-20T11:00:00.000Z",
notifySupported: true,
},
],
commandOwnerConfigured: false,
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
},
undefined,
);
expect(JSON.stringify(respond.mock.calls)).not.toContain("SECRET12");
});
it("approves access even when the optional notification fails", async () => {
mocks.approve.mockResolvedValue({
id: "+15551234567",
entry: {
id: "+15551234567",
code: "SECRET12",
createdAt: "2026-07-20T10:00:00.000Z",
lastSeenAt: "2026-07-20T10:00:00.000Z",
meta: { accountId: "personal" },
},
});
mocks.notify.mockRejectedValue(new Error("offline"));
const respond = await invoke("channels.pairing.approve", {
channel: "whatsapp",
accountId: "personal",
requestId: "opaque-request-id",
notify: true,
bootstrapCommandOwner: true,
});
expect(mocks.approve).toHaveBeenCalledWith({
channel: "whatsapp",
accountId: "personal",
requestId: "opaque-request-id",
pairingAdapter: pairingPlugin.pairing,
});
expect(mocks.bootstrapOwner).toHaveBeenCalledWith({
channel: "whatsapp",
id: "+15551234567",
});
expect(respond).toHaveBeenCalledWith(
true,
{
requestId: "opaque-request-id",
senderId: "+15551234567",
notification: "failed",
commandOwnerBootstrap: "configured",
},
undefined,
);
});
it("reports command-owner setup failure without rolling back DM approval", async () => {
mocks.approve.mockResolvedValue({
id: "+15551234567",
entry: {
id: "+15551234567",
code: "SECRET12",
createdAt: "2026-07-20T10:00:00.000Z",
lastSeenAt: "2026-07-20T10:00:00.000Z",
},
});
mocks.bootstrapOwner.mockRejectedValue(new Error("config write failed"));
const respond = await invoke("channels.pairing.approve", {
channel: "whatsapp",
accountId: "personal",
requestId: "opaque-request-id",
bootstrapCommandOwner: true,
});
expect(respond).toHaveBeenCalledWith(
true,
{
requestId: "opaque-request-id",
senderId: "+15551234567",
notification: "not-requested",
commandOwnerBootstrap: "unavailable",
},
undefined,
);
});
it("dismisses a request without approving the sender", async () => {
mocks.dismiss.mockResolvedValue({
id: "+15551234567",
entry: {
id: "+15551234567",
code: "SECRET12",
createdAt: "2026-07-20T10:00:00.000Z",
lastSeenAt: "2026-07-20T10:00:00.000Z",
},
});
const respond = await invoke("channels.pairing.dismiss", {
channel: "whatsapp",
accountId: "personal",
requestId: "opaque-request-id",
});
expect(mocks.dismiss).toHaveBeenCalledWith({
channel: "whatsapp",
accountId: "personal",
requestId: "opaque-request-id",
});
expect(mocks.approve).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
true,
{ requestId: "opaque-request-id", senderId: "+15551234567" },
undefined,
);
});
});

View File

@@ -0,0 +1,396 @@
// Gateway RPC handlers for DM sender access requests on pairing-policy channels.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
ErrorCodes,
errorShape,
type ChannelsPairingApproveParams,
type ChannelsPairingApproveResult,
type ChannelsPairingDismissParams,
type ChannelsPairingListParams,
type ChannelsPairingRequest,
validateChannelsPairingApproveParams,
validateChannelsPairingDismissParams,
validateChannelsPairingListParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { resolveChannelDmPolicy } from "../../channels/plugins/dm-access.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { notifyPairingApproved } from "../../channels/plugins/pairing.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import { hasConfiguredCommandOwners } from "../../commands/doctor-command-owner.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { bootstrapCommandOwnerFromPairing } from "../../pairing/command-owner.js";
import {
approveChannelPairingRequest,
CHANNEL_PAIRING_PENDING_MAX,
CHANNEL_PAIRING_PENDING_TTL_MS,
dismissChannelPairingRequest,
listChannelPairingRequests,
resolveChannelPairingRequestId,
} from "../../pairing/pairing-store.js";
import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
type PairingAccount = {
plugin: ChannelPlugin;
accountId: string;
accountLabel?: string;
};
class InvalidPairingTargetError extends Error {}
function normalizeFilter(value: string | undefined): string | undefined {
return normalizeOptionalString(value)?.toLowerCase();
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function resolvePairingPolicy(params: {
plugin: ChannelPlugin;
cfg: OpenClawConfig;
accountId: string;
account: unknown;
}): string | undefined {
const securityPolicy = params.plugin.security?.resolveDmPolicy?.({
cfg: params.cfg,
accountId: params.accountId,
account: params.account,
})?.policy;
if (securityPolicy) {
return securityPolicy;
}
const account = asRecord(params.account);
return resolveChannelDmPolicy({
account,
parent: asRecord(account?.config),
defaultPolicy: "pairing",
});
}
function resolvePairingAccountLabel(plugin: ChannelPlugin, account: unknown, cfg: OpenClawConfig) {
const described = plugin.config.describeAccount?.(account, cfg);
return (
normalizeOptionalString(described?.name) ?? normalizeOptionalString(asRecord(account)?.name)
);
}
async function listPairingAccounts(params: {
cfg: OpenClawConfig;
channel?: string;
accountId?: string;
}): Promise<PairingAccount[]> {
const requestedChannel = normalizeFilter(params.channel);
const requestedAccount = normalizeFilter(params.accountId);
const pairingPlugins = listChannelPlugins().filter((plugin) => plugin.pairing);
if (requestedChannel && !pairingPlugins.some((plugin) => plugin.id === requestedChannel)) {
throw new InvalidPairingTargetError(`unknown pairing channel: ${params.channel}`);
}
const accounts: PairingAccount[] = [];
for (const plugin of pairingPlugins) {
if (requestedChannel && plugin.id !== requestedChannel) {
continue;
}
for (const accountId of plugin.config.listAccountIds(params.cfg)) {
if (requestedAccount && accountId.toLowerCase() !== requestedAccount) {
continue;
}
const account = plugin.config.resolveAccount(params.cfg, accountId);
const configured = plugin.config.isConfigured
? await plugin.config.isConfigured(account, params.cfg)
: asRecord(account)?.configured !== false;
if (
!configured ||
resolvePairingPolicy({ plugin, cfg: params.cfg, accountId, account }) !== "pairing"
) {
continue;
}
const accountLabel = resolvePairingAccountLabel(plugin, account, params.cfg);
accounts.push({
plugin,
accountId,
...(accountLabel ? { accountLabel } : {}),
});
}
}
return accounts;
}
async function resolvePairingAccount(params: {
cfg: OpenClawConfig;
channel: string;
accountId: string;
}): Promise<PairingAccount | null> {
const accounts = await listPairingAccounts(params);
return accounts.length === 1 ? (accounts[0] ?? null) : null;
}
function publicAccount(account: PairingAccount) {
const adapter = account.plugin.pairing;
if (!adapter) {
throw new Error(`Channel ${account.plugin.id} does not support pairing`);
}
return {
channel: account.plugin.id,
channelLabel: account.plugin.meta.label,
accountId: account.accountId,
...(account.accountLabel ? { accountLabel: account.accountLabel } : {}),
notifySupported: Boolean(adapter.notifyApproval),
};
}
function publicRequest(params: {
account: PairingAccount;
request: Awaited<ReturnType<typeof listChannelPairingRequests>>[number];
}): ChannelsPairingRequest {
const adapter = params.account.plugin.pairing;
if (!adapter) {
throw new Error(`Channel ${params.account.plugin.id} does not support pairing`);
}
const metadata = params.request.meta
? Object.fromEntries(
Object.entries(params.request.meta).filter(([key, value]) => key !== "accountId" && value),
)
: undefined;
const createdAtMs = Date.parse(params.request.createdAt);
return {
requestId: resolveChannelPairingRequestId(params.account.plugin.id, params.request),
channel: params.account.plugin.id,
channelLabel: params.account.plugin.meta.label,
accountId: params.account.accountId,
...(params.account.accountLabel ? { accountLabel: params.account.accountLabel } : {}),
senderId: params.request.id,
senderLabel: adapter.idLabel,
...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
createdAt: params.request.createdAt,
lastSeenAt: params.request.lastSeenAt,
expiresAt: new Date(createdAtMs + CHANNEL_PAIRING_PENDING_TTL_MS).toISOString(),
notifySupported: Boolean(adapter.notifyApproval),
};
}
function invalidPairingAccount(respond: RespondFn, channel: string, accountId: string): void {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`channel account does not use DM pairing: ${channel}:${accountId}`,
),
);
}
function respondPairingFailure(respond: RespondFn, error: unknown): void {
respond(
false,
undefined,
errorShape(
error instanceof InvalidPairingTargetError
? ErrorCodes.INVALID_REQUEST
: ErrorCodes.UNAVAILABLE,
formatForLog(error),
),
);
}
export const channelPairingHandlers: GatewayRequestHandlers = {
"channels.pairing.list": async ({ params, respond, context }) => {
if (
!assertValidParams(
params,
validateChannelsPairingListParams,
"channels.pairing.list",
respond,
)
) {
return;
}
try {
const parsed = params as ChannelsPairingListParams;
const cfg = resolveGatewayPluginConfig({ config: context.getRuntimeConfig() });
const accounts = await listPairingAccounts({
cfg,
...(parsed.channel ? { channel: parsed.channel } : {}),
...(parsed.accountId ? { accountId: parsed.accountId } : {}),
});
const requests: ChannelsPairingRequest[] = [];
for (const account of accounts) {
const pending = await listChannelPairingRequests(
account.plugin.id,
process.env,
account.accountId,
);
requests.push(...pending.map((request) => publicRequest({ account, request })));
}
respond(
true,
{
accounts: accounts.map(publicAccount),
requests,
commandOwnerConfigured: hasConfiguredCommandOwners(cfg),
limits: {
pendingPerAccount: CHANNEL_PAIRING_PENDING_MAX,
ttlMs: CHANNEL_PAIRING_PENDING_TTL_MS,
},
},
undefined,
);
} catch (error) {
respondPairingFailure(respond, error);
}
},
"channels.pairing.approve": async ({ params, respond, context }) => {
if (
!assertValidParams(
params,
validateChannelsPairingApproveParams,
"channels.pairing.approve",
respond,
)
) {
return;
}
const parsed = params as ChannelsPairingApproveParams;
let cfg: OpenClawConfig;
let account: PairingAccount | null;
try {
cfg = resolveGatewayPluginConfig({ config: context.getRuntimeConfig() });
account = await resolvePairingAccount({
cfg,
channel: parsed.channel,
accountId: parsed.accountId,
});
} catch (error) {
respondPairingFailure(respond, error);
return;
}
if (!account?.plugin.pairing) {
invalidPairingAccount(respond, parsed.channel, parsed.accountId);
return;
}
try {
const approved = await approveChannelPairingRequest({
channel: account.plugin.id,
accountId: account.accountId,
requestId: parsed.requestId,
pairingAdapter: account.plugin.pairing,
});
if (!approved) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "pending DM access request no longer exists"),
);
return;
}
let commandOwnerBootstrap: ChannelsPairingApproveResult["commandOwnerBootstrap"] =
"not-requested";
if (parsed.bootstrapCommandOwner === true) {
try {
commandOwnerBootstrap = (
await bootstrapCommandOwnerFromPairing({
channel: account.plugin.id,
id: approved.id,
})
).status;
} catch (error) {
context.logGateway.warn(
`DM pairing command-owner bootstrap failed channel=${account.plugin.id} account=${account.accountId}: ${formatForLog(error)}`,
);
commandOwnerBootstrap = "unavailable";
}
}
let notification: "not-requested" | "sent" | "unsupported" | "failed" = "not-requested";
if (parsed.notify === true) {
if (!account.plugin.pairing.notifyApproval) {
notification = "unsupported";
} else {
try {
await notifyPairingApproved({
channelId: account.plugin.id,
accountId: account.accountId,
id: approved.id,
cfg,
pairingAdapter: account.plugin.pairing,
});
notification = "sent";
} catch (error) {
context.logGateway.warn(
`DM pairing approval notification failed channel=${account.plugin.id} account=${account.accountId}: ${formatForLog(error)}`,
);
notification = "failed";
}
}
}
respond(
true,
{
requestId: parsed.requestId,
senderId: approved.id,
notification,
commandOwnerBootstrap,
},
undefined,
);
} catch (error) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(error)));
}
},
"channels.pairing.dismiss": async ({ params, respond, context }) => {
if (
!assertValidParams(
params,
validateChannelsPairingDismissParams,
"channels.pairing.dismiss",
respond,
)
) {
return;
}
const parsed = params as ChannelsPairingDismissParams;
let account: PairingAccount | null;
try {
const cfg = resolveGatewayPluginConfig({ config: context.getRuntimeConfig() });
account = await resolvePairingAccount({
cfg,
channel: parsed.channel,
accountId: parsed.accountId,
});
} catch (error) {
respondPairingFailure(respond, error);
return;
}
if (!account) {
invalidPairingAccount(respond, parsed.channel, parsed.accountId);
return;
}
try {
const dismissed = await dismissChannelPairingRequest({
channel: account.plugin.id,
accountId: account.accountId,
requestId: parsed.requestId,
});
if (!dismissed) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "pending DM access request no longer exists"),
);
return;
}
respond(true, { requestId: parsed.requestId, senderId: dismissed.id }, undefined);
} catch (error) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(error)));
}
},
};

View File

@@ -0,0 +1,41 @@
// Shared first-owner bootstrap for DM pairing approval surfaces.
import {
formatCommandOwnerFromChannelSender,
hasConfiguredCommandOwners,
} from "../commands/doctor-command-owner.js";
import { readConfigFileSnapshotForWrite, replaceConfigFile } from "../config/config.js";
import type { PairingChannel } from "./pairing-store.types.js";
type PairingCommandOwnerBootstrapResult = {
ownerEntry: string | null;
status: "configured" | "already-configured" | "unavailable";
};
/** Adds the approved sender as command owner only when no owner exists yet. */
export async function bootstrapCommandOwnerFromPairing(params: {
channel: PairingChannel;
id: string;
}): Promise<PairingCommandOwnerBootstrapResult> {
const ownerEntry = formatCommandOwnerFromChannelSender(params);
if (!ownerEntry) {
return { ownerEntry: null, status: "unavailable" };
}
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
if (hasConfiguredCommandOwners(snapshot.sourceConfig)) {
return { ownerEntry, status: "already-configured" };
}
const nextConfig = structuredClone(snapshot.sourceConfig);
nextConfig.commands = {
...nextConfig.commands,
ownerAllowFrom: [ownerEntry],
};
await replaceConfigFile({
nextConfig,
snapshot,
writeOptions,
afterWrite: { mode: "auto" },
});
return { ownerEntry, status: "configured" };
}

View File

@@ -29,10 +29,13 @@ import {
import {
addChannelAllowFromStoreEntry,
approveChannelPairingCode,
approveChannelPairingRequest,
dismissChannelPairingRequest,
listChannelPairingRequests,
readChannelAllowFromStore,
readChannelAllowFromStoreSync,
removeChannelAllowFromStoreEntry,
resolveChannelPairingRequestId,
upsertChannelPairingRequest,
} from "./pairing-store.js";
@@ -272,6 +275,63 @@ describe("pairing store", () => {
await expect(readChannelAllowFromStore("demo-a", env)).resolves.toEqual(["fixture-entry"]);
});
it("approves and dismisses account-scoped requests by opaque id", async () => {
const { env } = createTestEnv();
await upsertChannelPairingRequest({
channel: "telegram",
accountId: "alpha",
id: "shared-sender",
env,
});
await upsertChannelPairingRequest({
channel: "telegram",
accountId: "beta",
id: "shared-sender",
env,
});
const alphaRequest = requireFirstPairingRequest(
await listChannelPairingRequests("telegram", env, "alpha"),
);
const betaRequest = requireFirstPairingRequest(
await listChannelPairingRequests("telegram", env, "beta"),
);
const alphaRequestId = resolveChannelPairingRequestId("telegram", alphaRequest);
const betaRequestId = resolveChannelPairingRequestId("telegram", betaRequest);
expect(alphaRequestId).not.toBe(betaRequestId);
expect(alphaRequestId).not.toContain(alphaRequest.code);
await expect(
approveChannelPairingRequest({
channel: "telegram",
accountId: "alpha",
requestId: alphaRequestId,
env,
}),
).resolves.toMatchObject({ id: "shared-sender" });
await expect(readChannelAllowFromStore("telegram", env, "alpha")).resolves.toEqual([
"shared-sender",
]);
await expect(
approveChannelPairingRequest({
channel: "telegram",
accountId: "beta",
requestId: alphaRequestId,
env,
}),
).resolves.toBeNull();
await expect(
dismissChannelPairingRequest({
channel: "telegram",
accountId: "beta",
requestId: betaRequestId,
env,
}),
).resolves.toMatchObject({ id: "shared-sender" });
await expect(readChannelAllowFromStore("telegram", env, "beta")).resolves.toEqual([]);
await expect(listChannelPairingRequests("telegram", env)).resolves.toEqual([]);
});
it("regenerates colliding codes and reports exhaustion without leaking codes", async () => {
const { env } = createTestEnv();
await withMockRandomInt({

View File

@@ -40,8 +40,8 @@ export function resolveChannelAllowFromPath(
const PAIRING_CODE_LENGTH = 8;
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const PAIRING_CODE_MAX_ATTEMPTS = 500;
const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
const PAIRING_PENDING_MAX = 3;
export const CHANNEL_PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
export const CHANNEL_PAIRING_PENDING_MAX = 3;
export type PairingRequest = {
id: string;
@@ -51,6 +51,19 @@ export type PairingRequest = {
meta?: Record<string, string>;
};
/** Stable opaque id for approving a request without exposing its human pairing code. */
export function resolveChannelPairingRequestId(
channel: PairingChannel,
request: PairingRequest,
): string {
const accountId = resolvePairingRequestAccountId(request);
return crypto
.createHash("sha256")
.update(`${channel}\0${accountId}\0${request.id}\0${request.createdAt}`)
.digest("base64url")
.slice(0, 32);
}
function parseTimestamp(value: string | undefined): number | null {
if (!value) {
return null;
@@ -61,7 +74,7 @@ function parseTimestamp(value: string | undefined): number | null {
function isExpired(entry: PairingRequest, nowMs: number): boolean {
const createdAt = parseTimestamp(entry.createdAt);
return createdAt === null || nowMs - createdAt > PAIRING_PENDING_TTL_MS;
return createdAt === null || nowMs - createdAt > CHANNEL_PAIRING_PENDING_TTL_MS;
}
function pruneExpiredRequests(reqs: PairingRequest[], nowMs: number) {
@@ -263,7 +276,7 @@ export async function listChannelPairingRequests(
return runOpenClawStateWriteTransaction((database) => {
const state = readChannelPairingStateFromDatabase(database, channel);
const expired = pruneExpiredRequests(state.requests, Date.now());
const capped = pruneExcessRequestsByAccount(expired.requests, PAIRING_PENDING_MAX);
const capped = pruneExcessRequestsByAccount(expired.requests, CHANNEL_PAIRING_PENDING_MAX);
if (expired.removed || capped.removed) {
state.requests = capped.requests;
writeChannelPairingStateToDatabase(database, channel, state);
@@ -326,17 +339,17 @@ export async function upsertChannelPairingRequest(params: {
lastSeenAt: now,
meta,
};
state.requests = pruneExcessRequestsByAccount(requests, PAIRING_PENDING_MAX).requests;
state.requests = pruneExcessRequestsByAccount(requests, CHANNEL_PAIRING_PENDING_MAX).requests;
writeChannelPairingStateToDatabase(database, params.channel, state);
return { code, created: false };
}
const capped = pruneExcessRequestsByAccount(requests, PAIRING_PENDING_MAX);
const capped = pruneExcessRequestsByAccount(requests, CHANNEL_PAIRING_PENDING_MAX);
requests = capped.requests;
const accountRequestCount = requests.filter((request) =>
requestMatchesAccountId(request, accountId),
).length;
if (PAIRING_PENDING_MAX > 0 && accountRequestCount >= PAIRING_PENDING_MAX) {
if (CHANNEL_PAIRING_PENDING_MAX > 0 && accountRequestCount >= CHANNEL_PAIRING_PENDING_MAX) {
if (expired.removed || capped.removed) {
state.requests = requests;
writeChannelPairingStateToDatabase(database, params.channel, state);
@@ -351,26 +364,25 @@ export async function upsertChannelPairingRequest(params: {
}, sqliteOptionsForEnv(env));
}
export async function approveChannelPairingCode(params: {
type ResolvePairingRequestParams = {
channel: PairingChannel;
code: string;
accountId?: string;
env?: NodeJS.ProcessEnv;
pairingAdapter?: ChannelPairingAdapter;
}): Promise<{ id: string; entry?: PairingRequest } | null> {
const env = params.env ?? process.env;
const code = (normalizeNullableString(params.code) ?? "").toUpperCase();
if (!code) {
return null;
}
matches: (request: PairingRequest) => boolean;
approve: boolean;
};
async function resolveChannelPairingRequest(
params: ResolvePairingRequestParams,
): Promise<{ id: string; entry: PairingRequest } | null> {
const env = params.env ?? process.env;
return runOpenClawStateWriteTransaction((database) => {
const state = readChannelPairingStateFromDatabase(database, params.channel);
const pruned = pruneExpiredRequests(state.requests, Date.now());
const accountId = normalizePairingAccountId(params.accountId);
const index = pruned.requests.findIndex(
(request) =>
request.code.toUpperCase() === code && requestMatchesAccountId(request, accountId),
(request) => requestMatchesAccountId(request, accountId) && params.matches(request),
);
if (index < 0) {
if (pruned.removed) {
@@ -385,26 +397,87 @@ export async function approveChannelPairingCode(params: {
}
pruned.requests.splice(index, 1);
state.requests = pruned.requests;
const allowAccountId = resolveAllowFromAccountId(
normalizeOptionalString(params.accountId) ?? normalizeOptionalString(entry.meta?.accountId),
);
const currentAllow = state.allowFrom?.[allowAccountId] ?? [];
const adapter = resolvePairingAdapter(params.channel, params.pairingAdapter);
// Channels with key-bound handoffs can persist an opaque approval token
// derived from request metadata instead of a durable sender allowlist id.
const approvalEntry = adapter?.resolveApprovalStoreEntry
? adapter.resolveApprovalStoreEntry({
id: entry.id,
...(entry.meta ? { meta: entry.meta } : {}),
})
: entry.id;
const normalizedAllow =
approvalEntry == null ? "" : normalizeAllowFromInput(params.channel, approvalEntry, adapter);
if (normalizedAllow && !currentAllow.includes(normalizedAllow)) {
state.allowFrom ??= {};
state.allowFrom[allowAccountId] = [...currentAllow, normalizedAllow];
if (params.approve) {
const allowAccountId = resolveAllowFromAccountId(
normalizeOptionalString(params.accountId) ?? normalizeOptionalString(entry.meta?.accountId),
);
const currentAllow = state.allowFrom?.[allowAccountId] ?? [];
const adapter = resolvePairingAdapter(params.channel, params.pairingAdapter);
// Channels with key-bound handoffs can persist an opaque approval token
// derived from request metadata instead of a durable sender allowlist id.
const approvalEntry = adapter?.resolveApprovalStoreEntry
? adapter.resolveApprovalStoreEntry({
id: entry.id,
...(entry.meta ? { meta: entry.meta } : {}),
})
: entry.id;
const normalizedAllow =
approvalEntry == null
? ""
: normalizeAllowFromInput(params.channel, approvalEntry, adapter);
if (normalizedAllow && !currentAllow.includes(normalizedAllow)) {
state.allowFrom ??= {};
state.allowFrom[allowAccountId] = [...currentAllow, normalizedAllow];
}
}
writeChannelPairingStateToDatabase(database, params.channel, state);
return { id: entry.id, entry };
}, sqliteOptionsForEnv(env));
}
export async function approveChannelPairingCode(params: {
channel: PairingChannel;
code: string;
accountId?: string;
env?: NodeJS.ProcessEnv;
pairingAdapter?: ChannelPairingAdapter;
}): Promise<{ id: string; entry: PairingRequest } | null> {
const code = (normalizeNullableString(params.code) ?? "").toUpperCase();
if (!code) {
return null;
}
return resolveChannelPairingRequest({
...params,
matches: (request) => request.code.toUpperCase() === code,
approve: true,
});
}
/** Approves a pending request by opaque id without exposing its pairing code. */
export async function approveChannelPairingRequest(params: {
channel: PairingChannel;
requestId: string;
accountId: string;
env?: NodeJS.ProcessEnv;
pairingAdapter?: ChannelPairingAdapter;
}): Promise<{ id: string; entry: PairingRequest } | null> {
const requestId = normalizeOptionalString(params.requestId);
if (!requestId) {
return null;
}
return resolveChannelPairingRequest({
...params,
matches: (request) => resolveChannelPairingRequestId(params.channel, request) === requestId,
approve: true,
});
}
/** Dismisses a pending request without blocking the sender from requesting again. */
export async function dismissChannelPairingRequest(params: {
channel: PairingChannel;
requestId: string;
accountId: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ id: string; entry: PairingRequest } | null> {
const requestId = normalizeOptionalString(params.requestId);
if (!requestId) {
return null;
}
return resolveChannelPairingRequest({
...params,
matches: (request) => resolveChannelPairingRequestId(params.channel, request) === requestId,
approve: false,
});
}

View File

@@ -16,6 +16,14 @@ import type {
export type { ConfigUiHint, ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
export type { SessionGoal } from "../../../src/config/sessions/types.js";
export type { FastMode } from "@openclaw/normalization-core/string-coerce";
export type ChannelsPairingAccount =
import("../../../packages/gateway-protocol/src/index.js").ChannelsPairingAccount;
export type ChannelsPairingApproveResult =
import("../../../packages/gateway-protocol/src/index.js").ChannelsPairingApproveResult;
export type ChannelsPairingListResult =
import("../../../packages/gateway-protocol/src/index.js").ChannelsPairingListResult;
export type ChannelsPairingRequest =
import("../../../packages/gateway-protocol/src/index.js").ChannelsPairingRequest;
export type ChannelsStatusSnapshot = {
ts: number;
channelOrder: string[];

View File

@@ -1,6 +1,16 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { hasOperatorApprovalsAccess } from "./operator-access.ts";
import { hasOperatorApprovalsAccess, hasOperatorPairingAccess } from "./operator-access.ts";
describe("hasOperatorPairingAccess", () => {
it("requires pairing scope while keeping admin and legacy auth compatible", () => {
expect(hasOperatorPairingAccess(null)).toBe(false);
expect(hasOperatorPairingAccess({ role: "operator" })).toBe(true);
expect(hasOperatorPairingAccess({ role: "operator", scopes: ["operator.read"] })).toBe(false);
expect(hasOperatorPairingAccess({ role: "operator", scopes: ["operator.pairing"] })).toBe(true);
expect(hasOperatorPairingAccess({ role: "operator", scopes: ["operator.admin"] })).toBe(true);
});
});
describe("hasOperatorApprovalsAccess", () => {
it("requires the approval scope when the gateway advertises scopes", () => {

View File

@@ -27,6 +27,22 @@ export function hasOperatorAdminAccess(
});
}
export function hasOperatorPairingAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {
if (!auth) {
return false;
}
if (!auth.scopes) {
return true;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: ["operator.pairing"],
allowedScopes: auth.scopes,
});
}
export function hasOperatorApprovalsAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {

View File

@@ -159,6 +159,54 @@ export const en: TranslationMap = {
saveBeforeSetup:
"You have unsaved channel config changes. Save or reload them before running guided setup.",
},
pairing: {
title: "DM access requests",
subtitle: "Review people waiting to send direct messages to pairing-protected channels.",
channelFilter: "Channel",
accountFilter: "Account",
allChannels: "All channels",
allAccounts: "All accounts",
approve: "Approve",
dismiss: "Dismiss",
approveAria: "Approve {sender} for {channel}, account {account}",
dismissAria: "Dismiss {sender} for {channel}, account {account}",
requested: "Requested {ago}",
expires: "Expires {ago}",
senderDetails: "Sender details",
missingPermission:
"This connection does not have operator.pairing access, so DM requests cannot be reviewed.",
noAccounts: "No configured channel accounts use DM sender pairing.",
noRequests: "No pending DM access requests.",
noFilteredRequests: "No pending requests match these filters.",
limits:
"Requests expire after {minutes} minutes. Each channel account can hold up to {count} pending requests.",
detailTitle: "DM access",
detailSubtitle: "People must be approved before their direct messages reach the agent.",
pendingCount: "{count} pending",
noPending: "No pending requests",
review: "Review requests",
approveDialogTitle: "Approve DM access",
dismissDialogTitle: "Dismiss DM access request",
approveExplanation:
"This lets the sender talk to the agent in direct messages. It does not grant group access.",
dismissExplanation:
"This removes the current request but does not block the sender. They can request access again later.",
notifyRequester: "Notify the requester after approval",
makeCommandOwner: "Also make this sender the first command owner",
commandOwnerHelp:
"Command owners can run privileged commands and approve dangerous actions. This option is only available while no owner is configured.",
commandOwnerNeedsAdmin:
"No command owner is configured. This connection needs operator.admin to assign the first owner.",
approvedNotice: "DM access approved.",
approvedOwnerNotice: "DM access approved and the first command owner was configured.",
approvedNotificationFailedNotice:
"DM access approved, but the requester notification could not be delivered.",
approvedOwnerFailedNotice:
"DM access approved, but the first command owner could not be configured.",
approvedFollowupsFailedNotice:
"DM access approved, but requester notification and command-owner setup both failed.",
dismissedNotice: "DM access request dismissed. The sender can request access again.",
},
setup: {
dialogLabel: "Set up {channel}",
title: "Set up {channel}",

View File

@@ -1,6 +1,6 @@
// Channels domain tests.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelsStatusSnapshot } from "../../api/types.ts";
import type { ChannelsPairingListResult, ChannelsStatusSnapshot } from "../../api/types.ts";
import { createChannelCapability } from "./index.ts";
function createDeferred<T>() {
@@ -98,6 +98,106 @@ describe("channels controller WhatsApp wait", () => {
channels.dispose();
});
it("keeps an active login wait across pairing-scope changes", async () => {
const pending = createDeferred<{
message: string;
connected: boolean;
qrDataUrl: string;
}>();
const request = vi.fn((method: string) =>
method === "web.login.wait"
? pending.promise
: Promise.resolve(createChannelsSnapshot("refreshed")),
);
const client = { request };
let snapshot = {
client,
connected: true,
hello: { auth: { role: "operator", scopes: ["operator.pairing"] } },
};
const listeners = new Set<(next: typeof snapshot) => void>();
const channels = createChannelCapability({
get snapshot() {
return snapshot;
},
subscribe(listener: (next: typeof snapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
} as never);
const wait = channels.waitWhatsApp();
await vi.waitFor(() => expect(channels.state.whatsappBusy).toBe(true));
snapshot = {
...snapshot,
hello: { auth: { role: "operator", scopes: ["operator.pairing", "operator.read"] } },
};
for (const listener of listeners) {
listener(snapshot);
}
expect(channels.state.whatsappBusy).toBe(true);
pending.resolve({
message: "login survived scope change",
connected: false,
qrDataUrl: "data:image/png;base64,scope-change",
});
await wait;
expect(channels.state.whatsappLoginMessage).toBe("login survived scope change");
expect(channels.state.whatsappLoginQrDataUrl).toBe("data:image/png;base64,scope-change");
channels.dispose();
});
it("rejects an active login result when admin access is revoked", async () => {
const pending = createDeferred<{
message: string;
connected: boolean;
qrDataUrl: string;
}>();
const request = vi.fn(() => pending.promise);
const client = { request };
let snapshot = {
client,
connected: true,
hello: { auth: { role: "operator", scopes: ["operator.admin", "operator.pairing"] } },
};
const listeners = new Set<(next: typeof snapshot) => void>();
const channels = createChannelCapability({
get snapshot() {
return snapshot;
},
subscribe(listener: (next: typeof snapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
} as never);
channels.state.whatsappLoginQrDataUrl = "data:image/png;base64,existing";
const wait = channels.waitWhatsApp();
await vi.waitFor(() => expect(channels.state.whatsappBusy).toBe(true));
snapshot = {
...snapshot,
hello: { auth: { role: "operator", scopes: ["operator.pairing"] } },
};
for (const listener of listeners) {
listener(snapshot);
}
expect(channels.state.whatsappBusy).toBe(false);
expect(channels.state.whatsappLoginQrDataUrl).toBeNull();
pending.resolve({
message: "stale login",
connected: true,
qrDataUrl: "data:image/png;base64,stale",
});
await wait;
expect(channels.state.whatsappLoginMessage).toBeNull();
expect(channels.state.whatsappLoginQrDataUrl).toBeNull();
channels.dispose();
});
it("does not apply or refresh a login result after its capability is disposed", async () => {
const pending = createDeferred<{
message: string;
@@ -209,7 +309,330 @@ describe("channels controller WhatsApp logout", () => {
});
});
describe("channels controller DM pairing", () => {
const emptyPairing: ChannelsPairingListResult = {
accounts: [],
requests: [],
commandOwnerConfigured: true,
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
};
const pendingPairing: ChannelsPairingListResult = {
...emptyPairing,
accounts: [
{
channel: "whatsapp",
channelLabel: "WhatsApp",
accountId: "personal",
notifySupported: true,
},
],
requests: [
{
requestId: "request-1",
channel: "whatsapp",
channelLabel: "WhatsApp",
accountId: "personal",
senderId: "+1555",
senderLabel: "Phone number",
createdAt: "2026-07-20T10:00:00.000Z",
lastSeenAt: "2026-07-20T10:00:00.000Z",
expiresAt: "2026-07-20T11:00:00.000Z",
notifySupported: true,
},
],
};
it("loads pending requests and refreshes after approval", async () => {
let listCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "channels.pairing.list") {
listCount += 1;
return listCount === 1 ? pendingPairing : emptyPairing;
}
if (method === "channels.pairing.approve") {
return {
requestId: "request-1",
senderId: "+1555",
notification: "sent",
commandOwnerBootstrap: "not-requested",
};
}
return {};
});
const channels = createChannelCapability({
snapshot: { client: { request }, connected: true },
subscribe: () => () => undefined,
} as never);
await channels.refreshPairing();
expect(channels.state.pairingSnapshot?.requests).toHaveLength(1);
const result = await channels.approvePairing({
channel: "whatsapp",
accountId: "personal",
requestId: "request-1",
notify: true,
bootstrapCommandOwner: false,
});
expect(result?.notification).toBe("sent");
expect(request).toHaveBeenCalledWith("channels.pairing.approve", {
channel: "whatsapp",
accountId: "personal",
requestId: "request-1",
notify: true,
bootstrapCommandOwner: false,
});
expect(channels.state.pairingSnapshot?.requests).toEqual([]);
expect(channels.state.pairingBusyRequestId).toBeNull();
channels.dispose();
});
it("keeps the row resolved when refresh fails and blocks polling during mutation", async () => {
const approvalResult = createDeferred<{
requestId: string;
senderId: string;
notification: "not-requested";
commandOwnerBootstrap: "not-requested";
}>();
let listCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "channels.pairing.list") {
listCount += 1;
if (listCount === 1) {
return pendingPairing;
}
throw new Error("refresh unavailable");
}
return approvalResult.promise;
});
const channels = createChannelCapability({
snapshot: { client: { request }, connected: true },
subscribe: () => () => undefined,
} as never);
await channels.refreshPairing();
const approval = channels.approvePairing({
channel: "whatsapp",
accountId: "personal",
requestId: "request-1",
notify: false,
bootstrapCommandOwner: false,
});
await vi.waitFor(() => expect(channels.state.pairingBusyRequestId).toBe("request-1"));
await channels.refreshPairing();
expect(listCount).toBe(1);
approvalResult.resolve({
requestId: "request-1",
senderId: "+1555",
notification: "not-requested",
commandOwnerBootstrap: "not-requested",
});
await approval;
expect(channels.state.pairingSnapshot?.requests).toEqual([]);
expect(channels.state.pairingError).toBe("Error: refresh unavailable");
channels.dispose();
});
it("does not let a pre-approval list restore the resolved request", async () => {
const staleList = createDeferred<ChannelsPairingListResult>();
const freshList = createDeferred<ChannelsPairingListResult>();
let listCount = 0;
const request = vi.fn(async (method: string) => {
if (method === "channels.pairing.list") {
listCount += 1;
return listCount === 1 ? staleList.promise : freshList.promise;
}
return {
requestId: "request-1",
senderId: "+1555",
notification: "not-requested",
commandOwnerBootstrap: "not-requested",
};
});
const channels = createChannelCapability({
snapshot: { client: { request }, connected: true },
subscribe: () => () => undefined,
} as never);
const staleRefresh = channels.refreshPairing();
await vi.waitFor(() => expect(listCount).toBe(1));
const approval = channels.approvePairing({
channel: "whatsapp",
accountId: "personal",
requestId: "request-1",
notify: false,
bootstrapCommandOwner: false,
});
await vi.waitFor(() => expect(listCount).toBe(2));
freshList.resolve(emptyPairing);
await approval;
staleList.resolve(pendingPairing);
await staleRefresh;
expect(channels.state.pairingSnapshot?.requests).toEqual([]);
channels.dispose();
});
it("clears sender metadata on disconnect and pairing-scope changes", () => {
const client = { request: vi.fn() };
let snapshot = {
client,
connected: true,
hello: { auth: { role: "operator", scopes: ["operator.pairing"] } },
};
const listeners = new Set<(next: typeof snapshot) => void>();
const channels = createChannelCapability({
get snapshot() {
return snapshot;
},
subscribe(listener: (next: typeof snapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
} as never);
channels.state.pairingSnapshot = pendingPairing;
snapshot = {
...snapshot,
hello: { auth: { role: "operator", scopes: ["operator.read"] } },
};
for (const listener of listeners) {
listener(snapshot);
}
expect(channels.state.pairingSnapshot).toBeNull();
channels.state.pairingSnapshot = pendingPairing;
snapshot = { ...snapshot, connected: false };
for (const listener of listeners) {
listener(snapshot);
}
expect(channels.state.pairingSnapshot).toBeNull();
channels.dispose();
});
it("ignores a pairing mutation result from an earlier authorization epoch", async () => {
const approval = createDeferred<{
requestId: string;
senderId: string;
notification: "not-requested";
commandOwnerBootstrap: "not-requested";
}>();
const request = vi.fn(async (method: string) => {
if (method === "channels.pairing.approve") {
return approval.promise;
}
return emptyPairing;
});
const client = { request };
let snapshot = {
client,
connected: true,
hello: { auth: { role: "operator", scopes: ["operator.pairing"] } },
};
const listeners = new Set<(next: typeof snapshot) => void>();
const channels = createChannelCapability({
get snapshot() {
return snapshot;
},
subscribe(listener: (next: typeof snapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
} as never);
channels.state.pairingSnapshot = pendingPairing;
const pendingApproval = channels.approvePairing({
channel: "whatsapp",
accountId: "personal",
requestId: "request-1",
notify: false,
bootstrapCommandOwner: false,
});
await vi.waitFor(() => expect(channels.state.pairingBusyRequestId).toBe("request-1"));
snapshot = {
...snapshot,
hello: { auth: { role: "operator", scopes: ["operator.pairing", "operator.read"] } },
};
for (const listener of listeners) {
listener(snapshot);
}
channels.state.pairingSnapshot = pendingPairing;
approval.resolve({
requestId: "request-1",
senderId: "+1555",
notification: "not-requested",
commandOwnerBootstrap: "not-requested",
});
await expect(pendingApproval).resolves.toBeNull();
expect(channels.state.pairingSnapshot).toBe(pendingPairing);
expect(request.mock.calls.filter(([method]) => method === "channels.pairing.list")).toEqual([]);
channels.dispose();
});
it("keeps the last request snapshot visible when refresh fails", async () => {
const request = vi.fn(async () => {
throw new Error("gateway unavailable");
});
const channels = createChannelCapability({
snapshot: { client: { request }, connected: true },
subscribe: () => () => undefined,
} as never);
channels.state.pairingSnapshot = emptyPairing;
await channels.refreshPairing();
expect(channels.state.pairingSnapshot).toBe(emptyPairing);
expect(channels.state.pairingError).toBe("Error: gateway unavailable");
channels.dispose();
});
});
describe("channel refresh sequencing", () => {
it("rejects an in-flight channel snapshot after read access is revoked", async () => {
const pending = createDeferred<ChannelsStatusSnapshot | null>();
const request = vi.fn(() => pending.promise);
const client = { request };
let snapshot = {
client,
connected: true,
hello: { auth: { role: "operator", scopes: ["operator.read"] } },
};
const listeners = new Set<(next: typeof snapshot) => void>();
const channels = createChannelCapability({
get snapshot() {
return snapshot;
},
subscribe(listener: (next: typeof snapshot) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
} as never);
const refresh = channels.refresh();
await vi.waitFor(() => expect(channels.state.channelsLoading).toBe(true));
snapshot = {
...snapshot,
hello: { auth: { role: "operator", scopes: ["operator.pairing"] } },
};
for (const listener of listeners) {
listener(snapshot);
}
expect(channels.state.channelsLoading).toBe(false);
expect(channels.state.channelsSnapshot).toBeNull();
pending.resolve(createChannelsSnapshot("stale"));
await refresh;
expect(channels.state.channelsSnapshot).toBeNull();
channels.dispose();
});
it("keeps a stale slow probe from replacing a newer runtime snapshot", async () => {
const slowProbe = createDeferred<ChannelsStatusSnapshot | null>();
const fastRuntime = createDeferred<ChannelsStatusSnapshot | null>();

View File

@@ -1,4 +1,9 @@
import type { ChannelsStatusSnapshot } from "../../api/types.ts";
import { roleScopesAllow } from "../../../../src/shared/operator-scope-compat.ts";
import type {
ChannelsPairingApproveResult,
ChannelsPairingListResult,
ChannelsStatusSnapshot,
} from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
import {
formatMissingOperatorReadScopeMessage,
@@ -16,6 +21,9 @@ type ChannelLogoutResult = {
type ChannelGatewaySnapshot = {
client: ChannelGatewayClient | null;
connected: boolean;
hello?: {
auth?: { role?: string; scopes?: readonly string[] } | null;
} | null;
};
type ChannelGateway = {
@@ -32,6 +40,12 @@ type ChannelsState = {
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
channelsLastSuccess: number | null;
pairingLoading: boolean;
pairingRefreshSeq: number;
pairingSnapshot: ChannelsPairingListResult | null;
pairingError: string | null;
pairingLastSuccess: number | null;
pairingBusyRequestId: string | null;
whatsappLoginMessage: string | null;
whatsappLoginQrDataUrl: string | null;
whatsappLoginConnected: boolean | null;
@@ -45,6 +59,19 @@ type LoadChannelsOptions = {
export type ChannelCapability = {
readonly state: ChannelsState;
refresh: (probe?: boolean, options?: LoadChannelsOptions) => Promise<void>;
refreshPairing: () => Promise<void>;
approvePairing: (params: {
channel: string;
accountId: string;
requestId: string;
notify: boolean;
bootstrapCommandOwner: boolean;
}) => Promise<ChannelsPairingApproveResult | null>;
dismissPairing: (params: {
channel: string;
accountId: string;
requestId: string;
}) => Promise<boolean>;
startWhatsApp: (force: boolean, accountId?: string) => Promise<void>;
waitWhatsApp: (accountId?: string) => Promise<void>;
logoutWhatsApp: (accountId?: string) => Promise<void>;
@@ -52,6 +79,31 @@ export type ChannelCapability = {
dispose: () => void;
};
export function resolveChannelPairingAuthSignature(
snapshot: Partial<ChannelGatewaySnapshot>,
): string {
const auth = snapshot.hello?.auth;
return JSON.stringify({
role: auth?.role ?? null,
scopes: auth?.scopes ? [...auth.scopes].toSorted() : null,
});
}
function channelSnapshotAllowsScope(
snapshot: Partial<ChannelGatewaySnapshot>,
scope: string,
): boolean {
const auth = snapshot.hello?.auth;
if (!auth?.scopes) {
return true;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: [scope],
allowedScopes: auth.scopes,
});
}
function createInitialChannelsState(snapshot: Partial<ChannelGatewaySnapshot> = {}): ChannelsState {
return {
client: snapshot.client ?? null,
@@ -62,6 +114,12 @@ function createInitialChannelsState(snapshot: Partial<ChannelGatewaySnapshot> =
channelsSnapshot: null,
channelsError: null,
channelsLastSuccess: null,
pairingLoading: false,
pairingRefreshSeq: 0,
pairingSnapshot: null,
pairingError: null,
pairingLastSuccess: null,
pairingBusyRequestId: null,
whatsappLoginMessage: null,
whatsappLoginQrDataUrl: null,
whatsappLoginConnected: null,
@@ -140,14 +198,174 @@ async function loadChannels(
await refresh;
}
function isCurrentPairingRefresh(
state: ChannelsState,
client: ChannelGatewayClient,
refreshSeq: number,
): boolean {
return state.connected && state.client === client && state.pairingRefreshSeq === refreshSeq;
}
function invalidatePairingRefresh(state: ChannelsState): void {
// A mutation must supersede any list that started before it; otherwise that
// stale list can put the resolved request back until the next poll.
state.pairingRefreshSeq += 1;
state.pairingLoading = false;
}
async function loadChannelPairing(
state: ChannelsState,
options: { duringMutation?: boolean } = {},
): Promise<void> {
const client = state.client;
if (
!client ||
!state.connected ||
state.pairingLoading ||
(state.pairingBusyRequestId && !options.duringMutation)
) {
return;
}
const refreshSeq = state.pairingRefreshSeq + 1;
state.pairingRefreshSeq = refreshSeq;
state.pairingLoading = true;
state.pairingError = null;
try {
const snapshot = await client.request<ChannelsPairingListResult>("channels.pairing.list", {});
if (!isCurrentPairingRefresh(state, client, refreshSeq)) {
return;
}
state.pairingSnapshot = snapshot;
state.pairingLastSuccess = Date.now();
} catch (error) {
if (isCurrentPairingRefresh(state, client, refreshSeq)) {
state.pairingError = String(error);
}
} finally {
if (isCurrentPairingRefresh(state, client, refreshSeq)) {
state.pairingLoading = false;
}
}
}
type PairingMutation = {
client: ChannelGatewayClient;
pairingEpoch: number;
requestId: string;
};
function isCurrentPairingMutation(state: ChannelsState, mutation: PairingMutation): boolean {
return (
state.connected &&
state.client === mutation.client &&
getChannelsLifecycle(state).pairingEpoch === mutation.pairingEpoch &&
state.pairingBusyRequestId === mutation.requestId
);
}
function removePairingRequestFromSnapshot(state: ChannelsState, requestId: string): void {
const snapshot = state.pairingSnapshot;
if (!snapshot || !snapshot.requests.some((request) => request.requestId === requestId)) {
return;
}
state.pairingSnapshot = {
...snapshot,
requests: snapshot.requests.filter((request) => request.requestId !== requestId),
};
}
async function approveChannelPairing(
state: ChannelsState,
params: {
channel: string;
accountId: string;
requestId: string;
notify: boolean;
bootstrapCommandOwner: boolean;
},
): Promise<ChannelsPairingApproveResult | null> {
const client = state.client;
if (!client || !state.connected || state.pairingBusyRequestId) {
return null;
}
const mutation: PairingMutation = {
client,
pairingEpoch: getChannelsLifecycle(state).pairingEpoch,
requestId: params.requestId,
};
invalidatePairingRefresh(state);
state.pairingBusyRequestId = params.requestId;
state.pairingError = null;
try {
const result = await client.request<ChannelsPairingApproveResult>(
"channels.pairing.approve",
params,
);
if (!isCurrentPairingMutation(state, mutation)) {
return null;
}
removePairingRequestFromSnapshot(state, params.requestId);
invalidatePairingRefresh(state);
await loadChannelPairing(state, { duringMutation: true });
return isCurrentPairingMutation(state, mutation) ? result : null;
} catch (error) {
if (isCurrentPairingMutation(state, mutation)) {
state.pairingError = String(error);
}
return null;
} finally {
if (isCurrentPairingMutation(state, mutation)) {
state.pairingBusyRequestId = null;
}
}
}
async function dismissChannelPairing(
state: ChannelsState,
params: { channel: string; accountId: string; requestId: string },
): Promise<boolean> {
const client = state.client;
if (!client || !state.connected || state.pairingBusyRequestId) {
return false;
}
const mutation: PairingMutation = {
client,
pairingEpoch: getChannelsLifecycle(state).pairingEpoch,
requestId: params.requestId,
};
invalidatePairingRefresh(state);
state.pairingBusyRequestId = params.requestId;
state.pairingError = null;
try {
await client.request("channels.pairing.dismiss", params);
if (!isCurrentPairingMutation(state, mutation)) {
return false;
}
removePairingRequestFromSnapshot(state, params.requestId);
invalidatePairingRefresh(state);
await loadChannelPairing(state, { duringMutation: true });
return isCurrentPairingMutation(state, mutation);
} catch (error) {
if (isCurrentPairingMutation(state, mutation)) {
state.pairingError = String(error);
}
return false;
} finally {
if (isCurrentPairingMutation(state, mutation)) {
state.pairingBusyRequestId = null;
}
}
}
type WhatsAppOperation = {
client: ChannelGatewayClient;
gatewayEpoch: number;
whatsappEpoch: number;
operationSeq: number;
};
type ChannelsLifecycle = {
gatewayEpoch: number;
whatsappEpoch: number;
pairingEpoch: number;
whatsappOperationSeq: number;
};
@@ -158,7 +376,7 @@ function getChannelsLifecycle(state: ChannelsState): ChannelsLifecycle {
if (existing) {
return existing;
}
const created = { gatewayEpoch: 0, whatsappOperationSeq: 0 };
const created = { whatsappEpoch: 0, pairingEpoch: 0, whatsappOperationSeq: 0 };
channelsLifecycles.set(state, created);
return created;
}
@@ -172,7 +390,7 @@ function beginWhatsAppOperation(state: ChannelsState): WhatsAppOperation | null
const operationSeq = lifecycle.whatsappOperationSeq + 1;
lifecycle.whatsappOperationSeq = operationSeq;
state.whatsappBusy = true;
return { client, gatewayEpoch: lifecycle.gatewayEpoch, operationSeq };
return { client, whatsappEpoch: lifecycle.whatsappEpoch, operationSeq };
}
function isCurrentWhatsAppOperation(state: ChannelsState, operation: WhatsAppOperation): boolean {
@@ -180,7 +398,7 @@ function isCurrentWhatsAppOperation(state: ChannelsState, operation: WhatsAppOpe
return (
state.connected &&
state.client === operation.client &&
lifecycle.gatewayEpoch === operation.gatewayEpoch &&
lifecycle.whatsappEpoch === operation.whatsappEpoch &&
lifecycle.whatsappOperationSeq === operation.operationSeq
);
}
@@ -351,6 +569,9 @@ export function resolveChannelExtras(params: {
export function createChannelCapability(gateway: ChannelGateway): ChannelCapability {
const state = createInitialChannelsState(gateway.snapshot);
const listeners = new Set<(state: ChannelsState) => void>();
let currentChannelReadAccess = channelSnapshotAllowsScope(gateway.snapshot, "operator.read");
let currentPairingAuthSignature = resolveChannelPairingAuthSignature(gateway.snapshot);
let currentWhatsAppAdminAccess = channelSnapshotAllowsScope(gateway.snapshot, "operator.admin");
let disposed = false;
const publish = () => {
@@ -376,18 +597,48 @@ export function createChannelCapability(gateway: ChannelGateway): ChannelCapabil
const stopGateway = gateway.subscribe((snapshot) => {
const clientChanged = state.client !== snapshot.client;
const connectionChanged = state.connected !== snapshot.connected;
const nextChannelReadAccess = channelSnapshotAllowsScope(snapshot, "operator.read");
const channelReadAccessChanged = currentChannelReadAccess !== nextChannelReadAccess;
currentChannelReadAccess = nextChannelReadAccess;
const nextPairingAuthSignature = resolveChannelPairingAuthSignature(snapshot);
const pairingAuthChanged = currentPairingAuthSignature !== nextPairingAuthSignature;
currentPairingAuthSignature = nextPairingAuthSignature;
const nextWhatsAppAdminAccess = channelSnapshotAllowsScope(snapshot, "operator.admin");
const whatsappAdminAccessChanged = currentWhatsAppAdminAccess !== nextWhatsAppAdminAccess;
currentWhatsAppAdminAccess = nextWhatsAppAdminAccess;
state.client = snapshot.client;
state.connected = snapshot.connected;
if (clientChanged || connectionChanged) {
// Every transport epoch invalidates both channel loads and login work.
// A reconnect may reuse the same client object, so identity alone is insufficient.
const lifecycle = getChannelsLifecycle(state);
lifecycle.gatewayEpoch += 1;
lifecycle.whatsappOperationSeq += 1;
const lifecycle = getChannelsLifecycle(state);
if (clientChanged || connectionChanged || channelReadAccessChanged) {
state.channelsLoading = false;
state.channelsLoadingProbe = null;
state.whatsappBusy = false;
state.channelsRefreshSeq = (state.channelsRefreshSeq ?? 0) + 1;
if (!nextChannelReadAccess) {
state.channelsSnapshot = null;
state.channelsError = null;
state.channelsLastSuccess = null;
}
}
if (clientChanged || connectionChanged || whatsappAdminAccessChanged) {
lifecycle.whatsappEpoch += 1;
lifecycle.whatsappOperationSeq += 1;
state.whatsappBusy = false;
if (!nextWhatsAppAdminAccess) {
state.whatsappLoginMessage = null;
state.whatsappLoginQrDataUrl = null;
state.whatsappLoginConnected = null;
}
}
if (clientChanged || connectionChanged || pairingAuthChanged) {
// Pairing authorization has its own epoch so scope changes cannot cancel
// unrelated channel login/logout operations on the same connection.
lifecycle.pairingEpoch += 1;
state.pairingSnapshot = null;
state.pairingError = null;
state.pairingLastSuccess = null;
state.pairingLoading = false;
state.pairingBusyRequestId = null;
state.pairingRefreshSeq += 1;
}
publish();
});
@@ -397,6 +648,21 @@ export function createChannelCapability(gateway: ChannelGateway): ChannelCapabil
return state;
},
refresh: (probe, options) => run(() => loadChannels(state, probe ?? false, options)),
refreshPairing: () => run(() => loadChannelPairing(state)),
approvePairing: async (params) => {
let result: ChannelsPairingApproveResult | null = null;
await run(async () => {
result = await approveChannelPairing(state, params);
});
return result;
},
dismissPairing: async (params) => {
let dismissed = false;
await run(async () => {
dismissed = await dismissChannelPairing(state, params);
});
return dismissed;
},
startWhatsApp: (force, accountId) =>
run(async () => {
if (await startWhatsAppLogin(state, force, accountId)) {
@@ -425,8 +691,11 @@ export function createChannelCapability(gateway: ChannelGateway): ChannelCapabil
}
disposed = true;
const lifecycle = getChannelsLifecycle(state);
lifecycle.gatewayEpoch += 1;
lifecycle.whatsappEpoch += 1;
lifecycle.pairingEpoch += 1;
lifecycle.whatsappOperationSeq += 1;
state.pairingRefreshSeq += 1;
state.pairingBusyRequestId = null;
state.whatsappBusy = false;
stopGateway();
listeners.clear();

View File

@@ -14,6 +14,12 @@ type ChannelsPageTestElement = HTMLElement & {
requestUpdate: () => void;
};
type PairingTestPage = ChannelsPageTestElement & {
pairingAccountFilter: string | null;
pairingChannelFilter: string | null;
pairingPrompt: object | null;
};
type NostrTestPage = ChannelsPageTestElement & {
nostrProfileFormState: {
values: NostrProfile;
@@ -58,7 +64,18 @@ function stubHangingFetch() {
}
function createGateway(): TestGateway {
const client = { request: vi.fn(async () => ({})) } as unknown as GatewayBrowserClient;
const client = {
request: vi.fn(async (method: string) =>
method === "channels.pairing.list"
? {
accounts: [],
requests: [],
commandOwnerConfigured: true,
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
}
: {},
),
} as unknown as GatewayBrowserClient;
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
@@ -141,6 +158,44 @@ describe("ChannelsPage lifecycle", () => {
second.channels.dispose();
});
it("refreshes pairing data when the authorized scope set changes", async () => {
const gateway = createGateway();
gateway.emit({
hello: {
auth: { role: "operator", scopes: ["operator.pairing"] },
} as unknown as ApplicationGatewaySnapshot["hello"],
});
const source = createContext(gateway);
source.channels.state.pairingSnapshot = {
accounts: [],
requests: [],
commandOwnerConfigured: true,
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
};
const refreshPairing = vi.spyOn(source.channels, "refreshPairing").mockResolvedValue();
const page = document.createElement("openclaw-channels-page") as PairingTestPage;
page.context = source.context;
document.body.append(page);
await page.updateComplete;
refreshPairing.mockClear();
page.pairingPrompt = {};
page.pairingChannelFilter = "whatsapp";
page.pairingAccountFilter = "personal";
gateway.emit({
hello: {
auth: { role: "operator", scopes: ["operator.pairing", "operator.read"] },
} as unknown as ApplicationGatewaySnapshot["hello"],
});
await vi.waitFor(() => expect(refreshPairing).toHaveBeenCalled());
expect(page.pairingPrompt).toBeNull();
expect(page.pairingChannelFilter).toBeNull();
expect(page.pairingAccountFilter).toBeNull();
source.runtimeConfig.dispose();
source.channels.dispose();
});
it("drops a profile save when the channel source is replaced", async () => {
const gateway = createGateway();
const first = createContext(gateway);

View File

@@ -2,20 +2,31 @@ import { consume } from "@lit/context";
import { html } from "lit";
import { state } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { NostrProfile } from "../../api/types.ts";
import type {
ChannelsPairingListResult,
ChannelsPairingRequest,
NostrProfile,
} from "../../api/types.ts";
import { titleForRoute } from "../../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { resolveControlUiAuthHeader } from "../../app/control-ui-auth.ts";
import { hasOperatorAdminAccess, hasOperatorPairingAccess } from "../../app/operator-access.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { resolveChannelPairingAuthSignature } from "../../lib/channels/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { PollController } from "../../lit/poll-controller.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { importNostrProfile, parseValidationErrors, putNostrProfile } from "./nostr-profile-ops.ts";
import { createNostrProfileFormState } from "./view.nostr-profile-form.ts";
import { renderChannels } from "./view.ts";
import type { ChannelPairingPrompt } from "./view.types.ts";
import { ChannelWizardHost } from "./wizard-host.ts";
type NostrProfileFormState = ReturnType<typeof createNostrProfileFormState> | null;
const CHANNEL_PAIRING_POLL_INTERVAL_MS = 30_000;
const NOSTR_PROFILE_TIMEOUT_ERROR =
"Request timed out after 30 seconds; the server may still have applied the change — check the profile before retrying.";
@@ -48,6 +59,18 @@ class ChannelsPage extends OpenClawLightDomElement {
@state()
private selectedChannel: string | null = null;
@state()
private pairingChannelFilter: string | null = null;
@state()
private pairingAccountFilter: string | null = null;
@state()
private pairingPrompt: ChannelPairingPrompt | null = null;
@state()
private pairingNotice: string | null = null;
private readonly wizardHost = new ChannelWizardHost({
getContext: () => this.context,
requestUpdate: () => this.requestUpdate(),
@@ -61,8 +84,20 @@ class ChannelsPage extends OpenClawLightDomElement {
private channelsSource?: ApplicationContext["channels"];
private gatewayClient: GatewayBrowserClient | null = null;
private gatewayConnected = false;
private gatewayPairingAuthSignature: string | null = null;
private hasGatewaySnapshot = false;
private nostrOperationGeneration = 0;
private readonly pairingPolling = new PollController(
this,
CHANNEL_PAIRING_POLL_INTERVAL_MS,
() => {
const gateway = this.context?.gateway.snapshot;
if (gateway?.connected && hasOperatorPairingAccess(gateway.hello?.auth ?? null)) {
void this.context.channels.refreshPairing();
}
},
false,
);
private readonly subscriptions = new SubscriptionsController(this)
.effect(
@@ -75,6 +110,7 @@ class ChannelsPage extends OpenClawLightDomElement {
}
const handleChange = () => {
if (this.channelsSource === channels) {
this.reconcilePairingFilter(channels.state.pairingSnapshot);
this.requestUpdate();
}
};
@@ -123,22 +159,58 @@ class ChannelsPage extends OpenClawLightDomElement {
const clientChanged = this.hasGatewaySnapshot && this.gatewayClient !== snapshot.client;
const connectionChanged =
this.hasGatewaySnapshot && this.gatewayConnected !== snapshot.connected;
const pairingAccess = hasOperatorPairingAccess(snapshot.hello?.auth ?? null);
const pairingAuthSignature = resolveChannelPairingAuthSignature(snapshot);
const pairingAuthChanged =
this.hasGatewaySnapshot && this.gatewayPairingAuthSignature !== pairingAuthSignature;
if (!this.hasGatewaySnapshot || sourceChanged || clientChanged || connectionChanged) {
this.nostrOperationGeneration += 1;
}
if (sourceChanged || clientChanged || !snapshot.connected) {
this.clearNostrForm();
}
if (
sourceChanged ||
clientChanged ||
pairingAuthChanged ||
!snapshot.connected ||
!pairingAccess
) {
this.pairingPrompt = null;
this.pairingChannelFilter = null;
this.pairingAccountFilter = null;
this.pairingNotice = null;
}
this.hasGatewaySnapshot = true;
this.gatewayClient = snapshot.client;
this.gatewayConnected = snapshot.connected;
this.gatewayPairingAuthSignature = pairingAuthSignature;
this.syncPairingPolling(snapshot);
if (snapshot.connected && snapshot.client) {
this.ensureInitialData();
if (
(sourceChanged || clientChanged || connectionChanged || pairingAuthChanged) &&
pairingAccess
) {
void this.context.channels.refreshPairing();
}
} else {
this.schemaLoadStarted = false;
}
}
private syncPairingPolling(snapshot: ApplicationContext["gateway"]["snapshot"]) {
if (
snapshot.connected &&
snapshot.client &&
hasOperatorPairingAccess(snapshot.hello?.auth ?? null)
) {
this.pairingPolling.start();
return;
}
this.pairingPolling.stop();
}
private ensureInitialData() {
const context = this.context;
const gateway = context.gateway.snapshot;
@@ -152,6 +224,13 @@ class ChannelsPage extends OpenClawLightDomElement {
if (!channels.channelsSnapshot && !channels.channelsLoading) {
void context.channels.refresh(false);
}
if (
hasOperatorPairingAccess(gateway.hello?.auth ?? null) &&
!channels.pairingSnapshot &&
!channels.pairingLoading
) {
void context.channels.refreshPairing();
}
if (!config.configSnapshot && !config.configLoading) {
void context.runtimeConfig.ensureLoaded();
}
@@ -168,7 +247,13 @@ class ChannelsPage extends OpenClawLightDomElement {
this.channelsSource = undefined;
this.gatewayClient = null;
this.gatewayConnected = false;
this.gatewayPairingAuthSignature = null;
this.hasGatewaySnapshot = false;
this.pairingPrompt = null;
this.pairingChannelFilter = null;
this.pairingAccountFilter = null;
this.pairingNotice = null;
this.pairingPolling.stop();
this.invalidateNostrForm();
this.subscriptions.clear();
this.schemaLoadStarted = false;
@@ -436,10 +521,113 @@ class ChannelsPage extends OpenClawLightDomElement {
}
}
private reconcilePairingFilter(snapshot: ChannelsPairingListResult | null) {
if (!snapshot || !this.pairingChannelFilter) {
return;
}
const channelAccounts = snapshot.accounts.filter(
(account) => account.channel === this.pairingChannelFilter,
);
if (channelAccounts.length === 0) {
this.pairingChannelFilter = null;
this.pairingAccountFilter = null;
return;
}
if (
this.pairingAccountFilter &&
!channelAccounts.some((account) => account.accountId === this.pairingAccountFilter)
) {
this.pairingAccountFilter = null;
}
}
private setPairingFilter(channel: string | null, accountId: string | null) {
this.pairingChannelFilter = channel;
this.pairingAccountFilter = channel ? accountId : null;
}
private reviewPairingAccount(channel: string, accountId: string) {
this.selectedChannel = null;
this.setPairingFilter(channel, accountId);
void this.updateComplete.then(() => {
this.renderRoot.querySelector("#channels-pairing-requests")?.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
}
private openPairingPrompt(kind: ChannelPairingPrompt["kind"], request: ChannelsPairingRequest) {
if (this.context.channels.state.pairingBusyRequestId) {
return;
}
this.pairingNotice = null;
this.pairingPrompt = {
kind,
request,
notify: false,
bootstrapCommandOwner: false,
};
}
private patchPairingPrompt(
patch: Partial<Pick<ChannelPairingPrompt, "notify" | "bootstrapCommandOwner">>,
) {
if (!this.pairingPrompt) {
return;
}
this.pairingPrompt = { ...this.pairingPrompt, ...patch };
}
private async confirmPairingPrompt() {
const prompt = this.pairingPrompt;
if (!prompt) {
return;
}
if (prompt.kind === "dismiss") {
const dismissed = await this.context.channels.dismissPairing({
channel: prompt.request.channel,
accountId: prompt.request.accountId,
requestId: prompt.request.requestId,
});
if (dismissed && this.pairingPrompt === prompt) {
this.pairingPrompt = null;
this.pairingNotice = t("channels.pairing.dismissedNotice");
}
return;
}
const result = await this.context.channels.approvePairing({
channel: prompt.request.channel,
accountId: prompt.request.accountId,
requestId: prompt.request.requestId,
notify: prompt.notify,
bootstrapCommandOwner: prompt.bootstrapCommandOwner,
});
if (!result || this.pairingPrompt !== prompt) {
return;
}
this.pairingPrompt = null;
if (result.notification === "failed" && result.commandOwnerBootstrap === "unavailable") {
this.pairingNotice = t("channels.pairing.approvedFollowupsFailedNotice");
} else if (result.commandOwnerBootstrap === "unavailable") {
this.pairingNotice = t("channels.pairing.approvedOwnerFailedNotice");
} else if (result.notification === "failed") {
this.pairingNotice = t("channels.pairing.approvedNotificationFailedNotice");
} else if (result.commandOwnerBootstrap === "configured") {
this.pairingNotice = t("channels.pairing.approvedOwnerNotice");
} else {
this.pairingNotice = t("channels.pairing.approvedNotice");
}
}
override render() {
const context = this.context;
const channels = context.channels.state;
const config = context.runtimeConfig.state;
const auth = context.gateway.snapshot.hello?.auth ?? null;
const canManagePairing = hasOperatorPairingAccess(auth);
const canAdmin = hasOperatorAdminAccess(auth);
return html`
<section class="content-header">
<div>
@@ -453,6 +641,17 @@ class ChannelsPage extends OpenClawLightDomElement {
snapshot: channels.channelsSnapshot,
lastError: channels.channelsError,
lastSuccessAt: channels.channelsLastSuccess,
pairingLoading: channels.pairingLoading,
pairingSnapshot: channels.pairingSnapshot,
pairingError: channels.pairingError,
pairingLastSuccessAt: channels.pairingLastSuccess,
pairingBusyRequestId: channels.pairingBusyRequestId,
pairingChannelFilter: this.pairingChannelFilter,
pairingAccountFilter: this.pairingAccountFilter,
pairingPrompt: this.pairingPrompt,
pairingNotice: this.pairingNotice,
canManagePairing,
canAdmin,
whatsappMessage: channels.whatsappLoginMessage,
whatsappQrDataUrl: channels.whatsappLoginQrDataUrl,
whatsappConnected: channels.whatsappLoginConnected,
@@ -480,6 +679,17 @@ class ChannelsPage extends OpenClawLightDomElement {
onWizardToggleMultiselect: (value) => this.wizardHost.toggleMultiselect(value),
onWizardClose: () => this.wizardHost.close(),
onRefresh: (probe) => void context.channels.refresh(probe),
onPairingRefresh: () => void context.channels.refreshPairing(),
onPairingFilterChange: (channel, accountId) => this.setPairingFilter(channel, accountId),
onPairingReviewAccount: (channel, accountId) =>
this.reviewPairingAccount(channel, accountId),
onPairingApprove: (request) => this.openPairingPrompt("approve", request),
onPairingDismiss: (request) => this.openPairingPrompt("dismiss", request),
onPairingPromptChange: (patch) => this.patchPairingPrompt(patch),
onPairingPromptCancel: () => {
this.pairingPrompt = null;
},
onPairingPromptConfirm: () => void this.confirmPairingPrompt(),
onWhatsAppStart: (force) =>
void context.channels.startWhatsApp(force, this.wizardHost.whatsappAccountId),
onWhatsAppWait: () =>

View File

@@ -11,6 +11,7 @@ import { renderDiscordCard } from "./view.discord.ts";
import { renderGoogleChatCard } from "./view.googlechat.ts";
import { renderIMessageCard } from "./view.imessage.ts";
import { renderNostrCard } from "./view.nostr.ts";
import { renderChannelPairingDetail } from "./view.pairing.ts";
import {
boolStatusKind,
formatNullableBoolean,
@@ -195,7 +196,7 @@ export function renderChannelDetail(params: {
${params.props.setupBlockedByDirtyConfig && params.props.configFormDirty
? html`<div class="callout warn">${t("channels.hub.saveBeforeSetup")}</div>`
: nothing}
${body}
${renderChannelPairingDetail(params.channelId, params.props)} ${body}
</div>
</div>
</openclaw-modal-dialog>

View File

@@ -0,0 +1,195 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import {
renderChannelPairingDetail,
renderChannelPairingPrompt,
renderChannelPairingQueue,
} from "./view.pairing.ts";
import type { ChannelsProps } from "./view.types.ts";
const request = {
requestId: "opaque-request-id",
channel: "whatsapp",
channelLabel: "WhatsApp",
accountId: "personal",
accountLabel: "Personal",
senderId: "+15551234567",
senderLabel: "Phone number",
metadata: { name: "Alice" },
createdAt: "2026-07-20T10:00:00.000Z",
lastSeenAt: "2026-07-20T10:05:00.000Z",
expiresAt: "2026-07-20T11:00:00.000Z",
notifySupported: true,
} as const;
function createProps(overrides: Partial<ChannelsProps> = {}): ChannelsProps {
return {
connected: true,
loading: false,
snapshot: null,
lastError: null,
lastSuccessAt: null,
pairingLoading: false,
pairingSnapshot: {
accounts: [
{
channel: "whatsapp",
channelLabel: "WhatsApp",
accountId: "personal",
accountLabel: "Personal",
notifySupported: true,
},
],
requests: [request],
commandOwnerConfigured: false,
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
},
pairingError: null,
pairingLastSuccessAt: null,
pairingBusyRequestId: null,
pairingChannelFilter: null,
pairingAccountFilter: null,
pairingPrompt: null,
pairingNotice: null,
canManagePairing: true,
canAdmin: true,
whatsappMessage: null,
whatsappQrDataUrl: null,
whatsappConnected: null,
whatsappBusy: false,
configSchema: null,
configSchemaLoading: false,
configForm: null,
configUiHints: {},
configSaving: false,
configFormDirty: false,
nostrProfileFormState: null,
nostrProfileAccountId: null,
selectedChannel: null,
wizard: { phase: "idle" },
wizardMultiselect: [],
setupBlockedByDirtyConfig: false,
onShowDetail: () => undefined,
onCloseDetail: () => undefined,
onStartSetup: () => undefined,
onWizardAnswer: () => undefined,
onWizardToggleMultiselect: () => undefined,
onWizardClose: () => undefined,
onRefresh: () => undefined,
onPairingRefresh: () => undefined,
onPairingFilterChange: () => undefined,
onPairingReviewAccount: () => undefined,
onPairingApprove: () => undefined,
onPairingDismiss: () => undefined,
onPairingPromptChange: () => undefined,
onPairingPromptCancel: () => undefined,
onPairingPromptConfirm: () => undefined,
onWhatsAppStart: () => undefined,
onWhatsAppWait: () => undefined,
onWhatsAppLogout: () => undefined,
onConfigPatch: () => undefined,
onConfigSave: () => undefined,
onConfigReload: () => undefined,
onNostrProfileEdit: () => undefined,
onNostrProfileCancel: () => undefined,
onNostrProfileFieldChange: () => undefined,
onNostrProfileSave: () => undefined,
onNostrProfileImport: () => undefined,
onNostrProfileToggleAdvanced: () => undefined,
...overrides,
};
}
function renderInto(template: unknown): HTMLDivElement {
const container = document.createElement("div");
document.body.append(container);
render(template as never, container);
return container;
}
describe("channel DM access request views", () => {
it("renders pending senders without exposing the pairing code", () => {
const onApprove = vi.fn();
const onDismiss = vi.fn();
const container = renderInto(
renderChannelPairingQueue(
createProps({ onPairingApprove: onApprove, onPairingDismiss: onDismiss }),
),
);
expect(container.textContent).toContain("+15551234567");
expect(container.textContent).toContain("Alice");
expect(container.textContent).not.toContain("SECRET12");
const buttons = Array.from(container.querySelectorAll("button"));
buttons.find((button) => button.textContent?.trim() === "Approve")?.click();
buttons.find((button) => button.textContent?.trim() === "Dismiss")?.click();
expect(onApprove).toHaveBeenCalledWith(request);
expect(onDismiss).toHaveBeenCalledWith(request);
});
it("hides cached sender data without pairing access", () => {
const container = renderInto(
renderChannelPairingQueue(createProps({ canManagePairing: false })),
);
expect(container.textContent).toContain("operator.pairing access");
expect(container.textContent).not.toContain("+15551234567");
expect(container.textContent).not.toContain("Alice");
});
it("disables every request action while one mutation is active", () => {
const secondRequest = {
...request,
requestId: "other-request",
senderId: "987654321",
};
const props = createProps({
pairingBusyRequestId: request.requestId,
pairingSnapshot: {
...createProps().pairingSnapshot!,
requests: [request, secondRequest],
},
});
const container = renderInto(renderChannelPairingQueue(props));
const actionButtons = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).filter((button) => /^(Approve|Dismiss) /u.test(button.getAttribute("aria-label") ?? ""));
expect(actionButtons).toHaveLength(4);
expect(actionButtons.every((button) => button.disabled)).toBe(true);
});
it("shows explicit notification and first-owner choices for an admin", () => {
const container = renderInto(
renderChannelPairingPrompt(
createProps({
pairingPrompt: {
kind: "approve",
request,
notify: false,
bootstrapCommandOwner: false,
},
}),
),
);
expect(container.textContent).toContain("Notify the requester after approval");
expect(container.textContent).toContain("Also make this sender the first command owner");
expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2);
});
it("links a channel account detail back to the filtered request queue", () => {
const review = vi.fn();
const container = renderInto(
renderChannelPairingDetail("whatsapp", createProps({ onPairingReviewAccount: review })),
);
expect(container.textContent).toContain("1 pending");
const button = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.trim() === "Review requests",
);
button?.click();
expect(review).toHaveBeenCalledWith("whatsapp", "personal");
});
});

View File

@@ -0,0 +1,352 @@
// DM sender access request queue shared by the Channels hub and detail panels.
import { html, nothing } from "lit";
import type { ChannelsPairingAccount, ChannelsPairingRequest } from "../../api/types.ts";
import "../../components/modal-dialog.ts";
import {
renderSettingsEmpty,
renderSettingsSection,
renderSettingsStatus,
} from "../../components/settings-ui.ts";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import type { ChannelsProps } from "./view.types.ts";
function accountName(account: ChannelsPairingAccount): string {
return account.accountLabel || account.accountId;
}
function requestAccountName(request: ChannelsPairingRequest): string {
return request.accountLabel || request.accountId;
}
function formatRequestTime(value: string): string {
const time = Date.parse(value);
return Number.isFinite(time) ? formatRelativeTimestamp(time) : value;
}
function selectValue(event: Event): string | null {
const value = event.currentTarget instanceof HTMLSelectElement ? event.currentTarget.value : "";
return value || null;
}
function filteredAccounts(props: ChannelsProps): ChannelsPairingAccount[] {
const accounts = props.pairingSnapshot?.accounts ?? [];
return props.pairingChannelFilter
? accounts.filter((account) => account.channel === props.pairingChannelFilter)
: accounts;
}
function filteredRequests(props: ChannelsProps): ChannelsPairingRequest[] {
return (props.pairingSnapshot?.requests ?? []).filter((request) => {
if (props.pairingChannelFilter && request.channel !== props.pairingChannelFilter) {
return false;
}
if (props.pairingAccountFilter && request.accountId !== props.pairingAccountFilter) {
return false;
}
return true;
});
}
function renderFilters(props: ChannelsProps) {
const accounts = props.pairingSnapshot?.accounts ?? [];
const channels = Array.from(
new Map(accounts.map((account) => [account.channel, account.channelLabel])).entries(),
).toSorted((left, right) => left[1].localeCompare(right[1]));
const accountsForChannel = filteredAccounts(props);
return html`
<div class="channels-pairing-filters">
<label>
<span>${t("channels.pairing.channelFilter")}</span>
<select
.value=${props.pairingChannelFilter ?? ""}
@change=${(event: Event) => props.onPairingFilterChange(selectValue(event), null)}
>
<option value="">${t("channels.pairing.allChannels")}</option>
${channels.map(([channel, label]) => html`<option value=${channel}>${label}</option>`)}
</select>
</label>
<label>
<span>${t("channels.pairing.accountFilter")}</span>
<select
.value=${props.pairingAccountFilter ?? ""}
?disabled=${!props.pairingChannelFilter}
@change=${(event: Event) =>
props.onPairingFilterChange(props.pairingChannelFilter, selectValue(event))}
>
<option value="">${t("channels.pairing.allAccounts")}</option>
${accountsForChannel.map(
(account) => html`<option value=${account.accountId}>${accountName(account)}</option>`,
)}
</select>
</label>
</div>
`;
}
function renderRequest(request: ChannelsPairingRequest, props: ChannelsProps) {
const busy = Boolean(props.pairingBusyRequestId);
const thisRequestBusy = props.pairingBusyRequestId === request.requestId;
const metadata = Object.entries(request.metadata ?? {});
return html`
<div class="settings-row settings-row--stacked channels-pairing-request">
<div class="channels-pairing-request__main">
<div class="settings-row__text">
<span class="settings-row__title">${request.senderId}</span>
<span class="settings-row__desc">
${request.senderLabel} · ${request.channelLabel} · ${requestAccountName(request)}
(${request.accountId})
</span>
<span class="settings-row__desc">
${t("channels.pairing.requested", { ago: formatRequestTime(request.createdAt) })} ·
${t("channels.pairing.expires", { ago: formatRequestTime(request.expiresAt) })}
</span>
</div>
<div class="settings-row__control channels-pairing-request__actions">
<button
type="button"
class="btn btn--sm primary"
?disabled=${busy || !props.canManagePairing}
aria-label=${t("channels.pairing.approveAria", {
sender: request.senderId,
channel: request.channelLabel,
account: requestAccountName(request),
})}
@click=${() => props.onPairingApprove(request)}
>
${thisRequestBusy ? t("common.loading") : t("channels.pairing.approve")}
</button>
<button
type="button"
class="btn btn--sm"
?disabled=${busy || !props.canManagePairing}
aria-label=${t("channels.pairing.dismissAria", {
sender: request.senderId,
channel: request.channelLabel,
account: requestAccountName(request),
})}
@click=${() => props.onPairingDismiss(request)}
>
${t("channels.pairing.dismiss")}
</button>
</div>
</div>
${metadata.length > 0
? html`
<details class="channels-pairing-request__details">
<summary>${t("channels.pairing.senderDetails")}</summary>
<dl class="settings-kv">
${metadata.map(
([key, value]) =>
html`<dt>${key}</dt>
<dd>${value}</dd>`,
)}
</dl>
</details>
`
: nothing}
</div>
`;
}
export function renderChannelPairingQueue(props: ChannelsProps) {
const snapshot = props.canManagePairing ? props.pairingSnapshot : null;
const accounts = snapshot?.accounts ?? [];
const requests = props.canManagePairing ? filteredRequests(props) : [];
const hasFilter = Boolean(props.pairingChannelFilter || props.pairingAccountFilter);
const count = snapshot?.requests.length ?? 0;
return html`
<div id="channels-pairing-requests">
${renderSettingsSection(
{
title: t("channels.pairing.title"),
description: t("channels.pairing.subtitle"),
...(count > 0 ? { count } : {}),
actions: html`
<span class="settings-row__value">
${props.canManagePairing && props.pairingLastSuccessAt
? t("channels.hub.updatedAgo", {
ago: formatRelativeTimestamp(props.pairingLastSuccessAt),
})
: t("common.na")}
</span>
<button
type="button"
class="btn btn--sm"
?disabled=${props.pairingLoading || !props.canManagePairing}
@click=${props.onPairingRefresh}
>
${t("common.refresh")}
</button>
`,
},
!props.canManagePairing
? html`<div class="callout warn">${t("channels.pairing.missingPermission")}</div>`
: html`
${props.pairingError
? html`<div class="callout danger">${props.pairingError}</div>`
: nothing}
${props.pairingNotice
? html`<div class="callout info" role="status">${props.pairingNotice}</div>`
: nothing}
${snapshot ? renderFilters(props) : nothing}
${props.pairingLoading && !snapshot
? html`<div class="settings-row">${t("common.loading")}</div>`
: accounts.length === 0
? renderSettingsEmpty(t("channels.pairing.noAccounts"))
: requests.length === 0
? renderSettingsEmpty(
hasFilter
? t("channels.pairing.noFilteredRequests")
: t("channels.pairing.noRequests"),
)
: requests.map((request) => renderRequest(request, props))}
${snapshot
? html`
<div class="channels-pairing-help">
${t("channels.pairing.limits", {
count: String(snapshot.limits.pendingPerAccount),
minutes: String(Math.round(snapshot.limits.ttlMs / 60_000)),
})}
</div>
`
: nothing}
`,
)}
</div>
`;
}
export function renderChannelPairingDetail(channelId: string, props: ChannelsProps) {
if (!props.canManagePairing) {
return nothing;
}
const accounts = (props.pairingSnapshot?.accounts ?? []).filter(
(account) => account.channel === channelId,
);
if (accounts.length === 0) {
return nothing;
}
const requests = props.pairingSnapshot?.requests ?? [];
return renderSettingsSection(
{
title: t("channels.pairing.detailTitle"),
description: t("channels.pairing.detailSubtitle"),
},
accounts.map((account) => {
const pending = requests.filter(
(request) => request.channel === account.channel && request.accountId === account.accountId,
).length;
return html`
<div class="settings-row">
<div class="settings-row__text">
<span class="settings-row__title">${accountName(account)}</span>
<span class="settings-row__desc">${account.accountId}</span>
</div>
<div class="settings-row__control">
${renderSettingsStatus({
kind: pending > 0 ? "warn" : "muted",
label:
pending > 0
? t("channels.pairing.pendingCount", { count: String(pending) })
: t("channels.pairing.noPending"),
})}
<button
type="button"
class="btn btn--sm"
@click=${() => props.onPairingReviewAccount(account.channel, account.accountId)}
>
${t("channels.pairing.review")}
</button>
</div>
</div>
`;
}),
);
}
export function renderChannelPairingPrompt(props: ChannelsProps) {
const prompt = props.pairingPrompt;
if (!prompt || !props.canManagePairing) {
return nothing;
}
const request = prompt.request;
const busy = props.pairingBusyRequestId === request.requestId;
const approving = prompt.kind === "approve";
const ownerMissing = props.pairingSnapshot?.commandOwnerConfigured === false;
const dialogTitle = approving
? t("channels.pairing.approveDialogTitle")
: t("channels.pairing.dismissDialogTitle");
return html`
<openclaw-modal-dialog label=${dialogTitle} @modal-cancel=${props.onPairingPromptCancel}>
<div class="channels-pairing-dialog">
<div class="settings-row__title">${dialogTitle}</div>
<div class="settings-row__desc">
${request.senderId} · ${request.channelLabel} · ${requestAccountName(request)}
(${request.accountId})
</div>
<div class="callout ${approving ? "info" : "warn"}">
${approving
? t("channels.pairing.approveExplanation")
: t("channels.pairing.dismissExplanation")}
</div>
${props.pairingError
? html`<div class="callout danger" role="alert">${props.pairingError}</div>`
: nothing}
${approving && request.notifySupported
? html`
<label class="channels-pairing-dialog__option">
<input
type="checkbox"
.checked=${prompt.notify}
@change=${(event: Event) =>
props.onPairingPromptChange({
notify:
event.currentTarget instanceof HTMLInputElement
? event.currentTarget.checked
: false,
})}
/>
<span>${t("channels.pairing.notifyRequester")}</span>
</label>
`
: nothing}
${approving && ownerMissing && props.canAdmin
? html`
<label class="channels-pairing-dialog__option">
<input
type="checkbox"
.checked=${prompt.bootstrapCommandOwner}
@change=${(event: Event) =>
props.onPairingPromptChange({
bootstrapCommandOwner:
event.currentTarget instanceof HTMLInputElement
? event.currentTarget.checked
: false,
})}
/>
<span>${t("channels.pairing.makeCommandOwner")}</span>
</label>
<div class="settings-row__desc">${t("channels.pairing.commandOwnerHelp")}</div>
`
: nothing}
${approving && ownerMissing && !props.canAdmin
? html`<div class="callout warn">${t("channels.pairing.commandOwnerNeedsAdmin")}</div>`
: nothing}
<div class="channels-pairing-dialog__actions">
<button
type="button"
class=${approving ? "btn primary" : "btn danger"}
?disabled=${busy}
@click=${props.onPairingPromptConfirm}
>
${approving ? t("channels.pairing.approve") : t("channels.pairing.dismiss")}
</button>
<button type="button" class="btn" ?disabled=${busy} @click=${props.onPairingPromptCancel}>
${t("common.cancel")}
</button>
</div>
</div>
</openclaw-modal-dialog>
`;
}

View File

@@ -17,6 +17,22 @@ function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps {
snapshot,
lastError: null,
lastSuccessAt: null,
pairingLoading: false,
pairingSnapshot: {
accounts: [],
requests: [],
commandOwnerConfigured: true,
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
},
pairingError: null,
pairingLastSuccessAt: null,
pairingBusyRequestId: null,
pairingChannelFilter: null,
pairingAccountFilter: null,
pairingPrompt: null,
pairingNotice: null,
canManagePairing: true,
canAdmin: true,
whatsappMessage: null,
whatsappQrDataUrl: null,
whatsappConnected: null,
@@ -40,6 +56,14 @@ function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps {
onWizardToggleMultiselect: () => {},
onWizardClose: () => {},
onRefresh: () => {},
onPairingRefresh: () => {},
onPairingFilterChange: () => {},
onPairingReviewAccount: () => {},
onPairingApprove: () => {},
onPairingDismiss: () => {},
onPairingPromptChange: () => {},
onPairingPromptCancel: () => {},
onPairingPromptConfirm: () => {},
onWhatsAppStart: () => {},
onWhatsAppWait: () => {},
onWhatsAppLogout: () => {},

View File

@@ -29,6 +29,7 @@ import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../../lib/format.ts";
import { renderChannelArt } from "./hub-meta.ts";
import { renderChannelDetail } from "./view.detail.ts";
import { renderChannelPairingPrompt, renderChannelPairingQueue } from "./view.pairing.ts";
import { channelEnabled, resolveChannelDisplayState } from "./view.shared.ts";
import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./view.types.ts";
import { renderChannelWizard } from "./wizard-view.ts";
@@ -61,6 +62,7 @@ export function renderChannels(props: ChannelsProps) {
${props.setupBlockedByDirtyConfig && props.configFormDirty
? html`<div class="callout warn">${t("channels.hub.saveBeforeSetup")}</div>`
: nothing}
${renderChannelPairingQueue(props)}
${renderSettingsSection(
{
title: t("channels.hub.connectedTitle"),
@@ -141,6 +143,7 @@ ${props.snapshot
onWhatsAppStart: props.onWhatsAppStart,
onWhatsAppWait: props.onWhatsAppWait,
})}
${renderChannelPairingPrompt(props)}
`;
}

View File

@@ -1,6 +1,8 @@
// Channels page view contracts.
import type {
ChannelAccountSnapshot,
ChannelsPairingListResult,
ChannelsPairingRequest,
ChannelsStatusSnapshot,
ConfigUiHints,
DiscordStatus,
@@ -18,12 +20,30 @@ import type { ChannelWizardState } from "./wizard-controller.ts";
export type ChannelKey = string;
export type ChannelPairingPrompt = {
kind: "approve" | "dismiss";
request: ChannelsPairingRequest;
notify: boolean;
bootstrapCommandOwner: boolean;
};
export type ChannelsProps = {
connected: boolean;
loading: boolean;
snapshot: ChannelsStatusSnapshot | null;
lastError: string | null;
lastSuccessAt: number | null;
pairingLoading: boolean;
pairingSnapshot: ChannelsPairingListResult | null;
pairingError: string | null;
pairingLastSuccessAt: number | null;
pairingBusyRequestId: string | null;
pairingChannelFilter: string | null;
pairingAccountFilter: string | null;
pairingPrompt: ChannelPairingPrompt | null;
pairingNotice: string | null;
canManagePairing: boolean;
canAdmin: boolean;
whatsappMessage: string | null;
whatsappQrDataUrl: string | null;
whatsappConnected: boolean | null;
@@ -47,6 +67,16 @@ export type ChannelsProps = {
onWizardToggleMultiselect: (value: unknown) => void;
onWizardClose: () => void;
onRefresh: (probe: boolean) => void;
onPairingRefresh: () => void;
onPairingFilterChange: (channel: string | null, accountId: string | null) => void;
onPairingReviewAccount: (channel: string, accountId: string) => void;
onPairingApprove: (request: ChannelsPairingRequest) => void;
onPairingDismiss: (request: ChannelsPairingRequest) => void;
onPairingPromptChange: (
patch: Partial<Pick<ChannelPairingPrompt, "notify" | "bootstrapCommandOwner">>,
) => void;
onPairingPromptCancel: () => void;
onPairingPromptConfirm: () => void;
onWhatsAppStart: (force: boolean) => void;
onWhatsAppWait: () => void;
onWhatsAppLogout: () => void;

View File

@@ -114,31 +114,32 @@ describe("chat pane presentation teardown", () => {
window.localStorage.removeItem(SKIP_REWIND_CONFIRM_PREFERENCE);
const owner = createConfirmationOwner();
for (const callback of frameCallbacks.splice(0)) {
callback(0);
}
const captureClickListener = addDocumentListener.mock.calls.find(
([type, listener, options]) => type === "click" && options === true && listener,
)?.[1];
const captureKeydownListener = addWindowListener.mock.calls.find(
([type, listener, options]) => type === "keydown" && options === true && listener,
)?.[1];
expect(captureClickListener).toBeDefined();
expect(captureKeydownListener).toBeDefined();
pane.appendChild(owner);
try {
for (const callback of frameCallbacks.splice(0)) {
callback(0);
}
const captureClickListener = addDocumentListener.mock.calls.find(
([type, listener, options]) => type === "click" && options === true && listener,
)?.[1];
const captureKeydownListener = addWindowListener.mock.calls.find(
([type, listener, options]) => type === "keydown" && options === true && listener,
)?.[1];
expect(captureClickListener).toBeDefined();
expect(captureKeydownListener).toBeDefined();
pane.appendChild(owner);
const resetPresentation = chatThread.resetChatThreadPresentationState;
const stopAfterReset = new Error("stop after thread presentation reset");
vi.spyOn(chatThread, "resetChatThreadPresentationState").mockImplementation(
(paneId, presentationOwner) => {
resetPresentation(paneId, presentationOwner);
const stopAfterReset = new Error("stop after thread presentation reset");
vi.spyOn(pane, "cancelHeaderRename").mockImplementation(() => {
throw stopAfterReset;
},
);
});
expect(() => pane.switchPaneSession("agent:main:next")).toThrow(stopAfterReset);
expect(owner.querySelector(".chat-delete-confirm")).toBeNull();
expect(removeDocumentListener).toHaveBeenCalledWith("click", captureClickListener, true);
expect(removeWindowListener).toHaveBeenCalledWith("keydown", captureKeydownListener, true);
expect(() => pane.switchPaneSession("agent:main:next")).toThrow(stopAfterReset);
expect(owner.querySelector(".chat-delete-confirm")).toBeNull();
expect(removeDocumentListener).toHaveBeenCalledWith("click", captureClickListener, true);
expect(removeWindowListener).toHaveBeenCalledWith("keydown", captureKeydownListener, true);
} finally {
dismissConfirmedActionPopovers(owner);
owner.remove();
}
});
});

View File

@@ -181,12 +181,8 @@ import {
saveRouteSessionSettings,
type ChatPageHost,
} from "./chat-state.ts";
import {
renderChat,
renderChatResizableDivider,
resetChatViewState,
type ChatProps,
} from "./chat-view.ts";
import { resetChatViewState } from "./chat-view-state.ts";
import { renderChat, renderChatResizableDivider, type ChatProps } from "./chat-view.ts";
import { renderCatalogTerminalButton } from "./components/catalog-terminal-button.ts";
import { chatAttachmentFromDataUrl } from "./components/chat-attachments.ts";
import {
@@ -196,6 +192,7 @@ import {
} from "./components/chat-background-tasks.ts";
import { isChatRunWorking } from "./components/chat-composer.ts";
import { renderChatControls } from "./components/chat-controls.ts";
import { dismissConfirmedActionPopovers } from "./components/chat-message.ts";
import {
canRevealSessionWorkspace,
renderChatPaneHeader,
@@ -930,9 +927,10 @@ class ChatPane extends OpenClawLightDomElement {
if (!state) {
return;
}
// Close old-session portals and listener-owning popovers before the next
// render detaches their DOM and makes owner-scoped cleanup impossible.
resetChatThreadPresentationState(this.paneId, this);
// Close old-session listener owners before the next render detaches their
// DOM; thread-global portals and caches are reset separately.
dismissConfirmedActionPopovers(this);
resetChatThreadPresentationState(this.paneId);
this.sessionDiscussionOpenUrls.clear();
const previousSessionKey = state.sessionKey;
// An in-progress title edit belongs to the previous session; committing
@@ -2490,7 +2488,8 @@ class ChatPane extends OpenClawLightDomElement {
this.headerWorktreePaths.clear();
this.headerBranches.clear();
this.announceCommandPaletteTarget(null);
resetChatViewState(this.paneId, this);
dismissConfirmedActionPopovers(this);
resetChatViewState(this.paneId);
this.state = undefined;
this.connectedClient = null;
disposeQuestionPromptState(this.questionPromptState);

View File

@@ -30,7 +30,8 @@ import {
} from "./attachment-payload-store.ts";
import { switchChatFastMode, switchChatModel, switchChatThinkingLevel } from "./chat-session.ts";
import * as chatThread from "./chat-thread.ts";
import { renderChat, resetChatViewState } from "./chat-view.ts";
import { resetChatViewState } from "./chat-view-state.ts";
import { renderChat } from "./chat-view.ts";
import { resetChatComposerState } from "./components/chat-composer.ts";
import * as chatMessage from "./components/chat-message.ts";
import {

View File

@@ -73,8 +73,6 @@ import type { CompactionStatus, FallbackStatus, PlanStatus } from "./tool-stream
import type { WorkspaceResultConflict } from "./workspace-conflict.ts";
import "../../components/resizable-divider.ts";
export { resetChatViewState } from "./chat-view-state.ts";
type ChatReplyTarget = {
messageId: string;
text: string;

View File

@@ -117,6 +117,107 @@
padding: var(--space-4) var(--space-4) var(--space-5);
}
/* ---------- DM sender access requests ---------- */
.channels-pairing-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3);
border-bottom: 1px solid var(--border);
}
.channels-pairing-filters label {
display: grid;
flex: 1 1 180px;
gap: var(--space-1);
color: var(--muted);
font-size: var(--control-ui-text-xs);
}
.channels-pairing-filters select {
width: 100%;
}
.channels-pairing-request {
align-items: stretch;
}
.channels-pairing-request__main {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
}
.channels-pairing-request__actions {
flex-wrap: wrap;
}
.channels-pairing-request__details {
width: 100%;
color: var(--muted);
font-size: var(--control-ui-text-sm);
}
.channels-pairing-request__details summary {
width: fit-content;
cursor: pointer;
}
.channels-pairing-request__details .settings-kv {
margin-top: var(--space-2);
}
.channels-pairing-help {
padding: var(--space-3);
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1.5;
}
.channels-pairing-dialog {
display: grid;
width: min(520px, calc(100vw - 48px));
gap: var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-xl);
padding: var(--space-4);
background: var(--popover);
box-shadow: var(--shadow-xl);
}
.channels-pairing-dialog__option {
display: flex;
align-items: flex-start;
gap: var(--space-2);
color: var(--text);
cursor: pointer;
}
.channels-pairing-dialog__option input {
margin-top: 3px;
}
.channels-pairing-dialog__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
flex-wrap: wrap;
}
@media (max-width: 640px) {
.channels-pairing-request__main {
align-items: stretch;
flex-direction: column;
}
.channels-pairing-request__actions {
justify-content: flex-start;
}
}
/* ---------- setup wizard dialog ---------- */
.channels-wizard {