Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f6f74a2
feat(web-shell): expose channel access policies
qqqys Aug 10, 2026
c197181
test(cli): cover shared Channel management fields
qqqys Aug 10, 2026
aed48dc
feat(web-shell): clarify channel policy controls
qqqys Aug 10, 2026
3807cb9
feat(web-shell): select channel workspace
qqqys Aug 10, 2026
111e17e
feat(web-shell): redesign channel management
qqqys Aug 10, 2026
2333a05
fix(web-shell): align channel manager with shell tabs
qqqys Aug 10, 2026
f7adc65
fix(web-shell): prioritize conversation settings
qqqys Aug 10, 2026
f8ac419
fix(web-shell): preserve legacy channel defaults
qqqys Aug 10, 2026
907b46a
fix(channels): address management review blockers
qqqys Aug 10, 2026
26849ba
fix(channels): address editor review blockers
qqqys Aug 10, 2026
b6f26d0
fix(channels): preserve workspace action and route state
qqqys Aug 11, 2026
49f7a25
fix(web-shell): prevent stale channel editor state
qqqys Aug 11, 2026
844d833
fix(channels): preserve stored group settings
qqqys Aug 11, 2026
410543b
fix(channels): preserve group behavior settings
qqqys Aug 11, 2026
7c5c125
fix(web-shell): reset channel workspace UI state
qqqys Aug 12, 2026
a811c93
fix(web-shell): resolve channel management conflicts
qqqys Aug 12, 2026
013b31e
test(web-shell): assert restored channel scope
qqqys Aug 12, 2026
3a7b0f0
Merge branch 'main' into codex/issue-8845-channel-access-policies
qqqys Aug 13, 2026
ec7f42f
fix(web-shell): preserve legacy channel scope
qqqys Aug 13, 2026
6c532fe
fix(web-shell): preserve inherited channel defaults
qqqys Aug 13, 2026
fd6241b
fix(channels): preserve compatible legacy settings
qqqys Aug 13, 2026
1bd4a48
fix(web-shell): keep workspace navigation available
qqqys Aug 13, 2026
ad365b8
fix(channels): default new channels to pairing
qqqys Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions docs/developers/daemon/15-channel-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ There are two current host modes:
- `qwen channel start [name]` is the standalone ACP-backed channel service. It passes adapters an `AcpBridge` implementation of `ChannelAgentBridge`.
- `qwen serve --channel <name>` and `qwen serve --channel all` are experimental daemon-managed modes. Named selections are grouped by owning workspace and `qwen serve` starts one out-of-process worker per owning runtime; each worker connects to the daemon through the SDK and adapters receive a `DaemonChannelBridge`-backed `ChannelAgentBridge` facade. `--channel all` remains a primary-only selection.

In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `thread`, or `single`). The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). Every named channel must resolve to one registered, trusted workspace. The worker uses that runtime's canonical cwd, `QWEN_DAEMON_WORKSPACE`, and environment overlay; ownership resolution never falls back to primary.
In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable `SessionScope` (`user`, `chat_thread`, or `single`). The legacy Channel value `thread` is deprecated and normalized to `chat_thread`; this is separate from the daemon bridge's own `single`/`thread` session creation knob. The adapter delegates to `DaemonChannelBridge`, which delegates to the SDK's `DaemonSessionClient` (see [`13-sdk-daemon-client.md`](./13-sdk-daemon-client.md)). Every named channel must resolve to one registered, trusted workspace. The worker uses that runtime's canonical cwd, `QWEN_DAEMON_WORKSPACE`, and environment overlay; ownership resolution never falls back to primary.

### Webhook-triggered channel tasks

Expand Down Expand Up @@ -194,14 +194,14 @@ Adapter `connect()` failures are reported separately from worker lifecycle error

`ChannelConfig` (from `packages/channels/base/src/types.ts`):

| Knob | Effect |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sessionScope` | `'user'` (sender + chat), `'thread'` (thread id or chat), `'chat_thread'` (channel + chatId + threadId, for polling adapters), or `'single'` (one shared session per channel). |
| `approvalMode` | `'auto'` (auto-respond) / `'prompt'` (render UI). |
| `allowlist?: string[]` | Sender ids allowed; missing = open. |
| `denylist?: string[]` | Sender ids denied. |
| `chunkSize`, `chunkIntervalMs` | Outbound block streaming settings. |
| `daemon: { baseUrl, token?, clientId? }` | Forwarded to `DaemonChannelSessionFactory`. |
| Knob | Effect |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sessionScope` | `'user'` (sender + chat), `'chat_thread'` (channel + chatId + threadId), or `'single'` (one shared session per channel). Legacy `'thread'` is accepted but normalized to `'chat_thread'`. |
| `approvalMode` | `'auto'` (auto-respond) / `'prompt'` (render UI). |
| `allowlist?: string[]` | Sender ids allowed; missing = open. |
| `denylist?: string[]` | Sender ids denied. |
| `chunkSize`, `chunkIntervalMs` | Outbound block streaming settings. |
| `daemon: { baseUrl, token?, clientId? }` | Forwarded to `DaemonChannelSessionFactory`. |

Channel-specific keys layer on top (DingTalk: `streamCredentials`; WeChat: `ilinkUrl`, `botId`; Telegram: `botToken`; Feishu: `clientId` (appId), `clientSecret` (appSecret), `verificationToken`, `encryptKey` (webhook mode)).

Expand Down
2 changes: 1 addition & 1 deletion docs/users/features/channels/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe
| `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input |
| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` |
| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) |
| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` |
| `sessionScope` | No | How sessions are scoped: `user` (default), `chat_thread`, or `single`. Legacy `thread` is accepted but normalized to `chat_thread` |
| `cwd` | No | Working directory for the agent. Defaults to the current directory |
| `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 |
Expand Down
22 changes: 11 additions & 11 deletions docs/users/features/channels/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,17 @@ The `type` must match a channel type registered by an installed extension. Check

All standard channel options work with custom channels:

| Option | Description |
| -------------- | ---------------------------------------------- |
| `senderPolicy` | `allowlist`, `pairing`, or `open` |
| `allowedUsers` | Static allowlist of sender IDs |
| `sessionScope` | `user`, `thread`, or `single` |
| `cwd` | Working directory for the agent |
| `instructions` | Prepended to the first message of each session |
| `model` | Model override for the channel |
| `groupPolicy` | `disabled`, `allowlist`, `pairing`, or `open` |
| `dmPolicy` | `open` or `disabled` |
| `groups` | Per-group settings |
| Option | Description |
| -------------- | ----------------------------------------------------------------- |
| `senderPolicy` | `allowlist`, `pairing`, or `open` |
| `allowedUsers` | Static allowlist of sender IDs |
| `sessionScope` | `user`, `chat_thread`, or `single`; legacy `thread` is deprecated |
| `cwd` | Working directory for the agent |
| `instructions` | Prepended to the first message of each session |
| `model` | Model override for the channel |
| `groupPolicy` | `disabled`, `allowlist`, `pairing`, or `open` |
| `dmPolicy` | `open` or `disabled` |
| `groups` | Per-group settings |

See [Overview](./overview) for details on each option.

Expand Down
2 changes: 1 addition & 1 deletion packages/channels/base/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Everything between `handleInbound()` and `sendMessage()` is handled by the base
| `Envelope` | Normalized inbound message format |
| `SenderPolicy` | `'allowlist' \| 'pairing' \| 'open'` |
| `GroupPolicy` | `'disabled' \| 'allowlist' \| 'pairing' \| 'open'` |
| `SessionScope` | `'user' \| 'thread' \| 'single'` |
| `SessionScope` | `'user' \| 'chat_thread' \| 'single'`; legacy `'thread'` is deprecated |
| `GroupConfig` | Per-group settings (e.g. `requireMention`) |
| `SessionTarget` | Maps a session back to its channel/sender/chat |
| `ToolCallEvent` | Agent tool-call event delivered to adapters |
Expand Down
6 changes: 5 additions & 1 deletion packages/channels/base/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface ChannelConfig {
clientSecret?: string;
senderPolicy: SenderPolicy;
allowedUsers: string[];
/** Channel routing scope. `thread` is deprecated here; use `chat_thread`. */
sessionScope: SessionScope;
cwd: string;
approvalMode?: string;
Expand Down Expand Up @@ -514,7 +515,10 @@ export interface ChannelPlugin {
/** Serializable metadata for safe configuration management. */
management?: ChannelManagementDescriptor;

/** Default session scope for this channel type (applied when config omits sessionScope). */
/**
* Default Channel routing scope (applied when config omits sessionScope).
* `thread` is deprecated here; use `chat_thread`.
*/
defaultSessionScope?: SessionScope;

/** Create a channel adapter instance. */
Expand Down
24 changes: 24 additions & 0 deletions packages/channels/wecom/src/WeComAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,30 @@ describe('WeComChannel', () => {
rmSync(join(tmpdir(), 'channel-files'), { recursive: true, force: true });
});

it('shares attachment routing across senders in chat_thread scope', () => {
const channel = new WeComChannel(
'bot',
makeConfig({ sessionScope: 'chat_thread' }),
makeBridge(),
);
const routeKey = (
channel as unknown as {
attachmentRouteKey(
senderId: string,
chatId: string,
threadId?: string,
): string;
}
).attachmentRouteKey.bind(channel);

expect(routeKey('alice', 'chat-1', 'topic-1')).toBe(
routeKey('bob', 'chat-1', 'topic-1'),
);
expect(routeKey('alice', 'chat-1', 'topic-1')).not.toBe(
routeKey('alice', 'chat-2', 'topic-1'),
);
});

it('requires botId and secret', () => {
expect(
() => new WeComChannel('bot', makeConfig({ botId: '' }), makeBridge()),
Expand Down
4 changes: 4 additions & 0 deletions packages/channels/wecom/src/WeComAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,10 @@ export class WeComChannel extends ChannelBase {
switch (this.config.sessionScope) {
case 'thread':
return `${this.name}:${threadId || chatId}`;
case 'chat_thread':
return threadId
? `${this.name}:${chatId}:${threadId}`
: `${this.name}:${chatId}`;
Comment on lines 771 to +776

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Routing key format change for 'chat_thread' orphans existing sessions with thread IDs on migration — Failure scenario: a channel with sessionScope: 'thread' had an active session routed as name:topic-1. After upgrade, parseChannelConfig normalizes the config to 'chat_thread', so SessionRouter.routingKey produces name:chat-1:topic-1. The old session is never found, and old attachment directories are orphaned.

中文说明

迁移后 'chat_thread' 的路由键格式变化会使现有含有 thread ID 的会话孤立。— 失败场景:sessionScope: 'thread' 的频道有一个活跃会话,路由键为 name:topic-1。升级后 parseChannelConfig 将配置归一化为 'chat_thread',SessionRouter.routingKey 产生 name:chat-1:topic-1。旧会话无法找到,旧附件目录也被孤立。建议添加路由键迁移兼容层。

— deepseek-v4-flash via Qwen Code /review (v0.21.8)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复 + 验证证据:b6f26d0dee;config-utils.test.ts 50/50、channel-editor-state.test.ts 31/31 通过,legacy thread 在加载和编辑保存两条路径保持原路由与授权语义。

case 'single':
return `${this.name}:__single__`;
case 'user':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,18 +108,26 @@ describe('built-in channel registry', () => {
const entry = (await supportedChannelCatalog()).find(
(candidate) => candidate.type === 'valid-nested-type-key',
);
expect(entry).toEqual({
expect(entry).toMatchObject({
type: 'valid-nested-type-key',
displayName: 'valid-nested-type-key',
manageable: true,
fields: [
{
key: 'settings',
label: 'Settings',
kind: 'object',
properties: [{ key: 'type', label: 'Type', kind: 'string' }],
},
],
});
expect(entry?.fields[0]).toEqual({
key: 'settings',
label: 'Settings',
kind: 'object',
properties: [{ key: 'type', label: 'Type', kind: 'string' }],
});
expect(entry?.fields.map((field) => field.key)).toEqual([
'settings',
'senderPolicy',
'allowedUsers',
'groupPolicy',
'sessionScope',
]);
expect(
entry?.fields.find((field) => field.key === 'senderPolicy'),
).toMatchObject({ default: 'allowlist' });
});
});
60 changes: 60 additions & 0 deletions packages/cli/src/commands/channel/channel-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ describe('channel registry', () => {
const plugin: ChannelPlugin = {
channelType: 'valid-optional-required-object',
displayName: 'valid-optional-required-object',
defaultSessionScope: 'thread',
management: {
fields: [
{
Expand Down Expand Up @@ -686,6 +687,17 @@ describe('channel registry', () => {
(candidate) => candidate.type === 'valid-optional-required-object',
);
expect(entry?.manageable).toBe(true);
expect(
entry?.fields.find((field) => field.key === 'sessionScope'),
).toMatchObject({
default: 'thread',
options: [
{ value: 'user' },
{ value: 'thread' },
{ value: 'chat_thread' },
{ value: 'single' },
],
});
});

it('only marks the manually configurable built-in types as manageable', async () => {
Expand Down Expand Up @@ -718,8 +730,46 @@ describe('channel registry', () => {
required: true,
}),
);
for (const type of ['dingtalk', 'wecom', 'feishu'] as const) {
const fields = catalog.find((entry) => entry.type === type)?.fields;
expect(
fields
?.find((field) => field.key === 'senderPolicy')
?.options?.map((option) => option.value),
).toEqual(['pairing', 'allowlist', 'open']);
expect(fields).toContainEqual(
expect.objectContaining({
key: 'allowedUsers',
kind: 'string-list',
}),
);
expect(
fields
?.find((field) => field.key === 'groupPolicy')
?.options?.map((option) => option.value),
).toEqual(['disabled', 'pairing', 'allowlist', 'open']);
expect(
fields?.find((field) => field.key === 'sessionScope'),
).toMatchObject({
kind: 'enum',
required: true,
default: 'user',
options: [
{ value: 'user' },
{ value: 'thread' },
{ value: 'chat_thread' },
{ value: 'single' },
],
});
}
for (const type of ['github', 'gitlab'] as const) {
const fields = catalog.find((entry) => entry.type === type)?.fields;
expect(
fields?.filter((field) => field.key === 'senderPolicy'),
).toHaveLength(1);
expect(
fields?.filter((field) => field.key === 'groupPolicy'),
).toHaveLength(1);
expect(fields).toContainEqual(
expect.objectContaining({
key: 'groupPolicy',
Expand All @@ -745,6 +795,16 @@ describe('channel registry', () => {
kind: 'string-list',
}),
);
expect(
fields?.filter((field) => field.key === 'sessionScope'),
).toHaveLength(1);
expect(
fields?.find((field) => field.key === 'sessionScope'),
).toMatchObject({
kind: 'enum',
required: true,
default: 'chat_thread',
});
}
expect(
catalog.find((entry) => entry.type === 'dingtalk')?.fields,
Expand Down
81 changes: 79 additions & 2 deletions packages/cli/src/commands/channel/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
ChannelConfigFieldDescriptor,
ChannelConfigFieldKind,
ChannelPlugin,
SessionScope,
} from '@qwen-code/channel-base';

export interface ChannelTypeDescriptor {
Expand Down Expand Up @@ -30,6 +31,77 @@ const FIELD_KINDS: ReadonlySet<ChannelConfigFieldKind> = new Set([
'object',
]);

const SHARED_ACCESS_FIELDS: readonly ChannelConfigFieldDescriptor[] = [
{
key: 'senderPolicy',
label: 'Sender Policy',
kind: 'enum',
required: true,
default: 'allowlist',
description: 'Controls who can start direct conversations',
options: [
{ value: 'pairing', label: 'Pairing' },
{ value: 'allowlist', label: 'Allowlist' },
{ value: 'open', label: 'Open' },
],
},
{
key: 'allowedUsers',
label: 'Allowed Users',
kind: 'string-list',
description: 'Stable user IDs allowed without pairing',
},
{
key: 'groupPolicy',
label: 'Group Policy',
kind: 'enum',
required: true,
default: 'disabled',
description: 'Controls which group conversations can use this Channel',
options: [
{ value: 'disabled', label: 'Disabled' },
{ value: 'pairing', label: 'Pairing' },
{ value: 'allowlist', label: 'Allowlist' },
{ value: 'open', label: 'Open' },
],
},
];

const SESSION_SCOPE_OPTIONS: ReadonlyArray<{
value: SessionScope;
label: string;
}> = [
{ value: 'user', label: 'Per User and Chat' },
{ value: 'thread', label: 'Per Thread (Legacy)' },
Comment on lines +70 to +75

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复。验证证据:旧 thread 配置仍可读取、编辑并原样保存;新建及非 legacy Channel 不再显示 thread 选项。聚焦回归测试 71/71 通过,Web Shell build/typecheck 与 channel-base build 通过。修复提交:ec7f42f02b。

{ value: 'chat_thread', label: 'Per Chat and Thread' },
{ value: 'single', label: 'One Shared Session' },
];

function managementFieldsWithSharedControls(
fields: readonly ChannelConfigFieldDescriptor[],
defaultSessionScope: SessionScope,
): readonly ChannelConfigFieldDescriptor[] {
const declared = new Set(fields.map((field) => field.key));
return [
...fields,
...SHARED_ACCESS_FIELDS.filter((field) => !declared.has(field.key)),
...(declared.has('sessionScope')
? []
: [
{
key: 'sessionScope',
label: 'Session Scope',
kind: 'enum' as const,
required: true,
default: defaultSessionScope,
description:
'Controls how conversations share persistent agent sessions',
options: SESSION_SCOPE_OPTIONS,
},
]),
];
}

function assertManagementFields(
fields: readonly ChannelConfigFieldDescriptor[],
parentPath?: string,
Expand Down Expand Up @@ -273,11 +345,16 @@ export async function supportedChannelCatalog(): Promise<
> {
await ensureBuiltins();
return [...registry.values()].map(
({ channelType, displayName, management }) => ({
({ channelType, displayName, management, defaultSessionScope }) => ({
type: channelType,
displayName,
manageable: management !== undefined,
fields: management?.fields ?? [],
fields: management
? managementFieldsWithSharedControls(
management.fields,
defaultSessionScope ?? 'user',
)
: [],
}),
);
}
Loading
Loading