diff --git a/.claude/rules/config-system.md b/.claude/rules/config-system.md index a429a3b..9b2b2b6 100644 --- a/.claude/rules/config-system.md +++ b/.claude/rules/config-system.md @@ -59,6 +59,7 @@ paths: - 专项复杂字段由 profile-editor 子组件维护,例如 Permissions、Sandbox、Hooks、Marketplace、Enabled Plugins、Status Line。 - 结构化设置分区的官方文档入口在 `StructuredSettingsSections.tsx`,新增分区时同步文档路径、i18n 和错误聚合。 - 复杂编辑器必须避免首次挂载 no-op writeback。尤其是 accordion 内懒挂载组件,语义等价时不要调用 `onChange`。 +- 同一语义存在两层存储时(`env.CLAUDE_CODE_EFFORT_LEVEL` 与顶层 `effortLevel`),编辑器必须按卡片的回退顺序读取:`env 覆盖 -> 供应商默认 -> 同义顶层键`,并在写回时清掉同义顶层键。只读写 env 会出现「卡片有值、编辑器未设置」,写回后残留的顶层键还会继续遮蔽新值(`SettingsFieldDefinition.legacyTopLevelKey`)。 - `ProfileEditor` 的 dirty 判断仍依赖 JSON 结构比较;局部编辑器写回时要保留未管理字段和 key 语义,避免只重建自己认识的字段。 ## 插件与 Marketplace @@ -75,6 +76,7 @@ paths: ## 权限与状态行 - 权限编辑器只管理 `defaultMode`、`disableBypassPermissionsMode`、`allow`、`deny`、`ask`、`additionalDirectories`;写回时保留其它顶层字段,例如 `disableAutoMode`。 +- 权限(`permission-presets.ts`)与沙箱(`sandbox-presets.ts`)的推荐预设逐条镜像维护者 dotfiles 的 `claude/settings.json` 对应段落,不额外追加项目自造的加固项;改任意一侧时同步另一侧和 `ProfileEditor.test.tsx` 断言。 - 修复权限 dirty 问题时优先做局部语义比较,不要扩大到全局 dirty 系统。 - 状态行默认脚本按平台分发:非 Windows 用 `src-tauri/resources/statusline/default.sh`(Bash,依赖 jq),Windows 用 `src-tauri/resources/statusline/default.ps1`(PowerShell,免 jq)。安装走后端 `install_status_line_preset`:Windows 写入 `~/.claude/statusline.ps1` 并把 `command` 设为绝对正斜杠**且加引号**的 `powershell -NoProfile -ExecutionPolicy Bypass -File "..."`(用户名含空格时不加引号会截断参数);两份脚本功能需保持对齐。 - 两份脚本源文件都**不带 BOM**;Windows 落盘时由 `config.rs::expected_status_line_script()` 前置 UTF-8 BOM。Windows PowerShell 5.1 读取无 BOM 的 `.ps1` 时按系统代码页(简中 CP936)解码,UTF-8 中文注释错位后残留的悬空 lead byte 会吞掉行尾换行,使下一行代码并入注释并触发 `ParserError`,状态行整行无输出。给源文件加 BOM 会变成双 BOM,Bash 脚本加 BOM 会让 shebang 失效——两者都不要做。 diff --git a/docs/user-manual.md b/docs/user-manual.md index be7483b..de84770 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -115,7 +115,7 @@ The configuration editor on the right is divided into several sections. - **Basic information**: name (required), description, optional provider (selecting one automatically fills in the connection endpoint and model mapping). - **Authentication**: the authentication key is written to `env.ANTHROPIC_AUTH_TOKEN`; the API endpoint is written to `env.ANTHROPIC_BASE_URL`, and when it is not set, the model test uses the official Anthropic endpoint. - **Models and behavior**: the default model (an editable dropdown whose candidates come from the current provider's models, or you can type a custom model), the effort level (`auto`/`low`/`medium`/`high`/`xhigh`/`max`), the Opus / Sonnet / Haiku default models (also editable dropdowns), the Subagent model, the reply language, and the output style. -- **Common options**: override common Claude Code switches such as deep thinking, thinking summaries, Fast Mode, disable Hooks, disable AI attribution, LSP tools, Tool Search, the new Init, no flicker, and Agent Teams. See the editor for the full list. +- **Common options**: override common Claude Code switches such as deep thinking, thinking summaries, Fast Mode, disable Hooks, disable AI attribution, LSP tools, Tool Search, the new Init, no flicker, subprocess credential scrubbing, and Agent Teams. See the editor for the full list. - **Environment variables**: maintain `env` keys other than authentication and common options. Duplicate keys, invalid JSON, or an unsaved row edit will block saving. - **Permissions**: default mode, disable `bypassPermissions`, allow / deny / ask rules, additional directories, and recommended rule presets. - **Sandbox**: can be enabled or disabled, with recommended presets to add; complex configurations can switch to JSON mode. diff --git a/docs/user-manual.zh-CN.md b/docs/user-manual.zh-CN.md index 2b98243..ece38ba 100644 --- a/docs/user-manual.zh-CN.md +++ b/docs/user-manual.zh-CN.md @@ -115,7 +115,7 @@ Skills 对应 `~/.claude/skills//SKILL.md`。启用的 Skill 保存在 `~/.c - **基础信息**:名称(必填)、描述、可选供应商(选择后自动带入连接地址与模型映射)。 - **认证**:认证密钥写入 `env.ANTHROPIC_AUTH_TOKEN`;API 地址写入 `env.ANTHROPIC_BASE_URL`,未设置时模型测试使用 Anthropic 官方地址。 - **模型与行为**:默认模型(可输入下拉框,候选取自当前供应商的模型,也可手工输入自定义模型)、努力级别(`auto`/`low`/`medium`/`high`/`xhigh`/`max`)、Opus / Sonnet / Haiku 默认模型(同为可输入下拉框)、Subagent 模型、回复语言、输出风格。 -- **常用选项**:覆盖深度思考、Thinking 摘要、Fast Mode、禁用 Hooks、禁用 AI 署名、LSP 工具、Tool Search、新版 Init、无闪烁、Agent Teams 等 Claude Code 常用开关。具体可在编辑器内查看。 +- **常用选项**:覆盖深度思考、Thinking 摘要、Fast Mode、禁用 Hooks、禁用 AI 署名、LSP 工具、Tool Search、新版 Init、无闪烁、子进程凭据清理、Agent Teams 等 Claude Code 常用开关。具体可在编辑器内查看。 - **环境变量**:维护除认证和常用选项外的 `env` 键。重复键、非法 JSON 或未保存的行编辑会阻止保存。 - **权限**:默认模式、禁用 `bypassPermissions`、允许 / 拒绝 / 询问规则、附加目录、推荐规则预设。 - **Sandbox**:可启用或关闭,可添加推荐预设;复杂配置可切换 JSON 模式。 diff --git a/src/components/ProfileEditor.tsx b/src/components/ProfileEditor.tsx index 15435d5..a0de8ef 100644 --- a/src/components/ProfileEditor.tsx +++ b/src/components/ProfileEditor.tsx @@ -410,16 +410,23 @@ const ProfileEditor = forwardRef(functi // providerDefault 是 provider 提供的继承默认,effectiveValue 是最终生效值。 const override = readEnvString(settings, field.envKey); const providerDefault = readProviderEnvDefault(field.envKey) || field.defaultValue || ""; + // 同义顶层键(如 effortLevel)是 schema 规范字段,Claude Code 的 /effort 会写入: + // 与配置卡片的回退顺序一致(env 覆盖 -> 供应商默认 -> 顶层键),避免两边显示不同的值 + const legacyValue = field.legacyTopLevelKey + ? readString(settings[field.legacyTopLevelKey]) + : ""; const source: "override" | "inherited" | "unset" = override ? "override" : providerDefault ? "inherited" - : "unset"; + : legacyValue + ? "override" + : "unset"; return { mappedToEnv: true, - value: override, + value: override || (!providerDefault ? legacyValue : ""), providerDefault, - effectiveValue: override || providerDefault, + effectiveValue: override || providerDefault || legacyValue, source, }; } @@ -441,13 +448,29 @@ const ProfileEditor = forwardRef(functi return readString(settings[field.key]); } + function setBehaviorEnvValue( + currentSettings: Record, + field: SettingsFieldDefinition, + value: string, + ) { + if (!field.envKey) { + return currentSettings; + } + let next = setEnvString(currentSettings, field.envKey, value); + if (field.legacyTopLevelKey) { + // 单字段与批量写回都清掉同义顶层键,避免旧值在之后的回退中重新出现。 + next = setTopLevelString(next, field.legacyTopLevelKey, ""); + } + return next; + } + function handleMappedFieldChange( field: SettingsFieldDefinition, value: string, _mappedToEnv: boolean, ) { if (field.envKey) { - applySettings(setEnvString(settings, field.envKey, value)); + applySettings(setBehaviorEnvValue(settings, field, value)); return; } handleSimpleFieldChange(field, value); @@ -658,7 +681,7 @@ const ProfileEditor = forwardRef(functi } const state = readBehaviorFieldState(field); if (state.source === "override" && state.providerDefault) { - return setEnvString(acc, field.envKey, ""); + return setBehaviorEnvValue(acc, field, ""); } return acc; }, settings); @@ -673,7 +696,7 @@ const ProfileEditor = forwardRef(functi } const state = readBehaviorFieldState(field); if (state.source === "inherited" && state.providerDefault) { - return setEnvString(acc, field.envKey, state.providerDefault); + return setBehaviorEnvValue(acc, field, state.providerDefault); } return acc; }, settings); diff --git a/src/components/__tests__/ProfileEditor.test.tsx b/src/components/__tests__/ProfileEditor.test.tsx index 6df22ad..f87d07f 100644 --- a/src/components/__tests__/ProfileEditor.test.tsx +++ b/src/components/__tests__/ProfileEditor.test.tsx @@ -12,6 +12,8 @@ import { OFFICIAL_MARKETPLACE_ID, OFFICIAL_MARKETPLACE_REPO, } from "../profile-editor/marketplace-presets"; +import { RECOMMENDED_PERMISSION_RULES } from "../profile-editor/permission-presets"; +import { RECOMMENDED_SANDBOX_PRESET } from "../profile-editor/sandbox-presets"; import { ThemeProvider } from "../theme-provider"; const { invokeMock, showToastMock, fetchMock, openDialogMock, openUrlMock } = vi.hoisted(() => ({ @@ -683,12 +685,12 @@ describe("ProfileEditor", () => { "aria-expanded", "false", ); - expect(within(commonSection).getByText("已启用 0/15")).toBeInTheDocument(); + expect(within(commonSection).getByText("已启用 0/16")).toBeInTheDocument(); expect(within(commonSection).queryByRole("button", { name: "控件" })).not.toBeInTheDocument(); expect(within(commonSection).queryByRole("button", { name: "JSON" })).not.toBeInTheDocument(); const commonHeader = within(commonSection) - .getByText("已启用 0/15") + .getByText("已启用 0/16") .closest('[data-slot="settings-section-header"]'); expect(commonHeader).toHaveClass("cursor-pointer"); fireEvent.click(commonHeader as HTMLElement); @@ -698,7 +700,7 @@ describe("ProfileEditor", () => { expect( within(commonSection).queryByRole("combobox", { name: "输出风格" }), ).not.toBeInTheDocument(); - expect(within(commonSection).getAllByRole("switch")).toHaveLength(15); + expect(within(commonSection).getAllByRole("switch")).toHaveLength(16); expect(within(commonSection).getByText("默认启用深度思考")).toBeInTheDocument(); expect(within(commonSection).getByText("显示 Thinking 摘要")).toBeInTheDocument(); expect(within(commonSection).getByText("接受计划时显示清理上下文")).toBeInTheDocument(); @@ -1806,6 +1808,80 @@ describe("ProfileEditor", () => { expect(saved.settings).not.toHaveProperty("effortLevel"); }); + it("shows a top-level effort level and migrates the edited value to env", async () => { + const onSave = vi.fn(); + renderEditor({ + onSave, + profile: { + ...PROFILE_FIXTURE, + providerId: "custom:team-plan", + settings: { + ...PROFILE_FIXTURE.settings, + model: "opus", + effortLevel: "xhigh", + }, + }, + }); + + // Claude Code 的 /effort 会写顶层 effortLevel;卡片按「env -> 供应商 -> 顶层」回退, + // 编辑器必须显示同一个值,否则会出现「卡片有值、编辑器未设置」的错位 + expect(screen.getByLabelText("努力级别")).toHaveTextContent("xhigh"); + + act(() => { + fireEvent.click(screen.getByLabelText("努力级别")); + }); + const effortSlider = document.querySelector('[data-slot="effort-level-slider"]') as HTMLElement; + act(() => { + fireEvent.click(within(effortSlider).getByRole("button", { name: "medium" })); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "保存" })); + }); + + const saved = onSave.mock.calls[0][0]; + expect(saved.settings.env.CLAUDE_CODE_EFFORT_LEVEL).toBe("medium"); + // 被编辑过的字段清掉顶层同义键,避免它继续遮蔽刚写入的 env 值 + expect(saved.settings).not.toHaveProperty("effortLevel"); + // 未编辑的字段保持原样,不做无谓迁移 + expect(saved.settings.model).toBe("opus"); + }); + + it("clears both effort layers when the level is unset", async () => { + const onSave = vi.fn(); + renderEditor({ + onSave, + profile: { + ...PROFILE_FIXTURE, + providerId: "custom:team-plan", + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: "token", + CLAUDE_CODE_EFFORT_LEVEL: "high", + }, + effortLevel: "xhigh", + }, + }, + }); + + act(() => { + fireEvent.click(screen.getByLabelText("努力级别")); + }); + const effortSlider = document.querySelector('[data-slot="effort-level-slider"]') as HTMLElement; + act(() => { + fireEvent.click(within(effortSlider).getByRole("button", { name: "未设置" })); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "保存" })); + }); + + const saved = onSave.mock.calls[0][0]; + // 清空后不应留下任何一层旧值,否则卡片会回退到顶层键继续显示努力级别 + expect(saved.settings.env.CLAUDE_CODE_EFFORT_LEVEL).toBeUndefined(); + expect(saved.settings).not.toHaveProperty("effortLevel"); + }); + it("renders env-backed model override fields inside behavior", async () => { await act(async () => { renderEditor(); @@ -2601,13 +2677,17 @@ describe("ProfileEditor", () => { expect(within(permissionsSection).queryByLabelText("询问规则 1")).not.toBeInTheDocument(); expect(within(permissionsSection).queryByLabelText("拒绝规则 1")).not.toBeInTheDocument(); - fireEvent.click(within(permissionsSection).getByRole("button", { name: "展开 允许规则" })); + // 预设的 allow 为空,列表不再显示展开开关 + expect( + within(permissionsSection).queryByRole("button", { name: "展开 允许规则" }), + ).not.toBeInTheDocument(); fireEvent.click(within(permissionsSection).getByRole("button", { name: "展开 询问规则" })); fireEvent.click(within(permissionsSection).getByRole("button", { name: "展开 拒绝规则" })); - expect(within(permissionsSection).getByLabelText("允许规则 1")).toHaveValue("Bash(pwd)"); - expect(within(permissionsSection).getByLabelText("询问规则 1")).toHaveValue("Bash(rm *)"); - expect(within(permissionsSection).getByLabelText("拒绝规则 1")).toHaveValue("Bash(sudo *)"); + expect(within(permissionsSection).getByLabelText("询问规则 1")).toHaveValue("Bash(git push *)"); + expect(within(permissionsSection).getByLabelText("拒绝规则 1")).toHaveValue( + "Bash(gh auth token*)", + ); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "保存" })); @@ -2623,10 +2703,18 @@ describe("ProfileEditor", () => { disableAutoMode: "disable", additionalDirectories: ["~/projects/shared"], }); - expect(savedPermissions?.allow).toContain("Bash(go test *)"); - expect(savedPermissions?.allow).not.toContain("Bash(old-allow *)"); - expect(savedPermissions?.ask).toContain("Bash(curl *)"); - expect(savedPermissions?.deny).toContain("Bash(git reset --hard*)"); + // 预设与 dotfiles 镜像:allow 为空,空列表不写回 allow 键 + expect(savedPermissions?.allow).toBeUndefined(); + expect(savedPermissions?.ask).toEqual([...RECOMMENDED_PERMISSION_RULES.ask]); + expect(savedPermissions?.deny).toEqual([...RECOMMENDED_PERMISSION_RULES.deny]); + expect(savedPermissions?.ask).toContain("Bash(git push *)"); + expect(savedPermissions?.ask).not.toContain("Bash(rm *)"); + expect(savedPermissions?.deny).toContain("Bash(gh auth token*)"); + expect(savedPermissions?.deny).not.toContain("Bash(sudo *)"); + expect(savedPermissions?.deny).not.toContain("Bash(git reset --hard*)"); + // 系统配置需可读(如 /etc/gitconfig),只禁写 + expect(savedPermissions?.deny).toContain("Edit(//etc/**)"); + expect(savedPermissions?.deny).not.toContain("Read(//etc/**)"); expect(savedPermissions?.deny).not.toContain("Read(**/config.yaml)"); }, 10_000); @@ -2762,82 +2850,6 @@ describe("ProfileEditor", () => { expect(savedPermissions?.deny).toEqual(["Bash(git reset --hard*)"]); }); - it("toggles loose mode by moving configured ask rules into allow and back", async () => { - const onSave = vi.fn(); - renderEditor({ - onSave, - profile: { - ...PROFILE_FIXTURE, - settings: { - permissions: { - defaultMode: "dontAsk", - disableBypassPermissionsMode: "disable", - allow: ["Bash(pwd)"], - ask: ["Bash(kill *)", "Bash(env)", "Bash(custom *)"], - additionalDirectories: ["~/projects/shared"], - }, - }, - }, - }); - - const permissionsSection = getSection("权限"); - await act(async () => { - toggleAccordionSection("权限"); - await Promise.resolve(); - }); - const looseModeSwitch = within(permissionsSection).getByRole("switch", { - name: "宽松模式", - }); - expect( - within(permissionsSection).getByRole("button", { name: "宽松模式说明" }), - ).toHaveAttribute( - "data-tooltip", - "启用后会把宽松规则从询问规则移动到允许规则;关闭后会把这些规则移回询问规则。只影响当前编辑草稿,保存后生效。", - ); - expect(looseModeSwitch).toHaveAttribute("aria-checked", "false"); - - await act(async () => { - fireEvent.click(looseModeSwitch); - await Promise.resolve(); - }); - expect(looseModeSwitch).toHaveAttribute("aria-checked", "true"); - expect( - within(permissionsSection).getByRole("button", { name: "收起 允许规则" }), - ).toBeInTheDocument(); - - expect(within(permissionsSection).getByLabelText("允许规则 1")).toHaveValue("Bash(pwd)"); - expect(within(permissionsSection).getByLabelText("允许规则 2")).toHaveValue("Bash(kill *)"); - expect(within(permissionsSection).getByLabelText("允许规则 3")).toHaveValue("Bash(env)"); - expect(within(permissionsSection).getByLabelText("询问规则 1")).toHaveValue("Bash(custom *)"); - - await act(async () => { - fireEvent.click(looseModeSwitch); - await Promise.resolve(); - }); - expect(looseModeSwitch).toHaveAttribute("aria-checked", "false"); - - expect(within(permissionsSection).getByLabelText("允许规则 1")).toHaveValue("Bash(pwd)"); - expect(within(permissionsSection).getByLabelText("询问规则 1")).toHaveValue("Bash(custom *)"); - expect(within(permissionsSection).getByLabelText("询问规则 2")).toHaveValue("Bash(kill *)"); - expect(within(permissionsSection).getByLabelText("询问规则 3")).toHaveValue("Bash(env)"); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "保存" })); - }); - - expect(onSave).toHaveBeenCalledTimes(1); - const savedPermissions = onSave.mock.calls[0]?.[0]?.settings.permissions as - | Record - | undefined; - expect(savedPermissions).toMatchObject({ - defaultMode: "dontAsk", - disableBypassPermissionsMode: "disable", - additionalDirectories: ["~/projects/shared"], - }); - expect(savedPermissions?.allow).toEqual(["Bash(pwd)"]); - expect(savedPermissions?.ask).toEqual(["Bash(custom *)", "Bash(kill *)", "Bash(env)"]); - }); - it("selects an additional directory from the add action and preserves cancel as no-op", async () => { const onSave = vi.fn(); openDialogMock @@ -3018,6 +3030,9 @@ describe("ProfileEditor", () => { filesystem: { allowWrite: ["/tmp/build"], }, + credentials: { + envVars: [{ name: "GH_TOKEN", mode: "deny" }], + }, excludedCommands: ["pnpm *"], network: { allowedDomains: ["example.com"], @@ -3048,13 +3063,28 @@ describe("ProfileEditor", () => { allowWrite: ["/tmp/build"], }, }); - expect(savedSandbox?.excludedCommands).toEqual(["pnpm *", "docker *", "git *"]); + expect(savedSandbox?.excludedCommands).toEqual([ + "pnpm *", + ...RECOMMENDED_SANDBOX_PRESET.excludedCommands, + ]); + expect(savedSandbox?.excludedCommands).toContain("git push *"); + const savedCredentials = savedSandbox?.credentials as Record | undefined; + const savedEnvVars = savedCredentials?.envVars as Array<{ name: string }> | undefined; + // 已存在的 GH_TOKEN 不重复追加 + expect(savedEnvVars?.[0]).toEqual({ name: "GH_TOKEN", mode: "deny" }); + expect(savedEnvVars?.filter((entry) => entry.name === "GH_TOKEN")).toHaveLength(1); + expect(savedEnvVars).toHaveLength(RECOMMENDED_SANDBOX_PRESET.credentials.envVars.length); + expect(savedCredentials?.files).toEqual(RECOMMENDED_SANDBOX_PRESET.credentials.files); const savedNetwork = savedSandbox?.network as Record | undefined; expect(savedNetwork).toMatchObject({ - allowedDomains: ["example.com"], allowLocalBinding: true, }); - expect(savedNetwork?.allowUnixSockets).toEqual(["/tmp/app.sock", "/var/run/docker.sock"]); + expect(savedNetwork?.allowedDomains).toEqual([ + "example.com", + ...RECOMMENDED_SANDBOX_PRESET.network.allowedDomains, + ]); + // 预设不再接管 allowUnixSockets,用户已有配置保持不变 + expect(savedNetwork?.allowUnixSockets).toEqual(["/tmp/app.sock"]); }); it("keeps delegate visible for existing permissions default mode without exposing it as a normal option", () => { @@ -4149,6 +4179,100 @@ describe("ProfileEditor", () => { expect(within(behaviorSection).getAllByText("来自供应商").length).toBeGreaterThan(0); }); + it("freezes the provider effort level instead of the top-level fallback", async () => { + const onSave = vi.fn(); + renderEditor({ + onSave, + providers: [ + ...BUILTIN_PRESETS, + { + id: "builtin:withdefaults", + name: "WithDefaults", + description: "带默认值的供应商", + modelSuggestions: [], + env: { + ANTHROPIC_MODEL: "prov-model", + CLAUDE_CODE_EFFORT_LEVEL: "max", + }, + }, + ], + profile: { + ...PROFILE_FIXTURE, + providerId: "builtin:withdefaults", + settings: { + env: { ANTHROPIC_AUTH_TOKEN: "token" }, + effortLevel: "xhigh", + }, + }, + }); + + expect(screen.getByLabelText("努力级别")).toHaveTextContent("max"); + fireEvent.click(screen.getByRole("button", { name: "固化当前值" })); + const dialog = screen.getByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "确认应用" })); + + // 固化后更换供应商,努力级别仍保持刚才显示的值。 + chooseComboboxOption("供应商", "团队计划"); + expect(screen.getByLabelText("努力级别")).toHaveTextContent("max"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "保存" })); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + const saved = onSave.mock.calls[0][0]; + expect(saved.settings.env.CLAUDE_CODE_EFFORT_LEVEL).toBe("max"); + expect(saved.settings).not.toHaveProperty("effortLevel"); + expect(saved.settings.env.ANTHROPIC_AUTH_TOKEN).toBe("token"); + }); + + it("clears both effort layers when restoring provider defaults", async () => { + const onSave = vi.fn(); + renderEditor({ + onSave, + providers: [ + ...BUILTIN_PRESETS, + { + id: "builtin:withdefaults", + name: "WithDefaults", + description: "带默认值的供应商", + modelSuggestions: [], + env: { CLAUDE_CODE_EFFORT_LEVEL: "max" }, + }, + ], + profile: { + ...PROFILE_FIXTURE, + providerId: "builtin:withdefaults", + settings: { + env: { + ANTHROPIC_AUTH_TOKEN: "token", + CLAUDE_CODE_EFFORT_LEVEL: "high", + }, + effortLevel: "xhigh", + }, + }, + }); + + expect(screen.getByLabelText("努力级别")).toHaveTextContent("high"); + fireEvent.click(screen.getByRole("button", { name: "恢复默认" })); + const dialog = screen.getByRole("dialog"); + expect(within(dialog).getByText("high")).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole("button", { name: "确认应用" })); + expect(screen.getByLabelText("努力级别")).toHaveTextContent("max"); + + // 切到没有默认值的供应商后,不应重新读到旧的顶层设置。 + chooseComboboxOption("供应商", "团队计划"); + expect(screen.getByLabelText("努力级别")).toHaveTextContent("未设置"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "保存" })); + }); + + expect(onSave).toHaveBeenCalledTimes(1); + const saved = onSave.mock.calls[0][0]; + expect(saved.settings.env).not.toHaveProperty("CLAUDE_CODE_EFFORT_LEVEL"); + expect(saved.settings).not.toHaveProperty("effortLevel"); + expect(saved.settings.env.ANTHROPIC_AUTH_TOKEN).toBe("token"); + }); + it("guides users to the merged config preview from the env section", () => { renderEditor(); @@ -4287,7 +4411,7 @@ describe("ProfileEditor", () => { renderEditor({ profile: null, onSave }); const commonSection = getSection("常用选项"); - expect(within(commonSection).getByText("已启用 8/15")).toBeInTheDocument(); + expect(within(commonSection).getByText("已启用 8/16")).toBeInTheDocument(); toggleAccordionSection("常用选项"); for (const label of [ "默认启用深度思考", @@ -4312,6 +4436,7 @@ describe("ProfileEditor", () => { "禁用所有 Hooks", "尊重 .gitignore", "禁用自动更新", + "子进程凭据清理", "显式启用 Tool Search", "启用 Agent Teams", ]) { @@ -4350,6 +4475,7 @@ describe("ProfileEditor", () => { expect(saved.settings.env).not.toHaveProperty("DISABLE_AUTOUPDATER"); expect(saved.settings.env).not.toHaveProperty("ENABLE_TOOL_SEARCH"); expect(saved.settings.env).not.toHaveProperty("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"); + expect(saved.settings.env).not.toHaveProperty("CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"); }); function opencodeGoProvider(): Provider { diff --git a/src/components/profile-editor/PermissionsEditor.tsx b/src/components/profile-editor/PermissionsEditor.tsx index 1262780..dc20935 100644 --- a/src/components/profile-editor/PermissionsEditor.tsx +++ b/src/components/profile-editor/PermissionsEditor.tsx @@ -1,5 +1,5 @@ import { open } from "@tauri-apps/plugin-dialog"; -import { ArrowRightLeft, FolderOpen, Info } from "lucide-react"; +import { ArrowRightLeft, FolderOpen } from "lucide-react"; import { type Dispatch, type SetStateAction, useEffect, useMemo, useRef, useState } from "react"; import { showOperationError } from "@/lib/user-facing-error"; import { useToast } from "../../hooks/useToast"; @@ -14,7 +14,6 @@ import { SelectTrigger, SelectValue, } from "../ui/select"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { buildStringListError, createRowId, @@ -47,52 +46,6 @@ const MANAGED_PERMISSION_KEYS = [ "additionalDirectories", ] as const; const PERMISSION_LIST_KEYS = ["allow", "deny", "ask", "additionalDirectories"] as const; -const LOOSE_MODE_PERMISSION_RULES = [ - "Bash(kill *)", - "Bash(env)", - "Bash(printenv *)", - "Bash(cp *)", - "Bash(mv *)", - "Bash(open *)", - "Bash(git commit *)", - "Bash(git pull *)", - "Bash(git checkout *)", - "Bash(git stash *)", - "Bash(sed -i*)", - "Bash(find * -exec*)", - "Bash(go run *)", - "Bash(go get *)", - "Bash(go install *)", - "Bash(go generate *)", - "Bash(go mod tidy *)", - "Bash(cargo run *)", - "Bash(cargo install *)", - "Bash(cargo update *)", - "Bash(rustup *)", - "Bash(npm *)", - "Bash(yarn *)", - "Bash(npx *)", - "Bash(curl *)", - "Bash(wget *)", - "Bash(pip *)", - "Bash(python -m pip *)", - "Bash(python *)", - "Bash(python3 *)", - "Bash(uv *)", - "Bash(pnpm install *)", - "Bash(pnpm add *)", - "Bash(pnpm remove *)", - "Bash(pnpm update *)", - "Bash(pnpm dlx *)", - "Bash(pnpm exec *)", - "Bash(pnpm approve-builds *)", - "Bash(bun install *)", - "Bash(bun add *)", - "Bash(bun remove *)", - "Bash(bunx *)", - "Bash(make install *)", -] as const; -const LOOSE_MODE_PERMISSION_RULE_SET = new Set(LOOSE_MODE_PERMISSION_RULES); type PermissionListKey = (typeof PERMISSION_LIST_KEYS)[number]; @@ -214,32 +167,6 @@ function setPermissionListFromRows( } } -function hasLooseModeRule(rows: StringRow[]): boolean { - return rows.some((row) => LOOSE_MODE_PERMISSION_RULE_SET.has(row.value.trim())); -} - -function isLooseModeEnabled(allowRows: StringRow[], askRows: StringRow[]): boolean { - return hasLooseModeRule(allowRows) && !hasLooseModeRule(askRows); -} - -function appendMissingRows(targetRows: StringRow[], rowsToAppend: StringRow[]): StringRow[] { - const existingValues = new Set(targetRows.map((row) => row.value.trim())); - const nextRows = [...targetRows]; - - for (const row of rowsToAppend) { - const normalizedValue = row.value.trim(); - if (!existingValues.has(normalizedValue)) { - existingValues.add(normalizedValue); - nextRows.push({ - id: createRowId("permission"), - value: row.value, - }); - } - } - - return nextRows; -} - function buildPermissionsValue( permissionObject: Record, draft: PermissionsDraftValue, @@ -397,7 +324,6 @@ function PermissionsEditor({ value, onChange, onError }: PermissionsEditorProps) const [recommendedDialogOpen, setRecommendedDialogOpen] = useState(false); const [clearRulesDialog, setClearRulesDialog] = useState(null); const skipStructuredSyncRef = useRef(false); - const looseModeEnabled = isLooseModeEnabled(allowRows, askRows); useEffect(() => { skipStructuredSyncRef.current = true; @@ -607,32 +533,6 @@ function PermissionsEditor({ value, onChange, onError }: PermissionsEditorProps) }); } - function handleToggleLooseMode() { - const movingRows = looseModeEnabled - ? allowRows.filter((row) => LOOSE_MODE_PERMISSION_RULE_SET.has(row.value.trim())) - : askRows.filter((row) => LOOSE_MODE_PERMISSION_RULE_SET.has(row.value.trim())); - - if (movingRows.length === 0) { - return; - } - - setAllowExpanded(true); - setAskExpanded(true); - - if (looseModeEnabled) { - setAllowRows((current) => - current.filter((row) => !LOOSE_MODE_PERMISSION_RULE_SET.has(row.value.trim())), - ); - setAskRows((current) => appendMissingRows(current, movingRows)); - return; - } - - setAskRows((current) => - current.filter((row) => !LOOSE_MODE_PERMISSION_RULE_SET.has(row.value.trim())), - ); - setAllowRows((current) => appendMissingRows(current, movingRows)); - } - function handleConfirmClearRules() { if (clearRulesDialog === "allow") { setAllowRows([]); @@ -663,43 +563,15 @@ function PermissionsEditor({ value, onChange, onError }: PermissionsEditorProps)
-
- - {t("profileEditor.permissions.disableBypass")} - - setDisableBypass(!disableBypass)} - /> -
+ + {t("profileEditor.permissions.disableBypass")} + setDisableBypass(!disableBypass)} /> - - - - - - - {t("profileEditor.permissions.looseModeHelp")} - - -