feat: add unified IM access policies

This commit is contained in:
xmanrui 2026-09-01 10:45:12 +08:00
parent 075d2713c1
commit 840e5aa857
89 changed files with 5573 additions and 1676 deletions

View file

@ -0,0 +1,219 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
accessPolicyProvider,
initialAccessPolicyFor,
privilegedSenderIdsFor,
} from '../plugin-src/host/channels/shared/access-policy-production.mjs';
import {
SET_ACCESS_POLICY_ENDPOINT,
validAccessPolicyPayload,
} from '../plugin-src/host/channels/shared/access-policy-rpc.mjs';
import { createWeixinRpcHandler, WEIXIN_ENDPOINTS } from '../plugin-src/host/channels/weixin/rpc.mjs';
import { createFeishuRpcHandler, FEISHU_ENDPOINTS } from '../plugin-src/host/channels/feishu/rpc.mjs';
import { createDingtalkRpcHandler, DINGTALK_ENDPOINTS } from '../plugin-src/host/channels/dingtalk/rpc.mjs';
import { createWecomRpcHandler, WECOM_ENDPOINTS } from '../plugin-src/host/channels/wecom/rpc.mjs';
import { createQqRpcHandler, QQ_ENDPOINTS } from '../plugin-src/host/channels/qq/rpc.mjs';
import { createSlackRpcHandler, SLACK_ENDPOINTS } from '../plugin-src/host/channels/slack/rpc.mjs';
import { createTelegramRpcHandler, TELEGRAM_ENDPOINTS } from '../plugin-src/host/channels/telegram/rpc.mjs';
import { createDiscordRpcHandler, DISCORD_ENDPOINTS } from '../plugin-src/host/channels/discord/rpc.mjs';
import { createWhatsappRpcHandler, WHATSAPP_ENDPOINTS } from '../plugin-src/host/channels/whatsapp/rpc.mjs';
const users = (values = []) => values.map((value) => (
value && typeof value === 'object'
? value
: { id: value, canExecuteCommands: true }
));
const scope = ({
mode,
defaultCanExecuteCommands,
commandPermissionOverrides = [],
allowlistUsers = [],
}) => ({
mode,
open: {
defaultCanExecuteCommands,
commandPermissionOverrides: users(commandPermissionOverrides),
},
allowlist: { users: users(allowlistUsers) },
});
const open = (allowlistUsers = [], defaultCanExecuteCommands = true) => scope({
mode: 'open', defaultCanExecuteCommands, allowlistUsers,
});
const allowlist = (allowlistUsers = []) => scope({
mode: 'allowlist', defaultCanExecuteCommands: false, allowlistUsers,
});
const policy = (direct = open(), group = open()) => ({ direct, group });
test('Host initialization preserves the nine channel access baselines and legacy migrations', () => {
assert.deepEqual(initialAccessPolicyFor('weixin', { ownerUserId: 'wx-owner' }),
policy(allowlist(), allowlist()));
assert.deepEqual(initialAccessPolicyFor('feishu', { ownerOpenIds: ['ou_owner'] }),
policy(allowlist(), allowlist()));
assert.deepEqual(initialAccessPolicyFor('feishu', { ownerOpenIds: ['*'] }), policy());
for (const channel of ['dingtalk', 'wecom', 'slack', 'discord']) {
assert.deepEqual(initialAccessPolicyFor(channel), policy());
}
assert.deepEqual(initialAccessPolicyFor('qq', { ownerUserOpenid: 'qq-owner' }),
policy(allowlist(), open()));
assert.deepEqual(initialAccessPolicyFor('qq', { ownerUserOpenid: '*' }), policy());
assert.deepEqual(initialAccessPolicyFor('telegram', {
accessMode: 'compatible', allowedUsers: ['101'],
}), policy(open(['101']), open()));
assert.deepEqual(initialAccessPolicyFor('telegram', {
accessMode: 'private-allowlist', allowedUsers: ['101'],
}), policy(allowlist(['101']), allowlist()));
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
accessMode: 'self-only', accountJid: '886900000000@s.whatsapp.net',
}), policy(allowlist(), allowlist()));
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
accessMode: 'private-allowlist',
accountJid: '886900000000@lid',
allowedNumbers: ['16505550999'],
}), policy(allowlist(['16505550999@s.whatsapp.net']), allowlist()));
assert.deepEqual(initialAccessPolicyFor('whatsapp', {
accessMode: 'open', allowedNumbers: ['16505550999'],
}), policy(open(['16505550999@s.whatsapp.net']), open()));
});
test('Host-only owner identities are not copied into public access-policy rows', () => {
const cases = [
['weixin', { ownerUserId: 'wx-private-owner' }, 'wx-private-owner'],
['feishu', { ownerOpenIds: ['ou_private_owner'] }, 'ou_private_owner'],
['qq', { ownerUserOpenid: 'qq-private-owner' }, 'qq-private-owner'],
['whatsapp', {
accessMode: 'private-allowlist',
accountJid: '886900000000@lid',
allowedNumbers: ['16505550999'],
}, '886900000000@lid'],
];
for (const [channel, config, ownerId] of cases) {
assert.equal(JSON.stringify(initialAccessPolicyFor(channel, config)).includes(ownerId), false, channel);
}
});
test('Host access provider reads the latest committed workspace policy', () => {
let current = policy(allowlist(), allowlist());
const provider = accessPolicyProvider({ accessPolicyFor: () => current }, 'bot_one', {
channel: 'feishu', config: { ownerOpenIds: ['ou_owner', '*'] },
});
assert.equal(provider.botId, 'bot_one');
assert.equal(provider.getSettings(), current);
current = policy();
assert.equal(provider.getSettings(), current);
assert.equal(provider.isPrivileged(['ou_owner'], 'direct'), true);
assert.equal(provider.isPrivileged(['*'], 'group'), false);
assert.equal(provider.isPrivileged(['ou_other'], 'direct'), false);
assert.equal(provider.isPrivileged(['ou_owner'], 'unknown'), false);
const whatsapp = accessPolicyProvider({ accessPolicyFor: () => current }, 'bot_wa', {
channel: 'whatsapp',
config: { accountJid: '16505550100@s.whatsapp.net' },
equals: (left, right) => left.split('@')[0] === right.split('@')[0],
});
assert.equal(whatsapp.isPrivileged(['16505550100@lid'], 'group'), true);
});
test('Host privileged identities come only from durable owner or legacy authorization fields', () => {
assert.deepEqual(privilegedSenderIdsFor('weixin', { ownerUserId: 'wx-owner' }), ['wx-owner']);
assert.deepEqual(privilegedSenderIdsFor('feishu', { ownerOpenIds: ['*', 'ou_owner'] }), ['ou_owner']);
assert.deepEqual(privilegedSenderIdsFor('dingtalk', {
approvedSenders: [{ staffId: 'ding-owner' }, { staffId: 'ding-owner' }],
}), ['ding-owner']);
assert.deepEqual(privilegedSenderIdsFor('qq', { ownerUserOpenid: '*' }), []);
assert.deepEqual(privilegedSenderIdsFor('qq', { ownerUserOpenid: 'qq-owner' }), ['qq-owner']);
assert.deepEqual(privilegedSenderIdsFor('whatsapp', {
accountJid: '886900000000@s.whatsapp.net',
}), ['886900000000@s.whatsapp.net']);
for (const channel of ['wecom', 'slack', 'telegram', 'discord']) {
assert.deepEqual(privilegedSenderIdsFor(channel, { allowedUsers: ['legacy'] }), []);
}
});
test('shared access-policy RPC payload is exact and validates the full atomic policy', () => {
const value = policy(open([], false), allowlist());
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', policy: value }), true);
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', policy: value, extra: true }), false);
assert.equal(validAccessPolicyPayload({ botId: '../bad', policy: value }), false);
assert.equal(validAccessPolicyPayload({ botId: 'bot_one', accessMode: 'open' }), false);
assert.equal(validAccessPolicyPayload({
botId: 'bot_one',
policy: {
direct: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
},
}), false, 'RPC accepts canonical scopes only');
assert.equal(validAccessPolicyPayload({
botId: 'bot_one',
policy: { ...value, direct: { ...value.direct, mode: 'legacy' } },
}), false);
});
function controllerFixture() {
const calls = [];
const snapshot = (accessPolicy = policy()) => ({
schemaVersion: 2,
revision: 1,
bots: [{
botId: 'bot_one',
configured: true,
connected: true,
state: 'connected',
accessPolicy,
}],
});
const controller = {
status: async () => snapshot(),
bindCredentials: async () => snapshot(),
reconnectBot: async () => snapshot(),
deleteBot: async () => snapshot(),
startProvisioning: async () => ({}),
registrationStatus: async () => ({}),
cancelProvisioning: async () => ({}),
submitVerification: async () => ({}),
approveSender: async () => snapshot(),
revokeSender: async () => snapshot(),
startRegistration: async () => ({}),
cancelRegistration: async () => ({}),
disconnect: async () => snapshot(),
async updateAccessPolicy(botId, accessPolicy, projectStatus) {
calls.push({ botId, policy: accessPolicy });
const value = snapshot(accessPolicy);
return projectStatus ? projectStatus(value) : value;
},
};
return { controller, calls };
}
test('all nine Host RPCs accept only the unified bot.access-policy.set contract', async () => {
const factories = [
['weixin', createWeixinRpcHandler, WEIXIN_ENDPOINTS],
['feishu', createFeishuRpcHandler, FEISHU_ENDPOINTS],
['dingtalk', createDingtalkRpcHandler, DINGTALK_ENDPOINTS],
['wecom', createWecomRpcHandler, WECOM_ENDPOINTS],
['qq', createQqRpcHandler, QQ_ENDPOINTS],
['slack', createSlackRpcHandler, SLACK_ENDPOINTS],
['telegram', createTelegramRpcHandler, TELEGRAM_ENDPOINTS],
['discord', createDiscordRpcHandler, DISCORD_ENDPOINTS],
['whatsapp', createWhatsappRpcHandler, WHATSAPP_ENDPOINTS],
];
const next = policy(open([], false), allowlist(['operator']));
for (const [channel, createHandler, endpoints] of factories) {
const { controller, calls } = controllerFixture();
const handler = createHandler(controller);
assert.equal(endpoints.setAccessPolicy, SET_ACCESS_POLICY_ENDPOINT, channel);
const result = await handler(endpoints.setAccessPolicy, { botId: 'bot_one', policy: next });
assert.equal(result.ok, true, `${channel}: ${JSON.stringify(result)}`);
assert.deepEqual(calls, [{ botId: 'bot_one', policy: next }], channel);
assert.deepEqual(result.value?.bots?.[0]?.accessPolicy, next, `${channel} update projection`);
const status = await handler(endpoints.status, {});
assert.equal(status.ok, true, `${channel} status: ${JSON.stringify(status)}`);
assert.deepEqual(status.value?.bots?.[0]?.accessPolicy, policy(), `${channel} status projection`);
const legacy = await handler(endpoints.setAccessPolicy, {
botId: 'bot_one', accessMode: 'open', allowedUsers: [],
});
assert.equal(legacy.ok, false, channel);
assert.equal(legacy.error.code, 'bad-request', channel);
assert.equal(calls.length, 1, channel);
}
});

584
test/access-policy.test.mjs Normal file
View file

@ -0,0 +1,584 @@
import assert from 'node:assert/strict';
import {
mkdtemp,
mkdir,
readFile,
realpath,
rename,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
DEFAULT_ACCESS_POLICY,
createAccessPolicy,
createAccessPolicyScope,
evaluateAccessPolicy,
normalizeAccessPolicy,
validateAccessPolicy,
} from '../src/channels/shared/access-policy.mjs';
import {
BotWorkspaceStore,
createWorkspaceAwareController,
} from '../src/channels/shared/bot-workspace-store.mjs';
function user(id, canExecuteCommands = true) {
return { id, canExecuteCommands };
}
function scope({
mode = 'open',
defaultCanExecuteCommands = true,
commandPermissionOverrides = [],
users = [],
} = {}) {
return {
mode,
open: { defaultCanExecuteCommands, commandPermissionOverrides },
allowlist: { users },
};
}
function openScope({
defaultCanExecuteCommands = true,
commandPermissionOverrides = [],
allowlistUsers = [],
} = {}) {
return scope({
mode: 'open',
defaultCanExecuteCommands,
commandPermissionOverrides,
users: allowlistUsers,
});
}
function allowlistScope(users = [], {
defaultCanExecuteCommands = false,
commandPermissionOverrides = [],
} = {}) {
return scope({
mode: 'allowlist',
defaultCanExecuteCommands,
commandPermissionOverrides,
users,
});
}
function policy({ direct = openScope(), group = openScope() } = {}) {
return { direct, group };
}
async function fixture(t) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-im-access-policy-')));
t.after(() => rm(root, { recursive: true, force: true }));
const defaultWorkspace = join(root, 'workspace');
await mkdir(defaultWorkspace);
return {
root,
defaultWorkspace,
path: join(root, 'workspaces.json'),
};
}
test('access policy validates and normalizes one complete atomic config', () => {
const normalized = validateAccessPolicy(policy({
direct: openScope({
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user(' user-one ')],
allowlistUsers: [user('inactive-member', false)],
}),
group: allowlistScope([user(8672352515, false)], {
commandPermissionOverrides: [user('inactive-admin')],
}),
}));
assert.deepEqual(normalized, {
direct: {
mode: 'open',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('user-one')],
},
allowlist: { users: [user('inactive-member', false)] },
},
group: {
mode: 'allowlist',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('inactive-admin')],
},
allowlist: { users: [user('8672352515', false)] },
},
});
assert.equal(Object.isFrozen(normalized), true);
assert.equal(Object.isFrozen(normalized.direct.open.commandPermissionOverrides), true);
assert.equal(Object.isFrozen(normalized.direct.allowlist.users), true);
assert.equal(normalizeAccessPolicy({ damaged: true }), null);
const invalid = [
{},
{ ...policy(), extra: true },
policy({ direct: { ...openScope(), extra: true } }),
policy({ direct: { ...openScope(), mode: 'private' } }),
policy({ direct: { ...openScope(), open: { defaultCanExecuteCommands: true } } }),
policy({ direct: openScope({ defaultCanExecuteCommands: 'yes' }) }),
policy({ direct: openScope({ commandPermissionOverrides: 'user-one' }) }),
policy({ direct: { ...openScope(), allowlist: { users: 'user-one' } } }),
policy({ direct: openScope({ commandPermissionOverrides: [user('')] }) }),
policy({ direct: openScope({ commandPermissionOverrides: [user('bad\u0000id')] }) }),
policy({ direct: openScope({ commandPermissionOverrides: [user('x'.repeat(257))] }) }),
policy({ direct: openScope({ commandPermissionOverrides: [user('user', 'yes')] }) }),
policy({ direct: openScope({
commandPermissionOverrides: [{ ...user('user'), extra: true }],
}) }),
policy({ direct: openScope({
commandPermissionOverrides: [user(' duplicate '), user('duplicate', false)],
}) }),
policy({ direct: allowlistScope([user(' duplicate '), user('duplicate', false)]) }),
{
direct: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
},
];
for (const input of invalid) {
assert.throws(() => validateAccessPolicy(input), { code: 'access-policy-invalid' });
}
});
test('access policy constructors default closed and accept only canonical scopes', () => {
assert.deepEqual(DEFAULT_ACCESS_POLICY, policy({
direct: allowlistScope(),
group: allowlistScope(),
}));
assert.deepEqual(createAccessPolicy({
direct: createAccessPolicyScope(allowlistScope([
user('owner'),
user(1234n),
])),
group: createAccessPolicyScope(openScope()),
}), policy({
direct: allowlistScope([
user('owner'),
user('1234'),
]),
group: openScope(),
}));
assert.deepEqual(createAccessPolicyScope({
mode: 'open',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('admin')],
},
allowlist: { users: [user('member')] },
}), openScope({
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('admin')],
allowlistUsers: [user('member')],
}));
assert.throws(() => createAccessPolicyScope({
mode: 'allowlist',
defaultCanExecuteCommands: false,
users: [],
}), { code: 'access-policy-invalid' });
});
test('access decisions keep direct, group, ordinary-message and command permissions separate', () => {
const settings = policy({
direct: openScope({
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('admin')],
allowlistUsers: [user('guest-deny', false)],
}),
group: allowlistScope([
user('member', false),
user('operator'),
], { commandPermissionOverrides: [user('unknown')] }),
});
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds: ['guest'],
}), { allowed: true, reason: 'allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds: ['guest'], isCommand: true,
}), { allowed: false, reason: 'command-not-allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds: ['admin'], isCommand: true,
}), { allowed: true, reason: 'allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds: ['guest-deny'], isCommand: true,
}), { allowed: false, reason: 'command-not-allowed' },
'the inactive allowlist does not override open-mode command defaults');
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group', senderIds: ['unknown'],
}), { allowed: false, reason: 'sender-not-allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group', senderIds: ['member'],
}), { allowed: true, reason: 'allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group', senderIds: ['member'], isCommand: true,
}), { allowed: false, reason: 'command-not-allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group',
senderIds: ['alternate', 'OPERATOR'],
isCommand: true,
equals: (left, right) => left.toLowerCase() === right.toLowerCase(),
}), { allowed: true, reason: 'allowed' });
const nonCanonical = {
direct: {
mode: 'allowlist',
defaultCanExecuteCommands: false,
users: [user('old-member', false)],
},
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
};
assert.equal(normalizeAccessPolicy(nonCanonical), null);
assert.deepEqual(evaluateAccessPolicy(nonCanonical, {
conversationType: 'direct', senderIds: ['old-member'],
}), { allowed: false, reason: 'policy-unavailable' });
assert.deepEqual(evaluateAccessPolicy(null, {
conversationType: 'direct', senderIds: ['admin'],
}), { allowed: false, reason: 'policy-unavailable' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'channel', senderIds: ['admin'],
}), { allowed: false, reason: 'invalid-context' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds: [null, 'bad\u0000id'],
}), { allowed: false, reason: 'sender-unavailable' });
});
test('access decisions read only the active scenario when one id exists in both lists', () => {
const sameId = policy({
direct: openScope({
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('same-user', true)],
allowlistUsers: [user('same-user', false)],
}),
group: allowlistScope([user('same-user', false)], {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [user('same-user', true)],
}),
});
assert.doesNotThrow(() => validateAccessPolicy(sameId),
'the same id may appear once in each independent scenario');
assert.deepEqual(evaluateAccessPolicy(sameId, {
conversationType: 'direct', senderIds: ['same-user'], isCommand: true,
}), { allowed: true, reason: 'allowed' },
'open mode reads its override and ignores the conflicting allowlist row');
assert.deepEqual(evaluateAccessPolicy(sameId, {
conversationType: 'group', senderIds: ['same-user'], isCommand: true,
}), { allowed: false, reason: 'command-not-allowed' },
'allowlist mode reads its row and ignores the conflicting open override');
});
test('access decisions deny commands when equivalent sender aliases match conflicting rows', () => {
const settings = policy({
direct: openScope({
defaultCanExecuteCommands: true,
commandPermissionOverrides: [
user('configured-pn', true),
user('configured-lid', false),
],
}),
group: allowlistScope([
user('configured-pn', true),
user('configured-lid', false),
]),
});
const aliases = new Set([
'sender-pn', 'sender-lid', 'configured-pn', 'configured-lid',
]);
const equals = (left, right) => aliases.has(left) && aliases.has(right);
const senderIds = ['sender-pn', 'sender-lid'];
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'direct', senderIds, isCommand: true, equals,
}), { allowed: false, reason: 'command-not-allowed' });
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group', senderIds, equals,
}), { allowed: true, reason: 'allowed' },
'any matching allowlist alias permits an ordinary message');
assert.deepEqual(evaluateAccessPolicy(settings, {
conversationType: 'group', senderIds, isCommand: true, equals,
}), { allowed: false, reason: 'command-not-allowed' });
const allAllowed = policy({
direct: openScope({
defaultCanExecuteCommands: false,
commandPermissionOverrides: [
user('configured-pn', true),
user('configured-lid', true),
],
}),
});
assert.deepEqual(evaluateAccessPolicy(allAllowed, {
conversationType: 'direct', senderIds, isCommand: true, equals,
}), { allowed: true, reason: 'allowed' },
'all equivalent matching rows must explicitly allow commands');
});
test('BotWorkspaceStore initializes a missing policy once and upgrades v1 to v2', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
await writeFile(path, `${JSON.stringify({
version: 1,
workspaces: { bot_one: defaultWorkspace },
agentPresets: { bot_one: 'router-standard' },
})}\n`);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
const initial = policy({ direct: allowlistScope([
{ id: 'owner', canExecuteCommands: true },
]) });
await store.ensure('bot_one', { initialAccessPolicy: initial });
assert.deepEqual(store.accessPolicyFor('bot_one'), initial);
const saved = JSON.parse(await readFile(path, 'utf8'));
assert.equal(saved.version, 2);
assert.equal(saved.workspaces.bot_one, defaultWorkspace);
assert.equal(saved.agentPresets.bot_one, 'router-standard');
assert.deepEqual(saved.accessPolicies.bot_one, initial);
const replacementSeed = policy({ direct: openScope() });
await store.ensure('bot_one', { initialAccessPolicy: replacementSeed });
assert.deepEqual(store.accessPolicyFor('bot_one'), initial, 'initialization is idempotent');
const reloaded = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.deepEqual(reloaded.accessPolicyFor('bot_one'), initial);
assert.deepEqual(reloaded.decorateStatus({ bots: [{ botId: 'bot_one' }] }).bots[0].accessPolicy, initial);
});
test('BotWorkspaceStore fail-closes non-canonical v2 policies without rewriting on load', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const valid = policy({
direct: allowlistScope([user('member')]),
group: openScope({ defaultCanExecuteCommands: false }),
});
const nonCanonical = {
direct: {
mode: 'open',
defaultCanExecuteCommands: false,
users: [user('old-override')],
},
group: {
mode: 'allowlist',
defaultCanExecuteCommands: true,
users: [user('old-member', false)],
},
};
const original = JSON.stringify({
version: 2,
workspaces: {
bot_good: defaultWorkspace,
bot_noncanonical: defaultWorkspace,
},
agentPresets: { bot_good: 'router-standard' },
contextEnhancement: {
bot_good: {
group: { enabled: false, fields: ['senderId'], guidance: '' },
direct: { enabled: true, fields: ['senderId', 'senderName'], guidance: 'direct' },
},
},
deliveryTargets: {
bot_good: { target: { kind: 'user', route: { userId: 'one' } } },
},
accessPolicies: {
bot_good: valid,
bot_noncanonical: nonCanonical,
},
});
await writeFile(path, original);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.deepEqual(store.accessPolicyFor('bot_good'), valid);
assert.equal(store.accessPolicyFor('bot_noncanonical'), null);
assert.equal(store.agentPresetFor('bot_good'), 'router-standard');
assert.equal(store.deliveryTargetFor('bot_good', 'target').route.userId, 'one');
assert.equal(await readFile(path, 'utf8'), original,
'loading invalid policy data must be read-only');
await store.ensure('bot_noncanonical', { initialAccessPolicy: policy() });
assert.equal(store.accessPolicyFor('bot_noncanonical'), null,
'invalid policy data is not treated as a missing seed');
assert.equal(await readFile(path, 'utf8'), original);
});
test('BotWorkspaceStore isolates damaged policies and does not initialize over them', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const valid = policy({ group: allowlistScope([
{ id: 'member', canExecuteCommands: true },
]) });
await writeFile(path, `${JSON.stringify({
version: 2,
workspaces: {
bot_good: defaultWorkspace,
bot_damaged: defaultWorkspace,
bot_missing: defaultWorkspace,
},
deliveryTargets: {
bot_good: { target: { kind: 'user', route: { userId: 'one' } } },
},
accessPolicies: {
bot_good: valid,
bot_damaged: { direct: { mode: 'open' } },
},
})}\n`);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.deepEqual(store.accessPolicyFor('bot_good'), valid);
assert.equal(store.accessPolicyFor('bot_damaged'), null);
assert.equal(store.accessPolicyFor('bot_missing'), null);
await store.ensure('bot_damaged', { initialAccessPolicy: policy() });
await store.ensure('bot_missing', { initialAccessPolicy: policy() });
assert.equal(store.accessPolicyFor('bot_damaged'), null, 'damaged is not treated as missing');
assert.deepEqual(store.accessPolicyFor('bot_missing'), policy());
assert.equal(store.deliveryTargetFor('bot_good', 'target').route.userId, 'one');
const saved = JSON.parse(await readFile(path, 'utf8'));
assert.equal(saved.accessPolicies.bot_damaged, null);
assert.deepEqual(saved.accessPolicies.bot_good, valid);
assert.deepEqual(saved.accessPolicies.bot_missing, policy());
});
test('BotWorkspaceStore fail-closes a damaged policy section without poisoning other bot data', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
await writeFile(path, `${JSON.stringify({
version: 2,
workspaces: { bot_one: defaultWorkspace },
agentPresets: { bot_one: 'router-standard' },
deliveryTargets: {
bot_one: { target: { kind: 'user', route: { userId: 'one' } } },
},
accessPolicies: 'damaged-section',
})}\n`);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
assert.equal(store.workspaceFor('bot_one'), defaultWorkspace);
assert.equal(store.agentPresetFor('bot_one'), 'router-standard');
assert.equal(store.deliveryTargetFor('bot_one', 'target').route.userId, 'one');
assert.equal(store.accessPolicyFor('bot_one'), null);
await store.ensure('bot_one', { initialAccessPolicy: policy() });
assert.equal(store.accessPolicyFor('bot_one'), null,
'startup initialization must not overwrite a damaged policy section');
});
test('BotWorkspaceStore publishes policy snapshots only after atomic persistence', async (t) => {
const { root, defaultWorkspace } = await fixture(t);
const storeDirectory = join(root, 'store');
const storePath = join(storeDirectory, 'workspaces.json');
await mkdir(storeDirectory);
const store = await new BotWorkspaceStore(storePath, { defaultWorkspace }).load();
const initial = policy({ direct: allowlistScope([
{ id: 'owner', canExecuteCommands: true },
]) });
await store.ensure('bot_io', { initialAccessPolicy: initial });
await rename(storeDirectory, `${storeDirectory}-saved`);
await writeFile(storeDirectory, 'blocks policy persistence');
await assert.rejects(store.setAccessPolicy('bot_io', policy()));
assert.deepEqual(store.accessPolicyFor('bot_io'), initial);
await rm(storeDirectory, { force: true });
await rename(`${storeDirectory}-saved`, storeDirectory);
assert.deepEqual(JSON.parse(await readFile(storePath, 'utf8')).accessPolicies.bot_io, initial);
});
test('BotWorkspaceStore cleans policies on reconcile and fences same-id stale updates', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await store.ensure('bot_one', { initialAccessPolicy: policy() });
await store.ensure('bot_two', { initialAccessPolicy: policy({ group: allowlistScope() }) });
const staleIncarnation = store.incarnationFor('bot_one');
await store.remove('bot_one');
await store.ensure('bot_one', {
initialAccessPolicy: policy({ direct: allowlistScope([
{ id: 'new-owner', canExecuteCommands: true },
]) }),
});
await assert.rejects(store.setAccessPolicy('bot_one', policy(), {
incarnation: staleIncarnation,
}), { code: 'workspace-bot-not-found' });
assert.equal(store.accessPolicyFor('bot_one').direct.allowlist.users[0].id, 'new-owner');
await store.reconcile(['bot_one']);
const saved = JSON.parse(await readFile(path, 'utf8'));
assert.equal(saved.workspaces.bot_two, undefined);
assert.equal(saved.accessPolicies.bot_two, undefined);
assert.equal(saved.accessPolicies.bot_one.direct.allowlist.users[0].id, 'new-owner');
});
test('workspace-aware controller preprojects a full policy status before commit', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
const initial = policy({ direct: allowlistScope() });
const updated = policy({ direct: openScope({ defaultCanExecuteCommands: false }) });
await store.ensure('bot_one', { initialAccessPolicy: initial });
const base = {
status() { return { bots: [{ botId: 'bot_one', connected: true }] }; },
};
const controller = createWorkspaceAwareController(base, {
workspaces: store,
stateFor: async () => ({ async clearSessions() {} }),
});
const result = await controller.updateAccessPolicy('bot_one', updated, async (projected) => {
assert.deepEqual(projected.bots[0].accessPolicy, updated);
assert.deepEqual(store.accessPolicyFor('bot_one'), initial, 'projection happens before commit');
return { ...projected, projected: true };
});
assert.equal(result.projected, true);
assert.deepEqual(result.bots[0].accessPolicy, updated);
assert.deepEqual(store.accessPolicyFor('bot_one'), updated);
await assert.rejects(controller.updateAccessPolicy('bot_one', initial, async () => {
throw new Error('projection failed');
}), /projection failed/);
assert.deepEqual(store.accessPolicyFor('bot_one'), updated);
assert.throws(() => controller.updateAccessPolicy('bot_one', {
direct: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
group: { mode: 'open', defaultCanExecuteCommands: true, users: [] },
}), {
code: 'access-policy-invalid',
});
assert.deepEqual(store.accessPolicyFor('bot_one'), updated,
'strict writes reject non-canonical payloads without changing the committed policy');
await assert.rejects(controller.updateAccessPolicy('missing', initial), {
code: 'workspace-bot-not-found',
});
});
test('workspace-aware controller cannot write a policy into a same-id rebound bot', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const store = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
const oldPolicy = policy({ direct: allowlistScope([
{ id: 'old-owner', canExecuteCommands: true },
]) });
const reboundPolicy = policy({ direct: allowlistScope([
{ id: 'new-owner', canExecuteCommands: true },
]) });
await store.ensure('bot_rebound', { initialAccessPolicy: oldPolicy });
let markStatusStarted;
let releaseStatus;
const statusStarted = new Promise((resolveStarted) => { markStatusStarted = resolveStarted; });
const statusGate = new Promise((resolveStatus) => { releaseStatus = resolveStatus; });
const controller = createWorkspaceAwareController({
async status() {
markStatusStarted();
await statusGate;
return { bots: [{ botId: 'bot_rebound' }] };
},
}, {
workspaces: store,
stateFor: async () => ({ async clearSessions() {} }),
});
const updating = controller.updateAccessPolicy('bot_rebound', policy());
await statusStarted;
await store.remove('bot_rebound');
await store.ensure('bot_rebound', { initialAccessPolicy: reboundPolicy });
releaseStatus();
await assert.rejects(updating, { code: 'workspace-bot-not-found' });
assert.deepEqual(store.accessPolicyFor('bot_rebound'), reboundPolicy);
});

View file

@ -0,0 +1,35 @@
export { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../src/channels/shared/inbound-access.mjs';
function scope(users = []) {
return {
mode: 'allowlist',
open: {
defaultCanExecuteCommands: false,
commandPermissionOverrides: [],
},
allowlist: {
users: users.map(({ id, canExecuteCommands = false }) => ({
id,
canExecuteCommands,
})),
},
};
}
export function directAccessPolicy({
users = [],
privilegedIds = [],
} = {}) {
const privileged = new Set(privilegedIds);
const settings = {
direct: scope(users),
group: scope(),
};
return {
getSettings: () => settings,
isPrivileged: (senderIds) => (
(Array.isArray(senderIds) ? senderIds : [senderIds])
.some((senderId) => privileged.has(senderId))
),
};
}

View file

@ -26,6 +26,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
setWorkspace: 'bot.workspace.set',
setAgentPreset: 'bot.preset.set',
setContextEnhancement: 'bot.context-enhancement.set',
setAccessPolicy: 'bot.access-policy.set',
});
});

View file

@ -21,6 +21,10 @@ import {
createOutboundArtifactTool,
releaseOutboundArtifact,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
directAccessPolicy,
} from '../access-policy-fixture.mjs';
function deferred() {
let resolve;
@ -681,6 +685,75 @@ test('DingTalk checks the group mention before downloading a picture', async ()
assert.equal(asks, 0);
});
test('DingTalk applies the unified access policy before attachments or Harness work', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-member', 'session-member');
let downloads = 0;
const harnessCalls = [];
const sent = [];
const accessPolicy = directAccessPolicy({
users: [{ id: 'staff-member', canExecuteCommands: false }],
privilegedIds: ['staff-owner'],
});
const bridge = new DingtalkHarnessBridge({
api: {
downloadImage: async () => {
downloads += 1;
return PNG_BYTES;
},
sendText: async ({ text }) => {
sent.push(text);
return { messageId: `dingtalk-policy-${sent.length}` };
},
},
clientId: 'ding-client',
clientSecret: 'host-secret',
accessPolicy,
harness: {
sessionExists: async (sessionId) => {
harnessCalls.push(['sessionExists', sessionId]);
return true;
},
ask: async (sessionId, prompt) => {
harnessCalls.push(['ask', sessionId, prompt]);
return '白名单消息已处理';
},
},
state: fixture.state,
});
await bridge.accept(message('policy-blocked-picture', '', {
senderStaffId: 'staff-blocked',
msgtype: 'picture',
text: undefined,
content: { downloadCode: 'blocked-picture' },
robotCode: 'robot-code',
}));
assert.equal(downloads, 0);
assert.deepEqual(harnessCalls, []);
assert.deepEqual(sent, []);
await bridge.accept(message('policy-member-text', '普通消息', {
senderStaffId: 'staff-member',
}));
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
assert.deepEqual(sent, ['白名单消息已处理']);
const callsBeforeDeniedCommand = harnessCalls.length;
const repliesBeforeDeniedCommand = sent.length;
await bridge.accept(message('policy-member-command', '/help', {
senderStaffId: 'staff-member',
}));
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
accessPolicy.getSettings().direct.allowlist.users = [];
await bridge.accept(message('policy-owner-command', '/help', {
senderStaffId: 'staff-owner',
}));
assert.match(sent.at(-1), /\/help/);
});
test('DingTalk returns a specific retry message when picture download fails', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', 'session-image');

View file

@ -21,6 +21,7 @@ import {
resolveDiscordMessageRoute,
} from '../../../src/channels/discord/discord-runtime.mjs';
import { setImHostLanguage } from '../../../src/channels/shared/i18n.mjs';
import { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../../src/channels/shared/inbound-access.mjs';
import {
DISCORD_ENDPOINTS,
createDiscordRpcHandler,
@ -1442,9 +1443,12 @@ test('Discord captures context settings before asynchronous Thread routing and u
const routingStarted = deferred();
const releaseRouting = deferred();
const seen = new Set();
const deliveries = [];
const prompts = [];
let socket;
let reads = 0;
let accessReads = 0;
let threadStarts = 0;
let config = {
group: {
enabled: true,
@ -1453,6 +1457,24 @@ test('Discord captures context settings before asynchronous Thread routing and u
},
direct: { enabled: false, fields: [], guidance: 'direct must not leak' },
};
const allowedAccessSettings = {
direct: {
mode: 'open',
open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: {
users: [
{ id: '333333333333333333', canExecuteCommands: true },
{ id: '333333333333333334', canExecuteCommands: false },
],
},
},
};
let accessSettings = allowedAccessSettings;
const runtime = new DiscordRuntime({
config: { botId: 'discord_internal', platformId: botId, name: 'Harness Discord' },
token: TOKEN,
@ -1460,6 +1482,12 @@ test('Discord captures context settings before asynchronous Thread routing and u
botId: 'discord_internal',
getSettings: () => { reads += 1; return config; },
},
accessPolicy: {
getSettings: () => {
accessReads += 1;
return accessSettings;
},
},
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
@ -1475,12 +1503,16 @@ test('Discord captures context settings before asynchronous Thread routing and u
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
getChannel: async () => assert.fail('The channel is already in the gateway cache'),
startThreadFromMessage: async () => {
threadStarts += 1;
routingStarted.resolve();
await releaseRouting.promise;
return { id: threadId, type: 11, parent_id: parentId, owner_id: botId };
},
sendTyping: async () => {},
createMessage: async () => ({ id: '888888888888888890' }),
createMessage: async (request) => {
deliveries.push(request);
return { id: '888888888888888890' };
},
editMessage: async ({ messageId }) => ({ id: messageId }),
}),
createWebSocket: () => {
@ -1498,13 +1530,37 @@ test('Discord captures context settings before asynchronous Thread routing and u
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'GUILD_CREATE', s: 2,
d: { id: '444444444444444444', channels: [{ id: parentId, type: 0 }], threads: [] },
}) });
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
id: '111111111111111189', channel_id: parentId, guild_id: '444444444444444444',
author: { id: '333333333333333332', bot: false },
mentions: [{ id: botId }], content: `<@${botId}> denied before Thread`,
} }) });
await eventually(() => runtime.status.messagesRejected === 1);
assert.equal(threadStarts, 0, 'a denied member must not create a Discord Thread');
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
id: '111111111111111188', channel_id: parentId, guild_id: '444444444444444444',
author: { id: '333333333333333334', bot: false },
mentions: [{ id: botId }], content: `<@${botId}> /new`,
} }) });
await eventually(() => runtime.status.messagesRejected === 2);
assert.equal(threadStarts, 0, 'a command-denied member must not create a Discord Thread');
assert.equal(deliveries.at(-1)?.channelId, parentId);
assert.equal(deliveries.at(-1)?.content, COMMAND_PERMISSION_DENIED_MESSAGE);
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
id: threadId, channel_id: parentId, guild_id: '444444444444444444',
author: { id: '333333333333333333', bot: false, global_name: 'Global Name', username: 'username' },
member: { nick: 'Group Nick' }, mentions: [{ id: botId }], content: `<@${botId}> first`,
} }) });
await routingStarted.promise;
assert.equal(threadStarts, 1);
config = { ...config, group: { ...config.group, enabled: false } };
accessSettings = {
...allowedAccessSettings,
group: {
...allowedAccessSettings.group,
allowlist: { users: [] },
},
};
releaseRouting.resolve();
await eventually(() => runtime.status.messagesReplied === 1);
assert.match(prompts[0], /accepted before routing/);
@ -1513,7 +1569,10 @@ test('Discord captures context settings before asynchronous Thread routing and u
senderName: 'Group Nick', botId: 'discord_internal',
});
assert.equal(reads, 1, 'routing and Bridge share one accepted configuration read');
assert.equal(accessReads, 3,
'each source event reads access once and the Thread keeps its arrival decision');
accessSettings = allowedAccessSettings;
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
id: '111111111111111191', channel_id: threadId, guild_id: '444444444444444444',
author: { id: '333333333333333333', bot: false }, content: 'second without enhancement',
@ -1521,6 +1580,7 @@ test('Discord captures context settings before asynchronous Thread routing and u
await eventually(() => runtime.status.messagesReplied === 2);
assert.equal(prompts[1], 'second without enhancement');
assert.equal(reads, 2);
assert.equal(accessReads, 4, 'the next managed-Thread event reads the latest policy once');
});
test('Discord runtime records one uncertain Thread result and suppresses Gateway replays', async () => {

View file

@ -15,6 +15,10 @@ import {
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
directAccessPolicy,
} from '../access-policy-fixture.mjs';
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
@ -894,6 +898,79 @@ test('bridge downloads an inbound Feishu image once and submits structured Harne
assert.deepEqual(sent, ['看到了一张图片']);
});
test('Feishu applies the unified access policy before attachments or Harness work', async () => {
const fixture = stateFixture([['p2p:ou_member', 'session-member']]);
let downloads = 0;
const harnessCalls = [];
const sent = [];
const accessPolicy = directAccessPolicy({
users: [{ id: 'ou_member', canExecuteCommands: false }],
privilegedIds: ['ou_owner'],
});
const client = {
im: { v1: {
messageResource: { get: async () => {
downloads += 1;
return { getReadableStream: () => Readable.from([PNG_1X1]) };
} },
message: { create: async (request) => {
sent.push(JSON.parse(request.data.content).text);
return { code: 0, data: { message_id: `om_policy_${sent.length}` } };
} },
} },
};
const bridge = new FeishuHarnessBridge({
client,
channel: {},
accessPolicy,
harness: {
sessionExists: async (sessionId) => {
harnessCalls.push(['sessionExists', sessionId]);
return true;
},
ask: async (sessionId, prompt) => {
harnessCalls.push(['ask', sessionId, prompt]);
return '白名单消息已处理';
},
},
state: fixture.state,
status: bridgeStatus(),
});
await bridge.accept(event('policy-blocked-image', '', {
senderOpenId: 'ou_blocked',
message_type: 'image',
content: JSON.stringify({ image_key: 'img_blocked' }),
}));
await bridge.waitForIdle();
assert.equal(downloads, 0);
assert.deepEqual(harnessCalls, []);
assert.deepEqual(sent, []);
await bridge.accept(event('policy-member-text', '普通消息', {
senderOpenId: 'ou_member',
}));
await bridge.waitForIdle();
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
assert.deepEqual(sent, ['白名单消息已处理']);
const callsBeforeDeniedCommand = harnessCalls.length;
const repliesBeforeDeniedCommand = sent.length;
await bridge.accept(event('policy-member-command', '/help', {
senderOpenId: 'ou_member',
}));
await bridge.waitForIdle();
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
accessPolicy.getSettings().direct.allowlist.users = [];
await bridge.accept(event('policy-owner-command', '/help', {
senderOpenId: 'ou_owner',
}));
await bridge.waitForIdle();
assert.match(sent.at(-1), /\/status/);
});
test('bridge hands a native Feishu file source to the current Harness turn', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-file']]);
const bytes = Buffer.from('feishu-native-file');
@ -3533,6 +3610,32 @@ test('card buttons from an unallowed sender are ignored', async () => {
assert.equal(sent.length, 1, 'a card action without an operator must fail closed');
});
test('a card callback without a trusted route stays silent before access evaluation', async () => {
const sent = [];
const bridge = new FeishuHarnessBridge({
client: cardClient(async (outgoing) => sent.push(outgoing)),
channel: {},
accessPolicy: directAccessPolicy({
users: [{ id: 'ou_member', canExecuteCommands: true }],
privilegedIds: ['ou_owner'],
}),
harness: sessionsHarness(1),
state: stateFixture().state,
status: bridgeStatus(),
});
await bridge.onCardAction({
...cardActionEvent('om_stale_after_restart', 'new', 'ou_member'),
context: {
open_message_id: 'om_stale_after_restart',
open_chat_id: 'oc_untrusted_scope',
},
});
await bridge.waitForIdle();
assert.deepEqual(sent, [], 'missing direct/group scope must fail closed without a reply');
});
test('card buttons from an allowed sender work', async () => {
const fixture = stateFixture();
const sent = [];

View file

@ -17,6 +17,10 @@ import {
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
directAccessPolicy,
} from '../access-policy-fixture.mjs';
function deferred() {
let resolve;
@ -475,6 +479,74 @@ test('QQ checks sender and group mention before downloading image attachments',
assert.equal(asks, 0);
});
test('QQ applies the unified access policy before attachments or Harness work', async () => {
const fixture = stateFixture([['c2c:member-openid', 'session-member']]);
let downloads = 0;
const harnessCalls = [];
const sent = [];
const accessPolicy = directAccessPolicy({
users: [{ id: 'member-openid', canExecuteCommands: false }],
privilegedIds: ['owner-openid'],
});
const bridge = new QqHarnessBridge({
bot: {
sendText: async (_target, text) => {
sent.push(text);
return { id: `qq-policy-${sent.length}` };
},
},
ownerUserOpenid: 'owner-openid',
accessPolicy,
harness: {
sessionExists: async (sessionId) => {
harnessCalls.push(['sessionExists', sessionId]);
return true;
},
ask: async (sessionId, prompt) => {
harnessCalls.push(['ask', sessionId, prompt]);
return '白名单消息已处理';
},
},
state: fixture.state,
fetchImpl: async () => {
downloads += 1;
return new Response(PNG_BYTES, { headers: { 'content-type': 'image/png' } });
},
});
const directMessage = (messageId, senderId, content, overrides = {}) => message({
messageId,
senderId,
content,
replyTarget: { scope: 'c2c', targetId: senderId, msgId: messageId },
...overrides,
});
await bridge.accept(directMessage('policy-blocked-image', 'blocked-openid', '', {
attachments: [{
content_type: 'image/png',
filename: 'blocked.png',
url: 'https://multimedia.nt.qq.com.cn/download/blocked',
}],
}));
assert.equal(downloads, 0);
assert.deepEqual(harnessCalls, []);
assert.deepEqual(sent, []);
await bridge.accept(directMessage('policy-member-text', 'member-openid', '普通消息'));
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
assert.deepEqual(sent, ['白名单消息已处理']);
const callsBeforeDeniedCommand = harnessCalls.length;
const repliesBeforeDeniedCommand = sent.length;
await bridge.accept(directMessage('policy-member-command', 'member-openid', '/help'));
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
accessPolicy.getSettings().direct.allowlist.users = [];
await bridge.accept(directMessage('policy-owner-command', 'owner-openid', '/help'));
assert.match(sent.at(-1), /\/help/);
});
test('QQ rejects non-platform image URLs without fetching and returns a retryable image error', async () => {
const fixture = stateFixture([['c2c:owner-openid', 'session-image']]);
const sent = [];

View file

@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isSharedLocalCommand } from '../../../src/channels/shared/command-permission.mjs';
import { evaluateInboundAccess } from '../../../src/channels/shared/inbound-access.mjs';
test('isSharedLocalCommand matches existing local command families', () => {
for (const command of [
'/help', '/status', '/new', '/version', '/stop', '/steer more',
'/batch', '/send', '/cancel', '/history 3', '/workspace /tmp',
'/workspacelist', '/sessionlist', '/sessions /tmp', '/session 2',
'/compact', '/models', '/model 2', '/reasonings', '/reasoning high',
'/presetlist', '/preset default',
]) {
assert.equal(isSharedLocalCommand(command), true, command);
}
});
test('isSharedLocalCommand leaves unknown and channel-specific slash text as ordinary prompts', () => {
for (const text of [
'/foo', '/help me', 'hello', '/', '',
'/menu', '/repair verify', '/watch session-id', '/unwatch session-id',
'/watchlist', '/archived off',
]) {
assert.equal(isSharedLocalCommand(text), false, text);
}
});
test('isSharedLocalCommand follows current media command routing', () => {
assert.equal(isSharedLocalCommand('/history', { hasFiles: true }), true);
assert.equal(isSharedLocalCommand('/batch', { hasFiles: true }), true);
assert.equal(isSharedLocalCommand('/status', { hasImages: true }), false);
assert.equal(isSharedLocalCommand('/workspace /tmp', { hasFiles: true }), false);
assert.equal(isSharedLocalCommand('/stop', { hasImages: true }), true);
assert.equal(isSharedLocalCommand('/stop', { hasFiles: true }), false);
});
test('evaluateInboundAccess always preserves an original owner privilege', () => {
const deniedPolicy = {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
const accessPolicy = {
getSettings: () => deniedPolicy,
isPrivileged: (senderIds) => senderIds === 'owner-id',
};
assert.deepEqual(evaluateInboundAccess(accessPolicy, {
conversationType: 'group',
senderIds: 'owner-id',
text: '/status',
}), { allowed: true, reason: 'privileged-sender' });
assert.equal(evaluateInboundAccess(accessPolicy, {
conversationType: 'group',
senderIds: 'another-user',
text: '/status',
}).allowed, false);
});

View file

@ -7,6 +7,7 @@ import manifest from '../../../package.json' with { type: 'json' };
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
import { COMMAND_PERMISSION_DENIED_MESSAGE } from '../../../src/channels/shared/inbound-access.mjs';
import { InboundFileError } from '../../../src/channels/shared/inbound-file.mjs';
import {
OUTBOUND_ARTIFACT_TOOL,
@ -88,6 +89,21 @@ function message(messageId, content, overrides = {}) {
};
}
function accessPolicy({ canExecuteCommands = false } = {}) {
return {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [{ id: 'actor-a', canExecuteCommands }] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
}
function questionInteraction({
id = 'question-one',
sessionId = 'session-one',
@ -277,6 +293,84 @@ test('shared status reactions replace processing with success without joining th
]);
});
test('all four shared text channels enforce fail-closed live access before side effects', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
['telegram', TelegramHarnessBridge],
['discord', DiscordHarnessBridge],
['whatsapp', WhatsappHarnessBridge],
]) {
const fixture = stateFixture();
const sent = [];
const asks = [];
let imageLoads = 0;
let sessionClears = 0;
let policyReadFails = true;
let settings = null;
const originalClearSession = fixture.state.clearSession.bind(fixture.state);
fixture.state.clearSession = async (...args) => {
sessionClears += 1;
return originalClearSession(...args);
};
const bridge = new Bridge({
accessPolicy: {
getSettings() {
if (policyReadFails) throw new Error('private policy read detail');
return settings;
},
isPrivileged: (senderIds) => senderIds.includes('owner-a'),
},
bot: { sendText: async (_target, text) => sent.push(text) },
state: fixture.state,
harness: {
createSession: async () => `session-access-${name}`,
sessionExists: async () => true,
ask: async (_sessionId, content) => {
asks.push(content);
return `${name} allowed reply`;
},
},
});
await bridge.accept(message(`access-blocked-${name}`, 'blocked attachment', {
images: [{
mediaType: 'image/png',
load: async () => {
imageLoads += 1;
return Buffer.from('must not load');
},
}],
}));
assert.equal(imageLoads, 0, `${name} authorizes before downloading attachments`);
assert.deepEqual(asks, [], `${name} fail-closed denial never reaches Harness`);
assert.equal(fixture.seen.has(`access-blocked-${name}`), true,
`${name} records a denial for replay suppression`);
await bridge.accept(message(`access-owner-${name}`, '/help', { senderId: 'owner-a' }));
assert.match(sent.at(-1), /\/help/, `${name} owner bypasses a failed policy read`);
assert.deepEqual(asks, [], `${name} owner command remains local`);
policyReadFails = false;
settings = accessPolicy();
await bridge.accept(message(`access-blocked-${name}`, 'replayed after policy update'));
assert.deepEqual(asks, [], `${name} a denied replay cannot bypass the new policy`);
await bridge.accept(message(`access-ordinary-${name}`, 'allowed ordinary message'));
assert.equal(asks.length, 1, `${name} applies the live policy to a new event`);
assert.equal(sent.at(-1), `${name} allowed reply`, `${name} keeps the normal reply path`);
await bridge.accept(message(`access-command-denied-${name}`, '/new'));
assert.equal(asks.length, 1, `${name} denied command never reaches Harness`);
assert.equal(sessionClears, 0, `${name} denied command has no command side effect`);
assert.equal(sent.at(-1), COMMAND_PERMISSION_DENIED_MESSAGE, `${name} explains command denial`);
settings = accessPolicy({ canExecuteCommands: true });
await bridge.accept(message(`access-command-allowed-${name}`, '/new'));
assert.equal(sessionClears, 1, `${name} policy hot-update applies without rebuilding the bridge`);
assert.equal(asks.length, 1, `${name} allowed local command is not a model prompt`);
}
});
test('runtime abort clears a queued interaction reply reaction instead of marking success', async () => {
const fixture = stateFixture();
const controller = new AbortController();

View file

@ -1053,6 +1053,78 @@ class FakeSocket {
}
}
test('Slack runtime forwards the live access policy provider into its shared bridge', async () => {
let socket;
let stateWrites = 0;
const runtime = new SlackRuntime({
config: {
botId: 'slack_access',
platformId: 'T12345678:U12345678',
name: 'DeepSeek Harness',
},
botToken: BOT_TOKEN,
appToken: APP_TOKEN,
harness: { ensureRunning: async () => true },
state: {
hasSeen: () => false,
markSeen: async () => { stateWrites += 1; },
},
accessPolicy: {
getSettings: () => ({
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
group: {
mode: 'open',
open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
}),
},
createApi: () => ({
authTest: async () => ({ team_id: 'T12345678', user_id: 'U12345678' }),
openConnection: async () => ({ url: 'wss://wss-primary.slack.com/link/?ticket=test' }),
}),
createWebSocket: () => {
socket = new FakeSocket();
queueMicrotask(() => socket.emit('message', {
data: JSON.stringify({
type: 'hello',
connection_info: { app_id: 'A12345678' },
}),
}));
return socket;
},
logger: { warn() {}, error(...args) { assert.fail(args.join(' ')); } },
});
try {
await runtime.start();
socket.emit('message', {
data: JSON.stringify({
envelope_id: 'env-denied',
type: 'events_api',
payload: {
type: 'event_callback',
api_app_id: 'A12345678',
event_id: 'Ev-denied',
event: {
type: 'message', channel_type: 'im', channel: 'D12345678',
user: 'U00000000', ts: '1700000000.009', text: 'must stay local',
},
},
}),
});
await eventually(() => runtime.status.messagesRejected === 1);
assert.equal(stateWrites, 1, 'the denial is recorded only for replay suppression');
assert.deepEqual(socket.sent.at(-1), { envelope_id: 'env-denied' });
} finally {
await runtime.stop();
}
});
test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready', async () => {
let socket;
const abortMark = deferred();

View file

@ -4,16 +4,12 @@ import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import {
TelegramAccessSettings,
TelegramAccountCard,
TelegramSettingsTab,
} from '../../../plugin-src/client/channels/telegram/index.js';
const { act } = TestRenderer;
test('Telegram settings exposes a Bot Token action without a fake QR action', () => {
const markup = renderToStaticMarkup(React.createElement(TelegramSettingsTab, {
rpcCall: async () => ({ ok: true, value: { bots: [] } }),
@ -44,125 +40,16 @@ test('Telegram account card matches the unified compact card layout', () => {
assert.doesNotMatch(markup, /Bot API 长轮询|消息通道|dim-botMetric/);
assert.match(markup, />检查连接</);
assert.match(markup, />移除接入</);
assert.match(markup, />访问设置</);
assert.match(markup, /aria-label="Telegram 访问模式"/);
assert.match(markup, />兼容模式(默认)</);
assert.match(markup, /aria-label="更多机器人设置"/);
assert.doesNotMatch(markup, /Telegram 访问模式|兼容模式(默认)|安全模式(私聊白名单)/);
assert.doesNotMatch(markup, /dim-cardSummary/);
});
test('Telegram access settings edits and saves one bot policy', async () => {
const saved = [];
let renderer;
await act(async () => {
renderer = TestRenderer.create(React.createElement(TelegramAccessSettings, {
account: {
botId: 'telegram_test',
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
},
onSave: async (policy) => saved.push(policy),
}));
});
const select = renderer.root.findByProps({ 'aria-label': 'Telegram 访问模式' });
let textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 Telegram User ID',
});
assert.equal(textarea.props.disabled, true);
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
});
textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 Telegram User ID',
});
assert.equal(textarea.props.disabled, false);
await act(async () => {
textarea.props.onChange({ target: { value: '6087707998\n1202499116\n6087707998' } });
});
await act(async () => {
select.props.onChange({ target: { value: 'compatible' } });
});
textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 Telegram User ID',
});
assert.equal(textarea.props.disabled, true);
assert.equal(textarea.props.value, '6087707998\n1202499116\n6087707998');
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
});
textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 Telegram User ID',
});
assert.equal(textarea.props.disabled, false);
assert.equal(textarea.props.value, '6087707998\n1202499116\n6087707998');
assert.deepEqual(
renderer.root.findByProps({ className: 'dtg-accessBadge' }).children,
['已生效:兼容模式'],
);
await act(async () => {
await renderer.root.findByType('form').props.onSubmit({ preventDefault() {} });
});
assert.deepEqual(saved, [{
accessMode: 'private-allowlist',
allowedUsers: ['6087707998', '1202499116'],
}]);
await act(async () => renderer.unmount());
});
test('Telegram access settings keeps both mode descriptions in an accessible help tooltip', async () => {
let renderer;
await act(async () => {
renderer = TestRenderer.create(React.createElement(TelegramAccessSettings, {
account: {
botId: 'telegram_test',
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
},
onSave() {},
}));
});
const helpButton = renderer.root.findByProps({
'aria-label': '查看 Telegram 访问模式说明',
});
const tooltip = renderer.root.findByProps({ role: 'tooltip' });
const heading = renderer.root.findByProps({ className: 'dtg-accessHeading' });
assert.equal(helpButton.props.type, 'button');
assert.ok(tooltip.props.id);
assert.equal(helpButton.props['aria-describedby'], tooltip.props.id);
assert.equal(heading.findAllByType('p').length, 0);
const markup = renderToStaticMarkup(React.createElement(TelegramAccessSettings, {
account: {
botId: 'telegram_test',
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
},
onSave() {},
}));
assert.match(markup, />兼容模式<\/strong>/);
assert.match(markup, />安全模式<\/strong>/);
assert.match(markup, /保持原有行为:私聊直接响应,群聊在被提及或回复时响应。/);
assert.match(markup, /群聊全部忽略,私聊仅允许白名单用户。/);
await act(async () => renderer.unmount());
});
test('Telegram access mode help opens for pointer hover and keyboard focus', async () => {
const styles = await readFile(
new URL('../../../plugin-src/client/channels/telegram/styles.js', import.meta.url),
test('Telegram cards shrink to a narrow English panel without horizontal scrolling', async () => {
const sharedStyles = await readFile(
new URL('../../../plugin-src/client/styles.js', import.meta.url),
'utf8',
);
assert.match(styles, /\.dtg-accessHeading \{[^}]*position: relative;/);
assert.match(styles, /\.dtg-accessHelp \{[^}]*position: static;/);
assert.match(styles, /\.dtg-accessTooltip \{[^}]*right: 0;[^}]*width: min\(300px, 100%\);[^}]*max-width: 100%;/);
assert.match(styles, /\.dtg-accessHelpButton:focus-visible \{/);
assert.match(styles, /\.dtg-accessHelp:hover \.dtg-accessTooltip, \.dtg-accessHelp:focus-within \.dtg-accessTooltip \{[^}]*opacity: 1;[^}]*visibility: visible;/);
});
test('Telegram cards shrink to a narrow English panel without horizontal scrolling', async () => {
const [sharedStyles, telegramStyles] = await Promise.all([
readFile(new URL('../../../plugin-src/client/styles.js', import.meta.url), 'utf8'),
readFile(
new URL('../../../plugin-src/client/channels/telegram/styles.js', import.meta.url),
'utf8',
),
]);
assert.match(sharedStyles, /\.dim-panel \.dim-botList \{[^}]*grid-template-columns: minmax\(0, 1fr\);/);
assert.match(sharedStyles, /\.dim-panel \.dim-botCard \{[^}]*min-width: 0;[^}]*width: 100%;[^}]*max-width: 100%;[^}]*overflow: hidden;/);
@ -170,19 +57,4 @@ test('Telegram cards shrink to a narrow English panel without horizontal scrolli
assert.doesNotMatch(sharedStyles, /\.dim-panel \.dim-botCardTop \{ flex-direction: column;/);
assert.match(sharedStyles, /\.dim-panel \.dim-workspacePath \{[^}]*overflow: hidden;[^}]*overflow-wrap: anywhere;[^}]*white-space: normal;/);
assert.doesNotMatch(sharedStyles, /\.dim-panel \.dim-workspacePath \{[^}]*overflow-x: auto;/);
assert.match(telegramStyles, /\.dtg-access \{[^}]*min-width: 0;[^}]*width: 100%;[^}]*max-width: 100%;/);
assert.match(telegramStyles, /\.dtg-accessHeading \{[^}]*flex-wrap: wrap;/);
assert.match(telegramStyles, /\.dtg-accessStatus \{[^}]*max-width: 100%;[^}]*flex-wrap: wrap;/);
assert.match(telegramStyles, /\.dtg-accessField select, \.dtg-accessField textarea \{[^}]*min-width: 0;[^}]*max-width: 100%;/);
});
test('Telegram access settings warns when safe mode has an empty allowlist', () => {
const markup = renderToStaticMarkup(React.createElement(TelegramAccessSettings, {
account: {
botId: 'telegram_test',
accessPolicy: { accessMode: 'private-allowlist', allowedUsers: [] },
},
onSave() {},
}));
assert.match(markup, /白名单为空;保存后该机器人会拒绝所有入站消息。/);
});

View file

@ -902,7 +902,7 @@ test('Telegram queued policy update cannot persist after controller close begins
assert.equal(configStore.get(botId).allowedUsers, undefined);
});
test('Telegram RPC accepts only token binding and strips credential internals', async () => {
test('Telegram RPC accepts the unified access policy and strips credential internals', async () => {
const calls = [];
const connectionTests = [];
const controller = {
@ -925,7 +925,7 @@ test('Telegram RPC accepts only token binding and strips credential internals',
}),
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
setAccessPolicy: async (botId, policy) => {
updateAccessPolicy: async (botId, policy) => {
calls.push({ botId, policy });
return {
bots: [{ botId, accessPolicy: policy }],
@ -973,28 +973,35 @@ test('Telegram RPC accepts only token binding and strips credential internals',
code: 'test-target-unavailable',
});
const unifiedPolicy = {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [{ id: '6087707998', canExecuteCommands: true }] },
},
group: {
mode: 'open',
open: { defaultCanExecuteCommands: true, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
const access = await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
botId: 'telegram_123',
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998', '6087707998'],
policy: unifiedPolicy,
});
assert.equal(access.ok, true);
assert.deepEqual(calls.at(-1), {
botId: 'telegram_123',
policy: {
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998'],
},
policy: unifiedPolicy,
});
assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
botId: 'telegram_123',
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['@username'],
allowedUsers: ['6087707998'],
})).error.code, 'bad-request');
assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
botId: 'telegram_123',
accessMode: TELEGRAM_ACCESS_MODES.compatible,
allowedUsers: [],
policy: unifiedPolicy,
extra: true,
})).error.code, 'bad-request');
});
@ -1009,7 +1016,7 @@ test('shared token RPC never sends a connection test after reconnect is cancelle
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
setAccessPolicy: async () => ({ bots: [] }),
updateAccessPolicy: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createTelegramRpcHandler(controller)(TELEGRAM_ENDPOINTS.reconnectBot, {
@ -1446,7 +1453,7 @@ test('Telegram runtime still starts when the command menu setup fails', async ()
}
});
test('Telegram runtime enforces the selected bot private allowlist', async () => {
test('Telegram runtime enforces the unified direct and group access policy', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-allowlist-runtime-'));
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
const asked = [];
@ -1508,8 +1515,10 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
botId: 'telegram_allowlist',
platformId: '123456789',
username: 'HarnessBot',
// Kept deliberately contradictory: legacy fields are migration input,
// not a second active Runtime gate after unified policy injection.
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['7'],
allowedUsers: ['999'],
},
token: TOKEN,
harness: {
@ -1521,6 +1530,20 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
},
},
state,
accessPolicy: {
getSettings: () => ({
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [{ id: '7', canExecuteCommands: true }] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
}),
},
createApi: () => fakeApi,
});

View file

@ -17,6 +17,10 @@ import {
OutboundArtifactRegistry,
createOutboundArtifactTool,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
directAccessPolicy,
} from '../access-policy-fixture.mjs';
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
@ -707,6 +711,78 @@ test('Enterprise WeChat exposes native file callbacks through the SDK downloader
}]);
});
test('Enterprise WeChat applies the unified access policy before attachments or Harness work', async () => {
const transport = testClient();
let downloads = 0;
const harnessCalls = [];
const accessPolicy = directAccessPolicy({
users: [{ id: 'member-1', canExecuteCommands: false }],
privilegedIds: ['owner-1'],
});
transport.client.downloadFile = async () => {
downloads += 1;
return { buffer: PNG_1X1, filename: 'blocked.png' };
};
const bridge = new WecomHarnessBridge({
client: transport.client,
generateStreamId: (() => {
let sequence = 0;
return () => `policy-stream-${++sequence}`;
})(),
accessPolicy,
harness: {
sessionExists: async (sessionId) => {
harnessCalls.push(['sessionExists', sessionId]);
return true;
},
ask: async (sessionId, prompt) => {
harnessCalls.push(['ask', sessionId, prompt]);
return '白名单消息已处理';
},
},
state: state(),
});
await bridge.accept(frame({
msgid: 'policy-blocked-image',
from: { userid: 'blocked-1' },
msgtype: 'image',
text: undefined,
image: { url: 'https://wecom.example/blocked', aeskey: 'blocked-key' },
}));
assert.equal(downloads, 0);
assert.deepEqual(harnessCalls, []);
assert.deepEqual(transport.streamed, []);
assert.deepEqual(transport.active, []);
await bridge.accept(frame({
msgid: 'policy-member-text',
text: { content: '普通消息' },
}));
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
assert.equal(transport.streamed.at(-1).content, streamedAnswer('白名单消息已处理'));
const callsBeforeDeniedCommand = harnessCalls.length;
const repliesBeforeDeniedCommand = transport.streamed.length;
await bridge.accept(frame({
msgid: 'policy-member-command',
text: { content: '/help' },
}));
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
assert.deepEqual(transport.streamed.slice(repliesBeforeDeniedCommand).map(({ content, finish }) => ({
content,
finish,
})), [{ content: COMMAND_PERMISSION_DENIED_MESSAGE, finish: true }]);
accessPolicy.getSettings().direct.allowlist.users = [];
await bridge.accept(frame({
msgid: 'policy-owner-command',
from: { userid: 'owner-1' },
text: { content: '/help' },
}));
assert.match(transport.streamed.at(-1).content, /\/help/);
});
test('Enterprise WeChat bridge hands its prefetched native file to the current Harness turn', async () => {
const transport = testClient();
const bytes = Buffer.from('wecom-bridge-file');

View file

@ -16,6 +16,10 @@ import {
createOutboundArtifactTool,
releaseOutboundArtifact,
} from '../../../src/channels/shared/semantic/artifact.mjs';
import {
COMMAND_PERMISSION_DENIED_MESSAGE,
directAccessPolicy,
} from '../access-policy-fixture.mjs';
function deferred() {
let resolve;
@ -651,6 +655,68 @@ test('Weixin authorizes the sender before resolving encrypted image references',
assert.equal(asks, 0);
});
test('Weixin applies the unified access policy before attachments or Harness work', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:member-user', 'session-member');
let imageExtractions = 0;
const harnessCalls = [];
const sent = [];
const accessPolicy = directAccessPolicy({
users: [{ id: 'member-user', canExecuteCommands: false }],
privilegedIds: ['owner-user'],
});
const bridge = new WeixinHarnessBridge({
api: {
inboundImages: (value) => {
imageExtractions += 1;
return value?.item_list?.some((item) => item?.image_item) ? [{ data: PNG_BYTES }] : [];
},
sendText: async (request) => sent.push(request.text),
},
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
accessPolicy,
harness: {
sessionExists: async (sessionId) => {
harnessCalls.push(['sessionExists', sessionId]);
return true;
},
ask: async (sessionId, prompt) => {
harnessCalls.push(['ask', sessionId, prompt]);
return '白名单消息已处理';
},
},
state: fixture.state,
});
await bridge.accept(message('policy-blocked-image', '', {
from_user_id: 'blocked-user',
item_list: [{ type: 2, image_item: { media: {} } }],
}));
assert.equal(imageExtractions, 0);
assert.deepEqual(harnessCalls, []);
assert.deepEqual(sent, []);
await bridge.accept(message('policy-member-text', '普通消息', {
from_user_id: 'member-user',
}));
assert.equal(harnessCalls.some(([operation]) => operation === 'ask'), true);
assert.deepEqual(sent, ['白名单消息已处理']);
const callsBeforeDeniedCommand = harnessCalls.length;
const repliesBeforeDeniedCommand = sent.length;
await bridge.accept(message('policy-member-command', '/help', {
from_user_id: 'member-user',
}));
assert.equal(harnessCalls.length, callsBeforeDeniedCommand);
assert.deepEqual(sent.slice(repliesBeforeDeniedCommand), [COMMAND_PERMISSION_DENIED_MESSAGE]);
accessPolicy.getSettings().direct.allowlist.users = [];
await bridge.accept(message('policy-owner-command', '/help'));
assert.match(sent.at(-1), /\/help/);
});
test('Weixin returns a specific retry message when encrypted image loading fails', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-image');

View file

@ -10,7 +10,6 @@ import {
EmptyView,
ProvisionView,
QrPanel,
WhatsappAccessSettings,
WhatsappAccountCard,
WhatsappSettingsTab,
} from '../../../plugin-src/client/channels/whatsapp/index.js';
@ -72,79 +71,18 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
assert.match(markup, /检查连接/);
assert.match(markup, /移除接入/);
assert.match(markup, /class="dim-presetSelect"/);
assert.match(markup, /仅自己模式(默认)/);
assert.match(markup, /指定联系人模式/);
assert.match(markup, /开放响应模式/);
assert.match(markup, /已绑定账号自己发出的群聊消息/);
assert.match(markup, /aria-label="更多机器人设置"/);
assert.doesNotMatch(markup, /仅自己模式(默认)|指定联系人模式|开放响应模式/);
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
});
test('WhatsApp access settings save a normalized selected-contact allowlist', async () => {
const saved = [];
let renderer;
await act(async () => {
renderer = create(React.createElement(WhatsappAccessSettings, {
account: {
accessPolicy: { accessMode: 'self-only', allowedNumbers: [] },
},
onSave: async (value) => saved.push(value),
}));
});
const select = renderer.root.findByProps({ 'aria-label': 'WhatsApp 访问模式' });
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
});
const textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 WhatsApp 电话号码',
});
await act(async () => {
textarea.props.onChange({ target: { value: '+16505550999\n16505550999' } });
});
await act(async () => {
renderer.root.findByType('form').props.onSubmit({ preventDefault() {} });
await flushMicrotasks();
});
assert.deepEqual(saved, [{
accessMode: 'private-allowlist',
allowedNumbers: ['16505550999'],
}]);
await act(async () => { renderer.unmount(); });
});
test('WhatsApp access settings only show the allowlist for selected contacts', async () => {
let renderer;
await act(async () => {
renderer = create(React.createElement(WhatsappAccessSettings, {
account: {
accessPolicy: { accessMode: 'self-only', allowedNumbers: ['16505550999'] },
},
onSave: async () => {},
}));
});
const select = renderer.root.findByProps({ 'aria-label': 'WhatsApp 访问模式' });
const allowlistFields = () => renderer.root.findAllByProps({
'aria-label': '允许私聊的 WhatsApp 电话号码',
});
assert.equal(allowlistFields().length, 0);
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
});
assert.equal(allowlistFields().length, 1);
await act(async () => {
select.props.onChange({ target: { value: 'open' } });
});
assert.equal(allowlistFields().length, 0);
await act(async () => { renderer.unmount(); });
});
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
const source = await readFile(new URL(
'../../../plugin-src/client/channels/whatsapp/index.js',
import.meta.url,
), 'utf8');
assert.match(source, /WHATSAPP_ENDPOINTS\.reconnectBot,[\s\S]*\{ botId: account\.botId, sendTest: true \}/);
assert.match(source, /WHATSAPP_ENDPOINTS\.setAccessPolicy/);
assert.doesNotMatch(source, /WHATSAPP_ENDPOINTS\.setAccessPolicy/);
assert.match(source, /\[account\.botId\]: '连接检查失败,请稍后重试。'/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
});

View file

@ -51,7 +51,10 @@ test('WhatsApp production has no per-bot result-file Gate', async (t) => {
const production = await createProductionController(ctx, { dataDir }, internals);
await controllerOptions.createRuntime({
botId: 'whatsapp_enabled',
config: { botId: 'whatsapp_enabled' },
config: {
botId: 'whatsapp_enabled',
accountJid: '16505550123@s.whatsapp.net',
},
authDir: '00000000-0000-4000-8000-000000000001',
});
await controllerOptions.createRuntime({
@ -62,6 +65,9 @@ test('WhatsApp production has no per-bot result-file Gate', async (t) => {
assert.equal(Object.hasOwn(runtimes[0], 'outboundArtifactsEnabled'), false);
assert.equal(Object.hasOwn(runtimes[1], 'outboundArtifactsEnabled'), false);
assert.equal(runtimes[0].accessPolicy.isPrivileged(['+16505550123'], 'direct'), true,
'production privileged matching uses the same bare-number normalization');
assert.equal(runtimes[0].accessPolicy.isPrivileged(['not-a-jid'], 'direct'), false);
await production.close();
const productionWithDefault = await createProductionController(ctx, { dataDir }, internals);

View file

@ -31,6 +31,7 @@ import {
WhatsappRuntime,
createWhatsappMediaDownloader,
normalizeWhatsappMessage,
whatsappAccessPolicyIdsEqual,
whatsappInboundAllowed,
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
import { createWhatsappWebSession } from '../../../src/channels/whatsapp/whatsapp-web-session.mjs';
@ -146,6 +147,29 @@ function linkedConfig(overrides = {}) {
};
}
test('WhatsApp access-policy equality accepts phone and user-JID aliases and rejects invalid ids', () => {
assert.equal(whatsappAccessPolicyIdsEqual(
'16505550999', '16505550999@s.whatsapp.net',
), true, 'a bare phone number matches its PN JID');
assert.equal(whatsappAccessPolicyIdsEqual(
'+16505550999', '16505550999@s.whatsapp.net',
), true, 'a +number matches its PN JID');
assert.equal(whatsappAccessPolicyIdsEqual(
'16505550999@s.whatsapp.net', '16505550999:4@s.whatsapp.net',
), true, 'full and device-qualified PN JIDs retain Baileys alias matching');
assert.equal(whatsappAccessPolicyIdsEqual(
'987654321098765@lid', '987654321098765@s.whatsapp.net',
), true, 'PN and LID aliases retain Baileys user matching');
assert.equal(whatsappAccessPolicyIdsEqual(
'16505550999', '16505550888@s.whatsapp.net',
), false);
for (const invalid of [undefined, null, '', 'not-a-jid', 'bad@', '@lid', '+']) {
assert.equal(whatsappAccessPolicyIdsEqual(invalid, invalid), false,
`invalid id must fail closed: ${String(invalid)}`);
assert.equal(whatsappAccessPolicyIdsEqual(invalid, ACCOUNT_JID), false);
}
});
test('WhatsApp config stores only linked-device metadata with restrictive permissions', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-config-'));
const path = join(root, 'config.json');
@ -616,8 +640,20 @@ test('WhatsApp keeps native and document images as images and exposes ordinary d
});
});
test('WhatsApp runtime filters messages before the bridge and applies policy updates live', async () => {
test('WhatsApp runtime uses live unified policy settings and existing JID alias matching', async () => {
let callbacks;
let accessSettings = {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
const calls = [];
const socket = {
sendPresenceUpdate: async (...args) => calls.push(['presence', ...args]),
@ -643,6 +679,10 @@ test('WhatsApp runtime filters messages before the bridge and applies policy upd
authDir: '/tmp/test-whatsapp-auth',
harness,
state,
accessPolicy: {
getSettings: () => accessSettings,
isPrivileged: (senderIds) => senderIds.includes(ACCOUNT_JID),
},
createSession: async (options) => {
callbacks = options;
return {
@ -661,13 +701,38 @@ test('WhatsApp runtime filters messages before the bridge and applies policy upd
assert.equal(runtime.status.ready, true);
assert.equal(runtime.status.messagesRejected, 1);
assert.equal(calls.length, 0);
runtime.setAccessPolicy({ accessMode: WHATSAPP_ACCESS_MODES.open, allowedNumbers: [] });
await callbacks.onMessage({
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-3', fromMe: false },
key: { remoteJid: ACCOUNT_JID, id: 'owner-1', fromMe: true },
message: { conversation: 'owner bypass' },
});
assert.ok(calls.some((call) => call[0] === 'message'
&& call[2].text === 'Harness answer'), 'linked owner bypasses an empty allowlist');
accessSettings = {
...accessSettings,
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: {
users: [{ id: '16505550999', canExecuteCommands: true }],
},
},
};
const answerCountBeforeAlternate = calls.filter((call) => (
call[0] === 'message' && call[2].text === 'Harness answer'
)).length;
await callbacks.onMessage({
key: {
remoteJid: '987654321098765@lid',
remoteJidAlt: '16505550999@s.whatsapp.net',
id: 'direct-3',
fromMe: false,
},
message: { conversation: 'hello again' },
});
assert.ok(calls.some((call) => call[0] === 'presence' && call[1] === 'composing'));
assert.ok(calls.some((call) => call[0] === 'message' && call[2].text === 'Harness answer'));
assert.equal(calls.filter((call) => (
call[0] === 'message' && call[2].text === 'Harness answer'
)).length, answerCountBeforeAlternate + 1,
'a bare allowlist number matches the PN alternate for an inbound LID');
await runtime.stop();
});
@ -1424,7 +1489,7 @@ test('WhatsApp reconnect RPC sends tests only for the connected target and keeps
cancelProvisioning: async () => null,
reconnectBot: async () => snapshot(),
deleteBot: async () => snapshot(),
setAccessPolicy: async () => snapshot(),
updateAccessPolicy: async () => snapshot(),
sendConnectionTest: async () => {
sendCalls += 1;
if (sendFailure) throw new Error('private provider failure');
@ -1490,7 +1555,7 @@ test('WhatsApp RPC never sends a connection test after reconnect is cancelled',
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
setAccessPolicy: async () => ({ bots: [] }),
updateAccessPolicy: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
@ -1533,10 +1598,6 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
},
start: async () => {},
stop: async () => {},
setAccessPolicy: (value) => appliedPolicies.push({
accessMode: value.accessMode,
allowedNumbers: value.allowedNumbers,
}),
}),
deleteAuth: async (name) => deletedAuth.push(name),
});
@ -1544,6 +1605,17 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
const handler = createWhatsappRpcHandler(controller, {
encodeQr: async () => 'data:image/png;base64,QUJDRA==',
});
controller.updateAccessPolicy = async (botId, policy, projectStatus) => {
appliedPolicies.push(policy);
const current = await controller.status();
const updated = {
...current,
bots: current.bots.map((bot) => bot.botId === botId
? { ...bot, accessPolicy: policy }
: bot),
};
return projectStatus ? projectStatus(updated) : updated;
};
const started = await handler(WHATSAPP_ENDPOINTS.beginProvisioning, {});
assert.equal(started.ok, true);
assert.match(started.value.qrCodeDataUrl, /^data:image\/png/);
@ -1562,20 +1634,30 @@ test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-on
allowedNumbers: [],
});
assert.doesNotMatch(JSON.stringify(status.value), /16505550123@s\.whatsapp\.net|authDirectory/);
const unifiedPolicy = {
direct: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: {
users: [{
id: '16505550999@s.whatsapp.net',
canExecuteCommands: true,
}],
},
},
group: {
mode: 'allowlist',
open: { defaultCanExecuteCommands: false, commandPermissionOverrides: [] },
allowlist: { users: [] },
},
};
const updated = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
botId: status.value.bots[0].botId,
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['+16505550999'],
policy: unifiedPolicy,
});
assert.equal(updated.ok, true);
assert.deepEqual(updated.value.bots[0].accessPolicy, {
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
});
assert.deepEqual(appliedPolicies, [{
accessMode: WHATSAPP_ACCESS_MODES.privateAllowlist,
allowedNumbers: ['16505550999'],
}]);
assert.deepEqual(updated.value.bots[0].accessPolicy, unifiedPolicy);
assert.deepEqual(appliedPolicies, [unifiedPolicy]);
const invalidPolicy = await handler(WHATSAPP_ENDPOINTS.setAccessPolicy, {
botId: status.value.bots[0].botId,
accessMode: 'compatible',

View file

@ -5,6 +5,10 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import {
ACCESS_CHANNEL_DEFINITIONS,
ACCESS_POLICY_ENDPOINT,
} from '../plugin-src/client/access-policy-settings.js';
import {
BOT_SETTINGS_TABS,
DELIVERY_CHANNEL_DEFINITIONS,
@ -40,6 +44,11 @@ function button(root, label) {
return root.findAllByType('button').find((entry) => textOf(entry) === label);
}
function accessHelpButtons(root) {
return root.findAll((entry) => entry.type === 'button'
&& String(entry.props['aria-label'] ?? '').endsWith('查看访问权限说明'));
}
function deferred() {
let resolve;
let reject;
@ -69,10 +78,31 @@ const connectedAccount = Object.freeze({
botId: 'bot_feishu_01',
botName: '通知机器人',
connected: true,
accessPolicy: Object.freeze({
direct: Object.freeze({
mode: 'open',
open: Object.freeze({
defaultCanExecuteCommands: true,
commandPermissionOverrides: Object.freeze([]),
}),
allowlist: Object.freeze({ users: Object.freeze([]) }),
}),
group: Object.freeze({
mode: 'open',
open: Object.freeze({
defaultCanExecuteCommands: false,
commandPermissionOverrides: Object.freeze([]),
}),
allowlist: Object.freeze({ users: Object.freeze([]) }),
}),
}),
});
test('delivery settings define only the nine supported IM channel routes', () => {
assert.deepEqual(BOT_SETTINGS_TABS, [{ id: 'delivery', label: '投递设置' }]);
assert.deepEqual(BOT_SETTINGS_TABS, [
{ id: 'delivery', label: '投递设置' },
{ id: 'access', label: '访问设置' },
]);
assert.equal(DELIVERY_RPC_CHANNEL, '/dsh-im-delivery');
assert.deepEqual(Object.keys(DELIVERY_CHANNEL_DEFINITIONS), [
'weixin', 'feishu', 'dingtalk', 'wecom', 'qq',
@ -91,6 +121,8 @@ test('delivery settings define only the nine supported IM channel routes', () =>
['chatId', 'messageThreadId'],
);
assert.equal('office' in DELIVERY_CHANNEL_DEFINITIONS, false);
assert.deepEqual(Object.keys(ACCESS_CHANNEL_DEFINITIONS), Object.keys(DELIVERY_CHANNEL_DEFINITIONS));
assert.equal(ACCESS_CHANNEL_DEFINITIONS.weixin.groupSupported, false);
});
test('all nine robot cards add one accessible settings gear beside existing content', () => {
@ -147,6 +179,7 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret
state: 'connected',
bot: { name: '微信通知助手', accountIdMasked: 'wx••01' },
health: { status: 'healthy', summary: '微信连接正常', lastCheckedAt: Date.now() },
accessPolicy: connectedAccount.accessPolicy,
};
const deliveryCalls = [];
const renderer = await (async () => {
@ -189,7 +222,9 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret
page.findByProps({ className: 'dim-deliveryHeader' }).findAllByType('h2').length,
0,
);
const settingsTab = page.findByProps({ role: 'tab' });
const settingsTabs = page.findAllByProps({ role: 'tab' });
assert.deepEqual(settingsTabs.map(textOf), ['投递设置', '访问设置']);
const settingsTab = settingsTabs[0];
const settingsPanel = page.findByProps({ role: 'tabpanel' });
assert.equal(textOf(settingsTab), '投递设置');
assert.equal(settingsTab.props['aria-selected'], true);
@ -226,6 +261,266 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret
assert.equal(renderer.root.findByProps({ id: 'dim-tab-weixin' }).props['aria-selected'], true);
});
test('access settings preserve independent mode drafts and save direct and group atomically', async (t) => {
const calls = [];
const renderer = await mount(t, {
channel: 'feishu',
account: connectedAccount,
rpcCall: async (endpoint) => {
assert.equal(endpoint, DELIVERY_ENDPOINTS.list);
return { ok: true, value: { targets: [] } };
},
accessRpcCall: async (endpoint, payload) => {
calls.push({ endpoint, payload });
return {
ok: true,
value: { bots: [{ botId: connectedAccount.botId, accessPolicy: payload.policy }] },
};
},
onBack() {},
});
assert.equal(accessHelpButtons(renderer.root).length, 0);
await act(async () => {
button(renderer.root, '访问设置').props.onClick();
await flush();
});
assert.equal(renderer.root.findAllByProps({ role: 'tab' }).length, 2);
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessScene' }).length, 2);
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessOwnerNotice' }).length, 0);
assert.equal(accessHelpButtons(renderer.root).length, 2);
for (const [scene, title] of [['direct', '私聊'], ['group', '群聊']]) {
const sceneEditor = renderer.root.findByProps({ 'data-scene': scene });
assert.equal(sceneEditor.props['aria-label'], title);
const accessHelpButton = sceneEditor.findByProps({
'aria-label': `${title} 查看访问权限说明`,
});
const accessHelpTooltip = sceneEditor.findByProps({
className: 'dim-channelTooltip dim-accessHelpTooltip',
});
assert.equal(accessHelpTooltip.props.role, 'tooltip');
assert.equal(accessHelpButton.props['aria-describedby'], accessHelpTooltip.props.id);
assert.match(textOf(accessHelpTooltip), /原所有者或扫码接入者始终可以访问并执行命令/);
}
assert.equal(accessHelpButtons(renderer.root.findByProps({ className: 'dim-accessActions' })).length, 0);
assert.equal(accessHelpButtons(renderer.root.findByProps({ role: 'tablist' })).length, 0);
assert.match(
textOf(renderer.root.findByProps({ 'data-scene': 'direct' })),
/命令权限例外/,
);
await act(async () => {
const direct = renderer.root.findByProps({ 'data-scene': 'direct' });
const addUser = direct.findByProps({ 'aria-label': '私聊 新增用户' });
assert.equal(addUser.props.title, '新增用户');
assert.match(addUser.props.className, /dim-accessAddUser/);
assert.equal(textOf(addUser), '+');
addUser.props.onClick();
await flush();
});
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.onChange({
target: { value: ' ou_override ' },
});
renderer.root.findByProps({ 'aria-label': '群聊 默认命令权限' }).props.onChange({
target: { value: 'allow' },
});
await flush();
});
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({
target: { value: 'allowlist' },
});
await flush();
});
const directAllowlistHelp = renderer.root.findByProps({
'aria-label': '私聊 查看白名单说明',
});
const directAllowlistTooltip = renderer.root.findByProps({
className: 'dim-channelTooltip dim-accessEmptyAllowlistTooltip',
});
assert.equal(directAllowlistTooltip.props.role, 'tooltip');
assert.equal(directAllowlistHelp.props['aria-describedby'], directAllowlistTooltip.props.id);
assert.equal(
textOf(directAllowlistTooltip),
'当前没有白名单用户,保存后普通用户将无法使用机器人。',
);
assert.equal(renderer.root.findAllByProps({ className: 'dim-accessWarning' }).length, 0);
assert.match(
textOf(renderer.root.findByProps({ 'data-scene': 'direct' })),
/白名单用户/,
);
assert.equal(
renderer.root.findAllByProps({ 'aria-label': '私聊 默认命令权限' }).length,
0,
);
await act(async () => {
const direct = renderer.root.findByProps({ 'data-scene': 'direct' });
direct.findByProps({ 'aria-label': '私聊 新增用户' }).props.onClick();
await flush();
});
assert.equal(
renderer.root.findAllByProps({ 'aria-label': '私聊 查看白名单说明' }).length,
0,
);
await act(async () => {
renderer.root.findByProps({ 'aria-label': '群聊 访问模式' }).props.onChange({
target: { value: 'allowlist' },
});
await flush();
});
assert.ok(renderer.root.findByProps({ 'aria-label': '群聊 查看白名单说明' }));
await act(async () => {
renderer.root.findByProps({ 'aria-label': '群聊 访问模式' }).props.onChange({
target: { value: 'open' },
});
await flush();
});
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.onChange({
target: { value: ' ou_allowed ' },
});
await flush();
});
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.onChange({
target: { value: 'allow' },
});
await flush();
});
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({
target: { value: 'open' },
});
await flush();
});
assert.equal(
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.value,
' ou_override ',
);
assert.equal(
renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.value,
'deny',
);
assert.equal(
renderer.root.findByProps({ 'aria-label': '私聊 默认命令权限' }).props.value,
'allow',
);
await act(async () => {
renderer.root.findByProps({ 'aria-label': '私聊 访问模式' }).props.onChange({
target: { value: 'allowlist' },
});
await flush();
});
assert.equal(
renderer.root.findByProps({ 'aria-label': '私聊 飞书 Open ID 1' }).props.value,
' ou_allowed ',
);
assert.equal(
renderer.root.findByProps({ 'aria-label': '私聊 用户 1 命令权限' }).props.value,
'allow',
);
await act(async () => {
renderer.root.findByProps({ className: 'dim-accessPage' }).props.onSubmit({
preventDefault() {},
});
await flush();
});
assert.deepEqual(calls, [{
endpoint: ACCESS_POLICY_ENDPOINT,
payload: {
botId: connectedAccount.botId,
policy: {
direct: {
mode: 'allowlist',
open: {
defaultCanExecuteCommands: true,
commandPermissionOverrides: [{ id: 'ou_override', canExecuteCommands: false }],
},
allowlist: {
users: [{ id: 'ou_allowed', canExecuteCommands: true }],
},
},
group: {
mode: 'open',
open: {
defaultCanExecuteCommands: true,
commandPermissionOverrides: [],
},
allowlist: { users: [] },
},
},
},
}]);
const feedback = renderer.root.findByProps({ className: 'dim-accessFeedback' });
assert.match(textOf(feedback), /访问设置已保存/);
assert.equal(feedback.props['data-tone'], 'success');
assert.equal(feedback.props.role, 'status');
await act(async () => {
button(renderer.root, '投递设置').props.onClick();
await flush();
});
assert.equal(accessHelpButtons(renderer.root).length, 0);
});
test('access settings keep a failed atomic save visible as an error', async (t) => {
const renderer = await mount(t, {
channel: 'feishu',
account: connectedAccount,
rpcCall: async () => ({ ok: true, value: { targets: [] } }),
accessRpcCall: async () => ({
ok: false,
error: { code: 'access-policy-invalid', message: '访问策略未保存。' },
}),
onBack() {},
});
await act(async () => {
button(renderer.root, '访问设置').props.onClick();
await flush();
});
await act(async () => {
renderer.root.findByProps({ className: 'dim-accessPage' }).props.onSubmit({
preventDefault() {},
});
await flush();
});
const feedback = renderer.root.findByProps({ className: 'dim-accessFeedback' });
assert.equal(textOf(feedback), '访问策略未保存。');
assert.equal(feedback.props['data-tone'], 'error');
assert.equal(feedback.props.role, 'alert');
});
test('WeChat keeps the shared access page but disables its unsupported group section', async (t) => {
const renderer = await mount(t, {
channel: 'weixin',
account: { ...connectedAccount, botId: 'wx_access_01' },
rpcCall: async () => ({ ok: true, value: { targets: [] } }),
accessRpcCall: async () => {
throw new Error('save should not run in this rendering test');
},
onBack() {},
});
await act(async () => {
button(renderer.root, '访问设置').props.onClick();
await flush();
});
const group = renderer.root.findByProps({ 'data-scene': 'group' });
assert.equal(group.props.disabled, true);
assert.match(textOf(group), /当前渠道不支持群聊/);
assert.equal(group.findAllByType('select').length, 0);
assert.ok(renderer.root.findByProps({ 'data-scene': 'direct' }));
});
test('new target waits for saved targets before generating aliases or checking duplicates', async (t) => {
const pending = deferred();
const renderer = await mount(t, {
@ -480,7 +775,10 @@ test('recent conversation names remain platform data in the English UI', async (
docsLink.props.href,
'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.en.md',
);
assert.equal(textOf(renderer.root.findByProps({ role: 'tab' })), 'Delivery settings');
assert.deepEqual(
renderer.root.findAllByProps({ role: 'tab' }).map(textOf),
['Delivery settings', 'Access settings'],
);
});
test('target create, edit, copy, and delete use the minimal RPC payloads', async (t) => {

View file

@ -32,6 +32,27 @@ const publicFailure = Object.freeze({
at: Date.UTC(2026, 7, 25, 7, 30),
});
const publicAccessPolicy = Object.freeze({
direct: Object.freeze({
mode: 'allowlist',
open: Object.freeze({
defaultCanExecuteCommands: false,
commandPermissionOverrides: Object.freeze([]),
}),
allowlist: Object.freeze({
users: Object.freeze([{ id: 'user_safe', canExecuteCommands: true }]),
}),
}),
group: Object.freeze({
mode: 'open',
open: Object.freeze({
defaultCanExecuteCommands: false,
commandPermissionOverrides: Object.freeze([]),
}),
allowlist: Object.freeze({ users: Object.freeze([]) }),
}),
});
function rawBot() {
return {
botId: 'bot_safe',
@ -40,6 +61,7 @@ function rawBot() {
state: 'connected',
workspace: '/workspace/current',
groupResponseMode: 'mention',
accessPolicy: publicAccessPolicy,
bot: {
name: 'Harness Bot',
username: 'harness_bot',
@ -107,6 +129,7 @@ test('all channel snapshot normalizers retain the same safe message failure', ()
for (const [channel, normalize] of normalizers) {
const snapshot = normalize({ bots: [rawBot()] });
assert.deepEqual(snapshot.bots[0].lastMessageError, publicFailure, channel);
assert.deepEqual(snapshot.bots[0].accessPolicy, publicAccessPolicy, channel);
assert.doesNotMatch(
JSON.stringify(snapshot.bots[0].lastMessageError),
/providerDetail|private|token/i,

View file

@ -286,9 +286,11 @@ test('all nine production channels use channel presets only as bot creation defa
for (const path of PRODUCTION_FILES) {
const source = await readFile(new URL(`../${path}`, import.meta.url), 'utf8');
assert.doesNotMatch(source, /\bagentPreset:\s*config\.agentPreset/, path);
const creationDefaults = source.match(
/workspaces\.ensure\([^;]*\{\s*defaultAgentPreset:\s*config\.agentPreset,?\s*\}\)/g,
) ?? [];
const creationDefaults = (source.match(
/workspaces\.ensure\((?:(?!workspaces\.ensure)[\s\S])*?\n\s*\}\)/g,
) ?? []).filter((call) => (
/\bdefaultAgentPreset:\s*config\.agentPreset\b/.test(call)
));
assert.equal(
creationDefaults.length,
2,