Happy 项目权限模式解析机制详解:App 端与 Claude CLI 的 State-Based 权限解析全流程
【免费下载链接】happyMobile and Web client for Codex and Claude Code, with realtime voice, encryption and fully featured项目地址: https://gitcode.com/gh_mirrors/happy20/happy
本篇技术指南围绕 happy 仓库中docs/permission-resolution.md的权限解析规范展开,系统讲解permissionMode(权限模式)在移动/Web 客户端(packages/happy-app)与 Claude CLI(packages/happy-cli)之间如何被解析、传递、映射与强制约束,包括会话状态合并、出站消息元数据、CLI 启动解析、逐消息更新与沙箱策略。读完本文,你将掌握 Happy 项目中从"用户选择权限模式"到"最终传给 Claude SDK 的权限模式"的完整状态机链路,以及沙箱会话为何始终强制bypassPermissions的底层原理。
一、范围与整体脉络
权限模式的解析发生在三个层面,原文档(docs/permission-resolution.md)将其明确划分为:
- App 端状态解析:会话默认值、持久化值、出站消息元数据的生成;
- Claude CLI 解析:启动模式、逐消息更新、沙箱策略;
- 最终发送给 Claude SDK 的模式:由 CLI 在 SDK 边界完成映射。
这三层共同保证了"用户在 App 上选择的权限模式"与"Claude 进程实际执行的权限行为"始终一致,并且任何一端都无法通过消息元数据绕过沙箱约束。
二、权限模式全集与 Claude 映射规则
Happy 项目定义了一套共享的权限模式类型(共享于 App 与 CLI 之间):
default | acceptEdits | bypassPermissions | plan | read-only | safe-yolo | yolo而从源码看,实际完整的PermissionMode联合类型还包含auto,共 8 种模式。这一全集定义在 permissionMode.ts 的VALID_PERMISSION_MODES常量中:
const VALID_PERMISSION_MODES: readonly PermissionMode[] = [ 'auto', 'default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo', ] as const;其中auto是 Agent SDK 自身PermissionMode联合类型中的一等公民模式,因此直接透传而非映射到default。
Claude SDK 仅支持 4 种模式
Claude SDK 实际支持的权限模式为:default | acceptEdits | bypassPermissions | plan。因此 Happy 需要在 SDK 边界把其他模式映射到 Claude 兼容模式。这一映射只存在于一个地方:permissionMode.ts 的mapToClaudeMode:
const codexToClaudeMap: Record<string, ClaudeSdkPermissionMode> = { 'yolo': 'bypassPermissions', 'safe-yolo': 'default', 'read-only': 'default', }; return codexToClaudeMap[mode] ?? (mode as ClaudeSdkPermissionMode);映射规则:
| Happy 模式 | Claude SDK 模式 | 语义 |
|---|---|---|
yolo | bypassPermissions | 两者都是跳过全部权限询问 |
safe-yolo | default | Claude 无对应模式,退化为询问权限 |
read-only | default | Claude 不支持只读模式,退化为询问权限 |
auto/default/acceptEdits/bypassPermissions/plan | 原样透传 | Claude 原生支持的五个模式 |
一个值得注意的实现细节:mapToClaudeMode对undefined也做了特判并返回undefined——注释明确说明 "Undefined is a meaningful value, not a missing one",即undefined表示"无覆盖",SDK 会读取 Claude 自身的配置,而不是被强制为询问模式。这避免了把所有未设置的会话都钉死在提示模式上(见 runClaude.ts 中对currentEnhancedMode的注释)。
未知模式的防护
normalizeRemotePermissionMode(permissionMode.ts)负责收窄从网络到达的模式:消息 schema 允许任意字符串(较新的 App 可能命名了当前 CLI 不认识的模式),未知模式会被丢弃并打一条警告日志,保证消息本身仍可投递、会话保持当前模式,而不是让整条消息失败。
三、App 端状态解析:四层优先级
1) 会话状态加载/合并时的解析
在 storage.ts 的applySessions中,App 端对session.permissionMode的解析遵循以下顺序:
- 内存中已存在的会话模式(非
default); - 本地存储中持久化的每会话模式(非
default); - 服务端会话 payload 中的模式(非
default); - 沙箱兜底:
- 若
session.metadata.sandbox.enabled === true:bypassPermissions - 否则:
default
- 若
源码中的核心是resolveModePick辅助函数(storage.ts),它不仅处理 permissionMode,还统一处理modelMode与effortLevel:
const resolveModePick = (field: 'permissionMode' | 'modelMode' | 'effortLevel'): string | null => { const existing = state.sessions[session.id]?.[field] ?? null; if (isAgentModePushPending(session.id, field)) { return existing; // 乐观推送在途时,保留本地新值 } return session.metadata && session.metadata[field] !== undefined ? session.metadata[field] ?? null // 同步元数据优先(含显式 null = 重置) : existing; };这段注释(对应 issue #1492)说明了设计动机:权限/模型/努力等级的选择通过会话元数据同步。元数据值(包括显式null表示重置)优先于本地镜像,唯一例外是当该字段的乐观推送仍在途时——此时入站事件携带的仍是旧元数据,若直接应用会把刚做的本地选择弹回去。
2) 新会话草稿兜底
在 persistence.ts 中,NewSessionDraft结构包含permissionMode: PermissionModeKey | null。如果草稿缺少权限模式,默认值为default。
3) 新会话 UI 默认值
新会话向导(NewSessionWizard)的默认选择为default。若当前所选模式对当前选中的 Agent 无效,UI 会重置回上述 Agent 默认值。此外,modelModeOptions.ts中的permissionModeSupportedByCli(modelModeOptions.ts)会根据会话 CLI 的版本过滤掉该 CLI 无法解析的模式:例如auto模式从CLI_VERSION_WITH_AUTO版本才开始支持,旧 CLI 的会话选择器会隐藏auto,而保存的旧模式键(如旧会话里的auto、或应用于旧 CLI 的全局默认auto)绝不允许上线传输——因为旧 CLI 的 schema 会拒绝它并丢弃整条消息。测试 modelModeOptions.test.ts 验证了这一门控逻辑:
permissionModeSupportedByCli('auto', '1.2.1-beta.1') // false permissionModeSupportedByCli('auto', '1.2.1-beta.2') // true permissionModeSupportedByCli('plan', '1.2.0') // true4) 出站消息模式解析
App 发送消息时,调用链为sync.ts→resolveMessageModeMeta(messageMeta.ts)。关键逻辑:
- 若
session.permissionMode非default:原样发送; - 否则:
- 若
session.metadata.sandbox.enabled === true:发送bypassPermissions - 否则发送
default
- 若
resolveMessageModeMeta对三种 Agent 风味做了差异化处理:
- Rig(v1 元数据):从
session.permissionMode→metadata.currentOperatingModeCode→metadata.permissionMode→metadata.session.permissionMode依次取第一个可用值; - Codex / Agy:总是以具体值发送——因为 Codex 会在 abort 时重置到启动模式,而 Agy 在 provider 边界独立映射 model + effort,若省略兜底值,执行结果可能与 UI 显示不一致;
- Claude(及其他):会话有值用会话值,否则回落到
agentDefaultOverrides.permissionMode。
这里还有一个重要的拒绝而非替换设计:当会话或其保存的默认值携带了接收方 CLI 无法解析的模式时,resolveMessageModeMeta会抛出UnsupportedPermissionModeError(messageMeta.ts),调用方 sync.ts 会弹出错误提示并拒绝发送。注释的说明很直白:静默换成一个默认模式会悄悄改变 Agent 的权限边界——对 Claude 而言,这可能把用户选择的"reviewed Auto"升级成 yolo。测试 messageMeta.test.ts 覆盖了旧 CLI 上保存auto的各种拒绝场景,并断言错误消息中携带了模式名与 CLI 版本号。
出站消息的传输载体
最终解析出的模式随消息发送到两个位置(见 sync.ts):
- 加密消息的
meta.permissionMode - socket envelope 的
permissionMode
meta: { sentFrom, ...(rigSendsMessageReceipts(session.metadata) ? { expectsAcceptance: true } : {}), appendSystemPrompt: systemPrompt, ...(modeMeta.permissionMode !== undefined ? { permissionMode: modeMeta.permissionMode } : {}), ...(modeMeta.model !== undefined ? { model: modeMeta.model } : {}), ... }注意出站时使用了条件展开:只有permissionMode !== undefined才写入,避免无意义的空值污染消息。
四、Claude CLI 端解析:启动、逐消息与本地进程
1) 启动解析优先级
CLI 启动时的初始模式解析在 runClaude.ts 与 permissionMode.ts 中实现,优先级从高到低:
--dangerously-skip-permissions(最高优先级)→bypassPermissions--permission-mode VALUE或--permission-mode=VALUE- 传入的
options.permissionMode
extractPermissionModeFromClaudeArgs(permissionMode.ts)同时支持两种命令行写法:
--permission-mode plan // 空格分隔 --permission-mode=plan // 等号连接随后应用沙箱策略applySandboxPermissionPolicy(permissionMode.ts):
- 沙箱启用:强制
bypassPermissions - 沙箱禁用:保留解析出的模式
runClaude中还会基于初始模式推导出dangerouslySkipPermissions标志,并写入会话元数据(runClaude.ts):
const dangerouslySkipPermissions = initialPermissionMode === 'bypassPermissions' || initialPermissionMode === 'yolo' || sandboxEnabled || Boolean(options.claudeArgs?.includes('--dangerously-skip-permissions'));2) 远程流程中的逐消息更新
当用户消息携带meta.permissionMode时,CLI 调用resolveRemoteClaudePermissionMode(permissionMode.ts):
- 沙箱启用:强制
bypassPermissions - 沙箱禁用:使用入站模式
该函数还有一个防止"环境性降级"的保护逻辑:Happy App 的各个版本可能每条消息都发送permissionMode: "default",即使 CLI 进程是以 yolo/bypass 模式启动的。由于 Claude 在 SDK 边界把yolo和bypassPermissions都映射为 bypass,不能让这种环境性的default把当前模式降级,但仍允许plan等显式模式生效:
export function resolveRemoteClaudePermissionMode( currentMode: PermissionMode | undefined, incomingMode: PermissionMode | undefined, sandboxEnabled: boolean, ): PermissionMode | undefined { if (!incomingMode) { return currentMode; } const nextMode = applySandboxPermissionPolicy(incomingMode, sandboxEnabled); if (isClaudeBypassEquivalent(currentMode) && nextMode === 'default') { return currentMode; // 当前是 yolo/bypass,入站 default 不降级 } return nextMode; }isClaudeBypassEquivalent定义于 permissionMode.ts:mode === 'bypassPermissions' || mode === 'yolo'。在 runClaude.ts 的消息循环中,这一函数与normalizeRemotePermissionMode组合使用,并记录了ignoredDefaultDowngrade以跟踪被忽略的降级。
3) 本地 Claude 进程
在 claudeLocal.ts 中,若沙箱启用,launcher 会在 spawn 前把--dangerously-skip-permissions追加进启动参数:
if (opts.sandboxConfig?.enabled) { ... if (!spawnArgs.includes('--dangerously-skip-permissions')) { spawnArgs = [...spawnArgs, '--dangerously-skip-permissions']; } const fullCommand = ['node', ...spawnArgs.map((arg) => quoteShellArg(arg))].join(' '); spawnCommand = await wrapCommand(fullCommand); spawnWithShell = true; logger.info(`[ClaudeLocal] Sandbox enabled: workspace=..., network=${opts.sandboxConfig.networkMode}`); }同时注意沙箱初始化失败时的降级路径:捕获异常后cleanupSandbox = null; spawnCommand = null; spawnWithShell = false;并以不带--dangerously-skip-permissions的原始参数继续启动(claudeLocal.ts)。另外,Windows 平台不支持沙箱,会直接警告并继续(claudeLocal.ts)。
五、有效结果矩阵
综合 App 端与 CLI 端的两侧解析,最终生效模式可用矩阵概括:
沙箱启用
| 场景 | 生效模式 |
|---|---|
会话模式为default或缺失 | App 兜底为bypassPermissions |
| 用户消息携带任意模式 | CLI 沙箱策略强制为bypassPermissions |
| 本地 Claude 进程 | launcher 追加--dangerously-skip-permissions |
沙箱会话在全链路上都无法通过消息元数据重新启用权限询问——这正是权限模型稳定性的关键。
沙箱禁用
| 场景 | 生效模式 |
|---|---|
App/会话模式非default | 使用该模式(如plan、acceptEdits) |
App/会话模式为default或缺失 | App 发送default,CLI 走正常模式解析(无沙箱强制) |
六、稳定性设计:为什么这个方案现在很稳定
原文档在结尾点明了该设计的两个核心稳定性保证,结合源码可以进一步展开:
客户端兜底只在沙箱会话强制跳过权限:App 端的沙箱兜底逻辑(
metadata.sandbox.enabled === true→bypassPermissions)被收敛在 storage 合并与出站消息解析两处,非沙箱会话不会受到任何强制,用户选择的模式(包括plan、read-only、acceptEdits)原样透传。CLI 沙箱策略保证沙箱化 Claude 会话无法通过消息元数据重新启用权限询问:三处实现形成闭环——启动解析时
applySandboxPermissionPolicy强制 bypass、逐消息更新时resolveRemoteClaudePermissionMode再次强制、本地进程 spawn 时追加--dangerously-skip-permissions。即使 App 发送了default或恶意构造的元数据,CLI 也会在三个入口统一拦截。版本兼容防护:App 端的
permissionModeSupportedByCli(modelModeOptions.ts)与 CLI 端的normalizeRemotePermissionMode(permissionMode.ts)分别在发送端和接收端做了双重校验:发送端对旧 CLI 无法解析的模式拒绝发送而非静默替换(UnsupportedPermissionModeError),接收端对未知模式丢弃字段而非丢弃消息。两个方向都遵循"宁可拒绝也不悄悄改变权限"的原则。
七、延伸阅读
- 权限解析规范原文:docs/permission-resolution.md
- CLI 端模式映射与解析工具:permissionMode.ts
- CLI 启动与远程消息循环:runClaude.ts
- 本地进程沙箱启动器:claudeLocal.ts
- App 端会话状态合并:storage.ts
- App 端出站消息模式解析:messageMeta.ts 及测试 messageMeta.test.ts
- 模式选择器与 CLI 版本兼容门控:modelModeOptions.ts 及测试 modelModeOptions.test.ts
- 消息发送调用链:sync.ts
- 新会话草稿持久化:persistence.ts
【免费下载链接】happyMobile and Web client for Codex and Claude Code, with realtime voice, encryption and fully featured项目地址: https://gitcode.com/gh_mirrors/happy20/happy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考