Fix agent to channel (#485)
This commit is contained in:
committed by
GitHub
Unverified
parent
f6de56fa78
commit
9f2bc3cf68
@@ -335,8 +335,8 @@ function getManagedWorkspaceDirectory(agent: AgentListEntry): string | null {
|
||||
return normalizedConfigured === normalizedManaged ? configuredWorkspace : null;
|
||||
}
|
||||
|
||||
async function removeAgentWorkspaceDirectory(agent: AgentListEntry): Promise<void> {
|
||||
const workspaceDir = getManagedWorkspaceDirectory(agent);
|
||||
export async function removeAgentWorkspaceDirectory(agent: { id: string; workspace?: string }): Promise<void> {
|
||||
const workspaceDir = getManagedWorkspaceDirectory(agent as AgentListEntry);
|
||||
if (!workspaceDir) {
|
||||
logger.warn('Skipping agent workspace deletion for unmanaged path', {
|
||||
agentId: agent.id,
|
||||
@@ -567,7 +567,7 @@ export async function updateAgentName(agentId: string, name: string): Promise<Ag
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAgentConfig(agentId: string): Promise<AgentsSnapshot> {
|
||||
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
|
||||
return withConfigLock(async () => {
|
||||
if (agentId === MAIN_AGENT_ID) {
|
||||
throw new Error('The main agent cannot be deleted');
|
||||
@@ -599,9 +599,14 @@ export async function deleteAgentConfig(agentId: string): Promise<AgentsSnapshot
|
||||
await writeOpenClawConfig(config);
|
||||
await deleteAgentChannelAccounts(agentId);
|
||||
await removeAgentRuntimeDirectory(agentId);
|
||||
await removeAgentWorkspaceDirectory(removedEntry);
|
||||
// NOTE: workspace directory is NOT deleted here intentionally.
|
||||
// The caller (route handler) defers workspace removal until after
|
||||
// the Gateway process has fully restarted, so that any in-flight
|
||||
// process.chdir(workspace) calls complete before the directory
|
||||
// disappears (otherwise process.cwd() throws ENOENT for the rest
|
||||
// of the Gateway's lifetime).
|
||||
logger.info('Deleted agent config entry', { agentId });
|
||||
return buildSnapshotFromConfig(config);
|
||||
return { snapshot: await buildSnapshotFromConfig(config), removedEntry };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,25 @@ const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'e
|
||||
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
|
||||
const PLUGIN_CHANNELS = ['whatsapp'];
|
||||
|
||||
// Unique credential key per channel type – used for duplicate bot detection.
|
||||
// Maps each channel type to the field that uniquely identifies a bot/account.
|
||||
// When two agents try to use the same value for this field, the save is rejected.
|
||||
const CHANNEL_UNIQUE_CREDENTIAL_KEY: Record<string, string> = {
|
||||
feishu: 'appId',
|
||||
wecom: 'botId',
|
||||
dingtalk: 'clientId',
|
||||
telegram: 'botToken',
|
||||
discord: 'token',
|
||||
qqbot: 'appId',
|
||||
signal: 'phoneNumber',
|
||||
imessage: 'serverUrl',
|
||||
matrix: 'accessToken',
|
||||
line: 'channelAccessToken',
|
||||
msteams: 'appId',
|
||||
googlechat: 'serviceAccountKey',
|
||||
mattermost: 'botToken',
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
@@ -316,6 +335,39 @@ function migrateLegacyChannelConfigToAccounts(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if the unique credential (e.g. appId for Feishu) in `config` is
|
||||
* already registered under a *different* account in the same channel section.
|
||||
* This prevents two agents from silently sharing the same bot connection.
|
||||
*/
|
||||
function assertNoDuplicateCredential(
|
||||
channelType: string,
|
||||
config: ChannelConfigData,
|
||||
channelSection: ChannelConfigData,
|
||||
resolvedAccountId: string,
|
||||
): void {
|
||||
const uniqueKey = CHANNEL_UNIQUE_CREDENTIAL_KEY[channelType];
|
||||
if (!uniqueKey) return;
|
||||
|
||||
const incomingValue = config[uniqueKey];
|
||||
if (!incomingValue || typeof incomingValue !== 'string') return;
|
||||
|
||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
||||
if (!accounts) return;
|
||||
|
||||
for (const [existingAccountId, accountCfg] of Object.entries(accounts)) {
|
||||
if (existingAccountId === resolvedAccountId) continue;
|
||||
if (!accountCfg || typeof accountCfg !== 'object') continue;
|
||||
const existingValue = accountCfg[uniqueKey];
|
||||
if (typeof existingValue === 'string' && existingValue === incomingValue) {
|
||||
throw new Error(
|
||||
`The ${channelType} bot (${uniqueKey}: ${incomingValue}) is already bound to another agent (account: ${existingAccountId}). ` +
|
||||
`Each agent must use a unique bot.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveChannelConfig(
|
||||
channelType: string,
|
||||
config: ChannelConfigData,
|
||||
@@ -358,6 +410,10 @@ export async function saveChannelConfig(
|
||||
|
||||
const channelSection = currentConfig.channels[channelType];
|
||||
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
||||
|
||||
// Guard: reject if this bot/app credential is already used by another account.
|
||||
assertNoDuplicateCredential(channelType, config, channelSection, resolvedAccountId);
|
||||
|
||||
const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId);
|
||||
const transformedConfig = transformChannelConfig(channelType, config, existingAccountConfig);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* main thread.
|
||||
*/
|
||||
import { access, readFile, writeFile, readdir, mkdir, unlink } from 'fs/promises';
|
||||
import { constants, Dirent } from 'fs';
|
||||
import { constants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { logger } from './logger';
|
||||
@@ -78,16 +78,11 @@ async function resolveAllWorkspaceDirs(): Promise<string[]> {
|
||||
// ignore config parse errors
|
||||
}
|
||||
|
||||
try {
|
||||
const entries: Dirent[] = await readdir(openclawDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('workspace')) {
|
||||
dirs.add(join(openclawDir, entry.name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
// We intentionally do NOT scan ~/.openclaw/ for any directory starting
|
||||
// with 'workspace'. Doing so causes a race condition where a recently deleted
|
||||
// agent's workspace (e.g., workspace-code23) is found and resuscitated by
|
||||
// the context merge routine before its deletion finishes. Only workspaces
|
||||
// explicitly declared in openclaw.json should be seeded.
|
||||
|
||||
if (dirs.size === 0) {
|
||||
dirs.add(join(openclawDir, 'workspace'));
|
||||
|
||||
Reference in New Issue
Block a user