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,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',