From 9ff19fd369e060d94a882574a010c85422581b8a Mon Sep 17 00:00:00 2001
From: qqqys <266654365+qqqys@users.noreply.github.com>
Date: Mon, 3 Aug 2026 15:46:11 +0800
Subject: [PATCH 01/10] feat(channels): support group pairing
---
docs/developers/qwen-serve-protocol.md | 12 +-
docs/users/features/channels/overview.md | 25 +-
packages/channels/base/README.md | 19 +-
.../channels/base/src/ChannelBase.test.ts | 213 +++++++++++++++++-
packages/channels/base/src/ChannelBase.ts | 74 +++++-
packages/channels/base/src/GroupGate.test.ts | 21 ++
packages/channels/base/src/GroupGate.ts | 40 +++-
.../channels/base/src/PairingStore.test.ts | 62 +++++
packages/channels/base/src/PairingStore.ts | 112 ++++++++-
packages/channels/base/src/index.ts | 2 +-
packages/channels/base/src/types.ts | 2 +-
.../cli/src/commands/channel/pairing.test.ts | 33 +++
packages/cli/src/commands/channel/pairing.ts | 14 +-
.../serve/channel-management-service.test.ts | 82 ++++++-
.../src/serve/channel-management-service.ts | 37 ++-
.../src/serve/channel-settings-store.test.ts | 2 +
.../cli/src/serve/channel-settings-store.ts | 2 +-
.../workspace-channel-management.test.ts | 51 ++++-
.../routes/workspace-channel-management.ts | 40 +++-
packages/sdk-typescript/src/daemon/index.ts | 1 +
packages/sdk-typescript/src/daemon/types.ts | 14 +-
packages/sdk-typescript/src/index.ts | 1 +
.../test/unit/DaemonClient.test.ts | 13 ++
.../test/unit/daemon-public-surface.test.ts | 2 +
24 files changed, 797 insertions(+), 77 deletions(-)
diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md
index 0d7456f3e29..473ad744fff 100644
--- a/docs/developers/qwen-serve-protocol.md
+++ b/docs/developers/qwen-serve-protocol.md
@@ -794,19 +794,21 @@ Runtime actions are strict-gated `POST` requests to
worker owned by the resolved workspace.
Pairing management is available only for instances configured with the
-`pairing` sender policy:
+`pairing` sender policy or group policy:
- `GET .../channels/:name/pairing-requests`
- `POST .../channels/:name/pairing-requests/approve` with `{ "code": "..." }`
- `GET .../channels/:name/pairing-approvals`
- `DELETE .../channels/:name/pairing-approvals` with
- `{ "senderId": "..." }`
+ either `{ "senderId": "..." }` or `{ "groupId": "..." }`
All pairing routes require a bearer token and use `Cache-Control: no-store`.
Requests, approvals, and revocations are scoped to the selected Channel
-instance and workspace. The approvals snapshot contains sender IDs because the
-allowlist does not persist sender display names. Revoking an unknown sender
-returns `404 channel_pairing_approval_not_found`.
+instance and workspace. Pending requests include a typed user or group subject;
+group requests also retain the sender who initiated the request. Approval
+snapshots contain `senderIds` and `groupIds` because allowlists do not persist
+display names. Revoking an unknown user or group returns
+`404 channel_pairing_approval_not_found`.
### Channel delivery and Notify
diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md
index 88133ac5703..ef64f640415 100644
--- a/docs/users/features/channels/overview.md
+++ b/docs/users/features/channels/overview.md
@@ -66,7 +66,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe
| `approvalMode` | No | Tool approval mode for channel sessions. Unattended webhook tasks require `yolo`; the setting applies to every session on the channel |
| `instructions` | No | Custom instructions prepended to the first message of each session |
| `webhooks` | No | Webhook sources and delivery targets for daemon-managed channels. See [Webhook-triggered tasks](#webhook-triggered-tasks) |
-| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) |
+| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, `pairing`, or `open`. See [Group Chats](#group-chats) |
| `dmPolicy` | No | Private/DM access: `open` (default) or `disabled` (silently drop all DMs). Useful for group-only bots |
| `groupHistoryLimit` | No | Opt-in group history backfill. `0` or omitted disables it. A positive number persists that many authorized, unmentioned group messages for the next bot mention/reply. |
| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) |
@@ -206,7 +206,7 @@ Run these from the channel's workspace directory (or pass `--cwd
`) — pai
## Group Chats
-By default, the bot only works in direct messages. To enable group chat support, set `groupPolicy` to `"allowlist"` or `"open"`.
+By default, the bot only works in direct messages. To enable group chat support, set `groupPolicy` to `"allowlist"`, `"pairing"`, or `"open"`.
### Group Policy
@@ -214,8 +214,21 @@ Controls whether the bot participates in group chats at all:
- **`disabled`** (default) — The bot ignores all group messages. Safest option.
- **`allowlist`** — The bot only responds in groups explicitly listed in `groups` by chat ID. The `"*"` key provides default settings but does **not** act as a wildcard allow.
+- **`pairing`** — A deliberate mention or reply from an unknown group creates one pairing request for the group. Once approved, every member can use the bot in that group; `senderPolicy` continues to control direct messages.
- **`open`** — The bot responds in all groups it's added to. Use with caution.
+Approve a group with the same CLI command used for user pairing. The pending
+request identifies the group and the member who initiated it:
+
+```bash
+qwen channel pairing approve my-channel
+```
+
+Group approvals are stored by stable chat ID in the channel's workspace scope.
+An unmentioned message never creates a group pairing request, even when a group
+sets `requireMention` to `false`; after approval, the configured mention policy
+applies normally.
+
### Mention Gating
In groups, the bot requires an `@mention` or a reply to one of its messages by default. This prevents the bot from responding to every message in a group chat.
@@ -262,7 +275,7 @@ By default, Qwen ignores unmentioned group messages and does not store them as s
- Omitted or `0` disables backfill.
- Group-level `groupHistoryLimit` overrides the channel-level value.
-- Only messages from authorized senders are persisted.
+- Only messages from authorized senders, or members of an approved paired group, are persisted.
- Messages rejected by `groupPolicy` or group allowlist are not persisted.
- Pending group history is stored as local JSONL under `~/.qwen/channels/-group-history.jsonl` or `$QWEN_HOME/channels/-group-history.jsonl`.
- Cached messages are injected as untrusted context on the next real trigger and are not written as standalone session turns.
@@ -270,10 +283,10 @@ By default, Qwen ignores unmentioned group messages and does not store them as s
### How group messages are evaluated
```
-1. groupPolicy — is this group allowed? (no → ignore)
-2. dmPolicy — is this DM allowed? (disabled → ignore)
+1. groupPolicy — is this group disabled, listed, paired, or open? (no → ignore/pairing flow)
+2. dmPolicy — is this DM allowed? (disabled → ignore)
3. requireMention — was the bot mentioned/replied to? (no → ignore)
-4. senderPolicy — is this sender approved? (no → pairing flow)
+4. senderPolicy — is this sender approved? (skipped for a paired group; otherwise no → user pairing flow)
5. Route to session
```
diff --git a/packages/channels/base/README.md b/packages/channels/base/README.md
index 3721c48520e..e19d92107b3 100644
--- a/packages/channels/base/README.md
+++ b/packages/channels/base/README.md
@@ -120,7 +120,7 @@ Everything between `handleInbound()` and `sendMessage()` is handled by the base
| `ChannelPlugin` | Plugin factory interface (what you export) |
| `Envelope` | Normalized inbound message format |
| `SenderPolicy` | `'allowlist' \| 'pairing' \| 'open'` |
-| `GroupPolicy` | `'disabled' \| 'allowlist' \| 'open'` |
+| `GroupPolicy` | `'disabled' \| 'allowlist' \| 'pairing' \| 'open'` |
| `SessionScope` | `'user' \| 'thread' \| 'single'` |
| `GroupConfig` | Per-group settings (e.g. `requireMention`) |
| `SessionTarget` | Maps a session back to its channel/sender/chat |
@@ -278,12 +278,12 @@ constructor(policy: SenderPolicy, allowedUsers?: string[], pairingStore?: Pairin
### GroupGate
```typescript
-constructor(policy?: GroupPolicy, groups?: Record)
+constructor(policy?: GroupPolicy, groups?: Record, pairingStore?: PairingStore)
```
-| Method | Description |
-| ----------------- | ---------------------------------------------------------------------------------------------- |
-| `check(envelope)` | Returns `{ allowed: boolean, reason?: 'disabled' \| 'not_allowlisted' \| 'mention_required' }` |
+| Method | Description |
+| ----------------- | -------------------------------------------------------------- |
+| `check(envelope)` | Returns the group policy decision and an optional pairing code |
**Policy behavior:**
@@ -291,6 +291,7 @@ constructor(policy?: GroupPolicy, groups?: Record)
| ----------- | ---------------------------------------- |
| `disabled` | All group messages rejected |
| `allowlist` | Only groups listed in config are allowed |
+| `pairing` | A group must be approved once by chat ID |
| `open` | All groups allowed |
When `requireMention` is `true` (default), group messages are only processed if the bot is @mentioned or the message is a reply to the bot.
@@ -301,16 +302,20 @@ When `requireMention` is `true` (default), group messages are only processed if
constructor(channelName: string, workspaceCwd?: string)
```
-Persists pairing state to `{channelName}-pairing.json` and `{channelName}-allowlist.json`. With `workspaceCwd` (what `ChannelBase` passes — the channel's `cwd`), the files live under the workspace-scoped directory `~/.qwen/channels//` so two workspaces reusing the same channel name never share pairing requests or allowlist entries. Without it, the legacy global `~/.qwen/channels/` layout is used. The first time a given (workspace, channel) pair is constructed, existing legacy global files are copied in once (grandfathering) so already-approved senders stay approved; a per-channel `.migrated` sentinel in the scope directory marks that decision, after which legacy files are never consulted again for that channel. Channel names are URI-encoded in file names, so a name containing path separators cannot escape the scope directory. `revoke(senderId)` removes the sender only from this store's allowlist and never mutates the legacy global baseline.
+Persists pending pairing state to `{channelName}-pairing.json`, user approvals to `{channelName}-allowlist.json`, and group approvals to `{channelName}-group-allowlist.json`. With `workspaceCwd` (what `ChannelBase` passes — the channel's `cwd`), the files live under the workspace-scoped directory `~/.qwen/channels//` so two workspaces reusing the same channel name never share pairing requests or allowlist entries. Without it, the legacy global `~/.qwen/channels/` layout is used. The first time a given (workspace, channel) pair is constructed, existing legacy global files are copied in once (grandfathering) so already-approved senders stay approved; a per-channel `.migrated` sentinel in the scope directory marks that decision, after which legacy files are never consulted again for that channel. Channel names are URI-encoded in file names, so a name containing path separators cannot escape the scope directory. `revoke(senderId)` removes the sender only from this store's allowlist and never mutates the legacy global baseline.
| Method | Description |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `createRequest(senderId, senderName)` | Generate an 8-char pairing code (or return existing). Returns `null` if 3 pending requests already exist. |
-| `approve(code)` | Approve a pairing request, adds sender to allowlist. Returns the request or `null`. |
+| `createGroupRequest(...)` | Generate a request keyed by stable group ID and record its initiating sender. |
+| `approve(code)` | Approve a user or group request and update its corresponding allowlist. |
| `isApproved(senderId)` | Check if sender is in the approved allowlist |
+| `isGroupApproved(groupId)` | Check if a group is approved |
| `listPending()` | Get active (non-expired) pending requests |
| `getAllowlist()` | Get approved sender IDs |
+| `getGroupAllowlist()` | Get approved group IDs |
| `revoke(senderId)` | Remove an approved sender. Returns whether the sender was present. |
+| `revokeGroup(groupId)` | Remove an approved group. Returns whether the group was present. |
## Envelope
diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts
index 1799d109968..32282af2ca7 100644
--- a/packages/channels/base/src/ChannelBase.test.ts
+++ b/packages/channels/base/src/ChannelBase.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'node:events';
-import { mkdtempSync } from 'node:fs';
+import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type {
@@ -34,6 +34,7 @@ import {
ChannelProactiveDeliveryError,
isChannelProactiveDeliveryError,
} from './ChannelProactiveDeliveryError.js';
+import { PairingStore } from './PairingStore.js';
// Concrete test implementation
class TestChannel extends ChannelBase {
@@ -2662,6 +2663,59 @@ describe('ChannelBase', () => {
);
});
+ it('backfills messages from members of an approved paired group', async () => {
+ const previousQwenHome = process.env['QWEN_HOME'];
+ const qwenHome = mkdtempSync(join(tmpdir(), 'qwen-group-pairing-'));
+ process.env['QWEN_HOME'] = qwenHome;
+ try {
+ const store = new PairingStore('test-chan', '/tmp');
+ const code = store.createGroupRequest(
+ 'chat1',
+ 'Release Team',
+ 'alice',
+ 'Alice',
+ );
+ store.approve(code!);
+ const ch = createChannel(
+ {
+ groupPolicy: 'pairing',
+ senderPolicy: 'allowlist',
+ allowedUsers: [],
+ groupHistoryLimit: 10,
+ groups: { '*': { requireMention: true } },
+ },
+ { groupHistoryPath: groupHistoryPath() },
+ );
+
+ await ch.handleInbound(
+ envelope({
+ isGroup: true,
+ senderId: 'bob',
+ senderName: 'Bob',
+ text: 'background',
+ }),
+ );
+ await ch.handleInbound(
+ envelope({
+ isGroup: true,
+ isMentioned: true,
+ senderId: 'carol',
+ senderName: 'Carol',
+ text: '@bot summarize',
+ }),
+ );
+
+ const prompt = (bridge.prompt as ReturnType).mock
+ .calls[0][1] as string;
+ expect(prompt).toContain('- [Bob] background');
+ expect(prompt).toContain('[Carol] @bot summarize');
+ } finally {
+ if (previousQwenHome === undefined) delete process.env['QWEN_HOME'];
+ else process.env['QWEN_HOME'] = previousQwenHome;
+ rmSync(qwenHome, { recursive: true, force: true });
+ }
+ });
+
it('persists group history across channel instances', async () => {
const historyPath = groupHistoryPath();
const config = {
@@ -13348,6 +13402,73 @@ describe('ChannelBase', () => {
expect(threadMessages[0]!.threadId).toBe('issue:42');
expect(threadMessages[0]!.text).toContain('pairing code');
});
+
+ it('pairs a mentioned group once and lets other members use it', async () => {
+ const previousQwenHome = process.env['QWEN_HOME'];
+ const qwenHome = mkdtempSync(join(tmpdir(), 'qwen-group-pairing-'));
+ process.env['QWEN_HOME'] = qwenHome;
+ try {
+ const ch = createChannel({
+ groupPolicy: 'pairing',
+ senderPolicy: 'allowlist',
+ allowedUsers: [],
+ });
+ const first = envelope({
+ isGroup: true,
+ isMentioned: true,
+ chatId: 'group-1',
+ chatName: 'Release Team',
+ senderId: 'alice',
+ senderName: 'Alice',
+ });
+
+ await ch.handleInbound(first);
+
+ expect(ch.sent).toHaveLength(1);
+ expect(bridge.prompt).not.toHaveBeenCalled();
+ const store = new PairingStore('test-chan', '/tmp');
+ const request = store.listPending()[0];
+ expect(request?.subject).toEqual({
+ type: 'group',
+ id: 'group-1',
+ name: 'Release Team',
+ });
+ store.approve(request!.code);
+
+ await ch.handleInbound({
+ ...first,
+ senderId: 'bob',
+ senderName: 'Bob',
+ });
+
+ expect(bridge.prompt).toHaveBeenCalledOnce();
+ expect(store.isApproved('alice')).toBe(false);
+ expect(store.isApproved('bob')).toBe(false);
+ } finally {
+ if (previousQwenHome === undefined) delete process.env['QWEN_HOME'];
+ else process.env['QWEN_HOME'] = previousQwenHome;
+ rmSync(qwenHome, { recursive: true, force: true });
+ }
+ });
+
+ it('does not create group pairing requests from ambient messages', async () => {
+ const previousQwenHome = process.env['QWEN_HOME'];
+ const qwenHome = mkdtempSync(join(tmpdir(), 'qwen-group-pairing-'));
+ process.env['QWEN_HOME'] = qwenHome;
+ try {
+ const ch = createChannel({ groupPolicy: 'pairing' });
+
+ await ch.handleInbound(envelope({ isGroup: true, chatId: 'group-1' }));
+
+ expect(ch.sent).toEqual([]);
+ expect(bridge.prompt).not.toHaveBeenCalled();
+ expect(new PairingStore('test-chan', '/tmp').listPending()).toEqual([]);
+ } finally {
+ if (previousQwenHome === undefined) delete process.env['QWEN_HOME'];
+ else process.env['QWEN_HOME'] = previousQwenHome;
+ rmSync(qwenHome, { recursive: true, force: true });
+ }
+ });
});
describe('setBridge', () => {
@@ -18318,6 +18439,96 @@ describe('ChannelBase', () => {
expect(bridge.prompt).toHaveBeenCalled();
});
+ it('allows a stored group job after the group is paired', async () => {
+ const previousQwenHome = process.env['QWEN_HOME'];
+ const qwenHome = mkdtempSync(join(tmpdir(), 'qwen-group-pairing-'));
+ process.env['QWEN_HOME'] = qwenHome;
+ try {
+ const store = new PairingStore('test-chan', '/tmp');
+ const code = store.createGroupRequest(
+ 'group-1',
+ 'Release Team',
+ 'alice',
+ 'Alice',
+ );
+ store.approve(code!);
+ const disable = vi.fn().mockResolvedValue(true);
+ const ch = createChannel(
+ {
+ groupPolicy: 'pairing',
+ senderPolicy: 'allowlist',
+ allowedUsers: [],
+ },
+ {
+ loopController: {
+ create: vi.fn(),
+ listForTarget: vi.fn(),
+ disable,
+ validateCron: vi.fn(),
+ },
+ },
+ );
+ ch.proactiveSupported = true;
+
+ await ch.runLoopPrompt({
+ id: 'job-1',
+ channelName: 'test-chan',
+ target: {
+ channelName: 'test-chan',
+ senderId: 'bob',
+ chatId: 'group-1',
+ isGroup: true,
+ },
+ cwd: '/tmp',
+ cron: '0 9 * * *',
+ prompt: 'post summary',
+ recurring: true,
+ enabled: true,
+ createdBy: 'Bob',
+ createdAt: '2026-06-30T01:00:00.000Z',
+ consecutiveFailures: 0,
+ runCount: 0,
+ });
+
+ expect(disable).not.toHaveBeenCalled();
+ expect(bridge.prompt).toHaveBeenCalled();
+
+ store.revokeGroup('group-1');
+ disable.mockClear();
+ (bridge.prompt as ReturnType).mockClear();
+
+ await expect(
+ ch.runLoopPrompt({
+ id: 'job-2',
+ channelName: 'test-chan',
+ target: {
+ channelName: 'test-chan',
+ senderId: 'bob',
+ chatId: 'group-1',
+ isGroup: true,
+ },
+ cwd: '/tmp',
+ cron: '0 9 * * *',
+ prompt: 'post summary',
+ recurring: true,
+ enabled: true,
+ createdBy: 'Bob',
+ createdAt: '2026-06-30T01:00:00.000Z',
+ consecutiveFailures: 0,
+ runCount: 1,
+ }),
+ ).rejects.toThrow('no longer authorized');
+
+ expect(disable).toHaveBeenCalledWith('job-2');
+ expect(bridge.prompt).not.toHaveBeenCalled();
+ expect(store.listPending()).toEqual([]);
+ } finally {
+ if (previousQwenHome === undefined) delete process.env['QWEN_HOME'];
+ else process.env['QWEN_HOME'] = previousQwenHome;
+ rmSync(qwenHome, { recursive: true, force: true });
+ }
+ });
+
it('rejects stored threaded jobs unless the adapter supports the target', async () => {
const ch = createChannel();
ch.proactiveSupported = true;
diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts
index 10450bcf359..d0ef49e0246 100644
--- a/packages/channels/base/src/ChannelBase.ts
+++ b/packages/channels/base/src/ChannelBase.ts
@@ -818,15 +818,18 @@ export abstract class ChannelBase {
this.observedContacts = options?.observedContacts;
this.bridgeRecovery = options?.bridgeRecovery;
- this.groupGate = new GroupGate(config.groupPolicy, config.groups);
- this.dmGate = new DmGate(config.dmPolicy);
-
// Scoped by the channel's workspace cwd: two workspaces reusing the same
// channel name must not share pairing/allowlist state (#7017).
const pairingStore =
- config.senderPolicy === 'pairing'
+ config.senderPolicy === 'pairing' || config.groupPolicy === 'pairing'
? new PairingStore(name, config.cwd)
: undefined;
+ this.groupGate = new GroupGate(
+ config.groupPolicy,
+ config.groups,
+ pairingStore,
+ );
+ this.dmGate = new DmGate(config.dmPolicy);
this.gate = new SenderGate(
config.senderPolicy,
config.allowedUsers,
@@ -3604,9 +3607,11 @@ export abstract class ChannelBase {
isReplyToBot: true,
};
return (
- this.groupGate.check(envelope).allowed &&
+ this.groupGate.check(envelope, { createPairingRequest: false }).allowed &&
this.dmGate.check(envelope).allowed &&
- this.gate.isAllowed(normalizedTarget.senderId) &&
+ (normalizedTarget.isGroup && this.config.groupPolicy === 'pairing'
+ ? true
+ : this.gate.isAllowed(normalizedTarget.senderId)) &&
this.isAuthorizedForSharedSession(envelope)
);
}
@@ -4714,7 +4719,10 @@ export abstract class ChannelBase {
return;
}
const senderId = truncateGroupHistoryField(envelope.senderId);
- if (!this.gate.isAllowed(senderId)) {
+ if (
+ this.config.groupPolicy !== 'pairing' &&
+ !this.gate.isAllowed(senderId)
+ ) {
return;
}
@@ -4777,9 +4785,10 @@ export abstract class ChannelBase {
return promptText;
}
- const lines = entries.filter((entry) =>
- this.gate.isAllowed(entry.senderId),
- );
+ const lines =
+ this.config.groupPolicy === 'pairing'
+ ? entries
+ : entries.filter((entry) => this.gate.isAllowed(entry.senderId));
if (lines.length === 0) {
return promptText;
}
@@ -4799,9 +4808,29 @@ export abstract class ChannelBase {
protected preflightInbound(envelope: Envelope): boolean | Promise {
const groupResult = this.groupGate.check(envelope);
if (!groupResult.allowed) {
+ if (groupResult.pairingCode !== undefined) {
+ this.logPreflightRejected('group_pairing_required');
+ return this.onGroupPairingRequired(
+ envelope.chatId,
+ groupResult.pairingCode,
+ envelope.threadId,
+ )
+ .then(() => false)
+ .catch((err: unknown) => {
+ process.stderr.write(
+ `[Channel:${this.name}] group pairing notification failed: ${sanitizeLogText(
+ err instanceof Error ? err.message : String(err),
+ 200,
+ )}\n`,
+ );
+ return false;
+ });
+ }
if (groupResult.reason === 'mention_required') {
// This is the expected high-frequency drop path for group bots.
this.recordPendingGroupHistory(envelope);
+ } else if (groupResult.reason === 'pairing_trigger_required') {
+ return false;
} else {
this.logPreflightRejected(`group_${groupResult.reason ?? 'denied'}`);
}
@@ -4814,6 +4843,11 @@ export abstract class ChannelBase {
return false;
}
+ if (envelope.isGroup && this.config.groupPolicy === 'pairing') {
+ this.markPreflighted(envelope);
+ return true;
+ }
+
const result = this.gate.check(envelope.senderId, envelope.senderName);
if (!result.allowed) {
if (result.pairingCode !== undefined) {
@@ -5738,6 +5772,26 @@ export abstract class ChannelBase {
);
}
}
+
+ protected async onGroupPairingRequired(
+ chatId: string,
+ code: string | null,
+ threadId?: string,
+ ): Promise {
+ if (code) {
+ await this.sendThreadMessage(
+ chatId,
+ threadId,
+ `This group requires approval. Its pairing code is: ${code}\n\nAsk the bot operator to approve the group with:\n qwen channel pairing approve ${this.name} ${code}`,
+ );
+ } else {
+ await this.sendThreadMessage(
+ chatId,
+ threadId,
+ 'Too many pending pairing requests. Please try again later.',
+ );
+ }
+ }
}
function truncateGroupHistoryField(value: string): string {
diff --git a/packages/channels/base/src/GroupGate.test.ts b/packages/channels/base/src/GroupGate.test.ts
index 4f541952306..fcaf60eb8fd 100644
--- a/packages/channels/base/src/GroupGate.test.ts
+++ b/packages/channels/base/src/GroupGate.test.ts
@@ -105,6 +105,27 @@ describe('GroupGate', () => {
});
});
+ describe('pairing policy', () => {
+ it('does not create a pairing request for ambient group messages', () => {
+ const gate = new GroupGate('pairing');
+ const result = gate.check(envelope({ isGroup: true }));
+ expect(result).toEqual({
+ allowed: false,
+ reason: 'pairing_trigger_required',
+ });
+ });
+
+ it('requests pairing after an explicit mention', () => {
+ const gate = new GroupGate('pairing');
+ const result = gate.check(envelope({ isGroup: true, isMentioned: true }));
+ expect(result).toEqual({
+ allowed: false,
+ reason: 'pairing_required',
+ pairingCode: null,
+ });
+ });
+ });
+
describe('defaults', () => {
it('defaults to disabled policy', () => {
const gate = new GroupGate();
diff --git a/packages/channels/base/src/GroupGate.ts b/packages/channels/base/src/GroupGate.ts
index 82b9754e6da..5e8e14a40e3 100644
--- a/packages/channels/base/src/GroupGate.ts
+++ b/packages/channels/base/src/GroupGate.ts
@@ -1,20 +1,30 @@
import type { GroupPolicy, GroupConfig, Envelope } from './types.js';
+import type { PairingStore } from './PairingStore.js';
export interface GroupCheckResult {
allowed: boolean;
- reason?: 'disabled' | 'not_allowlisted' | 'mention_required';
+ reason?:
+ | 'disabled'
+ | 'not_allowlisted'
+ | 'mention_required'
+ | 'pairing_trigger_required'
+ | 'pairing_required';
+ pairingCode?: string | null;
}
export class GroupGate {
private policy: GroupPolicy;
private groups: Record;
+ private pairingStore: PairingStore | null;
constructor(
policy: GroupPolicy = 'disabled',
groups: Record = {},
+ pairingStore?: PairingStore,
) {
this.policy = policy;
this.groups = groups;
+ this.pairingStore = pairingStore ?? null;
}
/**
@@ -27,7 +37,10 @@ export class GroupGate {
* Mention gating runs before sender gate so that unmentioned messages
* in groups don't trigger pairing flows.
*/
- check(envelope: Envelope): GroupCheckResult {
+ check(
+ envelope: Envelope,
+ options: { createPairingRequest?: boolean } = {},
+ ): GroupCheckResult {
if (!envelope.isGroup) {
return { allowed: true };
}
@@ -44,6 +57,29 @@ export class GroupGate {
}
}
+ if (
+ this.policy === 'pairing' &&
+ !this.pairingStore?.isGroupApproved(envelope.chatId)
+ ) {
+ if (
+ options.createPairingRequest === false ||
+ (!envelope.isMentioned && !envelope.isReplyToBot)
+ ) {
+ return { allowed: false, reason: 'pairing_trigger_required' };
+ }
+ const code = this.pairingStore?.createGroupRequest(
+ envelope.chatId,
+ envelope.chatName || envelope.chatId,
+ envelope.senderId,
+ envelope.senderName,
+ );
+ return {
+ allowed: false,
+ reason: 'pairing_required',
+ pairingCode: code ?? null,
+ };
+ }
+
// Per-group config, falling back to "*" defaults, then built-in defaults
const groupConfig = this.groups[envelope.chatId] || this.groups['*'] || {};
const requireMention = groupConfig.requireMention ?? true;
diff --git a/packages/channels/base/src/PairingStore.test.ts b/packages/channels/base/src/PairingStore.test.ts
index 21aa3fe9202..d69bf2024d4 100644
--- a/packages/channels/base/src/PairingStore.test.ts
+++ b/packages/channels/base/src/PairingStore.test.ts
@@ -55,6 +55,53 @@ describe('PairingStore workspace scoping (#7017)', () => {
expect(storeB.isApproved('sender-1')).toBe(false);
});
+ it('approves a group without approving the member who requested it', () => {
+ const store = new PairingStore('support-bot', workspaceA);
+ const code = store.createGroupRequest(
+ 'group-1',
+ 'Release Team',
+ 'sender-1',
+ 'Alice',
+ );
+
+ expect(code).toBeTruthy();
+ expect(store.listPending()).toEqual([
+ expect.objectContaining({
+ senderId: 'sender-1',
+ senderName: 'Alice',
+ subject: {
+ type: 'group',
+ id: 'group-1',
+ name: 'Release Team',
+ },
+ }),
+ ]);
+
+ store.approve(code!);
+
+ expect(store.isGroupApproved('group-1')).toBe(true);
+ expect(store.isApproved('sender-1')).toBe(false);
+ });
+
+ it('isolates group approvals and revocation by workspace', () => {
+ const storeA = new PairingStore('support-bot', workspaceA);
+ const storeB = new PairingStore('support-bot', workspaceB);
+
+ for (const store of [storeA, storeB]) {
+ const code = store.createGroupRequest(
+ 'group-1',
+ 'Release Team',
+ 'sender-1',
+ 'Alice',
+ );
+ store.approve(code!);
+ }
+
+ expect(storeA.revokeGroup('group-1')).toBe(true);
+ expect(storeA.isGroupApproved('group-1')).toBe(false);
+ expect(storeB.isGroupApproved('group-1')).toBe(true);
+ });
+
it('revokes an approved sender only from the selected workspace', () => {
const storeA = new PairingStore('support-bot', workspaceA);
const storeB = new PairingStore('support-bot', workspaceB);
@@ -155,6 +202,21 @@ describe('PairingStore workspace scoping (#7017)', () => {
]);
});
+ it('approves a legacy pending request as a user request', () => {
+ seedLegacy();
+ const store = new PairingStore('support-bot', workspaceA);
+
+ const approved = store.approve('ABCDEFGH');
+
+ expect(approved?.subject).toEqual({
+ type: 'user',
+ id: 'pending-sender',
+ name: 'Pending',
+ });
+ expect(store.isApproved('pending-sender')).toBe(true);
+ expect(store.getGroupAllowlist()).toEqual([]);
+ });
+
it('lets every workspace grandfather the same legacy baseline (copy, not move)', () => {
seedLegacy();
const storeA = new PairingStore('support-bot', workspaceA);
diff --git a/packages/channels/base/src/PairingStore.ts b/packages/channels/base/src/PairingStore.ts
index 8c409741692..db97deeb366 100644
--- a/packages/channels/base/src/PairingStore.ts
+++ b/packages/channels/base/src/PairingStore.ts
@@ -12,14 +12,26 @@ const MAX_PENDING = 3;
export interface PairingRequest {
senderId: string;
senderName: string;
+ subject: PairingSubject;
code: string;
createdAt: number; // epoch ms
}
+export interface PairingSubject {
+ type: 'user' | 'group';
+ id: string;
+ name: string;
+}
+
+type StoredPairingRequest = Omit & {
+ subject?: PairingSubject;
+};
+
export class PairingStore {
private dir: string;
private pendingPath: string;
private allowlistPath: string;
+ private groupAllowlistPath: string;
private migratedSentinelPath: string;
/**
@@ -50,6 +62,10 @@ export class PairingStore {
this.dir,
`${safeChannelName}-allowlist.json`,
);
+ this.groupAllowlistPath = path.join(
+ this.dir,
+ `${safeChannelName}-group-allowlist.json`,
+ );
this.migratedSentinelPath = path.join(
this.dir,
`${safeChannelName}.migrated`,
@@ -151,20 +167,53 @@ export class PairingStore {
return list.includes(senderId);
}
+ isGroupApproved(groupId: string): boolean {
+ return this.readGroupAllowlist().includes(groupId);
+ }
+
/**
* Create a pairing request for an unknown sender.
* Returns the code if created, or null if the pending cap is reached.
* If the sender already has a non-expired pending request, returns that code.
*/
createRequest(senderId: string, senderName: string): string | null {
+ return this.createSubjectRequest(
+ { type: 'user', id: senderId, name: senderName },
+ senderId,
+ senderName,
+ );
+ }
+
+ createGroupRequest(
+ groupId: string,
+ groupName: string,
+ senderId: string,
+ senderName: string,
+ ): string | null {
+ return this.createSubjectRequest(
+ { type: 'group', id: groupId, name: groupName },
+ senderId,
+ senderName,
+ );
+ }
+
+ private createSubjectRequest(
+ subject: PairingSubject,
+ senderId: string,
+ senderName: string,
+ ): string | null {
const pending = this.readPending();
// Purge expired
const now = Date.now();
const active = pending.filter((r) => now - r.createdAt < EXPIRY_MS);
- // Check if sender already has a pending request
- const existing = active.find((r) => r.senderId === senderId);
+ // Check if the same user or group already has a pending request
+ const existing = active.find(
+ (request) =>
+ request.subject.type === subject.type &&
+ request.subject.id === subject.id,
+ );
if (existing) {
return existing.code;
}
@@ -175,14 +224,14 @@ export class PairingStore {
}
const code = generateCode();
- active.push({ senderId, senderName, code, createdAt: now });
+ active.push({ senderId, senderName, subject, code, createdAt: now });
this.writePending(active);
return code;
}
/**
* Approve a pairing request by code.
- * Returns the sender ID if found, or null if not found / expired.
+ * Returns the request if found, or null if not found / expired.
*/
approve(code: string): PairingRequest | null {
const pending = this.readPending();
@@ -196,11 +245,18 @@ export class PairingStore {
pending.splice(idx, 1);
this.writePending(pending);
- // Add to allowlist
- const list = this.readAllowlist();
- if (!list.includes(request.senderId)) {
- list.push(request.senderId);
- this.writeAllowlist(list);
+ if (request.subject.type === 'group') {
+ const groups = this.readGroupAllowlist();
+ if (!groups.includes(request.subject.id)) {
+ groups.push(request.subject.id);
+ this.writeGroupAllowlist(groups);
+ }
+ } else {
+ const users = this.readAllowlist();
+ if (!users.includes(request.subject.id)) {
+ users.push(request.subject.id);
+ this.writeAllowlist(users);
+ }
}
return request;
@@ -216,6 +272,10 @@ export class PairingStore {
return this.readAllowlist();
}
+ getGroupAllowlist(): string[] {
+ return this.readGroupAllowlist();
+ }
+
revoke(senderId: string): boolean {
const list = this.readAllowlist();
const next = list.filter((id) => id !== senderId);
@@ -226,6 +286,16 @@ export class PairingStore {
return true;
}
+ revokeGroup(groupId: string): boolean {
+ const list = this.readGroupAllowlist();
+ const next = list.filter((id) => id !== groupId);
+ if (next.length === list.length) {
+ return false;
+ }
+ this.writeGroupAllowlist(next);
+ return true;
+ }
+
private ensureDir(): void {
if (!fs.existsSync(this.dir)) {
fs.mkdirSync(this.dir, { recursive: true });
@@ -235,7 +305,15 @@ export class PairingStore {
private readPending(): PairingRequest[] {
try {
const data = fs.readFileSync(this.pendingPath, 'utf-8');
- return JSON.parse(data) as PairingRequest[];
+ const requests = JSON.parse(data) as StoredPairingRequest[];
+ return requests.map((request) => ({
+ ...request,
+ subject: request.subject ?? {
+ type: 'user',
+ id: request.senderId,
+ name: request.senderName,
+ },
+ }));
} catch {
return [];
}
@@ -259,6 +337,20 @@ export class PairingStore {
this.ensureDir();
fs.writeFileSync(this.allowlistPath, JSON.stringify(list, null, 2));
}
+
+ private readGroupAllowlist(): string[] {
+ try {
+ const data = fs.readFileSync(this.groupAllowlistPath, 'utf-8');
+ return JSON.parse(data) as string[];
+ } catch {
+ return [];
+ }
+ }
+
+ private writeGroupAllowlist(list: string[]): void {
+ this.ensureDir();
+ fs.writeFileSync(this.groupAllowlistPath, JSON.stringify(list, null, 2));
+ }
}
function generateCode(): string {
diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts
index c454c9393d5..9062d5652e8 100644
--- a/packages/channels/base/src/index.ts
+++ b/packages/channels/base/src/index.ts
@@ -72,7 +72,7 @@ export type {
ChannelLoopStoreOptions,
} from './ChannelLoopStore.js';
export { PairingStore } from './PairingStore.js';
-export type { PairingRequest } from './PairingStore.js';
+export type { PairingRequest, PairingSubject } from './PairingStore.js';
export { GroupGate } from './GroupGate.js';
export type { GroupCheckResult } from './GroupGate.js';
export { DmGate } from './DmGate.js';
diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts
index f5338cc5646..77a1996009a 100644
--- a/packages/channels/base/src/types.ts
+++ b/packages/channels/base/src/types.ts
@@ -6,7 +6,7 @@ import type { ChannelWebhookConfig } from './ChannelWebhookTask.js';
export type SenderPolicy = 'allowlist' | 'pairing' | 'open';
export type SessionScope = 'user' | 'thread' | 'chat_thread' | 'single';
export type ChannelType = string;
-export type GroupPolicy = 'disabled' | 'allowlist' | 'open';
+export type GroupPolicy = 'disabled' | 'allowlist' | 'pairing' | 'open';
export type DmPolicy = 'disabled' | 'open';
export type DispatchMode = 'collect' | 'steer' | 'followup';
diff --git a/packages/cli/src/commands/channel/pairing.test.ts b/packages/cli/src/commands/channel/pairing.test.ts
index 351cd7615f4..072c106781c 100644
--- a/packages/cli/src/commands/channel/pairing.test.ts
+++ b/packages/cli/src/commands/channel/pairing.test.ts
@@ -114,6 +114,39 @@ describe('channel pairing CLI (--cwd scoping)', () => {
);
});
+ it('lists and approves a group request as a group', () => {
+ const store = new PairingStore('support-bot', wsA);
+ const code = store.createGroupRequest(
+ 'group-1',
+ 'Release Team',
+ 'user-alice',
+ 'Alice',
+ )!;
+
+ pairingListCommand.handler!({
+ name: 'support-bot',
+ cwd: wsA,
+ _: [],
+ $0: '',
+ } as unknown as ListArgs);
+ expect(stdoutText()).toContain('Group: Release Team (group-1)');
+ expect(stdoutText()).toContain('Requested by: Alice (user-alice)');
+
+ pairingApproveCommand.handler!({
+ name: 'support-bot',
+ code,
+ cwd: wsA,
+ _: [],
+ $0: '',
+ } as unknown as Parameters<
+ NonNullable
+ >[0]);
+
+ expect(store.isGroupApproved('group-1')).toBe(true);
+ expect(store.isApproved('user-alice')).toBe(false);
+ expect(stdoutText()).toContain('Approved: group Release Team (group-1)');
+ });
+
it('approve with a code from another workspace fails with the scoped error', () => {
const codeB = new PairingStore('support-bot', wsB).createRequest(
'user-bob',
diff --git a/packages/cli/src/commands/channel/pairing.ts b/packages/cli/src/commands/channel/pairing.ts
index 3ab0f31e6c9..6dcfca8298b 100644
--- a/packages/cli/src/commands/channel/pairing.ts
+++ b/packages/cli/src/commands/channel/pairing.ts
@@ -40,9 +40,11 @@ export const pairingListCommand: CommandModule<
writeStdoutLine(`Pending pairing requests for "${argv.name}":\n`);
for (const req of pending) {
const ago = Math.round((Date.now() - req.createdAt) / 60000);
- writeStdoutLine(
- ` Code: ${req.code} Sender: ${req.senderName} (${req.senderId}) ${ago}m ago`,
- );
+ const subject =
+ req.subject.type === 'group'
+ ? `Group: ${req.subject.name} (${req.subject.id}) Requested by: ${req.senderName} (${req.senderId})`
+ : `Sender: ${req.subject.name} (${req.subject.id})`;
+ writeStdoutLine(` Code: ${req.code} ${subject} ${ago}m ago`);
}
},
};
@@ -78,8 +80,12 @@ export const pairingApproveCommand: CommandModule<
return; // process.exit is mocked in tests; never fall through
}
+ const approved =
+ request.subject.type === 'group'
+ ? `group ${request.subject.name} (${request.subject.id})`
+ : `${request.subject.name} (${request.subject.id})`;
writeStdoutLine(
- `Approved: ${request.senderName} (${request.senderId}) can now use channel "${argv.name}".`,
+ `Approved: ${approved} can now use channel "${argv.name}".`,
);
},
};
diff --git a/packages/cli/src/serve/channel-management-service.test.ts b/packages/cli/src/serve/channel-management-service.test.ts
index 1343f31f3c5..92a79b92470 100644
--- a/packages/cli/src/serve/channel-management-service.test.ts
+++ b/packages/cli/src/serve/channel-management-service.test.ts
@@ -306,7 +306,10 @@ describe('createChannelManagementService', () => {
code: 'channel_workspace_mismatch',
});
await expect(
- service.revokePairingApproval('bot', 'sender-1'),
+ service.revokePairingApproval('bot', {
+ type: 'user',
+ id: 'sender-1',
+ }),
).rejects.toMatchObject({
code: 'channel_workspace_mismatch',
});
@@ -354,16 +357,24 @@ describe('createChannelManagementService', () => {
expect(pairing.isApproved('sender-1')).toBe(true);
await expect(service.pairingApprovals('bot')).resolves.toEqual({
senderIds: ['sender-1'],
+ groupIds: [],
});
await expect(
- service.revokePairingApproval('bot', 'sender-1'),
+ service.revokePairingApproval('bot', {
+ type: 'user',
+ id: 'sender-1',
+ }),
).resolves.toEqual({
revoked: 'sender-1',
senderIds: [],
+ groupIds: [],
});
expect(pairing.isApproved('sender-1')).toBe(false);
await expect(
- service.revokePairingApproval('bot', 'sender-1'),
+ service.revokePairingApproval('bot', {
+ type: 'user',
+ id: 'sender-1',
+ }),
).rejects.toMatchObject({
code: 'channel_pairing_approval_not_found',
});
@@ -374,6 +385,66 @@ describe('createChannelManagementService', () => {
}
});
+ it('manages group pairing when groupPolicy uses pairing mode', async () => {
+ const previousQwenHome = process.env['QWEN_HOME'];
+ const qwenHome = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'channel-management-group-pairing-'),
+ );
+ process.env['QWEN_HOME'] = qwenHome;
+ try {
+ const { service } = setup({
+ snapshot: settingsSnapshot({
+ channels: {
+ bot: {
+ type: 'dingtalk',
+ senderPolicy: 'open',
+ groupPolicy: 'pairing',
+ },
+ },
+ }),
+ });
+ const pairing = new PairingStore('bot', WORKSPACE);
+ const code = pairing.createGroupRequest(
+ 'group-1',
+ 'Release Team',
+ 'sender-1',
+ 'Alice',
+ );
+
+ await expect(service.pairingRequests('bot')).resolves.toEqual({
+ requests: [
+ expect.objectContaining({
+ senderId: 'sender-1',
+ subject: {
+ type: 'group',
+ id: 'group-1',
+ name: 'Release Team',
+ },
+ }),
+ ],
+ });
+ await service.approvePairing('bot', code!);
+ await expect(service.pairingApprovals('bot')).resolves.toEqual({
+ senderIds: [],
+ groupIds: ['group-1'],
+ });
+ await expect(
+ service.revokePairingApproval('bot', {
+ type: 'group',
+ id: 'group-1',
+ }),
+ ).resolves.toEqual({
+ revoked: 'group-1',
+ senderIds: [],
+ groupIds: [],
+ });
+ } finally {
+ if (previousQwenHome === undefined) delete process.env['QWEN_HOME'];
+ else process.env['QWEN_HOME'] = previousQwenHome;
+ await fs.rm(qwenHome, { recursive: true, force: true });
+ }
+ });
+
it('retains the reload diagnostic when stopping the failed replacement also fails', async () => {
const { service, manager } = setup({ committedNames: ['bot'] });
manager.reloadWorkspace.mockRejectedValueOnce(
@@ -847,7 +918,10 @@ describe('createChannelManagementService', () => {
code: 'channel_pairing_not_enabled',
});
await expect(
- service.revokePairingApproval('bot', 'sender-1'),
+ service.revokePairingApproval('bot', {
+ type: 'user',
+ id: 'sender-1',
+ }),
).rejects.toMatchObject({ code: 'channel_pairing_not_enabled' });
});
});
diff --git a/packages/cli/src/serve/channel-management-service.ts b/packages/cli/src/serve/channel-management-service.ts
index 002ca22078d..75e647ed4d4 100644
--- a/packages/cli/src/serve/channel-management-service.ts
+++ b/packages/cli/src/serve/channel-management-service.ts
@@ -80,6 +80,12 @@ export interface ChannelPairingApprovalResult
export interface ChannelPairingApprovalsSnapshot {
senderIds: string[];
+ groupIds: string[];
+}
+
+export interface ChannelPairingApprovalSubject {
+ type: 'user' | 'group';
+ id: string;
}
export interface ChannelPairingRevocationResult
@@ -112,7 +118,7 @@ export interface ChannelManagementService {
pairingApprovals(name: string): Promise;
revokePairingApproval(
name: string,
- senderId: string,
+ subject: ChannelPairingApprovalSubject,
): Promise;
}
@@ -398,7 +404,10 @@ export function createChannelManagementService(
}
const config = channels[name]!;
assertWorkspaceConfig(config);
- if (config['senderPolicy'] !== 'pairing') {
+ if (
+ config['senderPolicy'] !== 'pairing' &&
+ config['groupPolicy'] !== 'pairing'
+ ) {
throw new ChannelManagementError(
'channel_pairing_not_enabled',
`Channel "${name}" does not use pairing mode.`,
@@ -555,17 +564,29 @@ export function createChannelManagementService(
return { approved, requests: store.listPending() };
},
async pairingApprovals(name) {
- return { senderIds: pairingStoreFor(name).getAllowlist() };
+ const store = pairingStoreFor(name);
+ return {
+ senderIds: store.getAllowlist(),
+ groupIds: store.getGroupAllowlist(),
+ };
},
- async revokePairingApproval(name, senderId) {
+ async revokePairingApproval(name, subject) {
const store = pairingStoreFor(name);
- if (!store.revoke(senderId)) {
+ const revoked =
+ subject.type === 'group'
+ ? store.revokeGroup(subject.id)
+ : store.revoke(subject.id);
+ if (!revoked) {
throw new ChannelManagementError(
'channel_pairing_approval_not_found',
'Pairing approval was not found.',
);
}
- return { revoked: senderId, senderIds: store.getAllowlist() };
+ return {
+ revoked: subject.id,
+ senderIds: store.getAllowlist(),
+ groupIds: store.getGroupAllowlist(),
+ };
},
};
return {
@@ -583,7 +604,7 @@ export function createChannelManagementService(
approvePairing: (name, code) =>
inMutationLane(() => service.approvePairing(name, code)),
pairingApprovals: (name) => service.pairingApprovals(name),
- revokePairingApproval: (name, senderId) =>
- inMutationLane(() => service.revokePairingApproval(name, senderId)),
+ revokePairingApproval: (name, subject) =>
+ inMutationLane(() => service.revokePairingApproval(name, subject)),
};
}
diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts
index 07ada91ee78..38b6716cce5 100644
--- a/packages/cli/src/serve/channel-settings-store.test.ts
+++ b/packages/cli/src/serve/channel-settings-store.test.ts
@@ -426,6 +426,7 @@ describe('WorkspaceChannelSettingsStore', () => {
retries: 3,
mode: 'safe',
senderPolicy: 'open',
+ groupPolicy: 'pairing',
allowedUsers: ['user-1'],
groupHistoryLimit: 25,
blockStreaming: 'on',
@@ -446,6 +447,7 @@ describe('WorkspaceChannelSettingsStore', () => {
retries: 3,
mode: 'safe',
senderPolicy: 'open',
+ groupPolicy: 'pairing',
allowedUsers: ['user-1'],
groupHistoryLimit: 25,
blockStreaming: 'on',
diff --git a/packages/cli/src/serve/channel-settings-store.ts b/packages/cli/src/serve/channel-settings-store.ts
index d4458fc302b..02ddd836ed8 100644
--- a/packages/cli/src/serve/channel-settings-store.ts
+++ b/packages/cli/src/serve/channel-settings-store.ts
@@ -142,7 +142,7 @@ function assertSharedField(key: string, value: unknown): boolean {
const enumValues: Record> = {
senderPolicy: new Set(['allowlist', 'pairing', 'open']),
dmPolicy: new Set(['open', 'disabled']),
- groupPolicy: new Set(['disabled', 'allowlist', 'open']),
+ groupPolicy: new Set(['disabled', 'allowlist', 'pairing', 'open']),
sessionScope: new Set(['user', 'thread', 'single']),
dispatchMode: new Set(['steer', 'followup', 'collect']),
blockStreaming: new Set(['on', 'off']),
diff --git a/packages/cli/src/serve/routes/workspace-channel-management.test.ts b/packages/cli/src/serve/routes/workspace-channel-management.test.ts
index 5af1cca8a9a..90ba8a999ec 100644
--- a/packages/cli/src/serve/routes/workspace-channel-management.test.ts
+++ b/packages/cli/src/serve/routes/workspace-channel-management.test.ts
@@ -55,15 +55,24 @@ function service(): ChannelManagementService {
approved: {
senderId: 'sender-1',
senderName: 'Alice',
+ subject: {
+ type: 'user' as const,
+ id: 'sender-1',
+ name: 'Alice',
+ },
code,
createdAt: 1,
},
requests: [],
})),
- pairingApprovals: vi.fn(async () => ({ senderIds: ['sender-1'] })),
- revokePairingApproval: vi.fn(async (_name, senderId) => ({
- revoked: senderId,
+ pairingApprovals: vi.fn(async () => ({
+ senderIds: ['sender-1'],
+ groupIds: ['group-1'],
+ })),
+ revokePairingApproval: vi.fn(async (_name, subject) => ({
+ revoked: subject.id,
senderIds: [],
+ groupIds: [],
})),
};
}
@@ -301,10 +310,10 @@ describe('workspace Channel management routes', () => {
'ABCDEFGH',
);
expect(secondaryService.pairingApprovals).toHaveBeenCalledWith('bot');
- expect(secondaryService.revokePairingApproval).toHaveBeenCalledWith(
- 'bot',
- 'sender-1',
- );
+ expect(secondaryService.revokePairingApproval).toHaveBeenCalledWith('bot', {
+ type: 'user',
+ id: 'sender-1',
+ });
expect(primaryService.pairingRequests).not.toHaveBeenCalled();
expect(primaryService.pairingApprovals).not.toHaveBeenCalled();
@@ -315,6 +324,22 @@ describe('workspace Channel management routes', () => {
expect(primaryService.pairingRequests).toHaveBeenCalledWith('bot');
});
+ it('revokes a group pairing approval by stable group ID', async () => {
+ const { app, secondaryService } = mount();
+
+ const response = await auth(
+ request(app)
+ .delete('/workspaces/secondary/channels/bot/pairing-approvals')
+ .send({ groupId: 'group-1' }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(secondaryService.revokePairingApproval).toHaveBeenCalledWith('bot', {
+ type: 'group',
+ id: 'group-1',
+ });
+ });
+
it('returns 404 when a pairing approval no longer exists', async () => {
const { app, primaryService } = mount();
vi.mocked(primaryService.revokePairingApproval).mockRejectedValueOnce(
@@ -450,6 +475,16 @@ describe('workspace Channel management routes', () => {
.delete('/workspace/channels/bot/pairing-approvals')
.send({ senderId: '' }),
);
+ const invalidGroupId = await auth(
+ request(app)
+ .delete('/workspace/channels/bot/pairing-approvals')
+ .send({ groupId: '' }),
+ );
+ const ambiguousSubject = await auth(
+ request(app)
+ .delete('/workspace/channels/bot/pairing-approvals')
+ .send({ senderId: 'sender-1', groupId: 'group-1' }),
+ );
expect(invalidName.body.code).toBe('invalid_channel_instance_name');
expect(unsafeName.status).toBe(400);
@@ -460,6 +495,8 @@ describe('workspace Channel management routes', () => {
expect(invalidSecret.body.code).toBe('channel_settings_invalid_secret');
expect(invalidPairing.body.code).toBe('invalid_channel_pairing_code');
expect(invalidSenderId.body.code).toBe('invalid_channel_pairing_sender_id');
+ expect(invalidGroupId.body.code).toBe('invalid_channel_pairing_group_id');
+ expect(ambiguousSubject.body.code).toBe('invalid_channel_pairing_subject');
expect(primaryService.upsert).toHaveBeenCalledOnce();
expect(primaryService.start).not.toHaveBeenCalled();
expect(primaryService.approvePairing).not.toHaveBeenCalled();
diff --git a/packages/cli/src/serve/routes/workspace-channel-management.ts b/packages/cli/src/serve/routes/workspace-channel-management.ts
index 13e23e61da1..e37a7ecaf06 100644
--- a/packages/cli/src/serve/routes/workspace-channel-management.ts
+++ b/packages/cli/src/serve/routes/workspace-channel-management.ts
@@ -9,6 +9,7 @@ import { redactLogCredentials } from '@qwen-code/acp-bridge/logRedaction';
import { sanitizeLogText } from '@qwen-code/channel-base';
import { supportedChannelCatalog } from '../../commands/channel/channel-registry.js';
import type {
+ ChannelPairingApprovalSubject,
ChannelManagementService,
ChannelStartupRequest,
ChannelUpsertRequest,
@@ -182,19 +183,44 @@ function parsePairingCode(
return code.trim().toUpperCase();
}
-function parsePairingSenderId(
+function parsePairingApprovalSubject(
body: Record,
res: Response,
-): string | undefined {
+): ChannelPairingApprovalSubject | undefined {
const senderId = body['senderId'];
- if (typeof senderId !== 'string' || senderId.length === 0) {
+ const groupId = body['groupId'];
+ if (senderId !== undefined && groupId !== undefined) {
+ res.status(400).json({
+ error: 'Provide exactly one of `senderId` or `groupId`.',
+ code: 'invalid_channel_pairing_subject',
+ });
+ return undefined;
+ }
+ if (senderId !== undefined) {
+ if (typeof senderId === 'string' && senderId.length > 0) {
+ return { type: 'user', id: senderId };
+ }
res.status(400).json({
error: '`senderId` must be a non-empty string.',
code: 'invalid_channel_pairing_sender_id',
});
return undefined;
}
- return senderId;
+ if (groupId !== undefined) {
+ if (typeof groupId === 'string' && groupId.length > 0) {
+ return { type: 'group', id: groupId };
+ }
+ res.status(400).json({
+ error: '`groupId` must be a non-empty string.',
+ code: 'invalid_channel_pairing_group_id',
+ });
+ return undefined;
+ }
+ res.status(400).json({
+ error: 'Provide exactly one of `senderId` or `groupId`.',
+ code: 'invalid_channel_pairing_subject',
+ });
+ return undefined;
}
function errorCode(error: unknown): string | undefined {
@@ -390,13 +416,13 @@ export function registerWorkspaceChannelManagementRoutes(
if (!resolved || !validateClient(req, res, resolved.runtime)) return;
const name = parseInstanceName(req, res);
if (!name) return;
- const senderId = parsePairingSenderId(deps.safeBody(req), res);
- if (!senderId) return;
+ const subject = parsePairingApprovalSubject(deps.safeBody(req), res);
+ if (!subject) return;
try {
noStore(res);
res
.status(200)
- .json(await resolved.service.revokePairingApproval(name, senderId));
+ .json(await resolved.service.revokePairingApproval(name, subject));
} catch (error) {
sendManagementError(res, error);
}
diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts
index ce2c2a79d62..51ce82877df 100644
--- a/packages/sdk-typescript/src/daemon/index.ts
+++ b/packages/sdk-typescript/src/daemon/index.ts
@@ -367,6 +367,7 @@ export type {
DaemonChannelStartupRequest,
DaemonChannelMutationResult,
DaemonChannelPairingRequest,
+ DaemonChannelPairingSubject,
DaemonChannelPairingRequestsSnapshot,
DaemonChannelPairingApprovalRequest,
DaemonChannelPairingApprovalResult,
diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts
index 48c985adaf5..9805f99208f 100644
--- a/packages/sdk-typescript/src/daemon/types.ts
+++ b/packages/sdk-typescript/src/daemon/types.ts
@@ -3059,10 +3059,17 @@ export interface DaemonChannelMutationResult {
export interface DaemonChannelPairingRequest {
senderId: string;
senderName: string;
+ subject?: DaemonChannelPairingSubject;
code: string;
createdAt: number;
}
+export interface DaemonChannelPairingSubject {
+ type: 'user' | 'group';
+ id: string;
+ name: string;
+}
+
export interface DaemonChannelPairingRequestsSnapshot {
requests: DaemonChannelPairingRequest[];
}
@@ -3078,11 +3085,12 @@ export interface DaemonChannelPairingApprovalResult
export interface DaemonChannelPairingApprovalsSnapshot {
senderIds: string[];
+ groupIds?: string[];
}
-export interface DaemonChannelPairingRevocationRequest {
- senderId: string;
-}
+export type DaemonChannelPairingRevocationRequest =
+ | { senderId: string; groupId?: never }
+ | { senderId?: never; groupId: string };
export interface DaemonChannelPairingRevocationResult
extends DaemonChannelPairingApprovalsSnapshot {
diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts
index 521eeb0da29..e5aa473a7c3 100644
--- a/packages/sdk-typescript/src/index.ts
+++ b/packages/sdk-typescript/src/index.ts
@@ -71,6 +71,7 @@ export {
type DaemonChannelStartupRequest,
type DaemonChannelMutationResult,
type DaemonChannelPairingRequest,
+ type DaemonChannelPairingSubject,
type DaemonChannelPairingRequestsSnapshot,
type DaemonChannelPairingApprovalRequest,
type DaemonChannelPairingApprovalResult,
diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts
index ad21cc2245b..d60ec7eb035 100644
--- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts
+++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts
@@ -7427,5 +7427,18 @@ describe('DaemonClient', () => {
senderId: 'sender-1',
});
});
+
+ it('sends a group ID when revoking a group pairing approval', async () => {
+ const { fetch, calls } = recordingFetch(() =>
+ jsonResponse(200, { revoked: 'group-1', senderIds: [], groupIds: [] }),
+ );
+ const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
+
+ await client.revokeWorkspaceChannelPairingApproval('bot', {
+ groupId: 'group-1',
+ });
+
+ expect(JSON.parse(calls[0]!.body!)).toEqual({ groupId: 'group-1' });
+ });
});
});
diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts
index 2e110db031f..4fc4e7f56d6 100644
--- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts
+++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts
@@ -27,6 +27,7 @@ import type {
DaemonChannelNotifyRequest,
DaemonChannelNotifyResult,
DaemonChannelPairingApprovalsSnapshot,
+ DaemonChannelPairingSubject,
DaemonChannelPairingRevocationRequest,
DaemonChannelPairingRevocationResult,
DaemonChannelDeliveryErrorCode,
@@ -224,6 +225,7 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => {
expectTypeOf().not.toBeNever();
expectTypeOf().not.toBeNever();
expectTypeOf().not.toBeNever();
+ expectTypeOf().not.toBeNever();
expectTypeOf().not.toBeNever();
expectTypeOf().not.toBeNever();
expectTypeOf().not.toBeNever();
From e3da5f0070cdba6f1fb0aa51f2824c6fba174a01 Mon Sep 17 00:00:00 2001
From: qqqys <266654365+qqqys@users.noreply.github.com>
Date: Mon, 3 Aug 2026 19:46:54 +0800
Subject: [PATCH 02/10] fix(channels): address group pairing review
---
packages/channels/base/README.md | 2 +-
.../channels/base/src/PairingStore.test.ts | 18 +++
packages/channels/base/src/PairingStore.ts | 2 +-
.../channels/ChannelEditorDialog.test.tsx | 38 +++++
.../channels/ChannelEditorDialog.tsx | 13 +-
.../channels/ChannelPairingRequests.test.tsx | 101 +++++++++++-
.../channels/ChannelPairingRequests.tsx | 147 +++++++++++++-----
packages/web-shell/client/i18n.tsx | 30 ++--
.../src/daemon/workspace/actions.test.ts | 8 +-
.../webui/src/daemon/workspace/actions.ts | 6 +-
packages/webui/src/daemon/workspace/types.ts | 3 +-
11 files changed, 303 insertions(+), 65 deletions(-)
diff --git a/packages/channels/base/README.md b/packages/channels/base/README.md
index e19d92107b3..151fd36c09d 100644
--- a/packages/channels/base/README.md
+++ b/packages/channels/base/README.md
@@ -302,7 +302,7 @@ When `requireMention` is `true` (default), group messages are only processed if
constructor(channelName: string, workspaceCwd?: string)
```
-Persists pending pairing state to `{channelName}-pairing.json`, user approvals to `{channelName}-allowlist.json`, and group approvals to `{channelName}-group-allowlist.json`. With `workspaceCwd` (what `ChannelBase` passes — the channel's `cwd`), the files live under the workspace-scoped directory `~/.qwen/channels//` so two workspaces reusing the same channel name never share pairing requests or allowlist entries. Without it, the legacy global `~/.qwen/channels/` layout is used. The first time a given (workspace, channel) pair is constructed, existing legacy global files are copied in once (grandfathering) so already-approved senders stay approved; a per-channel `.migrated` sentinel in the scope directory marks that decision, after which legacy files are never consulted again for that channel. Channel names are URI-encoded in file names, so a name containing path separators cannot escape the scope directory. `revoke(senderId)` removes the sender only from this store's allowlist and never mutates the legacy global baseline.
+Persists pending pairing state to `{channelName}-pairing.json`, user approvals to `{channelName}-allowlist.json`, and group approvals to `{channelName}-groups.json`. With `workspaceCwd` (what `ChannelBase` passes — the channel's `cwd`), the files live under the workspace-scoped directory `~/.qwen/channels//` so two workspaces reusing the same channel name never share pairing requests or allowlist entries. Without it, the legacy global `~/.qwen/channels/` layout is used. The first time a given (workspace, channel) pair is constructed, existing legacy global files are copied in once (grandfathering) so already-approved senders stay approved; a per-channel `.migrated` sentinel in the scope directory marks that decision, after which legacy files are never consulted again for that channel. Channel names are URI-encoded in file names, so a name containing path separators cannot escape the scope directory. `revoke(senderId)` removes the sender only from this store's allowlist and never mutates the legacy global baseline.
| Method | Description |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------- |
diff --git a/packages/channels/base/src/PairingStore.test.ts b/packages/channels/base/src/PairingStore.test.ts
index d69bf2024d4..ab452c6be47 100644
--- a/packages/channels/base/src/PairingStore.test.ts
+++ b/packages/channels/base/src/PairingStore.test.ts
@@ -102,6 +102,24 @@ describe('PairingStore workspace scoping (#7017)', () => {
expect(storeB.isGroupApproved('group-1')).toBe(true);
});
+ it('keeps group approvals separate from similarly named channel user approvals', () => {
+ const groupStore = new PairingStore('support', workspaceA);
+ const userStore = new PairingStore('support-group', workspaceA);
+ const code = groupStore.createGroupRequest(
+ 'shared-id',
+ 'Release Team',
+ 'sender-1',
+ 'Alice',
+ );
+
+ groupStore.approve(code!);
+
+ expect(groupStore.isGroupApproved('shared-id')).toBe(true);
+ expect(userStore.isApproved('shared-id')).toBe(false);
+ expect(userStore.revoke('shared-id')).toBe(false);
+ expect(groupStore.isGroupApproved('shared-id')).toBe(true);
+ });
+
it('revokes an approved sender only from the selected workspace', () => {
const storeA = new PairingStore('support-bot', workspaceA);
const storeB = new PairingStore('support-bot', workspaceB);
diff --git a/packages/channels/base/src/PairingStore.ts b/packages/channels/base/src/PairingStore.ts
index db97deeb366..9bbf718ea21 100644
--- a/packages/channels/base/src/PairingStore.ts
+++ b/packages/channels/base/src/PairingStore.ts
@@ -64,7 +64,7 @@ export class PairingStore {
);
this.groupAllowlistPath = path.join(
this.dir,
- `${safeChannelName}-group-allowlist.json`,
+ `${safeChannelName}-groups.json`,
);
this.migratedSentinelPath = path.join(
this.dir,
diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx
index 07054c667c2..41205a6aaf0 100644
--- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx
+++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx
@@ -43,6 +43,23 @@ const OPTIONAL_SECRET: DaemonChannelTypeDescriptor = {
),
};
+const DINGTALK_WITH_GROUP_POLICY: DaemonChannelTypeDescriptor = {
+ ...DINGTALK,
+ fields: [
+ ...DINGTALK.fields,
+ {
+ key: 'groupPolicy',
+ label: 'Group Policy',
+ kind: 'enum',
+ required: true,
+ options: [
+ { value: 'pairing', label: 'Pairing' },
+ { value: 'open', label: 'Open' },
+ ],
+ },
+ ],
+};
+
const INSTANCE: DaemonChannelInstanceSnapshot = {
name: 'release-bot',
config: {
@@ -266,4 +283,25 @@ describe('ChannelEditorDialog', () => {
expect(document.body.textContent).toContain('Pairing approvals');
expect(document.body.textContent).not.toContain('Configured allowlist');
});
+
+ it('shows pairing management when only group pairing is enabled', async () => {
+ const groupPairingInstance: DaemonChannelInstanceSnapshot = {
+ ...INSTANCE,
+ config: {
+ ...INSTANCE.config,
+ senderPolicy: 'open',
+ groupPolicy: 'pairing',
+ },
+ };
+ const listPairingRequests = vi.fn().mockResolvedValue({ requests: [] });
+
+ await renderDialog({
+ descriptor: DINGTALK_WITH_GROUP_POLICY,
+ instance: groupPairingInstance,
+ listPairingRequests,
+ });
+
+ expect(document.body.textContent).toContain('Pairing approvals');
+ expect(listPairingRequests).toHaveBeenCalledWith('release-bot');
+ });
});
diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx
index 75326c5dfdf..99d7f7ef549 100644
--- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx
+++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx
@@ -18,6 +18,7 @@ import type {
DaemonChannelPairingApprovalResult,
DaemonChannelPairingApprovalsSnapshot,
DaemonChannelPairingRequestsSnapshot,
+ DaemonChannelPairingRevocationRequest,
DaemonChannelPairingRevocationResult,
DaemonChannelTypeDescriptor,
DaemonChannelUpsertRequest,
@@ -115,7 +116,7 @@ export interface ChannelEditorDialogProps {
) => Promise;
revokePairingApproval: (
name: string,
- senderId: string,
+ request: DaemonChannelPairingRevocationRequest,
) => Promise;
}
@@ -609,7 +610,12 @@ export function ChannelEditorDialog({
? String(draft.values['senderPolicy'] ?? '')
: draft.senderPolicy;
const showRadioGroup = !descriptorPolicy;
- const showPairing = effectivePolicy === 'pairing';
+ const effectiveGroupPolicy = String(
+ draft.values['groupPolicy'] ?? '',
+ );
+ const showPairing =
+ effectivePolicy === 'pairing' ||
+ effectiveGroupPolicy === 'pairing';
if (!showRadioGroup && !showPairing) return null;
return (
@@ -660,7 +666,8 @@ export function ChannelEditorDialog({
>
) : null}
{showPairing ? (
- instance?.config.senderPolicy === 'pairing' ? (
+ instance?.config.senderPolicy === 'pairing' ||
+ instance?.config.groupPolicy === 'pairing' ? (
Promise;
revokeApproval?: (
name: string,
- senderId: string,
+ request: DaemonChannelPairingRevocationRequest,
) => Promise;
staticAllowedUsers?: readonly string[];
language?: 'en' | 'zh-CN';
@@ -113,6 +130,42 @@ describe('ChannelPairingRequests', () => {
);
});
+ it('identifies the group and requesting member for a group pairing request', async () => {
+ const approval: DaemonChannelPairingApprovalResult = {
+ approved: GROUP_PENDING.requests[0],
+ requests: [],
+ };
+ const approve = vi.fn().mockResolvedValue(approval);
+ const listApprovals = vi
+ .fn()
+ .mockResolvedValueOnce({ senderIds: [], groupIds: [] })
+ .mockResolvedValueOnce({ senderIds: [], groupIds: ['group-7'] });
+ await renderRequests({
+ list: vi.fn().mockResolvedValue(GROUP_PENDING),
+ approve,
+ listApprovals,
+ });
+
+ expect(container.textContent).toContain('Group: Release Team');
+ expect(container.textContent).toContain('group-7');
+ expect(container.textContent).toContain('Requested by Ada');
+ const button = container.querySelector(
+ 'button[aria-label="Approve Group: Release Team, code GROUP123"]',
+ );
+
+ await act(async () => {
+ button?.click();
+ });
+
+ expect(approve).toHaveBeenCalledWith('release-bot', 'GROUP123');
+ expect(container.textContent).toContain(
+ 'Group: Release Team can now use this Channel.',
+ );
+ expect(
+ container.querySelector('button[aria-label="Revoke Group: group-7"]'),
+ ).not.toBeNull();
+ });
+
it('shows pairing approvals and distinguishes configured allowlist access', async () => {
await renderRequests({
listApprovals: vi.fn().mockResolvedValue({
@@ -186,7 +239,9 @@ describe('ChannelPairingRequests', () => {
confirm?.click();
});
- expect(revokeApproval).toHaveBeenCalledWith('release-bot', 'paired-user');
+ expect(revokeApproval).toHaveBeenCalledWith('release-bot', {
+ senderId: 'paired-user',
+ });
expect(
container.querySelector('button[aria-label="Revoke paired-user"]'),
).toBeNull();
@@ -196,6 +251,48 @@ describe('ChannelPairingRequests', () => {
);
});
+ it('lists and revokes a group pairing approval by group ID', async () => {
+ const revokeApproval = vi.fn().mockResolvedValue({
+ revoked: 'group-7',
+ senderIds: ['paired-user'],
+ groupIds: [],
+ });
+ await renderRequests({
+ listApprovals: vi.fn().mockResolvedValue({
+ senderIds: ['paired-user'],
+ groupIds: ['group-7'],
+ }),
+ revokeApproval,
+ });
+
+ const revoke = container.querySelector(
+ 'button[aria-label="Revoke Group: group-7"]',
+ );
+ await act(async () => {
+ revoke?.click();
+ });
+
+ expect(document.body.textContent).toContain(
+ 'Revoke pairing approval for Group: group-7?',
+ );
+ const confirm = Array.from(document.body.querySelectorAll('button')).find(
+ (item) => item.textContent?.trim() === 'Revoke approval',
+ );
+ await act(async () => {
+ confirm?.click();
+ });
+
+ expect(revokeApproval).toHaveBeenCalledWith('release-bot', {
+ groupId: 'group-7',
+ });
+ expect(
+ container.querySelector('button[aria-label="Revoke Group: group-7"]'),
+ ).toBeNull();
+ expect(container.textContent).toContain(
+ 'Pairing approval for Group: group-7 was revoked.',
+ );
+ });
+
it('does not approve a request while a revoke is in flight', async () => {
const revokeApproval = vi
.fn()
diff --git a/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx b/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx
index ef37d2ec98c..251effa6dd0 100644
--- a/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx
+++ b/packages/web-shell/client/components/channels/ChannelPairingRequests.tsx
@@ -19,6 +19,7 @@ import type {
DaemonChannelPairingApprovalsSnapshot,
DaemonChannelPairingRequest,
DaemonChannelPairingRequestsSnapshot,
+ DaemonChannelPairingRevocationRequest,
DaemonChannelPairingRevocationResult,
} from '@qwen-code/sdk/daemon';
import { useI18n } from '../../i18n';
@@ -51,15 +52,33 @@ export interface ChannelPairingRequestsProps {
) => Promise;
revokeApproval: (
name: string,
- senderId: string,
+ request: DaemonChannelPairingRevocationRequest,
) => Promise;
staticAllowedUsers?: readonly string[];
}
+type PairingApprovalTarget =
+ | { type: 'user'; id: string }
+ | { type: 'group'; id: string };
+
function senderLabel(request: DaemonChannelPairingRequest): string {
return request.senderName.trim() || request.senderId;
}
+function requestSubject(request: DaemonChannelPairingRequest) {
+ return (
+ request.subject ?? {
+ type: 'user' as const,
+ id: request.senderId,
+ name: request.senderName,
+ }
+ );
+}
+
+function targetKey(target: PairingApprovalTarget): string {
+ return `${target.type}:${target.id}`;
+}
+
function errorCode(error: unknown): string | undefined {
if (!error || typeof error !== 'object') return undefined;
const body = (error as { body?: unknown }).body;
@@ -101,12 +120,23 @@ export function ChannelPairingRequests({
const [error, setError] = useState();
const [success, setSuccess] = useState();
const [approvedSenderIds, setApprovedSenderIds] = useState([]);
+ const [approvedGroupIds, setApprovedGroupIds] = useState([]);
const [approvalsLoading, setApprovalsLoading] = useState(true);
const [approvalsReloadToken, setApprovalsReloadToken] = useState(0);
const [approvalsError, setApprovalsError] = useState();
const [revokeSuccess, setRevokeSuccess] = useState();
- const [revokeTarget, setRevokeTarget] = useState();
- const [revokingSenderId, setRevokingSenderId] = useState();
+ const [revokeTarget, setRevokeTarget] = useState();
+ const [revokingTarget, setRevokingTarget] = useState();
+
+ const approvalLabel = (target: PairingApprovalTarget) =>
+ target.type === 'group'
+ ? t('channels.editor.pairing.subject.group', { name: target.id })
+ : target.id;
+
+ const approvedTargets: PairingApprovalTarget[] = [
+ ...approvedSenderIds.map((id) => ({ type: 'user' as const, id })),
+ ...approvedGroupIds.map((id) => ({ type: 'group' as const, id })),
+ ];
useEffect(() => {
mounted.current = true;
@@ -148,14 +178,16 @@ export function ChannelPairingRequests({
let active = true;
setApprovalsLoading(true);
setApprovedSenderIds([]);
+ setApprovedGroupIds([]);
setApprovalsError(undefined);
setRevokeSuccess(undefined);
setRevokeTarget(undefined);
- setRevokingSenderId(undefined);
+ setRevokingTarget(undefined);
void listApprovals(channelName).then(
(snapshot) => {
if (!active) return;
setApprovedSenderIds(snapshot.senderIds);
+ setApprovedGroupIds(snapshot.groupIds ?? []);
setApprovalsLoading(false);
},
(loadError: unknown) => {
@@ -187,9 +219,15 @@ export function ChannelPairingRequests({
return;
}
setRequests(result.requests);
+ const subject = requestSubject(result.approved);
setSuccess(
t('channels.editor.pairing.approved', {
- sender: senderLabel(result.approved),
+ sender:
+ subject.type === 'group'
+ ? t('channels.editor.pairing.subject.group', {
+ name: subject.name.trim() || subject.id,
+ })
+ : subject.name.trim() || subject.id,
}),
);
setApprovalsReloadToken((current) => current + 1);
@@ -237,22 +275,30 @@ export function ChannelPairingRequests({
}
};
- const revoke = async (senderId: string) => {
- if (revokingSenderId) return;
+ const revoke = async (target: PairingApprovalTarget) => {
+ if (revokingTarget) return;
const revokeChannel = channelName;
+ const key = targetKey(target);
+ const label = approvalLabel(target);
setRevokeTarget(undefined);
- setRevokingSenderId(senderId);
+ setRevokingTarget(key);
setApprovalsError(undefined);
setRevokeSuccess(undefined);
setSuccess(undefined);
try {
- const result = await revokeApproval(channelName, senderId);
+ const result = await revokeApproval(
+ channelName,
+ target.type === 'group'
+ ? { groupId: target.id }
+ : { senderId: target.id },
+ );
if (!mounted.current || currentChannelName.current !== revokeChannel) {
return;
}
setApprovedSenderIds(result.senderIds);
+ setApprovedGroupIds(result.groupIds ?? []);
setRevokeSuccess(
- t('channels.editor.pairing.approvals.revoked', { senderId }),
+ t('channels.editor.pairing.approvals.revoked', { senderId: label }),
);
} catch (revokeError) {
if (!mounted.current || currentChannelName.current !== revokeChannel) {
@@ -263,6 +309,7 @@ export function ChannelPairingRequests({
const snapshot = await listApprovals(revokeChannel);
if (mounted.current && currentChannelName.current === revokeChannel) {
setApprovedSenderIds(snapshot.senderIds);
+ setApprovedGroupIds(snapshot.groupIds ?? []);
}
return;
} catch (refreshError) {
@@ -288,7 +335,7 @@ export function ChannelPairingRequests({
);
} finally {
if (mounted.current && currentChannelName.current === revokeChannel) {
- setRevokingSenderId(undefined);
+ setRevokingTarget(undefined);
}
}
};
@@ -364,13 +411,26 @@ export function ChannelPairingRequests({
{requests.length > 0 ? (