From 0c99a4e36288f53fb2a0cc7ee4bb1b162f8153bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 04:54:20 -0700 Subject: [PATCH] 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 --- .../openclaw/app/gateway/GatewayProtocol.kt | 3 + .../OpenClawProtocol/GatewayModels.swift | 140 ++++++ docs/channels/pairing.md | 37 +- docs/channels/whatsapp.md | 9 +- docs/cli/pairing.md | 7 +- docs/docs_map.md | 3 +- packages/gateway-protocol/src/index.ts | 23 + packages/gateway-protocol/src/schema.ts | 1 + .../src/schema/channel-pairing.ts | 83 ++++ .../src/schema/protocol-schemas.ts | 14 + src/cli/pairing-cli.ts | 44 +- src/gateway/method-scopes.test.ts | 23 + src/gateway/method-scopes.ts | 7 + .../methods/core-descriptors.since.test.ts | 3 + src/gateway/methods/core-descriptors.ts | 5 + src/gateway/server-methods.ts | 8 + .../server-methods/channel-pairing.test.ts | 251 +++++++++++ src/gateway/server-methods/channel-pairing.ts | 396 ++++++++++++++++ src/pairing/command-owner.ts | 41 ++ src/pairing/pairing-store.test.ts | 60 +++ src/pairing/pairing-store.ts | 143 ++++-- ui/src/api/types.ts | 8 + ui/src/app/operator-access.test.ts | 12 +- ui/src/app/operator-access.ts | 16 + ui/src/i18n/locales/en.ts | 48 ++ ui/src/lib/channels/index.test.ts | 425 +++++++++++++++++- ui/src/lib/channels/index.ts | 297 +++++++++++- ui/src/pages/channels/channels-page.test.ts | 57 ++- ui/src/pages/channels/channels-page.ts | 212 ++++++++- ui/src/pages/channels/view.detail.ts | 3 +- ui/src/pages/channels/view.pairing.test.ts | 195 ++++++++ ui/src/pages/channels/view.pairing.ts | 352 +++++++++++++++ ui/src/pages/channels/view.test.ts | 24 + ui/src/pages/channels/view.ts | 3 + ui/src/pages/channels/view.types.ts | 30 ++ ui/src/pages/chat/chat-pane-lifecycle.test.ts | 47 +- ui/src/pages/chat/chat-pane.ts | 19 +- ui/src/pages/chat/chat-view.test.ts | 3 +- ui/src/pages/chat/chat-view.ts | 2 - ui/src/styles/channels.css | 101 +++++ 40 files changed, 3014 insertions(+), 141 deletions(-) create mode 100644 packages/gateway-protocol/src/schema/channel-pairing.ts create mode 100644 src/gateway/server-methods/channel-pairing.test.ts create mode 100644 src/gateway/server-methods/channel-pairing.ts create mode 100644 src/pairing/command-owner.ts create mode 100644 ui/src/pages/channels/view.pairing.test.ts create mode 100644 ui/src/pages/channels/view.pairing.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index c7eaaa19a790..fdeb67ef99c2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -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"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 60ace37b7ef4..1b379600dc10 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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? diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index d028dd64dd59..c82c31d81ca9 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -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 ``` -Add `--notify` to the approve command to tell the requester on the same channel. Multi-account channels take `--account `. +Add `--notify` to tell the requester on the same channel. Multi-account channels +take `--account `. -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. + + +WhatsApp's login QR links a WhatsApp account to OpenClaw. DM access requests +approve people who message that account. These are separate flows. + 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`. diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index b19ea71eb6ce..ba6f7f4059a1 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -80,14 +80,19 @@ openclaw gateway - + + + 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 ``` - 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. diff --git a/docs/cli/pairing.md b/docs/cli/pairing.md index 7015a59b8b31..2144732ed7ec 100644 --- a/docs/cli/pairing.md +++ b/docs/cli/pairing.md @@ -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 `, `--account `, `--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. diff --git a/docs/docs_map.md b/docs/docs_map.md index 1f89ea737d1f..76c1b754dc4b 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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) diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 9015510d2c9b..de4da5b70cb0 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -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, diff --git a/packages/gateway-protocol/src/schema.ts b/packages/gateway-protocol/src/schema.ts index 1c83a5b425e7..2b76e0d40f8f 100644 --- a/packages/gateway-protocol/src/schema.ts +++ b/packages/gateway-protocol/src/schema.ts @@ -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"; diff --git a/packages/gateway-protocol/src/schema/channel-pairing.ts b/packages/gateway-protocol/src/schema/channel-pairing.ts new file mode 100644 index 000000000000..38b7c6ac792d --- /dev/null +++ b/packages/gateway-protocol/src/schema/channel-pairing.ts @@ -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; +export type ChannelsPairingListResult = Static; +export type ChannelsPairingApproveParams = Static; +export type ChannelsPairingApproveResult = Static; +export type ChannelsPairingDismissParams = Static; +export type ChannelsPairingDismissResult = Static; +export type ChannelsPairingAccount = Static; +export type ChannelsPairingRequest = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 6457cae8f5cb..69fef614e679 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -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, diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index 5f2340069ae8..5011cc236a31 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -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).")}`, ); diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 154b8e87361f..a71199638228 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -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", diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index ce23186a1a0c..73a773a12e85 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -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); } diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 03caa4e84d55..1ee0932c9aef 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -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", () => { diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 412fce735bd6..dd3ec87000a5 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -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 }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 24cf75a094ee..b6cf483b876a 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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", diff --git a/src/gateway/server-methods/channel-pairing.test.ts b/src/gateway/server-methods/channel-pairing.test.ts new file mode 100644 index 000000000000..40105fb0b56e --- /dev/null +++ b/src/gateway/server-methods/channel-pairing.test.ts @@ -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, +) { + const respond = vi.fn(); + const handler = expectDefined(channelPairingHandlers[method], `${method} test invariant`); + await handler({ + params, + respond, + context: createContext(), + } as unknown as Parameters[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, + ); + }); +}); diff --git a/src/gateway/server-methods/channel-pairing.ts b/src/gateway/server-methods/channel-pairing.ts new file mode 100644 index 000000000000..a2d03ad0d59d --- /dev/null +++ b/src/gateway/server-methods/channel-pairing.ts @@ -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 | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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 { + 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 { + 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>[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))); + } + }, +}; diff --git a/src/pairing/command-owner.ts b/src/pairing/command-owner.ts new file mode 100644 index 000000000000..40ab8e788a2c --- /dev/null +++ b/src/pairing/command-owner.ts @@ -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 { + 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" }; +} diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index 8599c5ed4c3f..da5be6109c7d 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -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({ diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index d0059193c722..a7a8c1012cb8 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -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; }; +/** 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, + }); +} diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index a6109775d57b..c32f7e2cee5d 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -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[]; diff --git a/ui/src/app/operator-access.test.ts b/ui/src/app/operator-access.test.ts index 6cd39a09c6d7..8c27f8376eb9 100644 --- a/ui/src/app/operator-access.test.ts +++ b/ui/src/app/operator-access.test.ts @@ -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", () => { diff --git a/ui/src/app/operator-access.ts b/ui/src/app/operator-access.ts index 7aedcf7d0024..b12966a2df12 100644 --- a/ui/src/app/operator-access.ts +++ b/ui/src/app/operator-access.ts @@ -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 { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index d4e7f493beef..ca51abf41e06 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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}", diff --git a/ui/src/lib/channels/index.test.ts b/ui/src/lib/channels/index.test.ts index 392bc2eea4fa..a74d668ac72f 100644 --- a/ui/src/lib/channels/index.test.ts +++ b/ui/src/lib/channels/index.test.ts @@ -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() { @@ -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(); + const freshList = createDeferred(); + 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(); + 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(); const fastRuntime = createDeferred(); diff --git a/ui/src/lib/channels/index.ts b/ui/src/lib/channels/index.ts index 0e52b196f2ac..3787d7babe26 100644 --- a/ui/src/lib/channels/index.ts +++ b/ui/src/lib/channels/index.ts @@ -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; + refreshPairing: () => Promise; + approvePairing: (params: { + channel: string; + accountId: string; + requestId: string; + notify: boolean; + bootstrapCommandOwner: boolean; + }) => Promise; + dismissPairing: (params: { + channel: string; + accountId: string; + requestId: string; + }) => Promise; startWhatsApp: (force: boolean, accountId?: string) => Promise; waitWhatsApp: (accountId?: string) => Promise; logoutWhatsApp: (accountId?: string) => Promise; @@ -52,6 +79,31 @@ export type ChannelCapability = { dispose: () => void; }; +export function resolveChannelPairingAuthSignature( + snapshot: Partial, +): string { + const auth = snapshot.hello?.auth; + return JSON.stringify({ + role: auth?.role ?? null, + scopes: auth?.scopes ? [...auth.scopes].toSorted() : null, + }); +} + +function channelSnapshotAllowsScope( + snapshot: Partial, + 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 = {}): ChannelsState { return { client: snapshot.client ?? null, @@ -62,6 +114,12 @@ function createInitialChannelsState(snapshot: Partial = 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 { + 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("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 { + 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( + "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 { + 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(); diff --git a/ui/src/pages/channels/channels-page.test.ts b/ui/src/pages/channels/channels-page.test.ts index f08884125b8f..283fd47ea28e 100644 --- a/ui/src/pages/channels/channels-page.test.ts +++ b/ui/src/pages/channels/channels-page.test.ts @@ -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); diff --git a/ui/src/pages/channels/channels-page.ts b/ui/src/pages/channels/channels-page.ts index 324b21fee5ee..5ecff9011f6b 100644 --- a/ui/src/pages/channels/channels-page.ts +++ b/ui/src/pages/channels/channels-page.ts @@ -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 | 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>, + ) { + 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`
@@ -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: () => diff --git a/ui/src/pages/channels/view.detail.ts b/ui/src/pages/channels/view.detail.ts index 7f7249a6a33e..287252acbc07 100644 --- a/ui/src/pages/channels/view.detail.ts +++ b/ui/src/pages/channels/view.detail.ts @@ -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`
${t("channels.hub.saveBeforeSetup")}
` : nothing} - ${body} + ${renderChannelPairingDetail(params.channelId, params.props)} ${body}
diff --git a/ui/src/pages/channels/view.pairing.test.ts b/ui/src/pages/channels/view.pairing.test.ts new file mode 100644 index 000000000000..37d03225139f --- /dev/null +++ b/ui/src/pages/channels/view.pairing.test.ts @@ -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 { + 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("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"); + }); +}); diff --git a/ui/src/pages/channels/view.pairing.ts b/ui/src/pages/channels/view.pairing.ts new file mode 100644 index 000000000000..ce0b37cdc34b --- /dev/null +++ b/ui/src/pages/channels/view.pairing.ts @@ -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` +
+ + +
+ `; +} + +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` +
+
+
+ ${request.senderId} + + ${request.senderLabel} · ${request.channelLabel} · ${requestAccountName(request)} + (${request.accountId}) + + + ${t("channels.pairing.requested", { ago: formatRequestTime(request.createdAt) })} · + ${t("channels.pairing.expires", { ago: formatRequestTime(request.expiresAt) })} + +
+
+ + +
+
+ ${metadata.length > 0 + ? html` +
+ ${t("channels.pairing.senderDetails")} +
+ ${metadata.map( + ([key, value]) => + html`
${key}
+
${value}
`, + )} +
+
+ ` + : nothing} +
+ `; +} + +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` +
+ ${renderSettingsSection( + { + title: t("channels.pairing.title"), + description: t("channels.pairing.subtitle"), + ...(count > 0 ? { count } : {}), + actions: html` + + ${props.canManagePairing && props.pairingLastSuccessAt + ? t("channels.hub.updatedAgo", { + ago: formatRelativeTimestamp(props.pairingLastSuccessAt), + }) + : t("common.na")} + + + `, + }, + !props.canManagePairing + ? html`
${t("channels.pairing.missingPermission")}
` + : html` + ${props.pairingError + ? html`
${props.pairingError}
` + : nothing} + ${props.pairingNotice + ? html`
${props.pairingNotice}
` + : nothing} + ${snapshot ? renderFilters(props) : nothing} + ${props.pairingLoading && !snapshot + ? html`
${t("common.loading")}
` + : 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` +
+ ${t("channels.pairing.limits", { + count: String(snapshot.limits.pendingPerAccount), + minutes: String(Math.round(snapshot.limits.ttlMs / 60_000)), + })} +
+ ` + : nothing} + `, + )} +
+ `; +} + +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` +
+
+ ${accountName(account)} + ${account.accountId} +
+
+ ${renderSettingsStatus({ + kind: pending > 0 ? "warn" : "muted", + label: + pending > 0 + ? t("channels.pairing.pendingCount", { count: String(pending) }) + : t("channels.pairing.noPending"), + })} + +
+
+ `; + }), + ); +} + +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` + +
+
${dialogTitle}
+
+ ${request.senderId} · ${request.channelLabel} · ${requestAccountName(request)} + (${request.accountId}) +
+
+ ${approving + ? t("channels.pairing.approveExplanation") + : t("channels.pairing.dismissExplanation")} +
+ ${props.pairingError + ? html`` + : nothing} + ${approving && request.notifySupported + ? html` + + ` + : nothing} + ${approving && ownerMissing && props.canAdmin + ? html` + +
${t("channels.pairing.commandOwnerHelp")}
+ ` + : nothing} + ${approving && ownerMissing && !props.canAdmin + ? html`
${t("channels.pairing.commandOwnerNeedsAdmin")}
` + : nothing} +
+ + +
+
+
+ `; +} diff --git a/ui/src/pages/channels/view.test.ts b/ui/src/pages/channels/view.test.ts index 007287d292af..5781288a7bfc 100644 --- a/ui/src/pages/channels/view.test.ts +++ b/ui/src/pages/channels/view.test.ts @@ -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: () => {}, diff --git a/ui/src/pages/channels/view.ts b/ui/src/pages/channels/view.ts index eab774802ced..aed5a2196d21 100644 --- a/ui/src/pages/channels/view.ts +++ b/ui/src/pages/channels/view.ts @@ -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`
${t("channels.hub.saveBeforeSetup")}
` : nothing} + ${renderChannelPairingQueue(props)} ${renderSettingsSection( { title: t("channels.hub.connectedTitle"), @@ -141,6 +143,7 @@ ${props.snapshot onWhatsAppStart: props.onWhatsAppStart, onWhatsAppWait: props.onWhatsAppWait, })} + ${renderChannelPairingPrompt(props)} `; } diff --git a/ui/src/pages/channels/view.types.ts b/ui/src/pages/channels/view.types.ts index ffc358e18830..0041f9d8622b 100644 --- a/ui/src/pages/channels/view.types.ts +++ b/ui/src/pages/channels/view.types.ts @@ -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>, + ) => void; + onPairingPromptCancel: () => void; + onPairingPromptConfirm: () => void; onWhatsAppStart: (force: boolean) => void; onWhatsAppWait: () => void; onWhatsAppLogout: () => void; diff --git a/ui/src/pages/chat/chat-pane-lifecycle.test.ts b/ui/src/pages/chat/chat-pane-lifecycle.test.ts index 91170994ac76..6f8f9cc2588e 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.test.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.test.ts @@ -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(); + } }); }); diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index c292f9ac04d4..460ee599674a 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -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); diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 40e2efa662a8..0acab561d230 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -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 { diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 90d35d9f261b..6f101d956e7b 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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; diff --git a/ui/src/styles/channels.css b/ui/src/styles/channels.css index 1865a893f683..5c7bc6802b0a 100644 --- a/ui/src/styles/channels.css +++ b/ui/src/styles/channels.css @@ -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 {