Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
de65e6b
fix(channels): manage DingTalk interactive card config
BenGuanRan Aug 4, 2026
5dbaa65
test(cli): cover nested channel object validation
BenGuanRan Aug 4, 2026
bbbd2c8
fix(channels): harden nested management metadata
BenGuanRan Aug 5, 2026
70d5e01
fix(channels): isolate invalid management descriptors
BenGuanRan Aug 5, 2026
a773876
fix(channels): isolate invalid management metadata from channel runtime
qwen-code-dev-bot Aug 5, 2026
16de7ba
fix(channels): reject reserved unknown keys in management config upserts
qwen-code-dev-bot Aug 5, 2026
3a4609c
Merge remote-tracking branch 'origin/main' into fix/dingtalk-interact…
qwen-code-dev-bot Aug 5, 2026
d06e09f
Merge branch 'main' into fix/dingtalk-interactive-card-management
qwen-code-dev-bot Aug 5, 2026
dc16cf2
Merge branch 'main' into fix/dingtalk-interactive-card-management
qwen-code-dev-bot Aug 5, 2026
ba7af04
fix(channels): harden channel management validation and editor checks
qwen-code-dev-bot Aug 5, 2026
bb78d3e
fix(channels): validate management field shapes and editor bounds (#8…
qwen-code-dev-bot Aug 6, 2026
12f6b17
fix(channels): align management validation layers and pin gate behavi…
qwen-code-dev-bot Aug 6, 2026
4a848be
fix(channels): close reserved-key preservation gaps and pin gate beha…
qwen-code-dev-bot Aug 6, 2026
68c4d3f
Merge branch 'main' into fix/dingtalk-interactive-card-management
wenshao Aug 6, 2026
9ec68ed
test(cli): tolerate IPv6-less hosts in serve ::1 bind tests (#8517)
qwen-code-dev-bot Aug 7, 2026
24c358e
fix(channels): align descriptor type contracts with runtime validatio…
qwen-code-dev-bot Aug 7, 2026
0d2e3ef
chore(channels): merge main into DingTalk interactive card management…
qwen-code-dev-bot Aug 7, 2026
243b58d
Merge branch 'main' into fix/dingtalk-interactive-card-management
qwen-code-dev-bot Aug 7, 2026
55cca44
fix(channels): defuse validateConfig rejection leak and close descrip…
qwen-code-dev-bot Aug 7, 2026
737d559
Merge branch 'fix/dingtalk-interactive-card-management' of https://gi…
qwen-code-dev-bot Aug 7, 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
5 changes: 5 additions & 0 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,11 @@ The catalog marks the types supported by this management API with
presence metadata, startup state, and runtime state; literal secrets are never
returned. Channel snapshots use `Cache-Control: no-store`.

Field descriptors can expose nested object metadata through `properties`.
Numeric descriptors can use `exclusiveMinimum` for open lower bounds. Clients
that do not render an advertised field kind must preserve its existing config
value instead of coercing or deleting it.

Configuration writes use optimistic concurrency and the strict bearer-token
gate:

Expand Down
5 changes: 4 additions & 1 deletion packages/channels/base/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,8 @@ export type ChannelConfigFieldKind =
| 'number'
| 'enum'
| 'string-list'
| 'record';
| 'record'
| 'object';

export interface ChannelConfigFieldDescriptor {
key: string;
Expand All @@ -396,6 +397,8 @@ export interface ChannelConfigFieldDescriptor {
envResolvable?: boolean;
options?: ReadonlyArray<{ value: string; label: string }>;
default?: string;
properties?: readonly ChannelConfigFieldDescriptor[];
exclusiveMinimum?: number;
description?: string;
}

Expand Down
42 changes: 42 additions & 0 deletions packages/channels/dingtalk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,48 @@ export const plugin: ChannelPlugin = {
required: true,
envResolvable: true,
},
{
key: 'interactiveCards',
label: 'Interactive Cards',
kind: 'object',
Comment thread
BenGuanRan marked this conversation as resolved.
Resolved
properties: [
{
key: 'enabled',
label: 'Enabled',
kind: 'boolean',
},
{
key: 'statusCard',
label: 'Status Card',
kind: 'object',
properties: [
{
key: 'enabled',
label: 'Enabled',
kind: 'boolean',
},
],
},
{
key: 'questionCard',
label: 'Question Card',
kind: 'object',
properties: [
{
key: 'enabled',
label: 'Enabled',
kind: 'boolean',
},
{
key: 'timeoutMs',
label: 'Timeout (ms)',
kind: 'number',
exclusiveMinimum: 0,
},
],
},
],
},
],
},
createChannel: (name, config, bridge, options) =>
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/commands/channel/channel-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ describe('channel registry', () => {
}),
);
}
expect(
catalog.find((entry) => entry.type === 'dingtalk')?.fields,
).toContainEqual(
expect.objectContaining({
key: 'interactiveCards',
kind: 'object',
properties: expect.arrayContaining([
expect.objectContaining({ key: 'enabled', kind: 'boolean' }),
expect.objectContaining({ key: 'statusCard', kind: 'object' }),
expect.objectContaining({ key: 'questionCard', kind: 'object' }),
]),
}),
);
expect(JSON.stringify(catalog)).not.toContain('createChannel');
});
});
79 changes: 79 additions & 0 deletions packages/cli/src/serve/channel-settings-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,85 @@ describe('WorkspaceChannelSettingsStore', () => {
});
});

it('persists DingTalk interactive card configuration through management metadata', async () => {
writeWorkspaceSettings(`{
"$version": 4,
"channels": { "bot": {
"type": "dingtalk",
"clientId": "client-id",
"clientSecret": "existing-secret"
} }
}\n`);
const store = new WorkspaceChannelSettingsStore(workspace);

const next = await store.upsert('bot', {
expectedRevision: store.snapshot().revision,
config: {
type: 'dingtalk',
clientId: 'client-id',
interactiveCards: {
enabled: true,
statusCard: { enabled: true },
questionCard: { enabled: true, timeoutMs: 270_000 },
},
},
secrets: { clientSecret: { operation: 'preserve' } },
});

expect(next.channels['bot']?.['interactiveCards']).toEqual({
enabled: true,
statusCard: { enabled: true },
questionCard: { enabled: true, timeoutMs: 270_000 },
});
});

it.each([
{
label: 'string enabled flag',
Comment thread
BenGuanRan marked this conversation as resolved.
Resolved
interactiveCards: { enabled: 'true' },
},
{
label: 'invalid nested enabled flag',
interactiveCards: { statusCard: { enabled: 'true' } },
},
{
label: 'non-positive question timeout',
interactiveCards: { questionCard: { timeoutMs: 0 } },
},
{
label: 'unknown nested field',
interactiveCards: { unexpected: true },
},
])(
'rejects DingTalk interactive card configuration with $label',
async ({ interactiveCards }) => {
writeWorkspaceSettings(`{
"$version": 4,
"channels": { "bot": {
"type": "dingtalk",
"clientId": "client-id",
"clientSecret": "existing-secret"
} }
}\n`);
const store = new WorkspaceChannelSettingsStore(workspace);
const before = fs.readFileSync(settingsPath, 'utf8');

await expect(
store.upsert('bot', {
expectedRevision: store.snapshot().revision,
config: {
type: 'dingtalk',
clientId: 'client-id',
interactiveCards,
},
secrets: { clientSecret: { operation: 'preserve' } },
}),
).rejects.toMatchObject({ code: 'channel_settings_invalid_config' });

expect(fs.readFileSync(settingsPath, 'utf8')).toBe(before);
},
);

it('rejects clearing an existing required secret without writing', async () => {
writeWorkspaceSettings(`{
"$version": 4,
Expand Down
38 changes: 36 additions & 2 deletions packages/cli/src/serve/channel-settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,39 @@ function assertSharedField(key: string, value: unknown): boolean {
function assertDescriptorValue(
field: ChannelConfigFieldDescriptor,
value: unknown,
path = field.key,
): void {
if (field.kind === 'object') {
if (!isRecord(value)) {
throw invalidConfig(`Channel field "${path}" has an invalid value.`);
}
const properties = new Map(
(field.properties ?? []).map((property) => [property.key, property]),
);
for (const [key, nestedValue] of Object.entries(value)) {
const property = properties.get(key);
if (!property) {
throw invalidConfig(
`Channel field "${path}.${key}" is not manageable.`,
);
}
assertDescriptorValue(property, nestedValue, `${path}.${key}`);
}
for (const property of field.properties ?? []) {
if (!property.required) continue;
const nestedValue = value[property.key];
Comment thread
BenGuanRan marked this conversation as resolved.
Resolved
if (
nestedValue === undefined ||
nestedValue === null ||
nestedValue === ''
) {
throw invalidConfig(
`Channel field "${path}.${property.key}" is required.`,
);
}
Comment on lines +244 to +247

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.

@T17.txt

}
return;
}
const invalidEnvironment =
typeof value === 'string' &&
isEnvironmentReference(value) &&
Comment thread
BenGuanRan marked this conversation as resolved.
Resolved
Expand All @@ -227,7 +259,9 @@ function assertDescriptorValue(
(field.kind === 'boolean' && typeof value === 'boolean') ||
(field.kind === 'number' &&
typeof value === 'number' &&
Number.isFinite(value)) ||
Number.isFinite(value) &&
(field.exclusiveMinimum === undefined ||
value > field.exclusiveMinimum)) ||
(field.kind === 'enum' &&
typeof value === 'string' &&
field.options?.some((option) => option.value === value) === true) ||
Expand All @@ -238,7 +272,7 @@ function assertDescriptorValue(
isRecord(value) &&
Object.values(value).every((v) => typeof v === 'string'));
if (!valid) {
throw invalidConfig(`Channel field "${field.key}" has an invalid value.`);
throw invalidConfig(`Channel field "${path}" has an invalid value.`);
}
}

Expand Down
5 changes: 4 additions & 1 deletion packages/sdk-typescript/src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2988,7 +2988,8 @@ export type DaemonChannelConfigFieldKind =
| 'number'
| 'enum'
| 'string-list'
| 'record';
| 'record'
| 'object';

export interface DaemonChannelConfigFieldDescriptor {
key: string;
Expand All @@ -2998,6 +2999,8 @@ export interface DaemonChannelConfigFieldDescriptor {
envResolvable?: boolean;
options?: ReadonlyArray<{ value: string; label: string }>;
default?: string;
properties?: readonly DaemonChannelConfigFieldDescriptor[];
exclusiveMinimum?: number;
description?: string;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ const DINGTALK: DaemonChannelTypeDescriptor = {
kind: 'secret',
required: true,
},
{
key: 'interactiveCards',
label: 'Interactive Cards',
kind: 'object',
},
],
};

Expand Down Expand Up @@ -125,6 +130,12 @@ afterEach(() => {
});

describe('ChannelEditorDialog', () => {
it('does not render object metadata as a text field', async () => {
await renderDialog();

expect(inputByLabel('Interactive Cards')).toBeNull();
});

it('preserves a stored secret until Replace is explicitly selected', async () => {
await renderDialog({ instance: INSTANCE });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ export function ChannelEditorDialog({
};

const renderField = (field: DaemonChannelConfigFieldDescriptor) => {
if (field.kind === 'object') return null;
Comment thread
BenGuanRan marked this conversation as resolved.
Resolved
if (field.kind === 'secret') return renderSecret(field);
const id = `${formId}-${field.key}`;
const value = draft.values[field.key];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ const DINGTALK: DaemonChannelTypeDescriptor = {
required: true,
envResolvable: true,
},
{
key: 'interactiveCards',
label: 'Interactive Cards',
kind: 'object',
},
],
};

Expand All @@ -46,6 +51,10 @@ function configuredInstance(): DaemonChannelInstanceSnapshot {
senderPolicy: 'open',
sessionScope: 'thread',
model: 'qwen3-coder-plus',
interactiveCards: {
enabled: true,
statusCard: { enabled: true },
},
},
secrets: {
clientSecret: { present: true, source: 'environment' },
Expand Down Expand Up @@ -96,6 +105,10 @@ describe('Channel editor state', () => {
senderPolicy: 'open',
sessionScope: 'thread',
model: 'qwen3-coder-plus',
interactiveCards: {
enabled: true,
statusCard: { enabled: true },
},
},
secrets: {
clientSecret: { operation: 'preserve' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function createChannelEditorDraft(
const values: Record<string, string | boolean> = {};
const secrets: Record<string, ChannelSecretDraft> = {};
for (const field of descriptor.fields) {
if (field.kind === 'object') continue;
if (field.kind === 'secret') {
secrets[field.key] = instance?.secrets[field.key]?.present
? { operation: 'preserve' }
Expand Down Expand Up @@ -239,6 +240,7 @@ export function buildChannelUpsertRequest(
};
const secrets: Record<string, DaemonChannelSecretUpdate> = {};
for (const field of descriptor.fields) {
if (field.kind === 'object') continue;
if (field.kind === 'secret') {
const secret = draft.secrets[field.key] ?? { operation: 'preserve' };
secrets[field.key] =
Expand Down
Loading