mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 18:03:18 +08:00
feat: add unified IM access policies
This commit is contained in:
parent
075d2713c1
commit
840e5aa857
89 changed files with 5573 additions and 1676 deletions
210
src/channels/shared/access-policy.mjs
Normal file
210
src/channels/shared/access-policy.mjs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
// Shared by the Host and settings UI; keep this module browser-compatible.
|
||||
export const ACCESS_POLICY_MODES = Object.freeze(['open', 'allowlist']);
|
||||
export const ACCESS_POLICY_CONVERSATION_TYPES = Object.freeze(['direct', 'group']);
|
||||
export const ACCESS_POLICY_USER_ID_MAX_LENGTH = 256;
|
||||
|
||||
const POLICY_KEYS = ['direct', 'group'];
|
||||
const SCOPE_KEYS = ['mode', 'open', 'allowlist'];
|
||||
const OPEN_KEYS = ['defaultCanExecuteCommands', 'commandPermissionOverrides'];
|
||||
const ALLOWLIST_KEYS = ['users'];
|
||||
const USER_KEYS = ['id', 'canExecuteCommands'];
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/;
|
||||
|
||||
function invalidAccessPolicy(message) {
|
||||
const error = new TypeError(message);
|
||||
error.code = 'access-policy-invalid';
|
||||
return error;
|
||||
}
|
||||
|
||||
function hasExactKeys(input, keys) {
|
||||
return input && typeof input === 'object' && !Array.isArray(input)
|
||||
&& [Object.prototype, null].includes(Object.getPrototypeOf(input))
|
||||
&& Reflect.ownKeys(input).length === keys.length
|
||||
&& keys.every((key) => Object.hasOwn(input, key));
|
||||
}
|
||||
|
||||
/** Normalize one opaque channel identity without interpreting its contents. */
|
||||
export function normalizeAccessPolicyUserId(value) {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) throw invalidAccessPolicy('用户标识无效。');
|
||||
value = String(value);
|
||||
} else if (typeof value === 'bigint') {
|
||||
value = String(value);
|
||||
}
|
||||
if (typeof value !== 'string') throw invalidAccessPolicy('用户标识必须是字符串。');
|
||||
const id = value.trim();
|
||||
if (!id || id.length > ACCESS_POLICY_USER_ID_MAX_LENGTH || CONTROL_CHARACTERS.test(id)) {
|
||||
throw invalidAccessPolicy(`用户标识不能为空、包含控制字符或超过 ${ACCESS_POLICY_USER_ID_MAX_LENGTH} 个字符。`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function validateAccessPolicyUser(input) {
|
||||
if (!hasExactKeys(input, USER_KEYS)) {
|
||||
throw invalidAccessPolicy('用户条目必须包含用户标识和命令权限。');
|
||||
}
|
||||
if (typeof input.canExecuteCommands !== 'boolean') {
|
||||
throw invalidAccessPolicy('命令权限必须是布尔值。');
|
||||
}
|
||||
return Object.freeze({
|
||||
id: normalizeAccessPolicyUserId(input.id),
|
||||
canExecuteCommands: input.canExecuteCommands,
|
||||
});
|
||||
}
|
||||
|
||||
function validateUsers(input, { listMessage, duplicateMessage }) {
|
||||
if (!Array.isArray(input)) throw invalidAccessPolicy(listMessage);
|
||||
const users = input.map(validateAccessPolicyUser);
|
||||
if (new Set(users.map(({ id }) => id)).size !== users.length) {
|
||||
throw invalidAccessPolicy(duplicateMessage);
|
||||
}
|
||||
return Object.freeze(users);
|
||||
}
|
||||
|
||||
function validateOpenSettings(input) {
|
||||
if (!hasExactKeys(input, OPEN_KEYS)) {
|
||||
throw invalidAccessPolicy('开放模式设置必须完整。');
|
||||
}
|
||||
if (typeof input.defaultCanExecuteCommands !== 'boolean') {
|
||||
throw invalidAccessPolicy('开放模式默认命令权限必须是布尔值。');
|
||||
}
|
||||
return Object.freeze({
|
||||
defaultCanExecuteCommands: input.defaultCanExecuteCommands,
|
||||
commandPermissionOverrides: validateUsers(input.commandPermissionOverrides, {
|
||||
listMessage: '开放模式命令权限覆盖用户必须是数组。',
|
||||
duplicateMessage: '开放模式命令权限覆盖用户不能包含重复的用户标识。',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function validateAllowlistSettings(input) {
|
||||
if (!hasExactKeys(input, ALLOWLIST_KEYS)) {
|
||||
throw invalidAccessPolicy('白名单模式设置必须完整。');
|
||||
}
|
||||
return Object.freeze({
|
||||
users: validateUsers(input.users, {
|
||||
listMessage: '白名单模式用户必须是数组。',
|
||||
duplicateMessage: '白名单模式用户不能包含重复的用户标识。',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function validateAccessPolicyScope(input) {
|
||||
if (!hasExactKeys(input, SCOPE_KEYS)) {
|
||||
throw invalidAccessPolicy('访问场景设置必须同时包含模式、开放模式设置和白名单模式设置。');
|
||||
}
|
||||
if (!ACCESS_POLICY_MODES.includes(input.mode)) {
|
||||
throw invalidAccessPolicy('访问模式只能是 open 或 allowlist。');
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: input.mode,
|
||||
open: validateOpenSettings(input.open),
|
||||
allowlist: validateAllowlistSettings(input.allowlist),
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate one canonical direct + group atomic save. */
|
||||
export function validateAccessPolicy(input) {
|
||||
if (!hasExactKeys(input, POLICY_KEYS)) {
|
||||
throw invalidAccessPolicy('请同时提交完整的私聊和群聊访问设置。');
|
||||
}
|
||||
return Object.freeze({
|
||||
direct: validateAccessPolicyScope(input.direct),
|
||||
group: validateAccessPolicyScope(input.group),
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize canonical persisted/runtime data; damaged settings fail closed. */
|
||||
export function normalizeAccessPolicy(input) {
|
||||
try {
|
||||
return validateAccessPolicy(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Small canonical constructor used by channel initialization. */
|
||||
export function createAccessPolicyScope(options) {
|
||||
return validateAccessPolicyScope(options === undefined ? {
|
||||
mode: 'allowlist',
|
||||
open: {
|
||||
defaultCanExecuteCommands: false,
|
||||
commandPermissionOverrides: [],
|
||||
},
|
||||
allowlist: { users: [] },
|
||||
} : options);
|
||||
}
|
||||
|
||||
const DENY_SCOPE = createAccessPolicyScope();
|
||||
|
||||
export const DEFAULT_ACCESS_POLICY = Object.freeze({
|
||||
direct: DENY_SCOPE,
|
||||
group: DENY_SCOPE,
|
||||
});
|
||||
|
||||
export const DENY_ALL_ACCESS_POLICY = DEFAULT_ACCESS_POLICY;
|
||||
|
||||
export function createAccessPolicy({
|
||||
direct = DEFAULT_ACCESS_POLICY.direct,
|
||||
group = DEFAULT_ACCESS_POLICY.group,
|
||||
} = {}) {
|
||||
return validateAccessPolicy({ direct, group });
|
||||
}
|
||||
|
||||
const ALLOWED = Object.freeze({ allowed: true, reason: 'allowed' });
|
||||
const POLICY_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'policy-unavailable' });
|
||||
const INVALID_CONTEXT = Object.freeze({ allowed: false, reason: 'invalid-context' });
|
||||
const SENDER_UNAVAILABLE = Object.freeze({ allowed: false, reason: 'sender-unavailable' });
|
||||
const SENDER_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'sender-not-allowed' });
|
||||
const COMMAND_NOT_ALLOWED = Object.freeze({ allowed: false, reason: 'command-not-allowed' });
|
||||
|
||||
/**
|
||||
* Decide access for one ordinary message or one already-recognized command.
|
||||
* `senderIds` accepts multiple identities so WhatsApp can reuse its JID aliases.
|
||||
* Privileged/owner bypass is intentionally handled by the inbound adapter.
|
||||
*/
|
||||
export function evaluateAccessPolicy(policy, {
|
||||
conversationType,
|
||||
senderIds,
|
||||
isCommand = false,
|
||||
equals = (left, right) => left === right,
|
||||
} = {}) {
|
||||
const normalizedPolicy = normalizeAccessPolicy(policy);
|
||||
if (!normalizedPolicy) return POLICY_UNAVAILABLE;
|
||||
if (!ACCESS_POLICY_CONVERSATION_TYPES.includes(conversationType)
|
||||
|| typeof isCommand !== 'boolean' || typeof equals !== 'function') {
|
||||
return INVALID_CONTEXT;
|
||||
}
|
||||
const candidates = (Array.isArray(senderIds) ? senderIds : [senderIds])
|
||||
.flatMap((candidate) => {
|
||||
try {
|
||||
return [normalizeAccessPolicyUserId(candidate)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
if (candidates.length === 0) return SENDER_UNAVAILABLE;
|
||||
|
||||
const scope = normalizedPolicy[conversationType];
|
||||
const users = scope.mode === 'open'
|
||||
? scope.open.commandPermissionOverrides
|
||||
: scope.allowlist.users;
|
||||
let matchedUsers;
|
||||
try {
|
||||
matchedUsers = users.filter((user) => (
|
||||
candidates.some((candidate) => equals(candidate, user.id) === true)
|
||||
));
|
||||
} catch {
|
||||
return INVALID_CONTEXT;
|
||||
}
|
||||
if (scope.mode === 'allowlist' && matchedUsers.length === 0) return SENDER_NOT_ALLOWED;
|
||||
if (isCommand) {
|
||||
const canExecuteCommands = scope.mode === 'open'
|
||||
? (matchedUsers.length > 0
|
||||
? matchedUsers.every((user) => user.canExecuteCommands)
|
||||
: scope.open.defaultCanExecuteCommands)
|
||||
: matchedUsers.every((user) => user.canExecuteCommands);
|
||||
if (!canExecuteCommands) return COMMAND_NOT_ALLOWED;
|
||||
}
|
||||
return ALLOWED;
|
||||
}
|
||||
|
|
@ -13,6 +13,10 @@ import {
|
|||
normalizeAgentPresetCatalog,
|
||||
validateAgentPresetId,
|
||||
} from './agent-preset.mjs';
|
||||
import {
|
||||
normalizeAccessPolicy,
|
||||
validateAccessPolicy,
|
||||
} from './access-policy.mjs';
|
||||
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
|
||||
import {
|
||||
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
|
||||
|
|
@ -131,6 +135,26 @@ function normalizeDeliveryTargets(value) {
|
|||
return deliveryTargets;
|
||||
}
|
||||
|
||||
function normalizeAccessPolicies(value, workspaces) {
|
||||
const accessPolicies = Object.create(null);
|
||||
if (value === undefined) return accessPolicies;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
// Preserve the distinction between a missing policy (eligible for startup
|
||||
// initialization) and damaged persisted data (fail closed).
|
||||
for (const botId of Object.keys(workspaces)) accessPolicies[botId] = null;
|
||||
return accessPolicies;
|
||||
}
|
||||
for (const [botId, policy] of Object.entries(value)) {
|
||||
try {
|
||||
botIdOf(botId);
|
||||
accessPolicies[botId] = normalizeAccessPolicy(policy);
|
||||
} catch {
|
||||
// An invalid key cannot identify a bot, so it is isolated and ignored.
|
||||
}
|
||||
}
|
||||
return accessPolicies;
|
||||
}
|
||||
|
||||
function normalizeDocument(value) {
|
||||
if (!value || ![1, 2].includes(value.version) || !value.workspaces
|
||||
|| typeof value.workspaces !== 'object' || Array.isArray(value.workspaces)) return null;
|
||||
|
|
@ -168,15 +192,52 @@ function normalizeDocument(value) {
|
|||
if (value.version === 1 && value.deliveryTargets !== undefined) return null;
|
||||
const deliveryTargets = normalizeDeliveryTargets(value.deliveryTargets);
|
||||
if (!deliveryTargets) return null;
|
||||
const accessPolicies = normalizeAccessPolicies(value.accessPolicies, workspaces);
|
||||
const version = value.accessPolicies === undefined ? value.version : 2;
|
||||
return {
|
||||
version: value.version,
|
||||
// A v1 file cannot be emitted with this optional v2 section. If one is
|
||||
// recovered from an interrupted/manual edit, retain it on the next write.
|
||||
version,
|
||||
workspaces,
|
||||
agentPresets,
|
||||
contextEnhancement,
|
||||
deliveryTargets,
|
||||
accessPolicies,
|
||||
};
|
||||
}
|
||||
|
||||
function storedDocument({
|
||||
version,
|
||||
workspaces,
|
||||
agentPresets,
|
||||
contextEnhancement,
|
||||
deliveryTargets,
|
||||
accessPolicies,
|
||||
}) {
|
||||
const document = { version, workspaces };
|
||||
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
|
||||
if (Object.keys(contextEnhancement).length > 0) {
|
||||
document.contextEnhancement = contextEnhancement;
|
||||
}
|
||||
if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
|
||||
document.deliveryTargets = deliveryTargets;
|
||||
}
|
||||
if (version >= 2 && Object.keys(accessPolicies).length > 0) {
|
||||
document.accessPolicies = accessPolicies;
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
async function writeStoredDocument(path, document) {
|
||||
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${path}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
await rename(temporary, path);
|
||||
}
|
||||
|
||||
export async function validateWorkspacePath(value) {
|
||||
if (typeof value !== 'string' || !value.trim() || !isAbsolute(value.trim())) {
|
||||
const error = new Error('工作区必须是绝对路径。');
|
||||
|
|
@ -208,6 +269,7 @@ export class BotWorkspaceStore {
|
|||
#agentPresets = {};
|
||||
#contextEnhancement = {};
|
||||
#deliveryTargets = Object.create(null);
|
||||
#accessPolicies = Object.create(null);
|
||||
#generations = new Map();
|
||||
#nextGeneration = 1;
|
||||
#incarnations = new Map();
|
||||
|
|
@ -233,6 +295,7 @@ export class BotWorkspaceStore {
|
|||
this.#agentPresets = normalized.agentPresets;
|
||||
this.#contextEnhancement = normalized.contextEnhancement;
|
||||
this.#deliveryTargets = normalized.deliveryTargets;
|
||||
this.#accessPolicies = normalized.accessPolicies;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
this.#version = 1;
|
||||
|
|
@ -240,6 +303,7 @@ export class BotWorkspaceStore {
|
|||
this.#agentPresets = {};
|
||||
this.#contextEnhancement = {};
|
||||
this.#deliveryTargets = Object.create(null);
|
||||
this.#accessPolicies = Object.create(null);
|
||||
}
|
||||
this.#generations.clear();
|
||||
this.#nextGeneration = 1;
|
||||
|
|
@ -278,6 +342,13 @@ export class BotWorkspaceStore {
|
|||
: DEFAULT_CONTEXT_ENHANCEMENT_CONFIG;
|
||||
}
|
||||
|
||||
accessPolicyFor(botId) {
|
||||
const id = botIdOf(botId);
|
||||
return this.has(id) && Object.hasOwn(this.#accessPolicies, id)
|
||||
? this.#accessPolicies[id]
|
||||
: null;
|
||||
}
|
||||
|
||||
listDeliveryTargets(botId) {
|
||||
const id = botIdOf(botId);
|
||||
if (!this.has(id)) throw deliveryTargetError('unknown-bot', 'Unknown bot');
|
||||
|
|
@ -373,28 +444,53 @@ export class BotWorkspaceStore {
|
|||
}
|
||||
}
|
||||
|
||||
async ensure(botId, { workspace = this.#defaultWorkspace, defaultAgentPreset } = {}) {
|
||||
async ensure(botId, {
|
||||
workspace = this.#defaultWorkspace,
|
||||
defaultAgentPreset,
|
||||
initialAccessPolicy,
|
||||
} = {}) {
|
||||
const id = botIdOf(botId);
|
||||
const initialWorkspace = resolve(workspace);
|
||||
return this.#enqueue(id, async () => {
|
||||
if (!this.#workspaces[id]) {
|
||||
const agentPreset = validateAgentPresetId(defaultAgentPreset);
|
||||
const createsBot = !this.#workspaces[id];
|
||||
const initializesAccessPolicy = initialAccessPolicy !== undefined
|
||||
&& !Object.hasOwn(this.#accessPolicies, id);
|
||||
if (createsBot || initializesAccessPolicy) {
|
||||
const accessPolicy = initializesAccessPolicy
|
||||
? validateAccessPolicy(initialAccessPolicy)
|
||||
: undefined;
|
||||
const agentPreset = createsBot ? validateAgentPresetId(defaultAgentPreset) : null;
|
||||
const hadAgentPreset = Object.hasOwn(this.#agentPresets, id);
|
||||
const previousAgentPreset = this.#agentPresets[id];
|
||||
this.#workspaces[id] = initialWorkspace;
|
||||
if (agentPreset) this.#agentPresets[id] = agentPreset;
|
||||
this.#generations.set(id, this.#freshGeneration());
|
||||
this.#incarnations.set(id, this.#freshIncarnation());
|
||||
const nextAccessPolicies = initializesAccessPolicy
|
||||
? { ...this.#accessPolicies, [id]: accessPolicy }
|
||||
: this.#accessPolicies;
|
||||
if (createsBot) {
|
||||
this.#workspaces[id] = initialWorkspace;
|
||||
if (agentPreset) this.#agentPresets[id] = agentPreset;
|
||||
this.#generations.set(id, this.#freshGeneration());
|
||||
this.#incarnations.set(id, this.#freshIncarnation());
|
||||
}
|
||||
const nextVersion = initializesAccessPolicy ? 2 : this.#version;
|
||||
try {
|
||||
await this.#persist();
|
||||
await this.#persist(
|
||||
this.#contextEnhancement,
|
||||
this.#deliveryTargets,
|
||||
nextVersion,
|
||||
nextAccessPolicies,
|
||||
);
|
||||
} catch (error) {
|
||||
delete this.#workspaces[id];
|
||||
if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
|
||||
else delete this.#agentPresets[id];
|
||||
this.#generations.delete(id);
|
||||
this.#incarnations.delete(id);
|
||||
if (createsBot) {
|
||||
delete this.#workspaces[id];
|
||||
if (hadAgentPreset) this.#agentPresets[id] = previousAgentPreset;
|
||||
else delete this.#agentPresets[id];
|
||||
this.#generations.delete(id);
|
||||
this.#incarnations.delete(id);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.#accessPolicies = nextAccessPolicies;
|
||||
this.#version = nextVersion;
|
||||
} else if (!this.#generations.has(id)) {
|
||||
this.#generations.set(id, this.#freshGeneration());
|
||||
}
|
||||
|
|
@ -487,6 +583,30 @@ export class BotWorkspaceStore {
|
|||
});
|
||||
}
|
||||
|
||||
async setAccessPolicy(botId, value, { incarnation } = {}) {
|
||||
const id = botIdOf(botId);
|
||||
const expectedIncarnation = incarnation === undefined ? this.incarnationFor(id) : incarnation;
|
||||
const policy = validateAccessPolicy(value);
|
||||
return this.#enqueue(id, async () => {
|
||||
if (!this.has(id) || expectedIncarnation !== this.incarnationFor(id)) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
const next = { ...this.#accessPolicies, [id]: policy };
|
||||
// Inbound messages keep the previous committed snapshot until rename succeeds.
|
||||
await this.#persist(
|
||||
this.#contextEnhancement,
|
||||
this.#deliveryTargets,
|
||||
2,
|
||||
next,
|
||||
);
|
||||
this.#accessPolicies = next;
|
||||
this.#version = 2;
|
||||
return policy;
|
||||
});
|
||||
}
|
||||
|
||||
async bindWorkspaceSession(botId, value, {
|
||||
conversationKey,
|
||||
sessionId,
|
||||
|
|
@ -646,6 +766,7 @@ export class BotWorkspaceStore {
|
|||
...Object.keys(this.#agentPresets),
|
||||
...Object.keys(this.#contextEnhancement),
|
||||
...Object.keys(this.#deliveryTargets),
|
||||
...Object.keys(this.#accessPolicies),
|
||||
...this.#dirtyRemovals,
|
||||
]);
|
||||
for (const botId of candidates) {
|
||||
|
|
@ -663,6 +784,7 @@ export class BotWorkspaceStore {
|
|||
workspace: this.workspaceFor(bot.botId),
|
||||
agentPreset: this.agentPresetFor(bot.botId),
|
||||
contextEnhancement: this.contextEnhancementFor(bot.botId),
|
||||
accessPolicy: this.accessPolicyFor(bot.botId),
|
||||
}
|
||||
: bot),
|
||||
};
|
||||
|
|
@ -694,12 +816,14 @@ export class BotWorkspaceStore {
|
|||
const hadPreset = Object.hasOwn(this.#agentPresets, id);
|
||||
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
|
||||
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
|
||||
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
|
||||
const needsCleanup = hadWorkspace || hadPreset || hadContextEnhancement
|
||||
|| hadDeliveryTargets || this.#dirtyRemovals.has(id);
|
||||
|| hadDeliveryTargets || hadAccessPolicy || this.#dirtyRemovals.has(id);
|
||||
delete this.#workspaces[id];
|
||||
delete this.#agentPresets[id];
|
||||
delete this.#contextEnhancement[id];
|
||||
delete this.#deliveryTargets[id];
|
||||
delete this.#accessPolicies[id];
|
||||
this.#generations.delete(id);
|
||||
this.#incarnations.delete(id);
|
||||
if (!needsCleanup) return {
|
||||
|
|
@ -733,24 +857,16 @@ export class BotWorkspaceStore {
|
|||
contextEnhancement = this.#contextEnhancement,
|
||||
deliveryTargets = this.#deliveryTargets,
|
||||
version = this.#version,
|
||||
accessPolicies = this.#accessPolicies,
|
||||
) {
|
||||
const document = { version, workspaces: this.#workspaces };
|
||||
if (Object.keys(this.#agentPresets).length > 0) {
|
||||
document.agentPresets = this.#agentPresets;
|
||||
}
|
||||
if (Object.keys(contextEnhancement).length > 0) {
|
||||
document.contextEnhancement = contextEnhancement;
|
||||
}
|
||||
if (version >= 2 && Object.keys(deliveryTargets).length > 0) {
|
||||
document.deliveryTargets = deliveryTargets;
|
||||
}
|
||||
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.#path}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
await rename(temporary, this.#path);
|
||||
await writeStoredDocument(this.#path, storedDocument({
|
||||
version,
|
||||
workspaces: this.#workspaces,
|
||||
agentPresets: this.#agentPresets,
|
||||
contextEnhancement,
|
||||
deliveryTargets,
|
||||
accessPolicies,
|
||||
}));
|
||||
this.#dirtyRemovals.clear();
|
||||
}
|
||||
|
||||
|
|
@ -758,7 +874,8 @@ export class BotWorkspaceStore {
|
|||
if (Object.keys(this.#workspaces).length > 0
|
||||
|| Object.keys(this.#agentPresets).length > 0
|
||||
|| Object.keys(this.#contextEnhancement).length > 0
|
||||
|| Object.keys(this.#deliveryTargets).length > 0) {
|
||||
|| Object.keys(this.#deliveryTargets).length > 0
|
||||
|| Object.keys(this.#accessPolicies).length > 0) {
|
||||
await this.#persist();
|
||||
return;
|
||||
}
|
||||
|
|
@ -1280,6 +1397,31 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
|
|||
return result;
|
||||
});
|
||||
};
|
||||
const updateAccessPolicy = (botId, value, projectStatus) => {
|
||||
const incarnation = workspaces.incarnationFor(botId);
|
||||
const policy = validateAccessPolicy(value);
|
||||
return withBotTransition(botId, async () => {
|
||||
const snapshot = await controller.status();
|
||||
if (!snapshot?.bots?.some((bot) => bot?.botId === botId)) {
|
||||
const error = new Error('找不到要修改的机器人。');
|
||||
error.code = 'workspace-bot-not-found';
|
||||
throw error;
|
||||
}
|
||||
const catalog = await resolveAgentPresetCatalog(agentPresetCatalog);
|
||||
const decorated = workspaces.decorateStatus(snapshot);
|
||||
const updated = {
|
||||
...decorated,
|
||||
bots: decorated.bots.map((bot) => bot?.botId === botId
|
||||
? { ...bot, accessPolicy: policy } : bot),
|
||||
...(catalog ? { agentPresetCatalog: catalog } : {}),
|
||||
};
|
||||
// Prepare the complete channel-specific response before commit. Failed
|
||||
// projections and disk writes must leave the live policy unchanged.
|
||||
const result = projectStatus ? await projectStatus(updated) : updated;
|
||||
await workspaces.setAccessPolicy(botId, policy, { incarnation });
|
||||
return result;
|
||||
});
|
||||
};
|
||||
const deleteWithWorkspace = (botId, invokeDelete) => withBotTransition(botId, async () => {
|
||||
// Fence the old runtime without changing the durable mapping. A crash
|
||||
// before the controller removes its config therefore keeps the bot's
|
||||
|
|
@ -1320,6 +1462,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
|
|||
if (property === 'updateWorkspace') return updateWorkspace;
|
||||
if (property === 'updateAgentPreset') return updateAgentPreset;
|
||||
if (property === 'updateContextEnhancement') return updateContextEnhancement;
|
||||
if (property === 'updateAccessPolicy') return updateAccessPolicy;
|
||||
const value = Reflect.get(target, property, target);
|
||||
if (typeof value !== 'function') return value;
|
||||
if (property === 'deleteBot') {
|
||||
|
|
|
|||
32
src/channels/shared/command-permission.mjs
Normal file
32
src/channels/shared/command-permission.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { isBatchInputCommand } from './batch-input.mjs';
|
||||
import { isCompactCommand } from './compact-command.mjs';
|
||||
import { isControlCommand } from './control-command.mjs';
|
||||
import { isHistoryCommand } from './history-command.mjs';
|
||||
import { isModelCommand } from './model-command.mjs';
|
||||
import { isPresetCommand } from './preset-command.mjs';
|
||||
import { isWorkspaceCommand } from './workspace-command.mjs';
|
||||
|
||||
const SIMPLE_TEXT_COMMANDS = new Set(['/help', '/status', '/new']);
|
||||
|
||||
/**
|
||||
* Match only commands that the shared bridges already execute locally.
|
||||
* Unknown slash-prefixed text remains an ordinary prompt.
|
||||
*/
|
||||
export function isSharedLocalCommand(text, {
|
||||
hasImages = false,
|
||||
hasFiles = false,
|
||||
} = {}) {
|
||||
if (typeof text !== 'string') return false;
|
||||
const command = text.trim();
|
||||
if (!command) return false;
|
||||
if (isBatchInputCommand(command) || isHistoryCommand(command)) return true;
|
||||
if (!hasFiles && (
|
||||
isControlCommand(command)
|
||||
|| isModelCommand(command)
|
||||
|| isPresetCommand(command)
|
||||
)) return true;
|
||||
if (hasImages || hasFiles) return false;
|
||||
return SIMPLE_TEXT_COMMANDS.has(command.toLowerCase())
|
||||
|| isWorkspaceCommand(command)
|
||||
|| isCompactCommand(command);
|
||||
}
|
||||
|
|
@ -70,9 +70,8 @@ function compactErrorMessage(error) {
|
|||
* Unknown input returns null so the caller may continue ordinary message routing.
|
||||
*/
|
||||
export async function runCompactCommand(text, harness, state, conversationKey, options = {}) {
|
||||
if (typeof text !== 'string') return null;
|
||||
if (!isCompactCommand(text)) return null;
|
||||
const match = COMPACT_COMMAND.exec(text.trim());
|
||||
if (!match) return null;
|
||||
if (match[1].trim()) return commandResult(t(COMPACT_USAGE));
|
||||
if (typeof state?.sessionFor !== 'function') {
|
||||
return commandResult(t('当前机器人没有可用的会话状态。'));
|
||||
|
|
@ -94,3 +93,7 @@ export async function runCompactCommand(text, harness, state, conversationKey, o
|
|||
return commandResult(compactErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export function isCompactCommand(text) {
|
||||
return typeof text === 'string' && COMPACT_COMMAND.test(text.trim());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,4 +185,6 @@ export default {
|
|||
'Collected {count}/{limit} messages. The batch is full; send /send or /cancel.',
|
||||
'批量内容提交失败,已保留 {count} 条消息。\n请再次发送 /send 重试或 /cancel 取消。':
|
||||
'Batch submission failed; {count} messages were retained.\nSend /send to retry or /cancel to cancel.',
|
||||
'你可以发送普通消息,但没有执行命令的权限。':
|
||||
'You can send regular messages, but you do not have permission to run commands.',
|
||||
};
|
||||
|
|
|
|||
43
src/channels/shared/inbound-access.mjs
Normal file
43
src/channels/shared/inbound-access.mjs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { evaluateAccessPolicy } from './access-policy.mjs';
|
||||
import { isSharedLocalCommand } from './command-permission.mjs';
|
||||
|
||||
export const COMMAND_PERMISSION_DENIED_MESSAGE = '你可以发送普通消息,但没有执行命令的权限。';
|
||||
|
||||
const POLICY_NOT_CONFIGURED = Object.freeze({ allowed: true, reason: 'policy-not-configured' });
|
||||
const PRIVILEGED_SENDER = Object.freeze({ allowed: true, reason: 'privileged-sender' });
|
||||
|
||||
/**
|
||||
* Read one committed policy snapshot and decide a single inbound event.
|
||||
* A missing provider is kept backward-compatible for direct bridge fixtures;
|
||||
* production always injects a provider, whose missing/damaged value fails closed.
|
||||
*/
|
||||
export function evaluateInboundAccess(accessPolicy, {
|
||||
conversationType,
|
||||
senderIds,
|
||||
text = '',
|
||||
hasImages = false,
|
||||
hasFiles = false,
|
||||
isCommand = isSharedLocalCommand(text, { hasImages, hasFiles }),
|
||||
} = {}) {
|
||||
if (!accessPolicy) return POLICY_NOT_CONFIGURED;
|
||||
try {
|
||||
if (typeof accessPolicy.isPrivileged === 'function'
|
||||
&& accessPolicy.isPrivileged(senderIds, conversationType) === true) {
|
||||
return PRIVILEGED_SENDER;
|
||||
}
|
||||
} catch {
|
||||
// A broken privilege lookup must not bypass the persisted policy.
|
||||
}
|
||||
let policy = null;
|
||||
try {
|
||||
policy = accessPolicy.getSettings();
|
||||
} catch {
|
||||
// Provider failures are equivalent to an unavailable persisted policy.
|
||||
}
|
||||
return evaluateAccessPolicy(policy, {
|
||||
conversationType,
|
||||
senderIds,
|
||||
isCommand,
|
||||
...(typeof accessPolicy.equals === 'function' ? { equals: accessPolicy.equals } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
import { t } from './i18n.mjs';
|
||||
import {
|
||||
COMMAND_PERMISSION_DENIED_MESSAGE,
|
||||
evaluateInboundAccess,
|
||||
} from './inbound-access.mjs';
|
||||
import { captureContextEnhancement, enhanceContextContent } from './context-enhancement.mjs';
|
||||
import { runWorkspaceCommand } from './workspace-command.mjs';
|
||||
import { runCompactCommand } from './compact-command.mjs';
|
||||
|
|
@ -129,6 +133,7 @@ export class TextHarnessBridge {
|
|||
#harness;
|
||||
#state;
|
||||
#contextEnhancement;
|
||||
#accessPolicy;
|
||||
#status;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
|
|
@ -149,6 +154,7 @@ export class TextHarnessBridge {
|
|||
harness,
|
||||
state,
|
||||
contextEnhancement,
|
||||
accessPolicy,
|
||||
status = createTextBridgeStatus(),
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
|
|
@ -162,6 +168,7 @@ export class TextHarnessBridge {
|
|||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#contextEnhancement = contextEnhancement;
|
||||
this.#accessPolicy = accessPolicy;
|
||||
this.#status = status;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
|
|
@ -176,7 +183,7 @@ export class TextHarnessBridge {
|
|||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
accept(message, { contextSnapshot } = {}) {
|
||||
accept(message, { contextSnapshot, accessDecision } = {}) {
|
||||
if (this.#signal?.aborted) return Promise.resolve();
|
||||
const conversationId = cleanText(message?.conversationId);
|
||||
const kind = message?.kind === 'group' ? 'group' : 'direct';
|
||||
|
|
@ -187,6 +194,40 @@ export class TextHarnessBridge {
|
|||
|| this.#state.hasSeen(messageId) || this.#acceptedMessageIds.has(messageId)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
// Preserve the channel trigger boundary. Access policy never turns an
|
||||
// unaddressed group message into a denial reply.
|
||||
if (kind === 'group' && normalized.addressed !== true) {
|
||||
this.#status.messagesRejected += 1;
|
||||
this.#status.lastRejectedAt = new Date().toISOString();
|
||||
this.#acceptedMessageIds.set(messageId, null);
|
||||
return this.#finishLocalMessage(normalized, messageId, null);
|
||||
}
|
||||
if (this.#accessPolicy || accessDecision) {
|
||||
const hasImages = hasInboundImages(normalized);
|
||||
const hasFiles = hasInboundFiles(normalized);
|
||||
const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
|
||||
conversationType: kind,
|
||||
senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
|
||||
text: normalized.content,
|
||||
hasImages,
|
||||
hasFiles,
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
this.#status.messagesRejected += 1;
|
||||
this.#status.lastRejectedAt = new Date().toISOString();
|
||||
// Mark policy denials so a webhook replay cannot repeat local work or
|
||||
// a command-permission notice.
|
||||
this.#acceptedMessageIds.set(messageId, null);
|
||||
return this.#finishLocalMessage(
|
||||
normalized,
|
||||
messageId,
|
||||
decision.reason === 'command-not-allowed'
|
||||
? t(COMMAND_PERMISSION_DENIED_MESSAGE)
|
||||
: null,
|
||||
{ recordReceived: decision.reason === 'command-not-allowed' },
|
||||
);
|
||||
}
|
||||
}
|
||||
this.#acceptedMessageIds.set(messageId, contextSnapshot === undefined
|
||||
? captureContextEnhancement(this.#contextEnhancement, message?.kind)
|
||||
: contextSnapshot);
|
||||
|
|
@ -350,13 +391,15 @@ export class TextHarnessBridge {
|
|||
return this.#enqueueMessage(normalized, messageId, senderId, key);
|
||||
}
|
||||
|
||||
#finishLocalMessage(message, messageId, reply) {
|
||||
#finishLocalMessage(message, messageId, reply, { recordReceived = true } = {}) {
|
||||
let task;
|
||||
task = (async () => {
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (recordReceived) {
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
}
|
||||
if (reply) await this.#bot.sendText(message.replyTarget, reply);
|
||||
this.#status.lastError = null;
|
||||
})().catch(async (error) => {
|
||||
|
|
|
|||
|
|
@ -353,7 +353,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
|
|||
}
|
||||
|
||||
export async function runWorkspaceCommand(text, harness, conversationKey) {
|
||||
if (typeof text !== 'string') return null;
|
||||
if (!isWorkspaceCommand(text)) return null;
|
||||
const command = text.trim();
|
||||
if (SESSION_BIND_PREFIX.test(command)) {
|
||||
return runSessionBindCommand(command, harness, conversationKey);
|
||||
|
|
@ -385,3 +385,12 @@ export async function runWorkspaceCommand(text, harness, conversationKey) {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorkspaceCommand(text) {
|
||||
if (typeof text !== 'string') return false;
|
||||
const command = text.trim();
|
||||
return SESSION_BIND_PREFIX.test(command)
|
||||
|| SESSION_LIST_COMMAND.test(command)
|
||||
|| WORKSPACE_LIST_COMMAND.test(command)
|
||||
|| WORKSPACE_COMMAND.test(command);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue