From be1995c44818ae362189a42df57d0fce43c99f0c Mon Sep 17 00:00:00 2001 From: ZHANG Shaobo <25368660+Hyperzsb@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:02:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E9=9A=94=E7=A6=BB=E6=AF=8F?= =?UTF-8?q?=E8=BD=AE=E5=8F=91=E9=80=81=E4=BA=BA=E7=9A=84=20CLI=20=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=20(#1796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 整合最新 master(含 #1606 标准会话 lark-cli 绑定):dashboard 授权路由按 boundTool 双轨分流(标准绑定会话走 bot 应用 requestUserAuthorization; 按人鉴权会话走发送人个人 app 的 beginLarkCliLogin),auth-status 轮询按 身份形态分别落 token / HOME,app-token 支不写 trigger-user 身份文件。 Co-authored-by: TRAE CLI --- src/adapters/backend/scratch-credentials.ts | 62 +- .../backend/scratch-sandbox-darwin.ts | 39 +- src/adapters/backend/scratch-sandbox.ts | 31 +- src/adapters/cli/shared-hints.ts | 4 +- src/core/cli-identity.ts | 317 +++++++- src/core/command-handler.ts | 39 +- src/core/dashboard-ipc-server.ts | 123 +++- src/core/dispatch-user-delegation.ts | 4 +- src/core/schedule-delegated-runtime.ts | 17 +- src/core/session-temp.ts | 7 +- src/core/turn-cli-identity.ts | 182 +++-- src/daemon.ts | 9 +- src/dashboard/web/bot-defaults-page.tsx | 7 +- src/dashboard/web/bot-defaults.ts | 2 +- src/dashboard/web/i18n.ts | 10 +- src/i18n/en.ts | 8 +- src/i18n/zh.ts | 8 +- src/services/bot-config-store.ts | 2 +- src/services/bytedcli-auth.ts | 65 +- src/services/lark-cli-auth.ts | 687 +++++++++++------- src/services/trigger-user-auth.ts | 25 +- src/skills/prompt-fragments.ts | 2 +- src/utils/bytecloud-sites.ts | 23 + src/utils/child-env.ts | 60 +- src/worker.ts | 20 +- test/bytedcli-auth-process.test.ts | 43 +- test/bytedcli-auth.test.ts | 107 ++- test/cli-identity.test.ts | 277 ++++++- test/command-handler.test.ts | 32 +- test/credential-boundary-prompt.test.ts | 6 +- test/fs-policy-bwrap.e2e.test.ts | 37 +- test/fs-policy.test.ts | 32 + test/ipc-auth-request.test.ts | 116 ++- test/ipc-lark-tool-identity.test.ts | 12 + test/lark-base-scope-authorization.test.ts | 26 +- test/lark-cli-auth.test.ts | 434 ++++++----- test/schedule-delegation-route.test.ts | 2 + test/scratch-credentials.test.ts | 41 +- test/scratch-sandbox-darwin-profile.test.ts | 30 +- test/scratch-sandbox-unit.test.ts | 54 ++ test/trigger-user-auth.test.ts | 26 +- test/trigger-user-shell-env.test.ts | 38 +- test/turn-cli-identity.test.ts | 197 +++-- 43 files changed, 2512 insertions(+), 751 deletions(-) create mode 100644 src/utils/bytecloud-sites.ts diff --git a/src/adapters/backend/scratch-credentials.ts b/src/adapters/backend/scratch-credentials.ts index 7d73a88446..7bc149f588 100644 --- a/src/adapters/backend/scratch-credentials.ts +++ b/src/adapters/backend/scratch-credentials.ts @@ -22,8 +22,8 @@ * * Paths that don't exist are skipped (the mask compiler existence-filters). */ -import { existsSync, lstatSync, readdirSync } from 'node:fs'; -import { join, dirname, basename } from 'node:path'; +import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs'; +import { join, dirname, basename, resolve, sep } from 'node:path'; import { homedir as osHomedir } from 'node:os'; import { resolveLarkCliLinuxStoreDir } from '../cli/fs-policy.js'; @@ -44,6 +44,9 @@ export interface ScratchSecretInput { * ..env/.bin/.turn files are returned as read-only carve paths * (the governed CLI + `botmux send` need them). */ sessionId?: string; + /** Current session's private cache tree. It is the only writable carve under + * the otherwise sealed shared session-tmp parent. */ + sessionScratchDir?: string; } export interface ScratchSecrets { @@ -51,6 +54,8 @@ export interface ScratchSecrets { denyPaths: string[]; /** Paths re-opened READ-ONLY inside an otherwise denied dir. */ readOnlyCarvePaths: string[]; + /** Paths re-opened READ+WRITE inside an otherwise denied shared dir. */ + writableCarvePaths: string[]; } // Trigger-user CLI identity lives in ONE per-session directory @@ -73,14 +78,36 @@ function isDir(p: string): boolean { try { return lstatSync(p).isDirectory(); } catch { return false; } } +/** Resolve symlink aliases when the path exists, while retaining a stable + * lexical fallback for optional paths that have not been created yet. */ +function canonical(p: string): string { + try { return realpathSync(p); } catch { return resolve(p); } +} + export function enumerateScratchSecretPaths(input: ScratchSecretInput): ScratchSecrets { const out = new Set(); const readOnlyCarve = new Set(); + const writableCarve = new Set(); const homeDir = input.homeDir || osHomedir(); const addFile = (p: string | undefined): void => { if (p && isFileOrLink(p)) out.add(p); }; + // Session-local lark-cli HOME snapshots live below one shared cache parent. + // Scratch otherwise exposes the real filesystem for reads, so seal the whole + // parent and re-open only this session's own scratch tree. This is independent + // of triggerUserAuth: a non-participating session must not read a sibling's + // snapshot created by a participating bot. + const sessionTempRoot = canonical(resolve(homeDir, '.cache', 'botmux', 'session-tmp')); + if (isDir(sessionTempRoot)) out.add(sessionTempRoot); + if (input.sessionScratchDir) { + const ownScratchInput = resolve(input.sessionScratchDir); + const ownScratch = canonical(ownScratchInput); + if (ownScratch.startsWith(`${sessionTempRoot}${sep}`) && isDir(ownScratchInput)) { + writableCarve.add(ownScratch); + } + } + for (const home of input.botmuxHomes) { let entries: string[]; try { entries = readdirSync(home); } catch { continue; } @@ -137,10 +164,15 @@ export function enumerateScratchSecretPaths(input: ScratchSecretInput): ScratchS } } catch { /* */ } - // Per-person secret subdirectories (whole dirs): VC daemon auth tokens - // and each person's bytedcli login HOME. The CLI never needs these inside - // a scratch turn (they are daemon-side / per-owner). - for (const secretDir of ['vc-meeting-daemon-auth', 'bytedcli-home']) { + // Per-person secret subdirectories (whole dirs): VC daemon auth tokens, + // bytedcli logins, and lark-cli personal apps/logins. The CLI receives only + // its own per-turn wrapper identity and must not enumerate another sender's + // HOME. + for (const secretDir of [ + 'vc-meeting-daemon-auth', + 'bytedcli-home', + 'lark-cli-home', + ]) { const p = join(dataDir, secretDir); if (isDir(p)) out.add(p); } @@ -175,7 +207,23 @@ export function enumerateScratchSecretPaths(input: ScratchSecretInput): ScratchS if (existsSync(p)) out.add(p); } - return { denyPaths: [...out], readOnlyCarvePaths: [...readOnlyCarve] }; + // larkdev's ByteCloud SDK keeps refresh/login state here. A governed wrapper + // always overrides LARKDEV_AUTH_DIR with an empty per-invocation directory, + // but scratch exposes the host tree for reads; mask both the default and an + // operator-configured auth root so the agent cannot bypass the wrapper by + // reading or invoking against the machine login directly. + for (const p of new Set([ + join(homeDir, '.larkdev', 'auth'), + ...(process.env.LARKDEV_AUTH_DIR?.startsWith('/') ? [process.env.LARKDEV_AUTH_DIR] : []), + ])) { + if (existsSync(p)) out.add(p); + } + + return { + denyPaths: [...out], + readOnlyCarvePaths: [...readOnlyCarve], + writableCarvePaths: [...writableCarve], + }; } /** Back-compat flat list (deny + ro-carve are all "secret-adjacent"); used by diff --git a/src/adapters/backend/scratch-sandbox-darwin.ts b/src/adapters/backend/scratch-sandbox-darwin.ts index 115917ad18..002f4c374a 100644 --- a/src/adapters/backend/scratch-sandbox-darwin.ts +++ b/src/adapters/backend/scratch-sandbox-darwin.ts @@ -63,6 +63,14 @@ export type ScratchStorage = 'disk'; // macOS: always APFS-COW-backed; the Linux const CLONE_TIMEOUT_CORE_MS = 5 * 60_000; const CLONE_TIMEOUT_BESTEFFORT_MS = 20_000; +/** Whether a broad denied root contains a trusted per-session carve. */ +export function macScratchDenyHostsWritableCarve( + root: string, + writableCarvePaths: readonly string[], +): boolean { + return writableCarvePaths.some(carve => carve !== root && carve.startsWith(`${root}${sep}`)); +} + /** CLI state dirs cloned REQUIRED (fail-closed): sessions/auth/config of the * supported CLIs must be writable COW for scratch to function. */ const CORE_CLONE_HOME_DIRS = new Set([ @@ -196,6 +204,8 @@ export function buildMacScratchProfile(input: { /** Final WRITE-only denies (subtrees degraded from clone to symlink must be * read-native but never writable, so writes can't silently reach the host). */ fileWriteDenyPaths?: readonly string[]; + /** Final trusted write carves below a sealed shared root. */ + writableCarvePaths?: readonly string[]; /** REAL host cache areas granted write (Foundation/cfprefsd ignore HOME). */ hostWritable?: readonly string[]; mcpSocket?: string; @@ -245,6 +255,13 @@ export function buildMacScratchProfile(input: { if (typeof raw !== 'string' || !raw || !isAbsolute(raw)) continue; lines.push(`(deny file-write* (subpath "${escSb(raw)}"))`); } + // Stage 6: trusted per-session carves below a shared deny root. These must + // come after final write seals such as ~/.cache symlink degradation. + for (const raw of input.writableCarvePaths ?? []) { + if (typeof raw !== 'string' || !raw || !isAbsolute(raw)) continue; + lines.push(`(allow file-read* (subpath "${escSb(raw)}"))`); + lines.push(`(allow file-write* (subpath "${escSb(raw)}"))`); + } if (!input.net) lines.push('(deny network*)'); return lines; } @@ -258,6 +275,8 @@ export interface PrepareMacScratchOpts { cliArgs: string[]; /** Credential/secret REAL host paths (read+write denied in the child). */ denyPaths?: readonly string[]; + /** Current session paths re-opened below a shared denied parent. */ + writableCarvePaths?: readonly string[]; mcpGatewaySocketPath?: string; net?: boolean; } @@ -286,6 +305,10 @@ export function prepareMacScratchSandbox(opts: PrepareMacScratchOpts): MacScratc const homeReal = canonical(opts.home); const cwdReal = canonical(opts.chdir); + const writableCarvePaths = (opts.writableCarvePaths ?? []) + .filter((p): p is string => typeof p === 'string' && !!p && isAbsolute(p)) + .map(p => canonical(p)) + .filter(p => existsSync(p)); if (!sameVolume(homeReal, homeCloneRoot)) { console.error(`[scratch-darwin] HOME ${homeReal} not on the same volume as ${homeCloneRoot}; cross-volume scratch refused.`); rmSync(sessionRoot, { recursive: true, force: true }); @@ -347,6 +370,10 @@ export function prepareMacScratchSandbox(opts: PrepareMacScratchOpts): MacScratc for (const p of opts.denyPaths ?? []) { if (typeof p !== 'string' || !p) continue; let cur = canonical(p); + // Keep a white-in-black root precise. Promoting session-tmp to its + // top-level ~/.cache ancestor would unnecessarily deny reads to every + // unrelated cache and would obscure the exact sibling-session boundary. + if (macScratchDenyHostsWritableCarve(cur, writableCarvePaths)) continue; while (dirname(cur) !== homeReal && cur !== dirname(cur) && cur.startsWith(homeReal)) cur = dirname(cur); if (dirname(cur) === homeReal) authorityRoots.add(cur); } @@ -530,11 +557,17 @@ export function prepareMacScratchSandbox(opts: PrepareMacScratchOpts): MacScratc const claudeMcpCache = join(homeReal, 'Library', 'Caches', 'claude-cli-nodejs'); // File-level caller-supplied denies outside the authority roots / session // tree (the sessionRoot is NOT denied on macOS: clones/outbox live under it). - const callerFileDenies = (opts.denyPaths ?? []) + const callerDenies = (opts.denyPaths ?? []) .filter((p): p is string => typeof p === 'string' && !!p && isAbsolute(p)) .map(p => canonical(p)) .filter(p => !p.startsWith(sessionRoot + sep) && p !== sessionRoot) .filter(p => ![...authorityRoots].some(root => p === root || p.startsWith(root.endsWith(sep) ? root : `${root}${sep}`))); + // A broad deny that contains a trusted carve belongs in stage 2, before the + // deeper stage-6 re-open. Leaving it in the final file-deny stage would mask + // this session's own lark-cli HOME together with its siblings. + const callerRootDenies = callerDenies.filter(root => + macScratchDenyHostsWritableCarve(root, writableCarvePaths)); + const callerFileDenies = callerDenies.filter(p => !callerRootDenies.includes(p)); // macOS lark-cli keystores. These are NOT under ~/.botmux and the shared // enumerator only resolves Linux store paths, so the mac module must seal @@ -545,12 +578,14 @@ export function prepareMacScratchSandbox(opts: PrepareMacScratchOpts): MacScratc const profilePath = join(sessionRoot, 'scratch.sb'); const lines = buildMacScratchProfile({ net: opts.net !== false, - authorityRootDenies: [...authorityRoots].sort((a, b) => b.length - a.length), + authorityRootDenies: [...new Set([...authorityRoots, ...callerRootDenies])] + .sort((a, b) => b.length - a.length), writable: [homeCloneRoot, workCloneRoot, tmp, outbox, shimBin], hostWritable, fileDenyPaths: [...new Set([...callerFileDenies, ...macLarkDenies])], fileWriteDenyPaths: [...degradedWriteDeny, claudeMcpCache] .filter(p => !authorityRoots.has(p)), + writableCarvePaths, mcpSocket: sandboxMcpSocket, }); writeFileSync(profilePath, lines.join('\n') + '\n', { mode: 0o600 }); diff --git a/src/adapters/backend/scratch-sandbox.ts b/src/adapters/backend/scratch-sandbox.ts index ac5b5cbb13..9f6ef135ef 100644 --- a/src/adapters/backend/scratch-sandbox.ts +++ b/src/adapters/backend/scratch-sandbox.ts @@ -119,6 +119,20 @@ function canonical(p: string): string { try { return realpathSync(p); } catch { return resolve(p); } } +/** Compile the later-wins RW binds for trusted paths below a sealed root. */ +export function compileScratchWritableCarveArgs( + denies: readonly string[], + writableCarves: readonly string[], +): string[] { + const args: string[] = []; + for (const p of writableCarves) { + if (denies.some(d => d === p || isWithin(d, p))) { + args.push('--bind', p, p); + } + } + return args; +} + function hasCmd(cmd: string): boolean { return spawnSync('sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' }).status === 0; } @@ -332,7 +346,7 @@ export interface PrepareScratchOpts { * files under the otherwise-sealed cli-identity/ dir). Bound READ-ONLY. */ readOnlyCarvePaths?: readonly string[]; /** Host paths that must be READ+WRITABLE inside even under a denied dir - * (the outbox is handled internally; reserved for future carve-outs). */ + * (the outbox is handled internally). */ writableCarvePaths?: readonly string[]; /** Env keys the WORKER may repoint at the host-side merged tree for its own * transcript reads (CODEX_HOME / TRAE_HOME). The container child must keep @@ -509,7 +523,15 @@ export function prepareScratchSandbox(opts: PrepareScratchOpts): ScratchSandboxS // Real-host passthrough carve-outs layered AFTER deny masks (later mount // wins). The outbox MUST stay host-real: the daemon watcher reads it without // any merged-tree awareness. - const carveTargets = new Set([outbox]); + const writableCarves = new Set(); + for (const raw of opts.writableCarvePaths ?? []) { + if (typeof raw !== 'string' || !raw) continue; + try { + const p = canonical(raw); + if (isAbsolute(p) && existsSync(p)) writableCarves.add(p); + } catch { /* missing/invalid carve */ } + } + const carveTargets = new Set([outbox, ...writableCarves]); // ── bwrap argv ─────────────────────────────────────────────────────────── const args: string[] = []; @@ -641,6 +663,11 @@ export function prepareScratchSandbox(opts: PrepareScratchOpts): ScratchSandboxS } } + // Writable carve-outs use host-real binds after the broad empty/tmpfs mask. + // Only emit a carve that is actually below a denied root; a standalone bind + // would unnecessarily bypass scratch COW semantics. + args.push(...compileScratchWritableCarveArgs(denies, [...writableCarves])); + // fnm/nvm/volta bin farms under /run get masked by the fresh /run tmpfs. args.push(...reexposeRunBinArgs([opts.cliBin, process.execPath])); diff --git a/src/adapters/cli/shared-hints.ts b/src/adapters/cli/shared-hints.ts index 4141fbe3fb..94f0da4a77 100644 --- a/src/adapters/cli/shared-hints.ts +++ b/src/adapters/cli/shared-hints.ts @@ -195,6 +195,8 @@ export function buildCredentialBoundaryBlock(policy: TriggerUserAuthConfig | und if (!policy?.enabled || policy.tools.length === 0) return ''; const lark = triggerUserAuthApplies(policy, 'lark-cli'); const bytedcli = triggerUserAuthApplies(policy, 'bytedcli'); + const larkdev = triggerUserAuthApplies(policy, 'larkdev'); + const bytecloud = bytedcli || larkdev; const line = (key: string, params?: Record): string => ` ${escapeXmlTagLikeTokens(t(key, params, locale))}`; return [ '', @@ -203,7 +205,7 @@ export function buildCredentialBoundaryBlock(policy: TriggerUserAuthConfig | und line('ai.credentials.never_read_others'), line('ai.credentials.never_forward'), ...(lark ? [line('ai.credentials.on_auth_failure')] : []), - ...(bytedcli ? [line('ai.credentials.on_bytedcli_auth_failure')] : []), + ...(bytecloud ? [line('ai.credentials.on_bytedcli_auth_failure')] : []), line('ai.credentials.on_auth_link'), ...(lark ? [line('ai.credentials.on_missing_scope')] : []), '', diff --git a/src/core/cli-identity.ts b/src/core/cli-identity.ts index 692922cfa4..7e7cd4cd11 100644 --- a/src/core/cli-identity.ts +++ b/src/core/cli-identity.ts @@ -36,6 +36,10 @@ import { accessSync, constants, existsSync, mkdirSync, readFileSync, rmSync } fr import { delimiter, join } from 'node:path'; import { atomicWriteFileSync } from '../utils/atomic-write.js'; import { clearLarkToolDelegations } from './lark-tool-delegation.js'; +import { + BYTECLOUD_USER_JWT_ENV_BY_SITE, + type ByteCloudUserSite, +} from '../utils/bytecloud-sites.js'; import { isCliIdentityPath } from '../utils/child-env.js'; import type { TriggerUserAuthConfig, TriggerUserAuthTool } from '../services/trigger-user-auth.js'; @@ -161,9 +165,20 @@ export const IDENTITY_ENV_KEYS: Record = // APP_SECRET appears only in bot mode; in user mode it is absent from the // file, and `export` on an unset name puts nothing in the child's env. 'lark-cli': ['LARKSUITE_CLI_APP_ID', 'LARKSUITE_CLI_APP_SECRET', 'LARKSUITE_CLI_USER_ACCESS_TOKEN'], - // ByteCloud JWT and the Codebase JWT derived from it. The latter is what git - // pushes authenticate with, so attribution of a commit follows from it. - bytedcli: ['BYTEDCLI_USER_CLOUD_JWT', 'BYTEDCLI_USER_CODE_JWT'], + // Site-specific ByteCloud SDK inputs. bytedcli also accepts the legacy + // `BYTEDCLI_USER_CLOUD_JWT`, but that variable carries only one region: when + // a command selects another site bytedcli skips the mismatched JWT and may + // fall back to the operator's on-disk login. Publish every site explicitly. + bytedcli: [ + ...Object.values(BYTECLOUD_USER_JWT_ENV_BY_SITE), + 'BYTECLOUD_AUTH_AS', + ], + // larkdev's ByteCloud SDK selects a site-specific JWT before consulting its + // on-disk auth directory. BOE aliases CN, so no separate BOE value exists. + larkdev: [ + ...Object.values(BYTECLOUD_USER_JWT_ENV_BY_SITE), + 'BYTECLOUD_AUTH_AS', + ], }; /** Common to every variant: the turn these credentials were published FOR. @@ -197,9 +212,14 @@ export interface LarkCliHomeIdentity extends TurnBound { export interface BytedCliIdentity extends TurnBound { tool: 'bytedcli'; - cloudJwt: string; - /** Optional: only present once a Codebase JWT has been minted for this person. */ - codeJwt?: string; + /** Site keys use bytedcli spelling; BOE intentionally reuses `cn`. */ + cloudJwts: Partial>; +} + +export interface LarkdevIdentity extends TurnBound { + tool: 'larkdev'; + /** Site keys use bytedcli spelling; BOE intentionally reuses `cn`. */ + cloudJwts: Partial>; } /** @@ -234,7 +254,7 @@ export interface DeniedIdentity extends TurnBound { message: string; } -export type CliIdentity = LarkCliIdentity | LarkCliHomeIdentity | BytedCliIdentity | BotIdentity | DeniedIdentity; +export type CliIdentity = LarkCliIdentity | LarkCliHomeIdentity | BytedCliIdentity | LarkdevIdentity | BotIdentity | DeniedIdentity; /** Exit code for a command refused for want of authorization. Distinct from the * tool's own failures so callers can tell "not allowed" from "did not work". @@ -291,8 +311,16 @@ export function renderIdentityEnv(identity: CliIdentity): string { ['LARKSUITE_CLI_USER_ACCESS_TOKEN', identity.userAccessToken], ); } else { - pairs.push([MODE_VAR, 'user'], ['BYTEDCLI_USER_CLOUD_JWT', identity.cloudJwt]); - if (identity.codeJwt) pairs.push(['BYTEDCLI_USER_CODE_JWT', identity.codeJwt]); + pairs.push([ + MODE_VAR, + identity.tool === 'bytedcli' ? 'bytedcli-user' : 'larkdev-user', + ], ['BYTECLOUD_AUTH_AS', 'user']); + for (const [site, key] of Object.entries(BYTECLOUD_USER_JWT_ENV_BY_SITE) as Array< + [ByteCloudUserSite, typeof BYTECLOUD_USER_JWT_ENV_BY_SITE[ByteCloudUserSite]] + >) { + const jwt = identity.cloudJwts[site]; + if (jwt) pairs.push([key, jwt]); + } } if (identity.turnId) pairs.push([TURN_VAR, identity.turnId]); const header = '# botmux trigger-user identity — rewritten each turn, do not edit\n'; @@ -387,12 +415,96 @@ export function clearAllSessionIdentities(sessionDataDir: string, sessionId: str } catch { /* best-effort: absence is the desired state */ } } +/** + * Identity-bearing variables that a governed wrapper must clear before it + * sources the current turn file. The parent agent can carry per-bot env added + * after daemon boot scrubbing, and both lark-cli and ByteCloud prefer env over + * their isolated on-disk state. An absent key in this turn must therefore mean + * absent, not "reuse whatever the parent happened to export". + */ +const WRAPPER_IDENTITY_OVERRIDE_ENV_KEYS = [ + ...new Set(Object.values(IDENTITY_ENV_KEYS).flat()), + // Pre-fix bytedcli values carry only one region. Scrub them so a stale + // parent/session cannot override the site-specific identity below or become + // an accidental fallback source. + 'BYTEDCLI_USER_CLOUD_JWT', + 'BYTEDCLI_USER_CODE_JWT', + 'BYTEDCLI_USER_CB_OAUTH_AT', + 'BYTEDCLI_PROFILE', + 'BYTEDCLI_CODEBASE_APP_ID', + 'BYTEDCLI_CODEBASE_APP_SECRET', + 'BYTEDCLI_CLOUD_SITE', + 'BYTEDCLI_AUTH_SITE', + 'BYTEDCLI_AI_AUTH_APP_KEY', + 'BYTEDCLI_AI_AUTH_SUBJECT', + 'BYTEDCLI_BYTECLOUD_AUTH_FALLBACK', + 'BYTEDCLI_OCEANUS_AUTH', + 'BYTEDCLI_USE_ZTI_JWT', + 'LARKSUITE_CLI_CONFIG_DIR', + 'LARKSUITE_CLI_DATA_DIR', + 'LARKSUITE_CLI_TENANT_ACCESS_TOKEN', + 'LARKSUITE_CLI_TENANT_ACCESS_TOKEN_SOURCE', + 'LARKSUITE_CLI_TOKEN_ONLY__', + 'LARKSUITE_CLI_PROFILE', + 'LARKSUITE_CLI_DEFAULT_AS', + 'FEISHU_USER_ACCESS_TOKEN', + 'LARKDEV_AUTH_DIR', + // The ByteCloud SDK treats this marker as an environment-managed ByteClaw + // identity even when LARKDEV_AUTH_DIR is empty. It must not bypass the + // current sender's injected JWTs (or supply a missing regional JWT). + 'AIPAAS_BYTECLAW', + 'BYTECLOUD_CLI_API_JWT_TOKEN', + 'BYTECLOUD_CLI_JWT_TOKEN', + 'AIME_USER_CLOUD_JWT', + 'AIME_USER_CODE_JWT', + 'AIME_WORKSPACE_PATH', + 'AIME_CURRENT_USER', + 'BYTECLOUD_AUTH_ACCESS_KEY_ID', + 'BYTECLOUD_AUTH_SECRET_ACCESS_KEY', + 'BYTECLOUD_ACCESS_KEY_ID', + 'BYTECLOUD_SECRET_ACCESS_KEY', +] as const; + +/** + * Shell fragment that removes ambient credentials by family rather than by a + * hard-coded list. The installed bytedcli is a toolbox: besides ByteCloud it + * accepts domain-specific tokens, cookies, passwords and AK/SK pairs. A new + * site or subcommand credential must fail closed until Botmux deliberately + * publishes a current-turn value for it. + * + * Use POSIX `command -p` so an agent-controlled PATH cannot replace `env` or + * `sed` with a helper that observes the turn JWT or skips the scrub. + */ +const DYNAMIC_BYTECLOUD_IDENTITY_SCRUB = [ + '__botmux_identity_env="$(command -p env)" || {', + ` printf '%s\\n' 'botmux: ByteCloud 身份环境清理工具缺失,命令未执行。' >&2`, + ` exit ${IDENTITY_DENIED_EXIT_CODE}`, + '}', + '__botmux_identity_keys="$(printf \'%s\\n\' "$__botmux_identity_env" | command -p sed -n \\', + " -e 's/^\\([A-Z0-9_]*TOKEN[A-Z0-9_]*\\)=.*/\\1/p' \\", + " -e 's/^\\([A-Z0-9_]*JWT[A-Z0-9_]*\\)=.*/\\1/p' \\", + " -e 's/^\\([A-Z0-9_]*COOKIE[A-Z0-9_]*\\)=.*/\\1/p' \\", + " -e 's/^\\([A-Z0-9_]*PASSWORD[A-Z0-9_]*\\)=.*/\\1/p' \\", + " -e 's/^\\([A-Z0-9_]*SECRET[A-Z0-9_]*\\)=.*/\\1/p' \\", + " -e 's/^\\([A-Z0-9_]*ACCESS_KEY[A-Z0-9_]*\\)=.*/\\1/p' \\", + " -e 's/^\\([A-Z0-9_]*AUTHORIZATION[A-Z0-9_]*\\)=.*/\\1/p')\" || {", + ` printf '%s\\n' 'botmux: ByteCloud 身份环境清理失败,命令未执行。' >&2`, + ` exit ${IDENTITY_DENIED_EXIT_CODE}`, + '}', + 'for __botmux_identity_key in $__botmux_identity_keys; do', + ' unset "$__botmux_identity_key"', + 'done', + 'unset __botmux_identity_env __botmux_identity_key __botmux_identity_keys', +] as const; + /** * The wrapper script for one tool. * * Design constraints, each learned the hard way elsewhere in this codebase: * - * - `exec` so signals and the exit code pass straight through. + * - Direct branches use `exec`; branches with an invocation-scoped HOME run + * the child under a tiny supervisor that forwards signals, preserves its + * exit code, and removes the temporary directory afterward. * - Absolute path to the real binary: the wrapper shadows the tool's own name * on `PATH`, so resolving by name again would re-enter this script forever. * - `BOTMUX_SESSION_ID` / `SESSION_DATA_DIR` come from the session env the @@ -423,10 +535,24 @@ export function clearAllSessionIdentities(sessionDataDir: string, sessionId: str */ export function renderIdentityWrapper(tool: TriggerUserAuthTool, realBinaryPath: string): string { const exportKeys = IDENTITY_ENV_KEYS[tool].join(' '); + const dynamicByteCloudScrub = tool === 'lark-cli' ? [] : DYNAMIC_BYTECLOUD_IDENTITY_SCRUB; + const legacyUserMode = tool === 'bytedcli' + ? [ + ` printf '%s\\n' 'botmux: bytedcli 身份文件仍是旧格式,命令未执行。请重新发起当前操作。' >&2`, + ` exit ${IDENTITY_DENIED_EXIT_CODE}`, + ' ;;', + ] + : [ + ` export ${exportKeys}`, + ' ;;', + ]; + const userHomeExecEnv = tool === 'lark-cli' + ? [' LARKSUITE_CLI_LOG_DIR="$__botmux_home/.local/state/lark-cli/logs" \\'] + : []; return [ '#!/bin/sh', '# botmux trigger-user identity wrapper — generated, do not edit.', - '# Sources the identity the daemon published for the CURRENT turn, then execs', + '# Sources the identity the daemon published for the CURRENT turn, then runs', '# the real tool. Re-read on every invocation, which is what lets the acting', '# identity change without restarting the session.', '#', @@ -434,6 +560,11 @@ export function renderIdentityWrapper(tool: TriggerUserAuthTool, realBinaryPath: '# would hand it the machine account\'s login, not the bot\'s.', `${MODE_VAR}=`, `${DENY_MSG_VAR}=`, + // Do this before sourcing the turn file. In particular, a missing regional + // larkdev token must fail in the empty LARKDEV_AUTH_DIR below, not fall back + // to a machine JWT/AK inherited from the parent process. + `unset ${WRAPPER_IDENTITY_OVERRIDE_ENV_KEYS.join(' ')}`, + ...dynamicByteCloudScrub, 'if [ -n "$SESSION_DATA_DIR" ] && [ -n "$BOTMUX_SESSION_ID" ]; then', ` __botmux_cred="$SESSION_DATA_DIR/cli-identity/$BOTMUX_SESSION_ID.bin/.data/${tool}.env"`, ' if [ -f "$__botmux_cred" ]; then', @@ -465,8 +596,7 @@ export function renderIdentityWrapper(tool: TriggerUserAuthTool, realBinaryPath: '', `case "$${MODE_VAR}" in`, ' user|bot)', - ` export ${exportKeys}`, - ' ;;', + ...legacyUserMode, ' user-home)', // Device-code identity. HOME is scoped to THIS exec only: assign it inline // to the lark-cli process rather than `export`-ing, so a sibling command or @@ -477,8 +607,147 @@ export function renderIdentityWrapper(tool: TriggerUserAuthTool, realBinaryPath: ` printf '%s\\n' 'botmux: ${tool} 的按人身份目录缺失,命令未执行。请重新发送 /login 完成授权后重试。' >&2`, ` exit ${IDENTITY_DENIED_EXIT_CODE}`, ' fi', - ` unset ${MODE_VAR} ${DENY_MSG_VAR} ${TURN_VAR}`, - ` HOME="$${HOME_VAR}" exec ${shellSingleQuote(realBinaryPath)} "$@"`, + ` __botmux_home="$${HOME_VAR}"`, + ` unset ${MODE_VAR} ${DENY_MSG_VAR} ${TURN_VAR} ${HOME_VAR}`, + ` HOME="$__botmux_home" \\`, + ' XDG_CONFIG_HOME="$__botmux_home/.config" \\', + ' XDG_DATA_HOME="$__botmux_home/.local/share" \\', + ' XDG_STATE_HOME="$__botmux_home/.local/state" \\', + ' XDG_CACHE_HOME="$__botmux_home/.cache" \\', + ...userHomeExecEnv, + ` exec ${shellSingleQuote(realBinaryPath)} "$@"`, + ' ;;', + ' bytedcli-user)', + // BYTECLOUD_AUTH_AS=user is a default, not a policy boundary: bytedcli's + // global --as option writes a higher-priority runtime override. Likewise a + // request-scoped JWT is consulted before the selected auth identity. Refuse + // both override channels so this wrapper can only act as the published turn. + ' for __botmux_arg in "$@"; do', + ' case "$__botmux_arg" in', + ' --as|--as=*|--bytecloud-user-jwt-file|--bytecloud-user-jwt-file=*)', + ` printf '%s\\n' 'botmux: bytedcli 身份由当前消息发起人固定,不能通过命令参数覆盖;命令未执行。' >&2`, + ` exit ${IDENTITY_DENIED_EXIT_CODE}`, + ' ;;', + ' esac', + ' done', + ' unset __botmux_arg', + // A single BYTEDCLI_USER_CLOUD_JWT is valid but bound to one region; when a + // command selects another site bytedcli deliberately skips it. The SDK's + // site-specific variables avoid that ambiguity. Run in a fresh HOME so an + // unavailable site/token fails closed instead of consulting ~/.local/share/ + // bytedcli and silently acting as the machine's persistent login. + ' __botmux_bytedcli_base="${TMPDIR:-/tmp}/botmux-bytedcli-home"', + ' umask 077', + ' command -p mkdir -p "$__botmux_bytedcli_base" || {', + ` printf '%s\\n' 'botmux: bytedcli 隔离认证目录创建失败,命令未执行。' >&2`, + ` exit ${IDENTITY_DENIED_EXIT_CODE}`, + ' }', + ' __botmux_bytedcli_home="$(command -p mktemp -d "$__botmux_bytedcli_base/invocation.XXXXXX")" || {', + ` printf '%s\\n' 'botmux: bytedcli 隔离认证目录创建失败,命令未执行。' >&2`, + ` exit ${IDENTITY_DENIED_EXIT_CODE}`, + ' }', + ` export ${exportKeys}`, + ` unset ${MODE_VAR} ${DENY_MSG_VAR} ${TURN_VAR} ${HOME_VAR}`, + ' __botmux_bytedcli_child=', + ' __botmux_bytedcli_forwarded=', + ' __botmux_bytedcli_cleanup() { command -p rm -rf -- "$__botmux_bytedcli_home"; }', + ' __botmux_bytedcli_forward() {', + ' __botmux_bytedcli_forwarded="$1"', + ' if [ -n "$__botmux_bytedcli_child" ]; then', + ' command -p kill -s "$1" "$__botmux_bytedcli_child" 2>/dev/null || :', + ' fi', + ' }', + " trap '__botmux_bytedcli_forward HUP' HUP", + " trap '__botmux_bytedcli_forward INT' INT", + " trap '__botmux_bytedcli_forward TERM' TERM", + ' trap __botmux_bytedcli_cleanup EXIT', + ' exec 9<&0', + ' (', + // Non-interactive shells may make asynchronous children ignore INT/QUIT + // and attach stdin to /dev/null. Reset signal dispositions before exec and + // preserve fd 0 explicitly so governed CLIs keep normal PTY behavior. + ' trap - HUP INT TERM', + ' HOME="$__botmux_bytedcli_home" \\', + ' XDG_CONFIG_HOME="$__botmux_bytedcli_home/.config" \\', + ' XDG_DATA_HOME="$__botmux_bytedcli_home/.local/share" \\', + ' XDG_STATE_HOME="$__botmux_bytedcli_home/.local/state" \\', + ' XDG_CACHE_HOME="$__botmux_bytedcli_home/.cache" \\', + ` BYTECLOUD_AUTH_AS=user BYTEDCLI_NO_AUTO_UPGRADE=1 exec ${shellSingleQuote(realBinaryPath)} "$@"`, + ' ) <&9 9<&- &', + ' __botmux_bytedcli_child=$!', + ' exec 9<&-', + // A signal can land after the async child exists but before `$!` is stored. + // The trap records it even when no PID is available; replay it now so that + // narrow launch window cannot turn cancellation into an indefinite wait. + ' if [ -n "$__botmux_bytedcli_forwarded" ]; then', + ' command -p kill -s "$__botmux_bytedcli_forwarded" "$__botmux_bytedcli_child" 2>/dev/null || :', + ' fi', + ' while :; do', + ' wait "$__botmux_bytedcli_child"', + ' __botmux_bytedcli_status=$?', + // A caught signal may interrupt wait before the forwarded signal has made + // the child exit. Keep its HOME intact and wait again until it is reaped. + ' if [ -n "$__botmux_bytedcli_forwarded" ] \\', + ' && command -p kill -0 "$__botmux_bytedcli_child" 2>/dev/null; then', + ' continue', + ' fi', + ' break', + ' done', + ' trap - EXIT HUP INT TERM', + ' __botmux_bytedcli_cleanup', + ' exit "$__botmux_bytedcli_status"', + ' ;;', + ' larkdev-user)', + // A fresh auth directory per invocation makes every non-injected site fail + // closed instead of consulting ~/.larkdev/auth or state left by another + // turn. Injected SDK JWTs are not persisted as login credentials. + ' __botmux_larkdev_base="${TMPDIR:-/tmp}/botmux-larkdev-auth"', + ' umask 077', + ' command -p mkdir -p "$__botmux_larkdev_base" || {', + ` printf '%s\\n' 'botmux: larkdev 隔离认证目录创建失败,命令未执行。' >&2`, + ` exit ${IDENTITY_DENIED_EXIT_CODE}`, + ' }', + ' __botmux_larkdev_auth="$(command -p mktemp -d "$__botmux_larkdev_base/invocation.XXXXXX")" || {', + ` printf '%s\\n' 'botmux: larkdev 隔离认证目录创建失败,命令未执行。' >&2`, + ` exit ${IDENTITY_DENIED_EXIT_CODE}`, + ' }', + ` export ${exportKeys}`, + ` unset ${MODE_VAR} ${DENY_MSG_VAR} ${TURN_VAR} ${HOME_VAR}`, + ' __botmux_larkdev_child=', + ' __botmux_larkdev_forwarded=', + ' __botmux_larkdev_cleanup() { command -p rm -rf -- "$__botmux_larkdev_auth"; }', + ' __botmux_larkdev_forward() {', + ' __botmux_larkdev_forwarded="$1"', + ' if [ -n "$__botmux_larkdev_child" ]; then', + ' command -p kill -s "$1" "$__botmux_larkdev_child" 2>/dev/null || :', + ' fi', + ' }', + " trap '__botmux_larkdev_forward HUP' HUP", + " trap '__botmux_larkdev_forward INT' INT", + " trap '__botmux_larkdev_forward TERM' TERM", + ' trap __botmux_larkdev_cleanup EXIT', + ' exec 9<&0', + ' (', + ' trap - HUP INT TERM', + ` LARKDEV_AUTH_DIR="$__botmux_larkdev_auth" BYTECLOUD_AUTH_AS=user exec ${shellSingleQuote(realBinaryPath)} "$@"`, + ' ) <&9 9<&- &', + ' __botmux_larkdev_child=$!', + ' exec 9<&-', + ' if [ -n "$__botmux_larkdev_forwarded" ]; then', + ' command -p kill -s "$__botmux_larkdev_forwarded" "$__botmux_larkdev_child" 2>/dev/null || :', + ' fi', + ' while :; do', + ' wait "$__botmux_larkdev_child"', + ' __botmux_larkdev_status=$?', + ' if [ -n "$__botmux_larkdev_forwarded" ] \\', + ' && command -p kill -0 "$__botmux_larkdev_child" 2>/dev/null; then', + ' continue', + ' fi', + ' break', + ' done', + ' trap - EXIT HUP INT TERM', + ' __botmux_larkdev_cleanup', + ' exit "$__botmux_larkdev_status"', ' ;;', ' turn-mismatch)', ` printf '%s\\n' 'botmux: 这条命令属于上一轮对话,而凭证已经切换到新消息的发起人;为避免用错人的权限,命令未执行。' >&2`, @@ -658,16 +927,22 @@ export function installIdentityWrapper( export function renderGitAskpassScript(bytedcliPath: string, exchangeUrl?: string): string { const fallback = exchangeUrl ? [ - // Second source: exchange the ByteCloud JWT the wrapper already exported - // for a Codebase JWT over HTTP. Without it a bytedcli that is missing or - // mid-upgrade turns every push into an unexplained auth failure. - ' if [ -z "$__botmux_token" ] && [ -n "$BYTEDCLI_USER_CLOUD_JWT" ]; then', + // Second source: ask the SAME wrapped bytedcli for the current turn's + // ByteCloud JWT, then exchange it. A child wrapper cannot export a value + // back into this parent askpass process, so reading BYTEDCLI_USER_* here + // never worked and could only pick up an unrelated ambient value. + ' if [ -z "$__botmux_token" ]; then', + ` __botmux_cloud="$(GIT_CONFIG_COUNT=0 GIT_SSH_COMMAND=false ${shellSingleQuote(bytedcliPath)} \\`, + ' auth get-bytecloud-jwt-token 2>/dev/null | tr -d \'\\r\\n\')"', + ' fi', + ' if [ -z "$__botmux_token" ] && [ -n "$__botmux_cloud" ]; then', ` __botmux_token="$(curl -fsS --max-time 10 -X POST ${shellSingleQuote(exchangeUrl)} \\`, ' -H "Content-Type: application/json" -H "domain: tenant;v1" \\', - ' -H "x-jwt-token: $BYTEDCLI_USER_CLOUD_JWT" \\', - ' --data-binary "$(printf \'{"jwt_token":"%s"}\' "$BYTEDCLI_USER_CLOUD_JWT")" 2>/dev/null \\', + ' -H "x-jwt-token: $__botmux_cloud" \\', + ' --data-binary "$(printf \'{"jwt_token":"%s"}\' "$__botmux_cloud")" 2>/dev/null \\', ' | sed -n \'s/.*"code_base_token"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p\')"', ' fi', + ' unset __botmux_cloud', ] : []; return [ diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index b9e84b3b53..0a5845a8f9 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -65,7 +65,7 @@ import { repinSessionWorkingDir } from './session-cwd.js'; import { validateAdoptTarget, adoptTargetKey, adoptTargetLabel, type AdoptableSession } from './session-discovery.js'; import { validateZellijAdoptTarget, type ZellijAdoptableSession } from './zellij-adopt-discovery.js'; import { listCodexAppThreads, type CodexAppThreadSummary } from '../services/codex-app-threads.js'; -import { generateAuthUrl, getTokenStatus, resolveUserToken, listAuthorizedUsers, resolveOAuthRedirectUri, DOC_COMMENT_OAUTH_SCOPES, FEED_GROUP_OAUTH_SCOPES } from '../utils/user-token.js'; +import { generateAuthUrl, getTokenStatus, resolveUserToken, resolveOAuthRedirectUri, DOC_COMMENT_OAUTH_SCOPES, FEED_GROUP_OAUTH_SCOPES } from '../utils/user-token.js'; import { DocSubscriptionPermissionError, fetchDocTitle, listDocComments, resolveDocFile, subscribeDocFile, unsubscribeDocFile } from '../im/lark/doc-comment.js'; import { parseDocWatchCommand } from './doc-watch-command.js'; import { parseVcMeetingPrepareCommand } from './vc-meeting-prepare-command.js'; @@ -1251,16 +1251,9 @@ async function triggerUserAuthStatusLines( ): Promise { const policy = botCfg.triggerUserAuth; if (!policy?.enabled || !policy.tools.length) return []; - const brand = normalizeBrand(botCfg.brand); - // lark-cli acts as the person via either the new per-person device-code HOME - // or a legacy bot-app OAuth token. Both must count (and /login status reads the - // same two sources), or someone who authorized through the device flow would - // be told here they had not. The legacy lookup also supplies a display name. - const legacyLarkUser = senderOpenId - ? listAuthorizedUsers(botCfg.larkAppId, brand).find(u => u.openId === senderOpenId) - : undefined; - const larkAuthorized = senderOpenId !== undefined - && (hasLarkCliHome(senderOpenId) || !!legacyLarkUser); + // A Bot-app OAuth token is intentionally NOT a trigger-user fallback. Only + // the sender-keyed HOME created from scratch counts as lark-cli authorization. + const larkAuthorized = senderOpenId !== undefined && hasLarkCliHome(senderOpenId); const bytedStatus = policy.tools.includes('bytedcli') ? await bytedcliLoginStatus(senderOpenId) : undefined; const lines = ['Trigger-user auth: 已开启']; @@ -1268,7 +1261,7 @@ async function triggerUserAuthStatusLines( lines.push(` ${tool}: ${ tool === 'lark-cli' ? larkAuthorized - ? `以${legacyLarkUser?.userName ? `「${legacyLarkUser.userName}」` : '你自己'}的身份调用` + ? '以你自己的身份调用' // lark-cli no longer degrades to the bot's own identity: an // unauthorized call is refused, and the refusal carries a ready // device-code link. Saying "running as the bot" here would describe a @@ -3663,9 +3656,7 @@ export async function handleCommand( await sessionReply(rootId, t('cmd.login.no_credentials', undefined, loc)); break; } - // 授权归属到「发起这条 /login 的人」。token 代表一个人而不是一个 bot:不带 - // 这个 open_id,同 bot 里第二个人 /login 会覆盖第一个人,之后所有人的操作 - // 都在用最后授权那个人的权限。回调仍会用 user_info 复核真实授权人。 + // 授权归属到「发起这条 /login 的人」。每个人的 lark-cli HOME 独立。 const loginOpenId = message.senderId; // Only sessions created with the new entry use the receiving app here. // Existing sessions keep the original login flow below. @@ -3743,13 +3734,16 @@ export async function handleCommand( break; } - // `/login lark` — lark-cli device-code (QR) authorization against the - // provisioned per-person issuer app. Non-blocking: returns a verify URL. + // `/login lark` — compatibility entry for the same automatic flow: + // select/bind an app in this person's HOME, then authorize through it. if (subCmd === 'lark' || subCmd.startsWith('lark ')) { if (loginOpenId) { - const started = await beginLarkCliLogin(loginOpenId); + const brand = normalizeBrand(botCfg2.brand); + const started = await (brand === 'lark' + ? beginLarkCliLogin(loginOpenId, [], brand) + : beginLarkCliLogin(loginOpenId)); if (!started) { - await sessionReply(rootId, t('cmd.login.lark_begin_failed', { detail: 'lark-cli has no provisioned issuer app on the server' }, loc)); + await sessionReply(rootId, t('cmd.login.lark_begin_failed', { detail: 'personal app setup could not be started' }, loc)); break; } await sessionReply(rootId, [ @@ -3773,9 +3767,12 @@ export async function handleCommand( const larkDeviceOn = triggerUserAuthApplies(botCfg2.triggerUserAuth, 'lark-cli'); if (larkDeviceOn && subCmd === '') { if (loginOpenId) { - const started = await beginLarkCliLogin(loginOpenId); + const brand = normalizeBrand(botCfg2.brand); + const started = await (brand === 'lark' + ? beginLarkCliLogin(loginOpenId, [], brand) + : beginLarkCliLogin(loginOpenId)); if (!started) { - await sessionReply(rootId, t('cmd.login.lark_begin_failed', { detail: 'no provisioned issuer app' }, loc)); + await sessionReply(rootId, t('cmd.login.lark_begin_failed', { detail: 'personal app setup could not be started' }, loc)); break; } await sessionReply(rootId, [ diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 471553d388..54b7e0bf26 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -370,6 +370,13 @@ import { import { getBotBrand, getBot, getBotClient, getBotOpenId, getOwnerOpenId, loadBotConfigs, readBotSkillPolicy, getBotTuiSlashAllow, updateBotNativeSubagentRuntime, MAX_TURN_TIMEOUT_MS, normalizeDshProfile, type BotConfig, type NativeSubagentRuntimeConfigState, type UsageDisplayMode, type MessageListenerConfig } from '../bot-registry.js'; import { generateAuthUrl, tryHandleCallbackUrl, getFeedGroupAuthStatus, listAuthorizedUsers, FEED_GROUP_OAUTH_SCOPES, requestUserAuthorization } from '../utils/user-token.js'; import { tokenStoreProtection, triggerUserAuthApplies, type TriggerUserAuthConfig } from '../services/trigger-user-auth.js'; +import { + beginLarkCliLogin, + completeLarkCliLogin, + larkCliHomeForTurn, + larkCliLoginScopes, + materializeLarkCliHomeForSession, +} from '../services/lark-cli-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'; @@ -2496,10 +2503,19 @@ ipcRoute('POST', '/api/sessions/:sessionId/lark-tool-identity', async (req, res, return jsonRes(res, 200, { ok: true, appId: bot.larkAppId, mode: 'user', credentialType: 'user_access_token', credential: token }); }); +type SessionAuthPollResult = + | { status: 'pending' } + // Personal-app trigger-user path: the acting identity is a HOME directory. + | { status: 'ready'; mode: 'user-home'; home: string } + // Standard session-bound lark tool (#1606): the acting identity is a + // Bot-application-issued user_access_token. + | { status: 'ready'; mode: 'app-token'; token: string } + | { status: 'failed'; error: string }; + const sessionAuthRequests = new Map boolean; - poll: Awaited>['poll']; + poll: () => Promise; }>(); for (const action of ['auth-request', 'auth-status']) { @@ -2580,9 +2596,14 @@ for (const action of ['auth-request', 'auth-status']) { if (result.status === 'failed') { return jsonRes(res, 400, { ok: false, status: 'failed', error: result.error }); } - if (!boundTool && !refreshSessionIdentity(config.session.dataDir, params.sessionId, { - tool: 'lark-cli', appId: cfg.larkAppId, userAccessToken: result.token, turnId, - })) { + // Personal-app trigger-user path publishes a per-turn HOME. The bound + // lark-tool path (#1606) owns NO trigger-user identity file — its token + // is served live by the narrow lark-tool-identity route — so it must not + // be written here (that would resurrect the removed Bot-app-token file). + if (result.mode === 'user-home' + && !refreshSessionIdentity(config.session.dataDir, params.sessionId, { + tool: 'lark-cli', mode: 'user-home', home: result.home, turnId, + })) { return jsonRes(res, 409, { ok: false, error: 'auth_turn_changed' }); } return jsonRes(res, 200, { ok: true, status: 'ready' }); @@ -2602,21 +2623,97 @@ for (const action of ['auth-request', 'auth-status']) { if (!identity || identity.type !== 'user' || identity.openId !== callerOpenId || !isCurrent()) { return jsonRes(res, 403, { ok: false, error: 'current_actor_unverified' }); } - let authorization: Awaited>; + const requestId = randomBytes(32).toString('hex'); + if (boundTool) { + // Standard session-bound lark tool (#1606): authorize against the Bot's + // own application and serve the resulting user_access_token live. This + // session's worker removed lark-cli from trigger-user tools, so it never + // uses the personal-app path below. + let authorization: Awaited>; + try { + authorization = await requestUserAuthorization( + cfg.larkAppId, cfg.larkAppSecret, normalizeBrand(cfg.brand), scopes, callerOpenId, isCurrent, + ); + } catch { + return jsonRes(res, 502, { ok: false, error: 'authorization_request_failed' }); + } + if (!isCurrent()) return jsonRes(res, 409, { ok: false, error: 'auth_turn_changed' }); + sessionAuthRequests.set(requestId, { + sessionId: params.sessionId, + isCurrent, + poll: async () => { + const r = await authorization.poll(); + if (r.status !== 'ready') return r; + return { status: 'ready', mode: 'app-token', token: r.token }; + }, + }); + setTimeout(() => sessionAuthRequests.delete(requestId), authorization.expiresIn * 1_000).unref(); + return jsonRes(res, 200, { + ok: true, + authUrl: authorization.authUrl, + requestId, + scopes: authorization.scopes, + expiresIn: authorization.expiresIn, + autoCallback: true, + }); + } + + // Trigger-user personal-app path: the sender selects/creates their own + // application first (stage app-setup), then authorizes the user against it; + // the acting identity published per turn is a HOME directory. + let authorization: Awaited>; try { - authorization = await requestUserAuthorization( - cfg.larkAppId, cfg.larkAppSecret, normalizeBrand(cfg.brand), scopes, callerOpenId, isCurrent, - ); + const brand = normalizeBrand(cfg.brand); + authorization = await (brand === 'lark' + ? beginLarkCliLogin(callerOpenId, scopes, brand) + : beginLarkCliLogin(callerOpenId, scopes)); } catch { return jsonRes(res, 502, { ok: false, error: 'authorization_request_failed' }); } if (!isCurrent()) return jsonRes(res, 409, { ok: false, error: 'auth_turn_changed' }); - const requestId = randomBytes(32).toString('hex'); - sessionAuthRequests.set(requestId, { sessionId: params.sessionId, isCurrent, poll: authorization.poll }); - setTimeout(() => sessionAuthRequests.delete(requestId), authorization.expiresIn * 1_000).unref(); + if (!authorization) { + return jsonRes(res, 502, { ok: false, error: 'authorization_request_failed' }); + } + if (authorization.stage === 'app-setup') { + // Scope top-ups only make sense after the sender has a personal app. Hand + // the first-step URL back explicitly; never fall through to the Bot app. + return jsonRes(res, 409, { + ok: false, + error: 'personal_lark_app_required', + authUrl: authorization.authUrl, + }); + } + const expiresIn = 9 * 60; + sessionAuthRequests.set(requestId, { + sessionId: params.sessionId, + isCurrent, + poll: async () => { + const completed = await completeLarkCliLogin(callerOpenId); + if (completed.state === 'pending') return { status: 'pending' }; + if (completed.state === 'failed') { + return { status: 'failed', error: completed.detail ?? 'authorization_failed' }; + } + const sandbox = ds.session.sandbox; + const home = sandbox === true || sandbox === 'oncall' || sandbox === 'scratch' + ? await materializeLarkCliHomeForSession( + callerOpenId, + config.session.dataDir, + params.sessionId, + ) + : larkCliHomeForTurn(callerOpenId); + return home + ? { status: 'ready', mode: 'user-home', home } + : { status: 'failed', error: 'authorization_identity_unavailable' }; + }, + }); + setTimeout(() => sessionAuthRequests.delete(requestId), expiresIn * 1_000).unref(); return jsonRes(res, 200, { - ok: true, authUrl: authorization.authUrl, requestId, - scopes: authorization.scopes, expiresIn: authorization.expiresIn, autoCallback: true, + ok: true, + authUrl: authorization.authUrl, + requestId, + scopes: authorization.scopes ?? larkCliLoginScopes(scopes), + expiresIn, + autoCallback: true, }); }); } diff --git a/src/core/dispatch-user-delegation.ts b/src/core/dispatch-user-delegation.ts index 9e749bfe7c..10e216147f 100644 --- a/src/core/dispatch-user-delegation.ts +++ b/src/core/dispatch-user-delegation.ts @@ -17,7 +17,7 @@ const authorityV1Schema = z.object({ appId: identity('cli_'), openId: identity('ou_'), unionId: identity('on_'), - tools: z.array(z.enum(['lark-cli', 'bytedcli'])).min(1).max(2), + tools: z.array(z.enum(['lark-cli', 'bytedcli', 'larkdev'])).min(1).max(3), }).strict(); const scheduleCreateCapabilitySchema = z.object({ action: z.literal('schedule:create'), @@ -33,7 +33,7 @@ const authorityV2Schema = z.object({ appId: identity('cli_'), openId: identity('ou_'), unionId: identity('on_'), - tools: z.array(z.enum(['lark-cli', 'bytedcli'])).max(2), + tools: z.array(z.enum(['lark-cli', 'bytedcli', 'larkdev'])).max(3), capabilities: z.array(scheduleCreateCapabilitySchema).min(1).max(64), }).strict(); const payloadBase = { diff --git a/src/core/schedule-delegated-runtime.ts b/src/core/schedule-delegated-runtime.ts index 13fd9e950a..dbf92d35c5 100644 --- a/src/core/schedule-delegated-runtime.ts +++ b/src/core/schedule-delegated-runtime.ts @@ -1,6 +1,17 @@ import type { ScheduledTask } from '../types.js'; import type { ScheduleAuthorityRecord } from '../services/schedule-authority-store.js'; -import { TRIGGER_USER_AUTH_TOOLS, type TriggerUserAuthTool } from '../services/trigger-user-auth.js'; +import type { TriggerUserAuthTool } from '../services/trigger-user-auth.js'; + +/** + * Persistent delegation currently grants only the legacy bytedcli run scope. + * Keep its isolation prerequisite pinned to the two tools that existed when + * those grants were issued; adding opt-in larkdev support must not revoke every + * stored schedule merely because the global supported-tool enum grew. + */ +export const DELEGATED_SCHEDULE_REQUIRED_IDENTITY_TOOLS = [ + 'lark-cli', + 'bytedcli', +] as const satisfies readonly TriggerUserAuthTool[]; export interface DelegatedScheduleRuntimeDependencies { runEnabled: boolean; @@ -49,7 +60,9 @@ export async function authorizeDelegatedScheduleRun( if (authority.kind !== 'delegated') throw new Error('schedule authority is not delegated'); if (authority.state !== 'active') throw new Error(`schedule authority state is ${authority.state}`); if (!deps.runEnabled) throw new Error('delegated schedule execution is revoked by host policy'); - if (!TRIGGER_USER_AUTH_TOOLS.every(tool => deps.triggerUserAuthTools.includes(tool))) { + if (!DELEGATED_SCHEDULE_REQUIRED_IDENTITY_TOOLS.every( + tool => deps.triggerUserAuthTools.includes(tool), + )) { throw new Error('delegated schedule requires triggerUserAuth isolation for every identity tool'); } if (authority.runScopes.some(scope => !deps.hostRunScopes.includes(scope) diff --git a/src/core/session-temp.ts b/src/core/session-temp.ts index 798f9670fa..8ff7216d1f 100644 --- a/src/core/session-temp.ts +++ b/src/core/session-temp.ts @@ -28,7 +28,12 @@ export function sessionTempDir(dataDir: string, sessionId: string): string { .update(resolve(dataDir)) .digest('hex') .slice(0, 16); - return join(homedir(), '.cache', 'botmux', 'session-tmp', profile, sessionTempSegment(sessionId)); + return join(sessionTempRootDir(), profile, sessionTempSegment(sessionId)); +} + +/** Shared parent containing every bot/session scratch tree for this OS user. */ +export function sessionTempRootDir(): string { + return join(homedir(), '.cache', 'botmux', 'session-tmp'); } /** Create and validate the session scratch directory before exposing it. */ diff --git a/src/core/turn-cli-identity.ts b/src/core/turn-cli-identity.ts index 904e3f4e88..7929f4ab7c 100644 --- a/src/core/turn-cli-identity.ts +++ b/src/core/turn-cli-identity.ts @@ -13,12 +13,20 @@ * previous person, which is the exact failure the feature exists to remove. */ import { logger } from '../utils/logger.js'; -import { resolveUserToken, lookupAuthorizedUserName } from '../utils/user-token.js'; +import { lookupAuthorizedUserName } from '../utils/user-token.js'; import { t } from '../i18n/index.js'; import type { Locale } from '../i18n/index.js'; import { normalizeBrand } from '../im/lark/lark-hosts.js'; -import { beginBytedcliLogin, mintBytedcliJwts } from '../services/bytedcli-auth.js'; -import { resolveLarkCliHomeForTurn, beginLarkCliLogin } from '../services/lark-cli-auth.js'; +import { + beginBytedcliLogin, + mintByteCloudUserJwts, +} from '../services/bytedcli-auth.js'; +import { + resolveLarkCliHomeForTurn, + materializeLarkCliHomeForSession, + beginLarkCliLogin, + type LarkCliLoginChallenge, +} from '../services/lark-cli-auth.js'; import type { BotConfig } from '../bot-registry.js'; import { hasLarkToolBinding } from './lark-tool-binding.js'; import { rememberLarkToolDelegation } from './lark-tool-delegation.js'; @@ -59,6 +67,8 @@ export interface DelegatedCliIdentity { export interface PublishTurnIdentityArgs { botConfig: BotConfig; + /** Sandbox mode frozen on the session at creation time. */ + sessionSandbox?: BotConfig['sandbox']; sessionDataDir: string; sessionId: string; /** The person who sent THIS turn. Absent for turns with no human sender. */ @@ -74,6 +84,24 @@ export interface PublishTurnIdentityArgs { turnId?: string; } +function isByteCloudTool(tool: TriggerUserAuthTool): boolean { + return tool === 'bytedcli' || tool === 'larkdev'; +} + +async function larkCliHomeForExecution( + sandbox: BotConfig['sandbox'], + sessionDataDir: string, + sessionId: string, + openId: string, +): Promise { + const sandboxed = sandbox === true + || sandbox === 'oncall' + || sandbox === 'scratch'; + return sandboxed + ? await materializeLarkCliHomeForSession(openId, sessionDataDir, sessionId) + : await resolveLarkCliHomeForTurn(openId); +} + /** * Publish (or withhold) each governed tool's identity for the current turn. * @@ -103,7 +131,16 @@ export async function publishTurnCliIdentity( try { outcomes.push(args.delegatedIdentity ? await publishDelegated(tool, args, args.delegatedIdentity) - : await publishOne(tool, botConfig, sessionDataDir, sessionId, senderOpenId, locale, turnId)); + : await publishOne( + tool, + botConfig, + args.sessionSandbox ?? botConfig.sandbox, + sessionDataDir, + sessionId, + senderOpenId, + locale, + turnId, + )); } catch (e) { // Fail closed through the SAME policy as an ordinary missing token, so a // credential-store outage and "this person never authorized" cannot end @@ -114,13 +151,13 @@ export async function publishTurnCliIdentity( `[trigger-user-auth] withheld ${tool} identity for session ${sessionId}: ` + `${e instanceof Error ? e.message : String(e)}`, ); - if (tool === 'bytedcli') { + if (isByteCloudTool(tool)) { try { writeSessionIdentity(sessionDataDir, sessionId, { tool, mode: 'denied', ...(turnId ? { turnId } : {}), message: locale === 'en' - ? 'botmux: bytedcli authorization service is unavailable. Stop automatic retries and repeated login requests; retry after the service recovers. Existing authorization is retained.' - : 'botmux: bytedcli 授权服务暂时不可用。请停止自动重试和重复要求用户登录;服务恢复后再重试,已有授权会保留。', + ? `botmux: ${tool} ByteCloud authorization service is unavailable. Stop automatic retries and repeated login requests; retry after the service recovers. Existing authorization is retained.` + : `botmux: ${tool} 的 ByteCloud 授权服务暂时不可用。请停止自动重试和重复要求用户登录;服务恢复后再重试,已有授权会保留。`, }); } catch { clearSessionIdentity(sessionDataDir, sessionId, tool); } outcomes.push({ tool, state: 'unavailable' }); @@ -159,18 +196,22 @@ async function publishDelegated(tool: TriggerUserAuthTool, args: PublishTurnIden if (tool === 'bytedcli') { // Keep the issuer-scoped credential key; do not copy a source open_id into // the target app or duplicate/extend the lifetime of the user's login. - const jwt = await mintBytedcliJwts(user.credentialOpenId); - if (jwt) identity = { tool, cloudJwt: jwt.cloudJwt, ...(jwt.codeJwt ? { codeJwt: jwt.codeJwt } : {}) }; - } else { - const home = await resolveLarkCliHomeForTurn(user.credentialOpenId); + const cloudJwts = await mintByteCloudUserJwts(user.credentialOpenId); + if (cloudJwts) identity = { tool, cloudJwts }; + } else if (tool === 'lark-cli') { + const home = await larkCliHomeForExecution( + args.sessionSandbox ?? args.botConfig.sandbox, + args.sessionDataDir, + args.sessionId, + user.credentialOpenId, + ); if (home) identity = { tool, mode: 'user-home', home }; - else if (user.targetOpenId && args.botConfig.larkAppId && args.botConfig.larkAppSecret) { - // Legacy bot-app OAuth is application-bound: only a target-app token is - // valid here. A source-app OAuth token is never presented as a target one. - const token = await resolveUserToken(args.botConfig.larkAppId, args.botConfig.larkAppSecret, - normalizeBrand(args.botConfig.brand), user.targetOpenId); - if (token) identity = { tool, appId: args.botConfig.larkAppId, userAccessToken: token }; - } + // No bot-app OAuth fallback. Delegation may carry the original human's + // personal lark-cli HOME, but it must never turn a receiving Bot's shared + // app into that person's OAuth client. + } else { + const cloudJwts = await mintByteCloudUserJwts(user.credentialOpenId); + if (cloudJwts) identity = { tool, cloudJwts }; } if (!identity) return denyDelegated(tool, args, user); writeSessionIdentity(args.sessionDataDir, args.sessionId, { ...identity, ...(args.turnId ? { turnId: args.turnId } : {}) }); @@ -180,6 +221,7 @@ async function publishDelegated(tool: TriggerUserAuthTool, args: PublishTurnIden async function publishOne( tool: TriggerUserAuthTool, botConfig: BotConfig, + sessionSandbox: BotConfig['sandbox'], sessionDataDir: string, sessionId: string, senderOpenId: string | undefined, @@ -194,7 +236,14 @@ async function publishOne( // creator's or the owner's credentials to fill the gap. if (!senderOpenId) return await withheld(); - const identity = await resolveIdentityFor(tool, botConfig, senderOpenId); + const identity = await resolveIdentityFor( + tool, + botConfig, + sessionSandbox, + sessionDataDir, + sessionId, + senderOpenId, + ); if (!identity) return await withheld(); writeSessionIdentity(sessionDataDir, sessionId, { ...identity, ...(turnId ? { turnId } : {}) }); @@ -227,22 +276,28 @@ async function withholdIdentity( // of asking the person to type a command. Beginning only mints a link and // messages nobody, so non-CLI turns are not disturbed. Uses a fresh, still // unexpired challenge when one exists. - let authUrl: string | undefined; + let login: LarkCliLoginChallenge | undefined; if (senderOpenId) { try { - authUrl = tool === 'bytedcli' - ? (await beginBytedcliLogin(senderOpenId))?.authUrl - : (await beginLarkCliLogin(senderOpenId))?.authUrl; - if (tool === 'bytedcli' && !authUrl) throw new Error('bytedcli login provider unavailable'); + if (isByteCloudTool(tool)) { + const started = await beginBytedcliLogin(senderOpenId); + if (!started?.authUrl) throw new Error('bytedcli login provider unavailable'); + login = { authUrl: started.authUrl, stage: 'user-login' }; + } else { + const brand = normalizeBrand(botConfig.brand); + login = await (brand === 'lark' + ? beginLarkCliLogin(senderOpenId, [], brand) + : beginLarkCliLogin(senderOpenId)) ?? undefined; + } } catch (e) { - if (tool === 'bytedcli') throw e; + if (isByteCloudTool(tool)) throw e; logger.warn( `[trigger-user-auth] could not pre-fetch ${tool} auth link for session ${sessionId}: ` + `${e instanceof Error ? e.message : String(e)}`, ); } } - writeDenial(sessionDataDir, sessionId, tool, senderOpenId, botConfig, locale, turnId, authUrl); + writeDenial(sessionDataDir, sessionId, tool, senderOpenId, botConfig, locale, turnId, login); return { tool, state: 'needs-authorization' }; } @@ -266,7 +321,7 @@ function writeDenial( botConfig: BotConfig, locale: Locale | undefined, turnId: string | undefined, - authUrl?: string, + login?: LarkCliLoginChallenge, ): void { try { const name = senderOpenId && botConfig.larkAppId @@ -277,7 +332,7 @@ function writeDenial( // Name the right provider. bytedcli authenticates against ByteCloud, so // saying "Feishu authorization" would send the reader to authorize the // wrong thing — the same mistake as naming the wrong /login command. - const provider = tool === 'bytedcli' + const provider = isByteCloudTool(tool) ? 'ByteCloud' : t('trigger_user_auth.provider_lark', undefined, locale); // With no name, address the reader directly rather than printing a raw @@ -300,15 +355,27 @@ function writeDenial( // one-tap instruction and a clear "authorize once" framing so it reads // as "one step left", not as a broken bot. Without a link (fetch failed // or no human sender), fall back to the /login instructions. - ...(authUrl + ...(login ? [ - t('trigger_user_auth.denied_auto_login', { tool, provider }, locale), - authUrl, - t('trigger_user_auth.denied_auto_retry', undefined, locale), + t( + login.stage === 'app-setup' + ? 'trigger_user_auth.denied_personal_app_setup' + : 'trigger_user_auth.denied_auto_login', + { tool, provider }, + locale, + ), + login.authUrl, + t( + login.stage === 'app-setup' + ? 'trigger_user_auth.denied_personal_app_retry' + : 'trigger_user_auth.denied_auto_retry', + undefined, + locale, + ), ] : [t( 'trigger_user_auth.denied_howto', - { command: tool === 'bytedcli' ? '/login bytedcli' : '/login' }, + { command: isByteCloudTool(tool) ? '/login bytedcli' : '/login' }, locale, ), t('trigger_user_auth.denied_howto_status', undefined, locale)]), @@ -326,6 +393,9 @@ function writeDenial( async function resolveIdentityFor( tool: TriggerUserAuthTool, botConfig: BotConfig, + sessionSandbox: BotConfig['sandbox'], + sessionDataDir: string, + sessionId: string, senderOpenId: string, ): Promise { if (tool === 'lark-cli') { @@ -338,35 +408,39 @@ async function resolveIdentityFor( // The resolver polls a pending device login once before deciding: a browser // approval writes nothing locally, so without that poll "tap the link, then // retry" could never succeed on the turn path. - const home = await resolveLarkCliHomeForTurn(senderOpenId); + const home = await larkCliHomeForExecution( + sessionSandbox, + sessionDataDir, + sessionId, + senderOpenId, + ); if (home) { return { tool: 'lark-cli', mode: 'user-home', home }; } - // Back-compat: a bot-app OAuth user token already stored server-side. - if (!botConfig.larkAppId || !botConfig.larkAppSecret) return null; - const token = await resolveUserToken( - botConfig.larkAppId, - botConfig.larkAppSecret, - normalizeBrand(botConfig.brand), - senderOpenId, - ); - if (!token) return null; - // The app id travels with the token: lark-cli refuses a token without it - // ("blocked by env: …USER_ACCESS_TOKEN is set but …APP_ID is missing"). - return { tool: 'lark-cli', appId: botConfig.larkAppId, userAccessToken: token }; + // Strict boundary: trigger-user lark-cli accepts only the sender's personal + // app + login HOME. A bot-app token is per-user but still shares one OAuth + // application, which is the identity escape this mode must prevent. + return null; + } + + if (tool === 'larkdev') { + // larkdev uses the same ByteCloud person as bytedcli, but receives + // site-specific short-lived JWTs. Its wrapper points LARKDEV_AUTH_DIR at a + // fresh directory, so an unavailable region cannot fall back to the + // operator's ~/.larkdev/auth state. + const cloudJwts = await mintByteCloudUserJwts(senderOpenId); + return cloudJwts ? { tool: 'larkdev', cloudJwts } : null; } // bytedcli authenticates against ByteCloud SSO, a different provider from - // Lark OAuth — a Lark user token is not convertible into a ByteCloud JWT. So - // this person's credentials come from their own bytedcli login, minted fresh - // per turn: the ByteCloud JWT lives ~2 hours while the login behind it lives - // ~3 weeks, and bytedcli refreshes it internally, so asking each time is what - // keeps people from being sent back to a QR code every couple of hours. + // Lark OAuth — a Lark user token is not convertible into a ByteCloud JWT. Mint + // this person's real ByteCloud SDK JWT inputs for every supported site. The + // wrapper runs the real bytedcli in an empty HOME, so a missing regional JWT + // fails closed instead of falling back to the operator's persistent login. // // Null when they have not authorized (or their login has ended), which the // caller turns into the ordinary "authorize, then retry" refusal rather than // falling back to the machine's own SSO session. - const jwts = await mintBytedcliJwts(senderOpenId); - if (!jwts) return null; - return { tool: 'bytedcli', cloudJwt: jwts.cloudJwt, ...(jwts.codeJwt ? { codeJwt: jwts.codeJwt } : {}) }; + const cloudJwts = await mintByteCloudUserJwts(senderOpenId); + return cloudJwts ? { tool: 'bytedcli', cloudJwts } : null; } diff --git a/src/daemon.ts b/src/daemon.ts index 16c1498984..f0e2120fd3 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -422,9 +422,8 @@ import { authorityForDispatch, dispatchCallerFromReply, deliverDispatchWithUser, import { resolveUnionIdFromOpenId } from './im/lark/client.js'; import { ScheduleAuthorityStore } from './services/schedule-authority-store.js'; import { computeInputHash } from './utils/canonical-input-hash.js'; -import { authorizeDelegatedScheduleRun, authorizeDelegatedScheduleSelfManage } from './core/schedule-delegated-runtime.js'; +import { authorizeDelegatedScheduleRun, authorizeDelegatedScheduleSelfManage, DELEGATED_SCHEDULE_REQUIRED_IDENTITY_TOOLS } from './core/schedule-delegated-runtime.js'; import { parseScheduledTurnId } from './core/scheduled-turn-provenance.js'; -import { TRIGGER_USER_AUTH_TOOLS } from './services/trigger-user-auth.js'; import { triggerSessionTurn, reconcileIdempotencyLeasesOnBoot, convergeIdempotentAsyncTurnOnWorkerExit, externalEventOpensOwnTopic } from './core/trigger-session.js'; import { runIdempotencyFailClose, @@ -3988,6 +3987,7 @@ async function prepareDelegatedScheduledTurnIdentity( ); await publishTurnCliIdentity({ botConfig: getBot(ds.larkAppId).config, + sessionSandbox: ds.session.sandbox, sessionDataDir: config.session.dataDir, sessionId: ds.session.sessionId, senderOpenId: undefined, @@ -4028,6 +4028,7 @@ async function refreshTurnCliIdentity(ds: DaemonSession, turnId: string): Promis } await publishTurnCliIdentity({ botConfig, + sessionSandbox: ds.session.sandbox, sessionDataDir: config.session.dataDir, sessionId: ds.session.sessionId, senderOpenId: delegationBlocked @@ -7127,7 +7128,9 @@ ipcRoute('POST', SCHEDULE_DELEGATED_ADD_ROUTE, async (req, res) => { const targetPolicy = readGlobalConfig().scheduleDelegation; const targetTriggerPolicy = getBot(ds.larkAppId).config.triggerUserAuth; if (!targetTriggerPolicy?.enabled - || !TRIGGER_USER_AUTH_TOOLS.every(tool => targetTriggerPolicy.tools.includes(tool))) { + || !DELEGATED_SCHEDULE_REQUIRED_IDENTITY_TOOLS.every( + tool => targetTriggerPolicy.tools.includes(tool), + )) { return jsonRes(res, 403, { ok: false, error: 'schedule_delegation_target_identity_isolation_required' }); } if (capability.allowedRunScopes.some(scope => !targetPolicy?.runScopes?.includes(scope) diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 3638b71d0e..a04ca495e6 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -3796,7 +3796,7 @@ function TriggerUserAuthSection(props: { bot: BotDefaultsRow; patchBot: PatchBot } } - function toggleTool(tool: 'lark-cli' | 'bytedcli', on: boolean): void { + function toggleTool(tool: 'lark-cli' | 'bytedcli' | 'larkdev', on: boolean): void { const next = on ? [...new Set([...tools, tool])] : tools.filter(t => t !== tool); void save({ enabled: true, tools: next, fallback }); } @@ -3818,7 +3818,7 @@ function TriggerUserAuthSection(props: { bot: BotDefaultsRow; patchBot: PatchBot <>
{tr('botDefaults.triggerUserAuthTools')} - {(['lark-cli', 'bytedcli'] as const).map(tool => ( + {(['lark-cli', 'bytedcli', 'larkdev'] as const).map(tool => (