diff --git a/src/adapters/cli/shared-hints.ts b/src/adapters/cli/shared-hints.ts index 9a2d9fe12f..ad0c5b2958 100644 --- a/src/adapters/cli/shared-hints.ts +++ b/src/adapters/cli/shared-hints.ts @@ -19,6 +19,7 @@ import { config } from '../../config.js'; import { escapeXmlTagLikeTokens, escapeXmlText } from '../../utils/xml.js'; import { resolveConditionalLine } from '../../skills/effective-builtins.js'; import type { ReplyDelivery } from '../../core/reply-delivery.js'; +import { triggerUserAuthApplies, type TriggerUserAuthConfig } from '../../services/trigger-user-auth.js'; /** The gated "no visible output is OK" hint reads `config.noVisibleOutputHint` * by default, but a user customization can force it on/off. Keyed by the i18n @@ -188,16 +189,21 @@ export const BOTMUX_SHELL_HINTS: string[] = [ * directory to debug an auth failure — which a clear instruction does prevent — * and it does NOT survive a determined user or a prompt injection. */ -export function buildCredentialBoundaryBlock(locale?: Locale): string { - const line = (key: string): string => ` ${escapeXmlTagLikeTokens(t(key, undefined, locale))}`; +export function buildCredentialBoundaryBlock(policy: TriggerUserAuthConfig | undefined, locale?: Locale): string { + if (!policy?.enabled || policy.tools.length === 0) return ''; + const lark = triggerUserAuthApplies(policy, 'lark-cli'); + const bytedcli = triggerUserAuthApplies(policy, 'bytedcli'); + const line = (key: string, params?: Record): string => ` ${escapeXmlTagLikeTokens(t(key, params, locale))}`; return [ '', - line('ai.credentials.acting_identity'), + line('ai.credentials.acting_identity', { tools: policy.tools.join(' / ') }), + ...(bytedcli && policy.gitHost ? [line('ai.credentials.git_identity', { host: policy.gitHost })] : []), line('ai.credentials.never_read_others'), line('ai.credentials.never_forward'), - line('ai.credentials.on_auth_failure'), + ...(lark ? [line('ai.credentials.on_auth_failure')] : []), + ...(bytedcli ? [line('ai.credentials.on_bytedcli_auth_failure')] : []), line('ai.credentials.on_auth_link'), - line('ai.credentials.on_missing_scope'), + ...(lark ? [line('ai.credentials.on_missing_scope')] : []), '', ].join('\n'); } @@ -229,7 +235,7 @@ export function buildBotmuxSystemPromptText(opts: { * block. Off → nothing is emitted, so a bot that never enabled the feature * gets no extra prompt text. */ - triggerUserAuth?: boolean; + triggerUserAuth?: TriggerUserAuthConfig; /** Per-bot replyDelivery frozen for this session (core/reply-delivery.ts). * 'transcript': the daemon forwards the final assistant message from the CLI * transcript, so the routing block never mentions `botmux send` at all — @@ -350,15 +356,13 @@ export function buildBotmuxSystemPromptText(opts: { // determined user or a prompt injection. It exists because the likeliest way // these files get read is an agent troubleshooting an auth failure and // grepping the data directory, which a clear instruction does prevent. - const credentialBoundaryBlock = triggerUserAuth - ? ['', buildCredentialBoundaryBlock(locale)] - : []; + const credentialBoundaryBlock = buildCredentialBoundaryBlock(triggerUserAuth, locale); return [ '', ...routingInner, '', ...identityBlock, - ...credentialBoundaryBlock, + ...(credentialBoundaryBlock ? ['', credentialBoundaryBlock] : []), ...(builtinSkillBlock ? ['', builtinSkillBlock] : []), ].join('\n'); } diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index 09344dcc99..52c3a314e6 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -1,4 +1,5 @@ import type { CodexAppTurnInput, TrustedCaller } from '../../types.js'; +import type { TriggerUserAuthConfig } from '../../services/trigger-user-auth.js'; export interface PtyHandle { /** `false` means the backend rejected the write before it could confirm @@ -187,7 +188,7 @@ export interface CliAdapter { * system prompt: the session acts with ONE person's credentials while the * on-disk store holds everyone else's, and nothing in the OS currently * stops an agent from reading those files. Off → no extra prompt text. */ - triggerUserAuth?: boolean; + triggerUserAuth?: TriggerUserAuthConfig; /** Env the CLI must forward to the SHELL COMMANDS it runs, not merely hold * itself. Codex does not pass its own environment to shell subprocesses, * so the trigger-user wrapper vars (BOTMUX_IDENTITY_BIN / ZDOTDIR / diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index 7ebe855702..50100b4fb7 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1161,6 +1161,19 @@ async function applyAllowedUsersSet( await reply(t('cmd.config.allow_ok', { count: r.resolved.length, total: r.raw.length }, loc)); } +function bytedcliLoginSuccessReply(botConfig: BotConfig, locale: Locale): string { + const policy = botConfig.triggerUserAuth; + const enabled = triggerUserAuthApplies(policy, 'bytedcli'); + const lines = [ + t('cmd.login.bytedcli_ok', undefined, locale), + t(enabled ? 'cmd.login.bytedcli_enabled' : 'cmd.login.bytedcli_configure', undefined, locale), + ]; + if (enabled && policy?.gitHost) { + lines.push(t('cmd.login.bytedcli_git_enabled', { host: policy.gitHost }, locale)); + } + return lines.join('\n'); +} + /** * `/botconfig` —— owner/allowedUsers 远程改本 bot 运营字段。sessionless:只认 larkAppId, * 不需活跃会话。严格 admin 闸(拒绝开放模式 bot),写盘 + 内存热更新,无需重启。 @@ -3670,7 +3683,7 @@ export async function handleCommand( if (bytedPending) { const { state, detail } = await completeBytedcliLogin(loginOpenId, bytedPending); doneLines.push(state === 'authorized' - ? t('cmd.login.bytedcli_ok', undefined, loc) + ? bytedcliLoginSuccessReply(botCfg2, loc) : state === 'pending' ? t('cmd.login.bytedcli_pending', undefined, loc) : state === 'unavailable' @@ -3785,7 +3798,7 @@ export async function handleCommand( } const { state, detail } = await completeBytedcliLogin(loginOpenId, challenge); await sessionReply(rootId, state === 'authorized' - ? t('cmd.login.bytedcli_ok', undefined, loc) + ? bytedcliLoginSuccessReply(botCfg2, loc) : state === 'pending' ? t('cmd.login.bytedcli_pending', undefined, loc) : state === 'unavailable' diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index cf8e5ddd44..5cde614b34 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -1217,13 +1217,10 @@ function sessionIsNoTransport(larkAppId?: string, chatId?: string): boolean { return !larkTransportEnabled({ chatId, apiOnly }); } -/** Whether this bot enabled trigger-user CLI auth, for the inline-prompt path. - * Absent bot / unreadable config → false: an uncertain answer must not add a - * block claiming a boundary that is not configured. */ -function triggerUserAuthEnabledForPrompt(larkAppId?: string): boolean { - if (!larkAppId) return false; - try { return getBot(larkAppId).config.triggerUserAuth?.enabled === true; } - catch { return false; } +function credentialBoundaryForPrompt(larkAppId: string | undefined, locale?: Locale): string { + if (!larkAppId) return ''; + try { return buildCredentialBoundaryBlock(getBot(larkAppId).config.triggerUserAuth, locale); } + catch { return ''; } } /** 本会话的最终回复投递方式(per-bot replyDelivery × 该 CLI 的转写能力,见 @@ -1417,9 +1414,8 @@ function buildNewTopicBlocks( // hook-capable this is the difference between the agent seeing the boundary // and not. An envelope the CLI cannot read drops the block silently — // nothing errors when a constraint is merely absent. - if (triggerUserAuthEnabledForPrompt(opts?.larkAppId)) { - blocks.push({ key: 'credentials', text: buildCredentialBoundaryBlock(locale) }); - } + const credentialBoundary = credentialBoundaryForPrompt(opts?.larkAppId, locale); + if (credentialBoundary) blocks.push({ key: 'credentials', text: credentialBoundary }); blocks.push({ key: 'sessionId', text: `${xmlEscape(sessionId)}` }); } if (roleBlock) blocks.push({ key: 'role', text: roleBlock }); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6eced40d52..a67d60bf05 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -492,7 +492,10 @@ export const messages: Record = { 'cmd.login.bytedcli_step2': '2. When you are done, send /login bytedcli done', 'cmd.login.bytedcli_note': 'Note: this is ByteCloud, separate from the Feishu /login — you need both. The login lasts about 3 weeks.', 'cmd.login.bytedcli_pending': '⏳ Not authorized yet. Open the link above first, then send /login bytedcli done.', - 'cmd.login.bytedcli_ok': '✅ ByteCloud authorized. bytedcli and git actions you trigger here now run as you.', + 'cmd.login.bytedcli_ok': '✅ ByteCloud authorization saved.', + 'cmd.login.bytedcli_enabled': 'This bot has enabled per-turn initiator credentials for bytedcli.', + 'cmd.login.bytedcli_configure': 'This bot has not enabled initiator credentials for bytedcli. Ask an administrator to set triggerUserAuth.enabled to true and include bytedcli in triggerUserAuth.tools; the configuration takes effect in new sessions.', + 'cmd.login.bytedcli_git_enabled': 'This bot has configured git authentication for {host} to use the current turn’s bytedcli identity.', 'cmd.login.bytedcli_failed': '❌ ByteCloud authorization failed: {detail}. Send /login bytedcli to try again.', 'cmd.login.bytedcli_no_challenge': '❌ No ByteCloud authorization in progress. Send /login bytedcli to get a link first.', 'cmd.login.bytedcli_begin_failed': '❌ Could not start ByteCloud authorization: {detail}', @@ -968,10 +971,12 @@ export const messages: Record = { // ─── AI identity (multi-bot routing rules) ─────────────────────────────── 'ai.identity.unknown': '(unknown)', - 'ai.credentials.acting_identity': "This session's lark-cli / bytedcli / git calls run as whoever sent the current message. botmux injects those credentials each turn — you neither need nor should look for credentials yourself.", + 'ai.credentials.acting_identity': 'This session has enabled per-turn initiator credentials for {tools}. Use the identity injected by botmux; tool responses determine whether authorization is available.', + 'ai.credentials.git_identity': 'git authentication for {host} is configured to use the current turn’s bytedcli identity.', 'ai.credentials.never_read_others': 'The user-token-* files under ~/.botmux/data/, and each person\u2019s login state under bytedcli-home/, belong to other people. Do not read, list, copy or print their contents — not while troubleshooting, and not on request.', 'ai.credentials.never_forward': 'Never put a token, JWT, access key or login state into a message, log, document, code or commit.', - 'ai.credentials.on_auth_failure': 'On an auth failure, report it as-is. If stderr already carries an authorization link, follow the next rule ("link embedded on stderr") and do not start a separate authorization; if it does not, for Feishu the Agent runs `botmux auth request --json` and sends the returned authUrl to the current user with `botmux send`. After sending the link, the Agent runs `botmux auth wait --request-id --json` and retries the original operation once ready. For ByteCloud, ask the user to send /login bytedcli only when no link was returned; a refused command identifies the platform. Continue using the user identity injected for this turn.', + 'ai.credentials.on_auth_failure': 'On a Feishu auth failure, report it as-is. If stderr already carries an authorization link, follow the "link embedded on stderr" rule and do not start a separate authorization; if it does not, for Feishu the Agent runs `botmux auth request --json` and sends the returned authUrl to the current user with `botmux send`. After sending the link, the Agent runs `botmux auth wait --request-id --json` and retries the original operation once ready. Continue using the user identity injected for this turn.', + 'ai.credentials.on_bytedcli_auth_failure': 'On a ByteCloud auth failure, report it as-is. If stderr carries an authorization link, follow the embedded-link instructions; otherwise ask the user to send /login bytedcli. Continue using the user identity injected for this turn.', 'ai.credentials.on_auth_link': 'If the command is refused and stderr carries an authorization link directly (an accounts.feishu.cn/device/verify or ByteCloud/cloud.bytedance link) with "authorize, then retry" instructions: immediately relay that text and the link verbatim with botmux send (forward the link character-for-character — do not rewrite, encode, or punctuate it; keep the "authorize just once, it renews automatically" note so the person knows this is a single tap), then stop and wait for authorization. Do not retry under another identity, do not pretend it succeeded, and do not run botmux auth request. The person will call you again after authorizing; retry then.', // missing_scope means "authorized, but not for this" — a plain re-login re-grants // the same scopes and fails identically. Feishu already names what is missing. diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index db071c4235..fcbe1108fe 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -490,7 +490,10 @@ export const messages: Record = { 'cmd.login.bytedcli_step2': '2. 授权完成后,回来发一条 /login bytedcli done', 'cmd.login.bytedcli_note': '说明:这是 ByteCloud 的授权,和飞书 /login 是两回事,两边都要授权一次。登录态约 3 周有效,到期前不用重复扫码。', 'cmd.login.bytedcli_pending': '⏳ 还没检测到授权完成。请先点上面那条链接授权,再发 /login bytedcli done。', - 'cmd.login.bytedcli_ok': '✅ ByteCloud 授权成功,之后本会话里你触发的 bytedcli / git 操作都会用你自己的权限。', + 'cmd.login.bytedcli_ok': '✅ ByteCloud 授权已保存。', + 'cmd.login.bytedcli_enabled': '当前 Bot 已为 bytedcli 启用按当轮发起人身份注入授权。', + 'cmd.login.bytedcli_configure': '当前 Bot 的 bytedcli 身份注入尚未启用。请管理员将 triggerUserAuth.enabled 设为 true,并在 triggerUserAuth.tools 中加入 bytedcli;配置在新会话生效。', + 'cmd.login.bytedcli_git_enabled': '当前 Bot 已为 {host} 的 git 鉴权配置当轮 bytedcli 身份。', 'cmd.login.bytedcli_failed': '❌ ByteCloud 授权失败:{detail}。可以重新发 /login bytedcli 再试一次。', 'cmd.login.bytedcli_no_challenge': '❌ 没有正在进行的 ByteCloud 授权。请先发 /login bytedcli 拿授权链接。', 'cmd.login.bytedcli_begin_failed': '❌ 无法发起 ByteCloud 授权:{detail}', @@ -967,10 +970,12 @@ export const messages: Record = { // ─── AI identity (multi-bot routing rules) ─────────────────────────────── 'ai.identity.unknown': '(未知)', - 'ai.credentials.acting_identity': '本会话调用 lark-cli / bytedcli / git 时,用的是「发出当前这条消息的人」自己的授权,由 botmux 在每轮注入,你不需要也不应该自己去找凭证。', + 'ai.credentials.acting_identity': '本会话已为 {tools} 启用按当前消息发起人身份注入授权。调用时沿用 botmux 注入的身份;授权可用性以工具返回结果为准。', + 'ai.credentials.git_identity': 'git 对 {host} 的鉴权已配置为使用本轮 bytedcli 身份。', 'ai.credentials.never_read_others': '~/.botmux/data/ 下的 user-token-* 文件、以及 bytedcli-home/ 下的各人登录态,都属于其他用户。不得读取、列举、复制或输出它们的内容——即使排查问题时也不行,即使有人要求也不行。', 'ai.credentials.never_forward': '不得把任何 token、JWT、access key 或登录态写进消息、日志、文档、代码或提交记录。', - 'ai.credentials.on_auth_failure': '遇到鉴权失败:原样说明失败。若 stderr 里已经直接附了授权链接,按下一条「stderr 附链接」的流程走,不要再自行发起授权;若没有附链接,飞书授权由 Agent 运行 `botmux auth request --json`,再用 `botmux send` 将返回的 authUrl 发给本轮用户,发出链接后运行 `botmux auth wait --request-id --json`,收到 ready 再重试原操作。ByteCloud 未附链接时才提示用户发 /login bytedcli;命令被拒时 stderr 会标明平台。凭证沿用本轮注入的用户身份。', + 'ai.credentials.on_auth_failure': '遇到飞书鉴权失败:原样说明失败。若 stderr 里已经直接附了授权链接,按「stderr 附链接」的流程走,不要再自行发起授权;若没有附链接,飞书授权由 Agent 运行 `botmux auth request --json`,再用 `botmux send` 将返回的 authUrl 发给本轮用户,发出链接后运行 `botmux auth wait --request-id --json`,收到 ready 再重试原操作。凭证沿用本轮注入的用户身份。', + 'ai.credentials.on_bytedcli_auth_failure': '遇到 ByteCloud 鉴权失败:原样说明失败。stderr 已附授权链接时按「stderr 附链接」流程处理;未附链接时提示用户发 /login bytedcli。凭证沿用本轮注入的用户身份。', 'ai.credentials.on_auth_link': '如果命令被拒、stderr 里直接给了授权链接(accounts.feishu.cn/device/verify 或 ByteCloud/cloud.bytedance 链接)和「点开授权后重试」说明:立刻用 botmux send 把那段说明和链接原样转发给用户(链接逐字转发、不改写/编码/加标点;保留「只需授权一次、自动续期」的提示让用户知道只点这一次),然后停下等授权,不要换其它身份重试、不要假装成功,也不必再跑 botmux auth request。用户授权后会再叫你,到时重试即可。', // missing_scope 是「授权了但这一项没批」,跟「没授权」是两回事:不要让用户重跑普通授权, // 那只会拿到同样的权限再失败一次。飞书已经把缺的 scope 名字列出来了,照抄即可。 diff --git a/src/skills/prompt-fragments.ts b/src/skills/prompt-fragments.ts index 8680739b16..5ae42336c2 100644 --- a/src/skills/prompt-fragments.ts +++ b/src/skills/prompt-fragments.ts @@ -236,9 +236,15 @@ export const PROMPT_FRAGMENTS: PromptFragmentSpec[] = [ // — trigger-user-auth sessions only { - key: 'ai.credentials.acting_identity', block: 'credentials', stage: 'new', kind: 'editable', + key: 'ai.credentials.acting_identity', block: 'credentials', stage: 'new', + kind: 'placeholder', placeholders: ['tools'], label: '凭证:当前授权身份说明', gateLabel: '仅该 bot 开启 trigger-user CLI 鉴权时注入', }, + { + key: 'ai.credentials.git_identity', block: 'credentials', stage: 'new', + kind: 'placeholder', placeholders: ['host'], + label: '凭证:git 鉴权身份说明', gateLabel: '仅当 bytedcli 在 tools 内且配置了 gitHost 时注入', + }, { key: 'ai.credentials.never_read_others', block: 'credentials', stage: 'new', kind: 'editable', label: '凭证:不得读取他人登录态', gateLabel: '仅 trigger-user 鉴权会话', @@ -249,7 +255,11 @@ export const PROMPT_FRAGMENTS: PromptFragmentSpec[] = [ }, { key: 'ai.credentials.on_auth_failure', block: 'credentials', stage: 'new', kind: 'editable', - label: '凭证:鉴权失败处理流程', gateLabel: '仅 trigger-user 鉴权会话', + label: '凭证:飞书鉴权失败处理流程', gateLabel: '仅当 lark-cli 在 triggerUserAuth.tools 内时注入', + }, + { + key: 'ai.credentials.on_bytedcli_auth_failure', block: 'credentials', stage: 'new', kind: 'editable', + label: '凭证:ByteCloud 鉴权失败处理流程', gateLabel: '仅当 bytedcli 在 triggerUserAuth.tools 内时注入', }, { key: 'ai.credentials.on_auth_link', block: 'credentials', stage: 'new', kind: 'editable', @@ -257,7 +267,7 @@ export const PROMPT_FRAGMENTS: PromptFragmentSpec[] = [ }, { key: 'ai.credentials.on_missing_scope', block: 'credentials', stage: 'new', kind: 'editable', - label: '凭证:missing_scope 补授权流程', gateLabel: '仅 trigger-user 鉴权会话', + label: '凭证:missing_scope 补授权流程', gateLabel: '仅当 lark-cli 在 triggerUserAuth.tools 内时注入', }, // ───────── cross-listed: 会话开始 + 每条新消息 (new + followup) ────────── diff --git a/src/worker.ts b/src/worker.ts index 9b550d2b97..748274dfbb 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -16705,7 +16705,7 @@ async function spawnCli( // block. It is a behavioral rule, not a control: nothing in the OS stops the // agent from reading another person's token file today, and the likeliest // way that happens is an agent grepping the data dir to debug an auth error. - triggerUserAuth: cfg.triggerUserAuth?.enabled === true, + triggerUserAuth: cfg.triggerUserAuth, // Codex and TraeX explicitly set these in tool shells instead of depending // on the CLI's default inheritance policy; other adapters inherit normally. ...(Object.keys(identityShellEnv).length ? { shellSubprocessEnv: identityShellEnv } : {}), diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index d2ac0fe7c2..995f53ee30 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -6104,14 +6104,37 @@ describe('handleCommand', () => { expect(text).toContain('两边都要授权'); }); - it('completes the pending challenge on done', async () => { + it.each([ + { name: 'absent', policy: undefined, enabled: false, git: false }, + { name: 'disabled', policy: { enabled: false }, enabled: false, git: false }, + { name: 'lark only', policy: { enabled: true, tools: ['lark-cli'], gitHost: 'code.example.com' }, enabled: false, git: false }, + { name: 'bytedcli', policy: { enabled: true, tools: ['bytedcli'] }, enabled: true, git: false }, + { name: 'bytedcli with git', policy: { enabled: true, tools: ['bytedcli'], gitHost: 'code.example.com' }, enabled: true, git: true }, + ])('reports saved authorization and the $name policy on done', async ({ policy, enabled, git }) => { vi.mocked(pendingBytedcliChallenge).mockReturnValue('tok-1'); vi.mocked(completeBytedcliLogin).mockResolvedValue({ state: 'authorized' }); - const deps = makeDeps(makeDaemonSession()); - await handleCommand('/login', ROOT_ID, makeLarkMessage('/login bytedcli done'), deps, LARK_APP_ID); + const bot = defaultGetBot(); + vi.mocked(getBot).mockReturnValue({ + ...bot, + config: { ...bot.config, triggerUserAuth: parseTriggerUserAuthConfig(policy) ?? undefined }, + } as any); + for (const command of ['/login bytedcli done', '/login done']) { + const deps = makeDeps(makeDaemonSession()); + await handleCommand('/login', ROOT_ID, makeLarkMessage(command), deps, LARK_APP_ID); - expect(completeBytedcliLogin).toHaveBeenCalledWith('ou_sender', 'tok-1'); - expect((deps.sessionReply as ReturnType).mock.calls[0][1]).toContain('授权成功'); + expect(completeBytedcliLogin).toHaveBeenCalledWith('ou_sender', 'tok-1'); + const text = (deps.sessionReply as ReturnType).mock.calls[0][1] as string; + expect(text).toContain('授权已保存'); + if (enabled) { + expect(text).toContain('已为 bytedcli 启用'); + expect(text).not.toContain('triggerUserAuth.enabled'); + } else { + expect(text).toContain('triggerUserAuth.enabled'); + expect(text).toContain('triggerUserAuth.tools'); + expect(text).not.toContain('已为 bytedcli 启用'); + } + expect(text.includes('code.example.com')).toBe(git); + } }); // Not an error: they just have not clicked yet. Reporting a failure would diff --git a/test/credential-boundary-prompt.test.ts b/test/credential-boundary-prompt.test.ts index 98eb21e3e5..ccfb9ccf00 100644 --- a/test/credential-boundary-prompt.test.ts +++ b/test/credential-boundary-prompt.test.ts @@ -20,6 +20,10 @@ import { buildBotmuxSystemPromptText, } from '../src/adapters/cli/shared-hints.js'; import { buildNewTopicPrompt } from '../src/core/session-manager.js'; +import { createCliAdapterSync } from '../src/adapters/cli/registry.js'; +import { parseTriggerUserAuthConfig } from '../src/services/trigger-user-auth.js'; + +const policy = parseTriggerUserAuthConfig({ enabled: true })!; /** Stub only getBot: the credential block is gated on this bot's config, and * everything else session-manager imports from the registry must stay real. */ @@ -33,8 +37,39 @@ vi.mock('../src/bot-registry.js', async (importOriginal) => ({ })); describe('buildCredentialBoundaryBlock', () => { + it.each(['zh', 'en'] as const)('names the governed tools and configured git host in %s', (locale) => { + const lark = buildCredentialBoundaryBlock({ ...policy, tools: ['lark-cli'], gitHost: 'code.example.com' }, locale); + expect(lark).toContain(locale === 'zh' ? '已为 lark-cli 启用' : 'credentials for lark-cli.'); + expect(lark).not.toContain('code.example.com'); + expect(lark).not.toContain('/login bytedcli'); + + const byted = buildCredentialBoundaryBlock({ ...policy, tools: ['bytedcli'] }, locale); + expect(byted).toContain(locale === 'zh' ? '已为 bytedcli 启用' : 'credentials for bytedcli.'); + expect(byted).not.toContain('git'); + expect(byted).not.toContain('missing_scope'); + expect(byted).toContain(locale === 'zh' ? 'stderr 已附授权链接' : 'If stderr carries an authorization link'); + expect(byted).toContain(locale === 'zh' ? '只需授权一次、自动续期' : 'authorize just once, it renews automatically'); + // The Feishu-specific failure flow must follow the same gate as lark-cli. + // Discriminate on that line itself: on_auth_link is emitted for every tool + // and also mentions `botmux auth request`, so the shared phrase cannot tell + // the two branches apart. + const feishuFailureLine = locale === 'zh' ? '遇到飞书鉴权失败' : 'On a Feishu auth failure'; + expect(byted).not.toContain(feishuFailureLine); + expect(lark).toContain(feishuFailureLine); + + const git = buildCredentialBoundaryBlock({ ...policy, gitHost: 'code.example.com' }, locale); + expect(git).toContain('git'); + expect(git).toContain('code.example.com'); + }); + + it('omits the block for disabled or empty policies', () => { + expect(buildCredentialBoundaryBlock(undefined, 'en')).toBe(''); + expect(buildCredentialBoundaryBlock({ ...policy, enabled: false }, 'en')).toBe(''); + expect(buildCredentialBoundaryBlock({ ...policy, tools: [] }, 'en')).toBe(''); + }); + it('names the exact files an agent must not read', () => { - const zh = buildCredentialBoundaryBlock('zh'); + const zh = buildCredentialBoundaryBlock(policy, 'zh'); // A vague "don't touch credentials" is unactionable; the path is what makes // the rule checkable by the agent itself. expect(zh).toContain('user-token-'); @@ -42,36 +77,48 @@ describe('buildCredentialBoundaryBlock', () => { }); it('tells the agent what to do instead of hunting for credentials', () => { - const zh = buildCredentialBoundaryBlock('zh'); + const zh = buildCredentialBoundaryBlock(policy, 'zh'); expect(zh).toContain('/login'); }); it('covers forwarding, not just reading', () => { // Reading is one leak path; pasting a token the agent legitimately holds // into a message or a commit is another, and far easier to do by accident. - const en = buildCredentialBoundaryBlock('en'); + const en = buildCredentialBoundaryBlock(policy, 'en'); expect(en.toLowerCase()).toContain('commit'); expect(en.toLowerCase()).toContain('log'); }); it('is wrapped in one tagged block so it reads as policy, not prose', () => { - const zh = buildCredentialBoundaryBlock('zh'); + const zh = buildCredentialBoundaryBlock(policy, 'zh'); expect(zh.startsWith('')).toBe(true); expect(zh.trimEnd().endsWith('')).toBe(true); }); it('renders in both locales', () => { for (const locale of ['zh', 'en'] as const) { - expect(buildCredentialBoundaryBlock(locale)).toContain('user-token-'); + expect(buildCredentialBoundaryBlock(policy, locale)).toContain('user-token-'); } }); }); describe('buildBotmuxSystemPromptText — claude-family path', () => { + it.each(['claude-code', 'genius', 'grok'] as const)('%s forwards the selected tools into its system prompt', (cli) => { + const args = createCliAdapterSync(cli).buildArgs({ + sessionId: 'sess-cred', resume: false, locale: 'en', + triggerUserAuth: { ...policy, tools: ['bytedcli'], gitHost: 'code.example.com' }, + }); + const text = args.find(arg => arg.includes(''))!; + expect(text).toContain('credentials for bytedcli.'); + expect(text).toContain('code.example.com'); + expect(text).not.toContain('missing_scope'); + }); + it('adds the block when trigger-user auth is on', () => { - const text = buildBotmuxSystemPromptText({ locale: 'zh', triggerUserAuth: true }); + const text = buildBotmuxSystemPromptText({ locale: 'zh', triggerUserAuth: { ...policy, tools: ['lark-cli'] } }); expect(text).toContain(''); expect(text).toContain('user-token-'); + expect(text).not.toContain('/login bytedcli'); }); // A bot that never enabled the feature must not pay for prompt text about a @@ -86,14 +133,14 @@ describe('buildBotmuxSystemPromptText — claude-family path', () => { // No Feishu channel does not mean no credentials: the CLI still runs as a // person and the token store still holds everyone else's files. const text = buildBotmuxSystemPromptText({ - locale: 'zh', triggerUserAuth: true, noTransport: true, + locale: 'zh', triggerUserAuth: policy, noTransport: true, }); expect(text).toContain(''); }); it('emits the block exactly once', () => { const text = buildBotmuxSystemPromptText({ - locale: 'zh', botName: 'b', botOpenId: 'ou_x', triggerUserAuth: true, + locale: 'zh', botName: 'b', botOpenId: 'ou_x', triggerUserAuth: policy, }); expect(text.match(//g)).toHaveLength(1); }); @@ -139,7 +186,9 @@ describe('buildNewTopicPrompt — inline prompt path', () => { it('includes the credential boundary when the policy is on', () => { withTriggerUserAuth(true, () => { - expect(opening(APP)).toContain(''); + const text = opening(APP); + expect(text).toContain(''); + expect(text).not.toContain('/login bytedcli'); }); }); diff --git a/test/prompt-fragments.test.ts b/test/prompt-fragments.test.ts index 9d9aaef9e9..579075ae45 100644 --- a/test/prompt-fragments.test.ts +++ b/test/prompt-fragments.test.ts @@ -76,6 +76,24 @@ describe('prompt fragment catalog', () => { expect(validateFragmentOverride(line.key, '群里有 {count} 个:{names}')).toBeUndefined(); }); + it('protects {tools}/{host} on the credential-identity fragments', () => { + // The governed-tool list and git host are runtime values: an override that + // drops either token would silently render a credential block that names no + // tool (or no host), recreating the over-broad promise this catalog tracks. + expect( + validateFragmentOverride('ai.credentials.acting_identity', '本会话已启用按发起人身份注入。'), + ).toMatch(/tools/); + expect( + validateFragmentOverride('ai.credentials.acting_identity', '已为 {tools} 启用。'), + ).toBeUndefined(); + expect( + validateFragmentOverride('ai.credentials.git_identity', 'git 鉴权使用本轮身份。'), + ).toMatch(/host/); + expect( + validateFragmentOverride('ai.credentials.git_identity', 'git 对 {host} 使用本轮身份。'), + ).toBeUndefined(); + }); + it('fragmentStages lists primary + extra stages without duplicates', () => { const attach = PROMPT_FRAGMENTS.find((f) => f.key === 'ai.attach.hint')!; expect(fragmentStages(attach).sort()).toEqual(['followup', 'new']);