Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 56 additions & 12 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -6350,25 +6361,58 @@ 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 };
try { body = await readJsonBody<{ triggerUserAuth?: unknown }>(req); }
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<string, unknown> = {};
try {
const prev = getBot(cachedLarkAppId).config.triggerUserAuth as
Record<string, unknown> | 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<string, unknown>) };
}
// 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 — 当前策略 + 已授权人数 + 两条如实的
Expand Down
25 changes: 20 additions & 5 deletions src/dashboard/bot-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 5 additions & 1 deletion src/dashboard/web/bot-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
33 changes: 32 additions & 1 deletion test/dashboard-bot-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
Loading