diff --git a/src/cli.ts b/src/cli.ts index 90ea49cda..9a59b19e2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ * botmux setup — interactive first-time configuration * botmux setup --no-open-platform-auto — skip Feishu Open Platform automation * botmux setup list|add|configure|edit|remove — scripted (non-TUI) bot management, see `botmux setup help` + * botmux clone [--name ] — create a new app, then copy an existing bot's configuration * botmux start — start daemon and auto plugin services * botmux stop [--with-plugin] — stop daemon (optionally stop auto plugin services) * botmux restart [--include-pm2] [--with-plugin] — restart daemon, then ensure auto plugin services; @@ -53,6 +54,8 @@ import { applyBotConfigEdits, assertUniqueBotProcessNames, botProcessName, + cloneBotConfig, + cloneOwnerEntries, normalizeBotConfig, parseBotConfigsJson, parseBotSelection, @@ -65,7 +68,7 @@ import { } from './setup/bot-config-editor.js'; import { resolveCliSelection, selectionKeyForBot } from './setup/cli-selection.js'; import { checkCliAvailability, hasAgentLaunchConfigChanged } from './setup/cli-availability.js'; -import { resolveSetupAppName } from './setup/app-name.js'; +import { resolveCloneAppName, resolveSetupAppName } from './setup/app-name.js'; import { blocksSetupBotStart, classifySetupOpenPlatformOutcome, @@ -1915,7 +1918,10 @@ function botJsonView(bot: Record, index: number): Record { +async function cmdSetupScripted( + argv: string[], + cloneSource?: Record, +): Promise { const wantsJson = argv.includes('--json'); let cmd: SetupCommand; try { @@ -2184,6 +2190,9 @@ async function cmdSetupScripted(argv: string[]): Promise { failSetupScripted(cmd.json, err?.message ?? String(err)); return; } + if (cloneSource) { + bot = cloneBotConfig(cloneSource, bot); + } if (existing.some(b => b?.larkAppId === bot.larkAppId)) { failSetupScripted(cmd.json, `AppID ${bot.larkAppId} 已存在,修改请用 botmux setup edit ${bot.larkAppId}。`); @@ -2486,6 +2495,51 @@ async function cmdSetupScripted(argv: string[]): Promise { } } +async function cmdClone(argv: string[]): Promise { + const [sourceSelector, nameFlag, requestedName] = argv; + if ( + !sourceSelector + || (argv.length !== 1 && (argv.length !== 3 || nameFlag !== '--name' || !requestedName?.trim())) + ) { + console.error('用法: botmux clone <进程名|配置名|AppID> [--name <新名称>]'); + process.exitCode = 1; + return; + } + const bots = loadBotsJson(); + const sourceIndex = parseBotSelection(sourceSelector, bots); + if (sourceIndex === undefined) { + console.error(`找不到机器人 "${sourceSelector}"。`); + process.exitCode = 1; + return; + } + const source = bots[sourceIndex]; + const owners = cloneOwnerEntries( + source, + process.env.BOTMUX_LARK_APP_ID, + process.env.BOTMUX_OWNER_OPEN_ID ?? process.env.__OWNER_OPEN_ID, + ); + if (!hasOwnerEntry(owners)) { + console.error('源机器人没有可跨应用复用的 owner(邮箱、手机号、on_ union_id,或当前会话已认证的 owner)。'); + process.exitCode = 1; + return; + } + const addArgs = ['add', '--create-app', '--allowed-users', owners.join(',')]; + if (botBrand(source) === 'lark') { + if (requestedName) { + console.error('Lark SDK 创建路径暂不支持自定义应用名称。'); + process.exitCode = 1; + return; + } + addArgs.push('--brand', 'lark'); + } else { + const sourceName = typeof source.displayName === 'string' && source.displayName.trim() + ? source.displayName.trim() + : botProcessName(source, sourceIndex); + addArgs.push('--app-name', resolveCloneAppName(requestedName, sourceName)); + } + await cmdSetupScripted(addArgs, source); +} + // ─── Commands ──────────────────────────────────────────────────────────────── async function cmdSetup(): Promise { @@ -7280,6 +7334,8 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接 命令: setup 交互式配置(首次使用 / 添加机器人) 默认使用 botmux 内置 Feishu Web QR 登录尝试自动导入权限/redirect/发布版本;可加 --no-open-platform-auto 跳过 + clone <机器人名> [--name <新名称>] + 创建新应用并复制该机器人的行为配置;留空名称自动使用 源名称-copy-时间戳 start 启动 daemon,并启动 mode=auto 的插件 service stop 停止 daemon(默认不停止插件 service;--with-plugin 显式停止 mode=auto 的插件 service) restart 重启 daemon(默认不停止插件 service,core 启动后确保 mode=auto 正在运行;--with-plugin 显式先停再启动 auto service) @@ -14145,6 +14201,7 @@ switch (command) { else await cmdSetup(); break; } + case 'clone': await cmdClone(process.argv.slice(3)); break; case 'start': await cmdStart(); break; case 'serve': await cmdServe(process.argv.slice(3)); break; case 'start-bot': await cmdStartBot(process.argv.slice(3)); break; diff --git a/src/dashboard.ts b/src/dashboard.ts index d22a50aed..160020c71 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -4780,6 +4780,7 @@ const server = createServer(async (req, res) => { if (req.method === 'POST' && url.pathname === '/api/bot-onboarding/start') { let parsed: { appName?: unknown; + cloneSourceAppId?: unknown; registrationMode?: unknown; sessionMode?: unknown; expectedIdentity?: unknown; @@ -4871,6 +4872,9 @@ const server = createServer(async (req, res) => { } const job = botOnboarding.start({ appName, + ...(typeof parsed.cloneSourceAppId === 'string' && parsed.cloneSourceAppId.trim() + ? { cloneSourceAppId: parsed.cloneSourceAppId.trim() } + : {}), registrationMode, ...(registrationMode === 'web' ? { sessionMode, expectedIdentity } : {}), cliId, diff --git a/src/dashboard/bot-onboarding.ts b/src/dashboard/bot-onboarding.ts index dad4cc104..428181686 100644 --- a/src/dashboard/bot-onboarding.ts +++ b/src/dashboard/bot-onboarding.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'; import { readBotsJsonOrEmpty, writeBotsJsonAtomic } from '../setup/bots-store.js'; import { atomicWriteFileSync } from '../utils/atomic-write.js'; import { logger } from '../utils/logger.js'; -import { normalizeBotConfig, findInvalidAllowedUserEntries, hasOwnerEntry } from '../setup/bot-config-editor.js'; +import { cloneBotConfig, normalizeBotConfig, findInvalidAllowedUserEntries, hasOwnerEntry } from '../setup/bot-config-editor.js'; import { detectUnusableOwnerEntries, resolveScannerAllowedUser, @@ -19,7 +19,7 @@ import { type CriticalScopeReadbackResult, type RemainingStep, } from '../setup/verify-permissions.js'; -import { resolveSetupAppName } from '../setup/app-name.js'; +import { resolveCloneAppName, resolveSetupAppName } from '../setup/app-name.js'; import { automateOpenPlatformSetup, BOT_BASELINE_APP_EVENTS, @@ -141,8 +141,9 @@ export interface BotOnboardingSnapshot { /** 调用方 (dashboard) 已校验过的表单输入: CLI / 工作目录 / model. */ export interface BotOnboardingInput { - /** 飞书应用名称;留空时按待追加的 bots.json 行号生成 botmux-N。 */ + /** 飞书应用名称;普通创建留空生成 botmux-N,克隆留空生成 源名称-copy-时间戳。 */ appName?: string; + cloneSourceAppId?: string; /** 默认 Feishu 单码主路径;compat 是用户明确确认过的 SDK 兼容模式。 */ registrationMode?: 'web' | 'compat'; /** @@ -1591,9 +1592,24 @@ export class BotOnboardingManager { } private async run(id: string, input: BotOnboardingInput = {}): Promise { + const configuredBots = readBotsJsonOrEmpty(this.opts.botsJsonPath); + const cloneSource = input.cloneSourceAppId + ? configuredBots.find((bot: any) => bot?.larkAppId === input.cloneSourceAppId) + : undefined; + if (input.cloneSourceAppId && !cloneSource) { + this.patch(id, { status: 'failed', error: 'clone_source_not_found', message: '源机器人不存在' }); + return; + } // Freeze the resolved name before any asynchronous work. Later bot list // changes must not make the name drift midway through onboarding. - const appName = resolveSetupAppName(input.appName, readBotsJsonOrEmpty(this.opts.botsJsonPath).length); + const appName = cloneSource + ? resolveCloneAppName( + input.appName, + [cloneSource.displayName, cloneSource.name, input.cloneSourceAppId] + .find(value => typeof value === 'string' && value.trim()), + this.now(), + ) + : resolveSetupAppName(input.appName, configuredBots.length); this.patch(id, { registrationMode: input.registrationMode ?? 'web', ...(input.requireCriticalScopesBeforeActivation @@ -1673,9 +1689,9 @@ export class BotOnboardingManager { // CLI / 工作目录 / model 来自前端表单 (dashboard 已用 resolveCliId + // invalidWorkingDirs 校验过). 留空回退到 setup 同款默认: claude-code / '~'. - const cliId: CliId = input.cliId ?? 'claude-code'; - const workingDir = input.workingDir?.trim() || '~'; - const bot: Record = { + let cliId: CliId = input.cliId ?? 'claude-code'; + let workingDir = input.workingDir?.trim() || '~'; + let bot: Record = { larkAppId: result.appId, larkAppSecret: result.appSecret, cliId, @@ -1690,6 +1706,11 @@ export class BotOnboardingManager { if (result.brand === 'lark') { bot.brand = 'lark'; } + if (cloneSource) { + bot = cloneBotConfig(cloneSource, bot); + cliId = bot.cliId ?? 'claude-code'; + workingDir = bot.defaultWorkingDir ?? bot.workingDir ?? '~'; + } // 注意:此处 **不** 立刻把 bot 写进 bots.json。空 allowedUsers 的 bot 一旦落盘, // 就是一个「可被 botmux start/restart 读取、运行时按无白名单全开放」的 fail-open // 隐患(哪怕没出 restart hint, 关弹窗 / 重启 / pm2 重启都会以开放模式起)。 diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 24932e0ca..6e763c905 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -822,6 +822,23 @@ export function BotDefaultsPage() {
void reload()} /> + {ui.authed && bots.length > 0 ? ( + + className="clone-bot-menu" + ariaLabel={tr('botOnboarding.clone')} + disabled={onboardingBusy} + label={tr('botOnboarding.clone')} + value="" + options={bots.map(bot => ({ + value: bot.larkAppId, + label: `${bot.botName ?? bot.larkAppId} · ${displayCliId(bot, cliIdOf(bot.larkAppId))}`, + }))} + onChange={sourceAppId => { + setOnboardingBusy(true); + void openBotOnboarding(sourceAppId).finally(() => setOnboardingBusy(false)); + }} + /> + ) : null} {ui.authed ? ( { +export async function openBotOnboarding(sourceAppId?: string): Promise { + cloneSourceAppId = sourceAppId; window.dispatchEvent(new Event(OPEN_BOT_ONBOARDING_EVENT)); } @@ -636,6 +638,7 @@ export function BotOnboardingDialog(props: { open: boolean; onClose(): void }): const close = useCallback(() => { stopPolling(); + cloneSourceAppId = undefined; props.onClose(); }, [props, stopPolling]); @@ -735,6 +738,7 @@ export function BotOnboardingDialog(props: { open: boolean; onClose(): void }): workingDir: form.workingDir.trim(), dirMode: form.dirMode, model: form.model.trim() || undefined, + cloneSourceAppId, }), }); const body = await res.json(); diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 8e0feb0a4..e2fcb00be 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -58,6 +58,7 @@ const zh = { 'skin.cyber': '2077', 'skin.fallout': 'Fallout', 'botOnboarding.add': '添加机器人', + 'botOnboarding.clone': '克隆机器人', 'botOnboarding.title': '添加机器人', 'botOnboarding.intro': '设置它如何工作。首次扫码登录后,后续可复用当前账号免扫码添加。', 'botOnboarding.sessionChecking': '正在检查飞书登录状态…', @@ -2554,6 +2555,7 @@ const en: Record = { 'skin.cyber': '2077', 'skin.fallout': 'Fallout', 'botOnboarding.add': 'Add Bot', + 'botOnboarding.clone': 'Clone Bot', 'botOnboarding.title': 'Add Bot', 'botOnboarding.intro': 'Choose how it works. Scan once to sign in, then reuse that account for future additions without scanning.', 'botOnboarding.sessionChecking': 'Checking your Feishu sign-in…', diff --git a/src/dashboard/web/style.css b/src/dashboard/web/style.css index 281422213..5e60c9913 100644 --- a/src/dashboard/web/style.css +++ b/src/dashboard/web/style.css @@ -22914,6 +22914,19 @@ dialog select[multiple]:hover:not(:disabled) { } /* ─── Bot 配置:平台化表单控件 ───────────────────────────────────────────── */ +.bot-defaults-page .clone-bot-menu[open] { + z-index: 1000; +} + +.bot-defaults-page .clone-bot-menu > .sect-sort-pop { + right: 0; + left: auto; + width: max-content; + min-width: 220px; + max-width: min(340px, calc(100vw - 48px)); + transform: none; +} + .bot-defaults-page .bd-detail, .bot-defaults-page .bd-card.bd-profile { background: transparent; diff --git a/src/setup/app-name.ts b/src/setup/app-name.ts index ab6417a40..c1fceb734 100644 --- a/src/setup/app-name.ts +++ b/src/setup/app-name.ts @@ -7,3 +7,14 @@ export function resolveSetupAppName(requestedName: string | undefined, nextBotIn const requested = requestedName?.trim(); return requested || `botmux-${nextBotIndex}`; } + +export function resolveCloneAppName( + requestedName: string | undefined, + sourceName: string | undefined, + now = Date.now(), +): string { + const requested = requestedName?.trim(); + if (requested) return requested; + const suffix = `-copy-${now}`; + return `${(sourceName?.trim() || 'botmux').slice(0, 64 - suffix.length)}${suffix}`; +} diff --git a/src/setup/bot-config-editor.ts b/src/setup/bot-config-editor.ts index 5447fbb76..e96e97f05 100644 --- a/src/setup/bot-config-editor.ts +++ b/src/setup/bot-config-editor.ts @@ -427,6 +427,62 @@ export function parseBotSelection( return byProcessName >= 0 ? byProcessName : undefined; } +/** + * 把源 Bot 的行为配置覆盖到刚创建的目标 Bot,同时保留目标应用自己的身份。 + * Dashboard 与 CLI clone 共用这里,避免两条入口各维护一份排除字段。 + */ +export function cloneBotConfig( + source: Record, + target: Record, +): Record { + const cloned: Record = { ...target, ...source }; + + for (const key of [ + 'apiOnly', + 'name', + 'displayName', + 'messageListeners', + 'oncallChats', + 'allowedChatGroups', + 'chatGrants', + 'globalGrants', + 'quotaState', + 'grantExpiryState', + 'sessionGroup', + 'chatReplyModes', + 'noCardChats', + 'activationPending', + 'activationDeactivating', + 'activationStarting', + 'activationCommitted', + ]) { + delete cloned[key]; + } + + for (const key of ['larkAppId', 'larkAppSecret', 'brand', 'allowedUsers', 'ownerOpenId']) { + if (Object.prototype.hasOwnProperty.call(target, key) && target[key] !== undefined) { + cloned[key] = target[key]; + } else { + delete cloned[key]; + } + } + + return cloned; +} + +/** 只允许 daemon 已认证的 source open_id 进入共享的跨应用 owner 归一化。 */ +export function cloneOwnerEntries( + source: Record, + sourceAppId?: string, + sourceOwnerOpenId?: string, +): string[] { + const managedOwner = sourceAppId === source.larkAppId ? sourceOwnerOpenId : undefined; + if (!Array.isArray(source.allowedUsers)) return []; + return source.allowedUsers.filter((entry: unknown): entry is string => ( + typeof entry === 'string' && (!entry.startsWith('ou_') || entry === managedOwner) + )); +} + export function removeBotConfig( bots: T[], selection: string, diff --git a/test/event-dispatcher.test.ts b/test/event-dispatcher.test.ts index 028f74f5c..319fb0d42 100644 --- a/test/event-dispatcher.test.ts +++ b/test/event-dispatcher.test.ts @@ -158,6 +158,8 @@ import { getPendingGrantLimits, _resetForTest as _resetGrantPending } from '../s import { logger } from '../src/utils/logger.js'; import { config } from '../src/config.js'; import { __resetPeerCrossRefCacheForTest } from '../src/services/peer-cross-ref-store.js'; +import { cloneBotConfig, cloneOwnerEntries } from '../src/setup/bot-config-editor.js'; +import { normalizeManagedOwnerEntries } from '../src/setup/owner-identity.js'; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -5263,6 +5265,55 @@ describe('globalGrants — global talk-only authorization (canTalk / canOperate) }); }); +describe('managed Agent clone owner boundary', () => { + beforeEach(() => { + mockIsChatOncallBoundForAnyBot.mockReturnValue(false); + mockReadFileSync.mockReturnValue('{}'); + }); + + it('keeps the human owner operable without cloning source instance state', async () => { + const instanceKeys = [ + 'displayName', + 'oncallChats', + 'allowedChatGroups', + 'chatGrants', + 'globalGrants', + 'quotaState', + 'grantExpiryState', + 'sessionGroup', + 'chatReplyModes', + 'noCardChats', + ] as const; + const source = { + larkAppId: 'cli_source', + larkAppSecret: 'source-secret', + cliId: 'codex', + allowedUsers: ['ou_source_owner', 'ou_stale_coowner'], + ...Object.fromEntries(instanceKeys.map(key => [key, { source: key }])), + }; + const owners = cloneOwnerEntries(source, 'cli_source', 'ou_source_owner'); + expect(owners).toEqual(['ou_source_owner']); + + const normalized = await normalizeManagedOwnerEntries( + owners.join(','), + { sourceAppId: 'cli_source', sourceOwnerOpenId: 'ou_source_owner', creatingApp: true }, + async () => 'on_human_owner', + ); + const target = cloneBotConfig(source, { + larkAppId: MY_APP_ID, + larkAppSecret: 'target-secret', + allowedUsers: normalized?.split(','), + }); + expect(target.allowedUsers).toEqual(['on_human_owner']); + for (const key of instanceKeys) expect(target).not.toHaveProperty(key); + + // 目标 app 启动时会把 union_id 解析成自己视角下的 open_id。 + setupBotState({ configAllowedUsers: target.allowedUsers, allowedUsers: [USER_OPEN_ID] }); + expect(canOperate(MY_APP_ID, 'chat-A', USER_OPEN_ID)).toBe(true); + expect(canOperate(MY_APP_ID, 'chat-A', 'ou_source_owner')).toBe(false); + }); +}); + describe('configured-but-unresolved allowlist stays fail-closed (not fail-open)', () => { beforeEach(() => { mockIsChatOncallBoundForAnyBot.mockReturnValue(false);