diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index bd7d49ef8c..073ced5323 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -268,7 +268,7 @@ import { } from './dashboard-rows.js'; import { getBotBrand, getBot, getBotOpenId, getOwnerOpenId, loadBotConfigs, readBotSkillPolicy, getBotTuiSlashAllow, updateBotNativeSubagentRuntime, MAX_TURN_TIMEOUT_MS, type BotConfig, type NativeSubagentRuntimeConfigState, type UsageDisplayMode, type MessageListenerConfig } from '../bot-registry.js'; import { generateAuthUrl, tryHandleCallbackUrl, getFeedGroupAuthStatus, listAuthorizedUsers, FEED_GROUP_OAUTH_SCOPES } from '../utils/user-token.js'; -import { tokenStoreProtection } from '../services/trigger-user-auth.js'; +import { tokenStoreProtection, type TriggerUserAuthConfig } from '../services/trigger-user-auth.js'; import { scanCredentialBearingMcpServers, credentialBearingMcpAdvisory } from '../services/credential-bearing-mcp.js'; import { clampSessionTagName, defaultSessionTagName } from '../services/feed-group-tagger.js'; import { normalizeBrand } from '../im/lark/lark-hosts.js'; @@ -4892,6 +4892,16 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { try { if (getBot(cachedLarkAppId).config.envelopeInjection === 'auto') envelopeInjection = 'auto'; } catch { /* default off */ } let codexAuthSync: 'shared' | 'isolated' = 'shared'; try { if (getBot(cachedLarkAppId).config.codexAuthSync === 'isolated') codexAuthSync = 'isolated'; } catch { /* default shared */ } + // Trigger-user CLI auth policy. Absent → null ("feature off"), which is what + // the dashboard toggle renders as unchecked. It has to be echoed here or the + // Bot Defaults page loses the setting on every refresh: the PUT persists it, + // but this aggregate is the only thing the page reloads from. + // + // Already-normalized by the registry parser (enabled/tools/fallback always + // present), and the policy carries no secret — just which tools it covers and + // what to do for an unauthorized sender. + let triggerUserAuth: TriggerUserAuthConfig | null = null; + try { triggerUserAuth = getBot(cachedLarkAppId).config.triggerUserAuth ?? null; } catch { /* default off */ } let skillInjection: 'global' | 'prompt' | 'off' | null = null; // How this bot's CLI delivers botmux skills, so the dashboard can render the // control correctly: 'dynamic' = per-session --plugin-dir (claude-family, not @@ -5089,6 +5099,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { grantDefaultDurationMs: grantPrefs.grantDefaultDurationMs, p2pMode, envelopeInjection, + triggerUserAuth, skillInjection, skillInjectionSupport, // Resolved machine-wide default → the dashboard shows it as the pre-selected @@ -6350,9 +6361,20 @@ ipcRoute('PUT', '/api/bot-codex-auth-sync', async (req, res) => { }); // PUT /api/bot-trigger-user-auth — 按触发人身份调用 CLI 的开关。Body -// `{ triggerUserAuth: object | null }`:null / 空对象 → 清除(关闭)。 -// 与 /botconfig set 共用 applyConfigField,因此两个门的校验完全一致:拒绝原因 -// (比如「fallback 不能是 device」)原样透出,不在这里另写一套判断。 +// `{ triggerUserAuth: object | null }`:null → 清除(关闭)。 +// +// 走 coerceConfigValue + applyConfigField,与 /botconfig set 的 json 分支同一口径: +// ① 校验一致,拒绝原因(比如「fallback 不能是 device」)原样透出,不在这里另写一套 +// 判断;② 落盘的是 **parser 归一化后的对象**。之前这里把 JSON.stringify 的结果直接 +// 交给 applyConfigField,而 json kind 的 applyConfigField 不解析、原样写入,于是 +// bots.json 里存的是一个 JSON **字符串**——三个后果都是静默的: +// • getBot().config.triggerUserAuth 是 string,`?.enabled` 恒为 undefined, +// 功能实际从未生效(开关看着开了,凭证边界并没有建立); +// • parser 从未被调用,`fallback:'device'` 这类被刻意禁止的值也会 200 落盘; +// • 下次 daemon 重启时 bot-registry 的 parser 抛 "must be an object",整个 +// bots.json 加载失败 —— 一个开关把 daemon 拒启了。 +const TRIGGER_USER_AUTH_UI_EDITABLE_KEYS = new Set(['enabled', 'tools', 'fallback']); + ipcRoute('PUT', '/api/bot-trigger-user-auth', async (req, res) => { if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); let body: { triggerUserAuth?: unknown }; @@ -6360,15 +6382,37 @@ ipcRoute('PUT', '/api/bot-trigger-user-auth', async (req, res) => { catch { return jsonRes(res, 400, { error: 'invalid_json' }); } const spec = findConfigField('triggerUserAuth'); if (!spec) return jsonRes(res, 500, { ok: false, error: 'field_unavailable' }); - // '' is the store's "clear" sentinel; anything else goes through the shared - // JSON coercion so a malformed policy is rejected the same way here as it is - // from chat. - const raw = body.triggerUserAuth === null || body.triggerUserAuth === undefined - ? '' - : JSON.stringify(body.triggerUserAuth); - const r = await applyConfigField(cachedLarkAppId, spec, raw); + + // null → 清除整份配置(关闭)。applyConfigField 的 null 分支 delete key。 + let value: TriggerUserAuthConfig | null = null; + if (body.triggerUserAuth !== null && body.triggerUserAuth !== undefined) { + const incoming = body.triggerUserAuth; + // 合并保存:dashboard 只回写 UI 展示的三个字段;接口支持但 UI 没有编辑器的 + // gitHost / gitTokenExchangeUrl 必须原样保留,否则用户只勾一个 tool 就会静默 + // 删掉「按当轮身份鉴权 git push」的配置。清除(body=null)仍是整份删除。 + let merged: unknown = incoming; + if (incoming && typeof incoming === 'object' && !Array.isArray(incoming)) { + let preserved: Record = {}; + try { + const prev = getBot(cachedLarkAppId).config.triggerUserAuth as + Record | undefined; + if (prev && typeof prev === 'object' && !Array.isArray(prev)) { + preserved = Object.fromEntries( + Object.entries(prev).filter(([k]) => !TRIGGER_USER_AUTH_UI_EDITABLE_KEYS.has(k)), + ); + } + } catch { /* 未注册 bot → applyConfigField 会给出 bot_not_registered */ } + merged = { ...preserved, ...(incoming as Record) }; + } + // coerceConfigValue 吃 JSON 文本(与 IM 入口一致),返回 parser 归一化后的对象。 + const coerced = coerceConfigValue(spec, JSON.stringify(merged)); + if (!coerced.ok) return jsonRes(res, 400, { ok: false, error: coerced.reason, reason: coerced.reason }); + value = coerced.value as TriggerUserAuthConfig; + } + const r = await applyConfigField(cachedLarkAppId, spec, value); if (!r.ok) return jsonRes(res, 400, r); - jsonRes(res, 200, { ok: true }); + // 回响规范化后的实际生效值,前端保存后无需再拉一次聚合接口就能对齐。 + jsonRes(res, 200, { ok: true, triggerUserAuth: value }); }); // GET /api/bot-trigger-user-auth-status — 当前策略 + 已授权人数 + 两条如实的 diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index 3c7f6b0dc8..f654be5e59 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -4,6 +4,7 @@ import { normalizeUsageDisplay } from '../bot-registry.js'; import type { CliRuntimeConfig } from '../adapters/cli/runtime.js'; import { GRANT_DURATION_OPTIONS } from '../services/grant-policy.js'; import { normalizeSparseReplyStyleConfig } from './reply-style.js'; +import { parseTriggerUserAuthConfig, type TriggerUserAuthConfig } from '../services/trigger-user-auth.js'; import type { NativeSubagentRuntimePolicy } from '../services/native-subagent-runtime-policy.js'; import { normalizeQuotaFallbackBotConfig } from '../services/quota-fallback.js'; @@ -50,6 +51,19 @@ export function brandMapByAppId( } } +/** + * Trigger-user CLI auth policy for the private Bot Defaults payload. + * + * A daemon that predates the field simply omits it, and an unregistered bot + * reports null — both mean "off", which is what the dashboard toggle renders as + * unchecked. A malformed value (hand-edited bots.json reaching an older daemon + * that echoed it verbatim) degrades to off rather than throwing: this aggregate + * builds every bot row, so one bad policy must not take the whole page down. + */ +function normalizeTriggerUserAuthForClient(raw: unknown): TriggerUserAuthConfig | null { + try { return parseTriggerUserAuthConfig(raw); } catch { return null; } +} + export function botSummaryPayload(bot: DashboardBotDescriptor) { return { larkAppId: bot.larkAppId, @@ -161,11 +175,12 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: p2pMode: j?.p2pMode === 'thread' ? 'thread' : j?.p2pMode === 'group' ? 'group' : 'chat', envelopeInjection: j?.envelopeInjection === 'auto' ? 'auto' : 'off', codexAuthSync: j?.codexAuthSync === 'isolated' ? 'isolated' : 'shared', - // Trigger-user CLI auth policy, verbatim (no secrets in it — just which - // tools and what to do when the sender has not authorized). - triggerUserAuth: (j?.triggerUserAuth && typeof j.triggerUserAuth === 'object') - ? j.triggerUserAuth - : null, + // Trigger-user CLI auth policy. No secrets in it — just which tools it + // covers and what to do when the sender has not authorized. Run through the + // SHARED parser so this door cannot drift from bots.json / /botconfig: a + // malformed hand edit degrades to null (feature off) instead of reaching + // form state as a half-shaped policy the toggle would misrender. + triggerUserAuth: normalizeTriggerUserAuthForClient(j?.triggerUserAuth), skillInjection: (j?.skillInjection === 'global' || j?.skillInjection === 'prompt' || j?.skillInjection === 'off') ? j.skillInjection : null, skillInjectionDefault: (j?.skillInjectionDefault === 'global' || j?.skillInjectionDefault === 'off') ? j.skillInjectionDefault : 'prompt', skillInjectionSupport: (j?.skillInjectionSupport === 'dynamic' || j?.skillInjectionSupport === 'global') ? j.skillInjectionSupport : 'none', diff --git a/src/dashboard/web/bot-defaults.ts b/src/dashboard/web/bot-defaults.ts index 3899671742..6160ab1a2a 100644 --- a/src/dashboard/web/bot-defaults.ts +++ b/src/dashboard/web/bot-defaults.ts @@ -90,11 +90,15 @@ export type BotDefaultsRow = { sandbox?: boolean; codexAuthSync?: 'shared' | 'isolated'; /** Trigger-user CLI auth: null / absent = off (the historical behavior, where - * CLI calls use whatever identity is logged in on the machine). */ + * CLI calls use whatever identity is logged in on the machine). + * gitHost / gitTokenExchangeUrl have no editor in the UI — they round-trip + * through the daemon's merge on PUT, so the page neither shows nor sends them. */ triggerUserAuth?: { enabled: boolean; tools: Array<'lark-cli' | 'bytedcli'>; fallback: 'bot-identity' | 'none'; + gitHost?: string; + gitTokenExchangeUrl?: string; } | null; /** Three-tier sandbox path whitelist (highest-precedence FsPolicy layer). * null/absent = none configured (pure deny-by-default baseline). */ diff --git a/test/dashboard-bot-payload.test.ts b/test/dashboard-bot-payload.test.ts index 0da60cd9c3..64616eb921 100644 --- a/test/dashboard-bot-payload.test.ts +++ b/test/dashboard-bot-payload.test.ts @@ -32,7 +32,7 @@ describe('dashboard bot payload helpers', () => { 'substituteMode', 'feedback', 'replyStyle', 'restrictGrantCommands', 'autoGrantRequestCards', 'p2pOpen', 'grantDefaultDurationMs', 'messageQuotaDefaultLimit', 'p2pMode', - 'envelopeInjection', 'codexAuthSync', + 'envelopeInjection', 'codexAuthSync', 'triggerUserAuth', 'skillInjection', 'skillInjectionDefault', 'skillInjectionSupport', 'maxLiveWorkers', 'logicalSessionCount', 'residentSessionCount', 'dormantSessionCount', 'nativeSubagentRuntime', @@ -76,6 +76,37 @@ describe('dashboard bot payload helpers', () => { expect(botSummaryPayload({ larkAppId: 'cli_source' })).not.toHaveProperty('quotaFallbackBot'); }); + it('carries the trigger-user auth policy through the aggregate, degrading unusable values to off', () => { + const policy = { + enabled: true, + tools: ['lark-cli' as const], + fallback: 'none' as const, + gitHost: 'code.example.com', + }; + // An enabled policy survives whole — a refresh rebuilds the toggle, the tool + // checkboxes and the fallback select from exactly this row. + expect(botDefaultsPayload({ larkAppId: 'app' }, { triggerUserAuth: policy })) + .toMatchObject({ triggerUserAuth: policy }); + + // Off / older daemon that omits the field / explicitly disabled — all null, + // which the toggle renders unchecked. + expect(botDefaultsPayload({ larkAppId: 'app' }, {})).toMatchObject({ triggerUserAuth: null }); + expect(botDefaultsPayload({ larkAppId: 'app' }, { triggerUserAuth: null })) + .toMatchObject({ triggerUserAuth: null }); + expect(botDefaultsPayload({ larkAppId: 'app' }, { triggerUserAuth: { enabled: false } })) + .toMatchObject({ triggerUserAuth: { enabled: false, tools: ['lark-cli', 'bytedcli'], fallback: 'bot-identity' } }); + + // A malformed policy must not throw here: this builds every bot row, so one + // bad value would blank the whole Bot Defaults page rather than one toggle. + expect(botDefaultsPayload({ larkAppId: 'app' }, { triggerUserAuth: { enabled: true, fallback: 'device' } })) + .toMatchObject({ triggerUserAuth: null }); + expect(botDefaultsPayload({ larkAppId: 'app' }, { triggerUserAuth: 'yes' })) + .toMatchObject({ triggerUserAuth: null }); + + // Never in the public summary — it names which credential boundary a bot runs. + expect(botSummaryPayload({ larkAppId: 'app' })).not.toHaveProperty('triggerUserAuth'); + }); + it('exposes only the normalized sparse reply style in private Bot Defaults payloads', () => { const replyStyle = { recipes: false, diff --git a/test/dashboard-ipc.test.ts b/test/dashboard-ipc.test.ts index c9a9bfa5e8..2f34bbce1a 100644 --- a/test/dashboard-ipc.test.ts +++ b/test/dashboard-ipc.test.ts @@ -2480,6 +2480,195 @@ describe('PUT /api/bot-card-prefs — reply-card usage display mode', () => { }); }); +describe('PUT + GET /api/bot-trigger-user-auth — 开关刷新回显', () => { + it('turning it on echoes the normalized policy back through GET /api/bot-default-oncall', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-trigger-user-auth-')); + const configPath = join(dir, 'bots.json'); + const appId = 'test-trigger-user-auth-app'; + const prevBotsConfig = process.env.BOTS_CONFIG; + try { + process.env.BOTS_CONFIG = configPath; + writeFileSync(configPath, JSON.stringify([{ + larkAppId: appId, + larkAppSecret: 'must-not-leak', + cliId: 'codex', + }], null, 2)); + loadBotConfigs().forEach((c: any) => registerBot(c)); + setLarkAppId(appId); + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const base = `http://127.0.0.1:${handle.port}`; + + // Unset → null. The dashboard toggle renders that as off; an omitted key + // would be indistinguishable from "the daemon forgot", which is the bug. + const initial = await (await fetch(`${base}/api/bot-default-oncall`)).json(); + expect(initial).toHaveProperty('triggerUserAuth'); + expect(initial.triggerUserAuth).toBeNull(); + + const on = await fetch(`${base}/api/bot-trigger-user-auth`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + triggerUserAuth: { enabled: true, tools: ['lark-cli'], fallback: 'none' }, + }), + }); + expect(on.status).toBe(200); + expect(await on.json()).toMatchObject({ + ok: true, + triggerUserAuth: { enabled: true, tools: ['lark-cli'], fallback: 'none' }, + }); + // Persisted as a real OBJECT, not a JSON string. A string here is silently + // catastrophic: `config.triggerUserAuth?.enabled` is undefined so the + // credential boundary never actually engages, and the next daemon restart + // refuses to load bots.json at all ("must be an object"). + const persisted = JSON.parse(readFileSync(configPath, 'utf-8'))[0].triggerUserAuth; + expect(typeof persisted).toBe('object'); + expect(persisted).toMatchObject({ enabled: true, tools: ['lark-cli'], fallback: 'none' }); + // The in-memory hot update has to be an object too — this is what every + // spawn path reads via `getBot(...).config.triggerUserAuth?.enabled`. + expect(getBot(appId).config.triggerUserAuth).toMatchObject({ enabled: true, fallback: 'none' }); + // And the written file must still load: a config a restart cannot parse + // would take the whole daemon down, not just this one bot's toggle. + expect(() => loadBotConfigs()).not.toThrow(); + + // The regression: a page refresh reloads from this aggregate, so the whole + // policy (not just `enabled`) has to survive the round trip. + expect((await (await fetch(`${base}/api/bot-default-oncall`)).json()).triggerUserAuth) + .toMatchObject({ enabled: true, tools: ['lark-cli'], fallback: 'none' }); + + // Omitted `tools` normalizes to every tool — the echo must show the + // effective value, not the sparse body the dashboard sent. + const allTools = await fetch(`${base}/api/bot-trigger-user-auth`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ triggerUserAuth: { enabled: true } }), + }); + expect(allTools.status).toBe(200); + expect((await (await fetch(`${base}/api/bot-default-oncall`)).json()).triggerUserAuth) + .toMatchObject({ enabled: true, tools: ['lark-cli', 'bytedcli'], fallback: 'bot-identity' }); + + // null clears → key dropped from disk, GET back to null (off). + const off = await fetch(`${base}/api/bot-trigger-user-auth`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ triggerUserAuth: null }), + }); + expect(off.status).toBe(200); + expect(await off.json()).toMatchObject({ ok: true, triggerUserAuth: null }); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].triggerUserAuth).toBeUndefined(); + expect((await (await fetch(`${base}/api/bot-default-oncall`)).json()).triggerUserAuth).toBeNull(); + + // The secret must never ride along in the aggregate the browser reads. + const payload = await (await fetch(`${base}/api/bot-default-oncall`)).text(); + expect(payload).not.toContain('must-not-leak'); + } finally { + if (handle) await handle.close(); + handle = null; + if (prevBotsConfig === undefined) delete process.env.BOTS_CONFIG; + else process.env.BOTS_CONFIG = prevBotsConfig; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('preserves the UI-less gitHost / gitTokenExchangeUrl across a tools-only save', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-trigger-user-auth-git-')); + const configPath = join(dir, 'bots.json'); + const appId = 'test-trigger-user-auth-git-app'; + const prevBotsConfig = process.env.BOTS_CONFIG; + try { + process.env.BOTS_CONFIG = configPath; + writeFileSync(configPath, JSON.stringify([{ + larkAppId: appId, + larkAppSecret: 'secret', + cliId: 'codex', + triggerUserAuth: { + enabled: true, + tools: ['lark-cli', 'bytedcli'], + fallback: 'bot-identity', + gitHost: 'code.example.com', + gitTokenExchangeUrl: 'https://exchange.example.com/token', + }, + }], null, 2)); + loadBotConfigs().forEach((c: any) => registerBot(c)); + setLarkAppId(appId); + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const base = `http://127.0.0.1:${handle.port}`; + + // The dashboard has no editor for the two git fields, so it PUTs only the + // three it renders. Dropping the rest would silently disable per-turn git + // auth for someone who merely unchecked a tool. + const put = await fetch(`${base}/api/bot-trigger-user-auth`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + triggerUserAuth: { enabled: true, tools: ['lark-cli'], fallback: 'bot-identity' }, + }), + }); + expect(put.status).toBe(200); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].triggerUserAuth).toMatchObject({ + enabled: true, + tools: ['lark-cli'], + fallback: 'bot-identity', + gitHost: 'code.example.com', + gitTokenExchangeUrl: 'https://exchange.example.com/token', + }); + expect((await (await fetch(`${base}/api/bot-default-oncall`)).json()).triggerUserAuth) + .toMatchObject({ tools: ['lark-cli'], gitHost: 'code.example.com' }); + } finally { + if (handle) await handle.close(); + handle = null; + if (prevBotsConfig === undefined) delete process.env.BOTS_CONFIG; + else process.env.BOTS_CONFIG = prevBotsConfig; + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('rejects a fallback that asks for another person\'s login, leaving config untouched', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-trigger-user-auth-bad-')); + const configPath = join(dir, 'bots.json'); + const appId = 'test-trigger-user-auth-bad-app'; + const prevBotsConfig = process.env.BOTS_CONFIG; + try { + process.env.BOTS_CONFIG = configPath; + writeFileSync(configPath, JSON.stringify([{ + larkAppId: appId, + larkAppSecret: 'secret', + cliId: 'codex', + }], null, 2)); + loadBotConfigs().forEach((c: any) => registerBot(c)); + setLarkAppId(appId); + handle = await startIpcServer({ port: 0, host: '127.0.0.1' }); + const base = `http://127.0.0.1:${handle.port}`; + + // The dashboard door must validate exactly like `/botconfig set` does. + // Accepting these would persist a policy the registry parser later refuses, + // i.e. a toggle that bricks the daemon on its next restart. + for (const bad of [ + { enabled: true, fallback: 'device' }, // deliberately-forbidden fallback + { enabled: true, tools: ['lark-cli', 'nope'] }, // typo'd tool name + { enabled: 'yes' }, // wrong type + { enabled: true, gitHost: 'https://x/y' }, // not a bare hostname + 'enabled', // not an object at all + ]) { + const res = await fetch(`${base}/api/bot-trigger-user-auth`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ triggerUserAuth: bad }), + }); + expect(res.status, `should reject ${JSON.stringify(bad)}`).toBe(400); + expect(JSON.parse(readFileSync(configPath, 'utf-8'))[0].triggerUserAuth).toBeUndefined(); + } + expect((await (await fetch(`${base}/api/bot-default-oncall`)).json()).triggerUserAuth).toBeNull(); + expect(() => loadBotConfigs()).not.toThrow(); + } finally { + if (handle) await handle.close(); + handle = null; + if (prevBotsConfig === undefined) delete process.env.BOTS_CONFIG; + else process.env.BOTS_CONFIG = prevBotsConfig; + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('PUT /api/bot-reply-style — sparse reply-card appearance', () => { it('persists normalized overrides, hot-updates GET, and clears the default block', async () => { const dir = mkdtempSync(join(tmpdir(), 'dashboard-ipc-reply-style-'));