Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
17ac0ab
feat(user-token): token 按 (app, 授权人) 存储,移除全局 env 旁路
xiaoxueSunn Sep 4, 2026
d59c0aa
feat(login): /login 归属到发起人,owner 个人功能走专用解析
xiaoxueSunn Sep 4, 2026
76bc0f3
feat(trigger-user-auth): 加 bot 级开关,默认关闭
xiaoxueSunn Sep 4, 2026
3e2a091
feat(trigger-user-auth): wrapper + 每轮发布当轮身份
xiaoxueSunn Sep 4, 2026
4a449bd
feat(trigger-user-auth): 内部 token 消费点按人取
xiaoxueSunn Sep 4, 2026
84ae916
feat(trigger-user-auth): 身份文件按会话授权,并如实报告 token 存储的保护程度
xiaoxueSunn Sep 4, 2026
c4486d4
feat(trigger-user-auth): git 推送归因到当轮操作人
xiaoxueSunn Sep 4, 2026
c6ef4a1
feat(trigger-user-auth): Dashboard 开关、自带凭证 MCP 警告、/status 身份行
xiaoxueSunn Sep 4, 2026
c53a538
fix(trigger-user-auth): 关闭时不引入 await 边界,并修好受影响的测试 mock
xiaoxueSunn Sep 4, 2026
b4b3b65
feat(trigger-user-auth): 吸收 Orchestra 的三条 git 归因加固
xiaoxueSunn Sep 5, 2026
eb1014e
feat(trigger-user-auth): 加 agent 侧凭证边界,作为本期的明示约束
xiaoxueSunn Sep 5, 2026
8da2cd2
fix(login): match auth prompt to the real callback mode
xiaoxueSunn Sep 5, 2026
b537353
fix(trigger-user-auth): refuse instead of falling back to the machine…
xiaoxueSunn Sep 5, 2026
04d3f01
fix(trigger-user-auth): bind published credentials to the turn they w…
xiaoxueSunn Sep 5, 2026
2cdbcde
feat(trigger-user-auth): per-person bytedcli authorization
xiaoxueSunn Sep 5, 2026
fc32953
fix(trigger-user-auth): name the right provider, and stop printing op…
xiaoxueSunn Sep 5, 2026
3aa7717
style(trigger-user-auth): space around the provider name in refusals
xiaoxueSunn Sep 6, 2026
9228cb0
fix(trigger-user-auth): survive path_helper, and say so when unprotected
xiaoxueSunn Sep 6, 2026
14663fe
fix(trigger-user-auth): forward identity vars to codex shell commands…
xiaoxueSunn Sep 6, 2026
4c71632
test(trigger-user-auth): skip login-shell cases when the shell is absent
xiaoxueSunn Sep 6, 2026
bf0bff4
feat(login): request document read scopes by default, and support /lo…
xiaoxueSunn Sep 6, 2026
7f80f01
fix(user-token): let every reader find a per-person token (review F1)
xiaoxueSunn Sep 6, 2026
64be0cc
test(trigger-user-auth): resolve the shell on PATH instead of spawnin…
xiaoxueSunn Sep 6, 2026
0b44d97
test(trigger-user-auth): 覆盖 inline 路径的凭证边界块
deepcoldy Sep 8, 2026
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
4 changes: 2 additions & 2 deletions src/adapters/cli/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,7 @@ export function createClaudeFamilyAdapter(variant: ClaudeFamilyVariant, rawBin:
return discoverClaudeFamilySessions(variant.dataDir, limit, exclude);
},

buildArgs({ sessionId, resume, resumeSessionId, forkSession, botName, botOpenId, locale, model, reasoningEffort, disableCliBypass, skillPluginDir, noTransport }) {
buildArgs({ sessionId, resume, resumeSessionId, forkSession, botName, botOpenId, locale, model, reasoningEffort, disableCliBypass, skillPluginDir, noTransport, triggerUserAuth }) {
const args: string[] = [];
if (resume) {
args.push('--resume', resumeSessionId ?? sessionId);
Expand Down Expand Up @@ -916,7 +916,7 @@ export function createClaudeFamilyAdapter(variant: ClaudeFamilyVariant, rawBin:
// `claude` never surfaces/mis-fires `botmux send` etc.
args.push('--plugin-dir', CLAUDE_PLUGIN_DIR);
if (skillPluginDir) args.push('--plugin-dir', skillPluginDir);
args.push('--append-system-prompt', buildBotmuxSystemPromptText({ locale, botName, botOpenId, noTransport }));
args.push('--append-system-prompt', buildBotmuxSystemPromptText({ locale, botName, botOpenId, noTransport, triggerUserAuth }));
return args;
},

Expand Down
13 changes: 12 additions & 1 deletion src/adapters/cli/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
authPaths: ['~/.codex'],
get resolvedBin(): string { return (cachedBin ??= resolveCommand(rawBin)); },

buildArgs({ sessionId, resume, resumeSessionId, forkSession, workingDir, model, reasoningEffort, disableCliBypass, bypassHookTrust, readIsolation, remoteWsUrl, remoteThreadId }) {
buildArgs({ sessionId, resume, resumeSessionId, forkSession, workingDir, model, reasoningEffort, disableCliBypass, bypassHookTrust, readIsolation, remoteWsUrl, remoteThreadId, shellSubprocessEnv }) {
// Hybrid RPC input mode: attach this TUI to the botmux-owned app-server
// thread. User input is delivered out-of-band via JSON-RPC (turn/start,
// see codex-rpc-engine + worker), so the pane is a pure viewer — no paste
Expand Down Expand Up @@ -236,6 +236,17 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter {
'-c', 'shell_environment_policy.ignore_default_excludes=true',
);
}
// Trigger-user CLI identity: the wrapper only intercepts `lark-cli` if the
// shell codex spawns can see these. Same mechanism as the block above and
// the same failure if omitted — measured: without them `lark-cli whoami`
// inside a session reports the machine owner, not the acting identity.
//
// Enumerated with `.set` rather than `inherit="all"`: this needs exactly
// these keys, while inherit would hand every shell command the whole
// worker environment, which is a much wider surface for a narrower need.
for (const [key, value] of Object.entries(shellSubprocessEnv ?? {})) {
baseArgs.push('-c', `shell_environment_policy.set.${key}=${JSON.stringify(value)}`);
}
if (model && model.trim()) {
// Codex 接受 `--model <id>` / `-m <id>`,写全名最稳,错的会在 codex 自己启动时报。
baseArgs.push('--model', model.trim());
Expand Down
25 changes: 25 additions & 0 deletions src/adapters/cli/fs-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,31 @@ export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy {
// daemon 在每次提交 user turn 前把 reminder/whiteboard 写到这里;hook 子进程
// (在沙盒内)按内容指纹读回。worker 预创建目录以通过 existence-filter。
if (ctx.sessionId) push([`${ctx.sessionDataDir}/prompt-ctx/${ctx.sessionId}`], 'readOnly', 'internal');
// Trigger-user CLI identity (this session ONLY) — the wrapper on PATH sources
// `cli-identity/<sessionId>.<tool>.env` on every invocation, so a sandboxed
// session needs to READ exactly those files plus the wrapper scripts.
//
// Granted per file/dir, never the `cli-identity/` parent: that directory holds
// every concurrent session's files, each with a live user token belonging to a
// different person. A parent grant would let one session read another's — the
// exact cross-person leak this feature exists to prevent — and it would fail
// OPEN for sessions created after spawn.
//
// Read-only by construction: the daemon writes these, the CLI must never be
// able to. Writable would let an agent publish its own identity and act as
// anyone whose token it could name.
if (ctx.sessionId && larkTransport) {
push([
`${ctx.sessionDataDir}/cli-identity/${ctx.sessionId}.lark-cli.env`,
`${ctx.sessionDataDir}/cli-identity/${ctx.sessionId}.bytedcli.env`,
`${ctx.sessionDataDir}/cli-identity/${ctx.sessionId}.bin`,
// The turn the CLI is currently executing. The wrapper compares it against
// the turn stamped on the identity and refuses on a mismatch, so without
// this grant a sandboxed session reads nothing and every governed command
// fails — the deny is safe, but it is not the behavior we want.
`${ctx.sessionDataDir}/cli-identity/${ctx.sessionId}.turn`,
], 'readOnly', 'internal');
}
// Own per-bot lark-cli config (agent-facing lark-cli identity). Withheld from
// a no-transport turn — it IS this bot's Feishu credential surface.
if (larkTransport) push([`${ctx.homeDir}/.lark-cli-bots/${ctx.currentAppId}`], 'readWrite', 'internal');
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/cli/genius.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export function createGeniusAdapter(pathOverride?: string): CliAdapter {
}
},

buildArgs({ sessionId, resume, resumeSessionId, botName, botOpenId, larkAppId, locale, model, disableCliBypass, workingDir, skillPluginDir, noTransport }) {
buildArgs({ sessionId, resume, resumeSessionId, botName, botOpenId, larkAppId, locale, model, disableCliBypass, workingDir, skillPluginDir, noTransport, triggerUserAuth }) {
const args: string[] = [];
if (workingDir) args.push('--add-dir', workingDir);
if (resume) {
Expand Down Expand Up @@ -136,6 +136,7 @@ export function createGeniusAdapter(pathOverride?: string): CliAdapter {
botName,
botOpenId,
noTransport,
triggerUserAuth,
builtinSkillBlock: builtinSkillBlockForInjectsSessionContext(larkAppId, locale, {
asksViaHook: false,
whiteboardEnabled: whiteboardEnabled(),
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/cli/grok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export function createGrokAdapter(pathOverride?: string): CliAdapter {
locale,
larkAppId,
noTransport,
triggerUserAuth,
}) {
const args: string[] = [];
if (!disableCliBypass) {
Expand Down Expand Up @@ -215,6 +216,7 @@ export function createGrokAdapter(pathOverride?: string): CliAdapter {
botName,
botOpenId,
noTransport,
triggerUserAuth,
builtinSkillBlock: builtinSkillBlockForInjectsSessionContext(larkAppId, locale, {
asksViaHook: false,
whiteboardEnabled: whiteboardEnabled(),
Expand Down
47 changes: 46 additions & 1 deletion src/adapters/cli/shared-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,33 @@ export const BOTMUX_SHELL_HINTS: string[] = [
* Shell heredoc operators remain copyable, and bot fields are still rendered
* from trusted bot config without changing their historical handling.
*/
/**
* The credential-boundary block for a trigger-user-auth session.
*
* ONE definition for both prompt paths — claude-family adapters
* (`buildBotmuxSystemPromptText`) and the inline-prompt CLIs that go through
* session-manager. A second copy would drift, and the copy that drifted would
* be the one nobody notices is missing.
*
* This is a behavioral rule, not a security control: nothing in the OS stops a
* curious agent from reading another person's token file today. It exists
* because the likeliest path to that happening is an agent grepping the data
* 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))}`;
return [
'<botmux_credentials>',
line('ai.credentials.acting_identity'),
line('ai.credentials.never_read_others'),
line('ai.credentials.never_forward'),
line('ai.credentials.on_auth_failure'),
line('ai.credentials.on_missing_scope'),
'</botmux_credentials>',
].join('\n');
}

export function buildBotmuxSystemPromptText(opts: {
locale?: Locale;
botName?: string;
Expand All @@ -164,8 +191,14 @@ export function buildBotmuxSystemPromptText(opts: {
* reintroduce a sentinel line here. Computed daemon-side as
* `!larkTransportEnabled({chatId, apiOnly})` and threaded through buildArgs. */
noTransport?: boolean;
/**
* Trigger-user CLI auth is on for this session: add the credential boundary
* block. Off → nothing is emitted, so a bot that never enabled the feature
* gets no extra prompt text.
*/
triggerUserAuth?: boolean;
}): string {
const { locale, botName, botOpenId, builtinSkillBlock, noTransport } = opts;
const { locale, botName, botOpenId, builtinSkillBlock, noTransport, triggerUserAuth } = opts;
const unknown = t('ai.identity.unknown', undefined, locale);
const workflowHint = workflowDiscoveryHint(locale);
const prose = (key: string): string =>
Expand Down Expand Up @@ -239,11 +272,23 @@ export function buildBotmuxSystemPromptText(opts: {
hiddenContextDefense(locale),
...whiteboardRouting,
];
// Trigger-user auth: this session acts with ONE person's credentials, and the
// token store on disk holds everyone else's. Nothing in the OS stops a curious
// agent from reading those files today, so the boundary is stated here.
//
// This is a behavioral rule, not a security control — it does not survive a
// 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)]
: [];
return [
'<botmux_routing>',
...routingInner,
'</botmux_routing>',
...identityBlock,
...credentialBoundaryBlock,
...(builtinSkillBlock ? ['', builtinSkillBlock] : []),
].join('\n');
}
14 changes: 14 additions & 0 deletions src/adapters/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,20 @@ export interface CliAdapter {
* session-manager's buildBotmuxShellHints. Adapters without a routing block
* ignore it. */
noTransport?: boolean;
/** Trigger-user CLI auth is enabled for this bot. injectsSessionContext
* adapters forward it so the credential-boundary block is added to the
* 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;
/** 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 /
* BASH_ENV / GIT_ASKPASS …) are stripped before `lark-cli` ever runs and
* the tool resolves the machine's own login instead. Adapters whose CLI
* has such a knob declare these keys; the rest ignore the field, since for
* them a plain child inherits the environment anyway. */
shellSubprocessEnv?: Record<string, string>;
/** UI / response language for prompts injected into the CLI (e.g. zh / en). */
locale?: import('../../i18n/index.js').Locale;
/** Optional model name from BotConfig.model. Adapters whose CLI accepts a
Expand Down
19 changes: 19 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { VoiceConfig } from './services/voice/types.js';
import type { PricingOverrides } from './services/model-pricing.js';
import type { BudgetConfig } from './services/budget-tracker.js';
import { normalizePricingOverrides } from './services/model-pricing.js';
import { parseTriggerUserAuthConfig } from './services/trigger-user-auth.js';
import { parseBudgetConfig } from './services/budget-tracker.js';
import { type Brand, sdkDomain, normalizeBrand } from './im/lark/lark-hosts.js';
import type { BotSkillPolicy, SkillSelector } from './core/skills/types.js';
Expand Down Expand Up @@ -1541,6 +1542,14 @@ export interface BotConfig {
* CODEX_HOME and never reads or copies global auth, with or without sandbox.
*/
codexAuthSync?: import('./services/codex-auth-sync.js').CodexAuthSyncMode;
/**
* Trigger-user CLI authentication. Missing → off; this bot's CLI calls keep
* using whatever identity is logged in on the machine. Enabled → `lark-cli` /
* `bytedcli` run as the person who sent the current message, and a sender who
* has not authorized gets the bot's own tenant identity (or a failure, per
* `fallback`) — never another person's login. See services/trigger-user-auth.ts.
*/
triggerUserAuth?: import('./services/trigger-user-auth.js').TriggerUserAuthConfig;
/**
* Run this bot's CLI inside a per-session file sandbox (unified three-tier
* whitelist, deny-by-default; Linux bwrap + macOS Seatbelt with identical
Expand Down Expand Up @@ -3104,6 +3113,15 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
entry.existingAppServer,
`Bot config [${i}].existingAppServer`,
);
// Malformed input throws instead of degrading to off: an operator who typo'd
// a tool name would otherwise believe a credential boundary is enforced
// when it is not — worse than not having the feature.
let triggerUserAuth;
try {
triggerUserAuth = parseTriggerUserAuthConfig(entry.triggerUserAuth) ?? undefined;
} catch (e) {
throw new Error(`Bot config [${i}]: ${(e as Error).message}`);
}
const codexBrowser = normalizeCodexBrowserConfig(
entry.codexBrowser,
`Bot config [${i}].codexBrowser`,
Expand Down Expand Up @@ -3462,6 +3480,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
existingAppServer,
// Missing keeps the historical every-cold-spawn global auth refresh.
codexAuthSync: entry.codexAuthSync === 'isolated' ? 'isolated' : 'shared',
...(triggerUserAuth ? { triggerUserAuth } : {}),
sandbox: entry.sandbox === true,
sandboxPaths: entry.sandboxPaths && typeof entry.sandboxPaths === 'object' && !Array.isArray(entry.sandboxPaths)
? {
Expand Down
Loading