diff --git a/src/permissions/settings_paths.py b/src/permissions/settings_paths.py index 0a8663e07..eba029eb5 100644 --- a/src/permissions/settings_paths.py +++ b/src/permissions/settings_paths.py @@ -22,7 +22,17 @@ def user_settings_path() -> str: - return os.path.expanduser(USER_SETTINGS_FILENAME) + """The user tier: ``$CLAWCODEX_CONFIG_DIR/settings.json``, else ``~/.clawcodex``. + + Resolved through :func:`get_user_config_dir` so a relocated config dir + keeps its permissions, hooks and trust settings with the rest of its state + — sessions and transcripts have honored the override since the config-dir + work, and reading this tier from ``~/.clawcodex`` regardless meant a + profile pointed elsewhere silently answered from the default home. + """ + from src.utils.clawcodex_dirs import get_user_config_dir + + return str(get_user_config_dir() / "settings.json") def project_settings_path(cwd: str | None = None) -> str: diff --git a/tests/test_permission_settings_paths.py b/tests/test_permission_settings_paths.py new file mode 100644 index 000000000..e183c4d1b --- /dev/null +++ b/tests/test_permission_settings_paths.py @@ -0,0 +1,51 @@ +"""The user permission-settings tier follows $CLAWCODEX_CONFIG_DIR. + +Sessions, transcripts and config already relocate with the override; this +tier used to be hardcoded to ``~/.clawcodex/settings.json``, so a profile +pointed elsewhere silently answered permission, hook and trust questions from +the default home instead of its own. + +Bound at import time on purpose: ``tests/conftest.py`` has an autouse fixture +that replaces ``settings_paths.user_settings_path`` with a temp path, and this +module-level name keeps hold of the real implementation. +""" + +from __future__ import annotations + +import os + +from src.permissions.settings_paths import ( + project_settings_path, + user_settings_path as real_user_settings_path, +) + + +def test_user_tier_follows_the_config_dir_override(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("CLAWCODEX_CONFIG_DIR", str(tmp_path / "profile-b")) + + assert real_user_settings_path() == str(tmp_path / "profile-b" / "settings.json") + + +def test_user_tier_expands_a_tilde_in_the_override(monkeypatch) -> None: + monkeypatch.setenv("CLAWCODEX_CONFIG_DIR", os.path.join("~", ".cc-alt")) + resolved = real_user_settings_path() + + assert "~" not in resolved + assert resolved.endswith(os.path.join(".cc-alt", "settings.json")) + + +def test_user_tier_defaults_to_the_home_dir(monkeypatch, tmp_path) -> None: + monkeypatch.delenv("CLAWCODEX_CONFIG_DIR", raising=False) + monkeypatch.setattr("pathlib.Path.home", classmethod(lambda cls: tmp_path)) + + assert real_user_settings_path() == str(tmp_path / ".clawcodex" / "settings.json") + + +def test_project_tier_is_unaffected_by_the_override(monkeypatch, tmp_path) -> None: + """The repo tier is scoped to the workspace, not the config home — moving + the config dir must not start reading a project's rules from elsewhere.""" + monkeypatch.setenv("CLAWCODEX_CONFIG_DIR", str(tmp_path / "profile-b")) + + assert project_settings_path(str(tmp_path / "repo")) == str( + tmp_path / "repo" / ".clawcodex" / "settings.json" + ) diff --git a/ui-desktop/src/app/shell/approval-mode-menu.test.tsx b/ui-desktop/src/app/shell/approval-mode-menu.test.tsx index 040471a19..f260c7bcc 100644 --- a/ui-desktop/src/app/shell/approval-mode-menu.test.tsx +++ b/ui-desktop/src/app/shell/approval-mode-menu.test.tsx @@ -73,6 +73,17 @@ describe('approval mode statusbar item', () => { }) }) + it('says the choice reaches past this window', async () => { + // Selecting a level writes permissions.defaultMode to the user settings + // file, which the CLI and TUI read too. A toolbar dropdown reads like a + // local toggle, so the menu has to admit its real scope. + render( ({ value: 'smart' }))} />) + + fireEvent.pointerDown(screen.getByRole('button', { name: /smart/i }), { button: 0 }) + + expect(await screen.findByText(/default for new sessions, including the CLI/i)).toBeTruthy() + }) + it('renders the shared trigger and menu in the active locale', async () => { const response = new Promise(() => undefined) render( diff --git a/ui-desktop/src/app/shell/approval-mode-menu.tsx b/ui-desktop/src/app/shell/approval-mode-menu.tsx index 4d6b0b1a2..26fac7de4 100644 --- a/ui-desktop/src/app/shell/approval-mode-menu.tsx +++ b/ui-desktop/src/app/shell/approval-mode-menu.tsx @@ -68,6 +68,13 @@ export function useApprovalModeStatusbarItem(profile: string, requestGateway: Ap ))} + + {/* Picking a level here writes `permissions.defaultMode` to the user + settings file — the same tier `/permissions` writes and the CLI + and TUI read. Persisting is deliberate (a deliberate step DOWN + must survive a relaunch), but a toolbar dropdown gives no hint + that it reaches past this window, so say so. */} +

{copy.scopeNote}

), title: copy.ariaLabel(labels[mode]), diff --git a/ui-desktop/src/i18n/en.ts b/ui-desktop/src/i18n/en.ts index 9738b6803..0066b68a7 100644 --- a/ui-desktop/src/i18n/en.ts +++ b/ui-desktop/src/i18n/en.ts @@ -2455,7 +2455,8 @@ export const en: Translations = { smart: 'Smart', smartDescription: 'Automatically assess actions and ask when needed', off: 'Off', - offDescription: 'Run without approval prompts' + offDescription: 'Run without approval prompts', + scopeNote: 'Becomes your default for new sessions, including the CLI' }, statusbar: { unknown: 'unknown', diff --git a/ui-desktop/src/i18n/ja.ts b/ui-desktop/src/i18n/ja.ts index caa5b2af2..b4b410614 100644 --- a/ui-desktop/src/i18n/ja.ts +++ b/ui-desktop/src/i18n/ja.ts @@ -2292,7 +2292,8 @@ export const ja = defineLocale({ smart: 'スマート', smartDescription: '必要な場合にのみ確認します', off: 'オフ', - offDescription: '承認プロンプトなしで実行します' + offDescription: '承認プロンプトなしで実行します', + scopeNote: 'CLI を含む新しいセッションの既定値になります' }, statusbar: { unknown: '不明', diff --git a/ui-desktop/src/i18n/types.ts b/ui-desktop/src/i18n/types.ts index d4b24d658..4343ca4fe 100644 --- a/ui-desktop/src/i18n/types.ts +++ b/ui-desktop/src/i18n/types.ts @@ -2055,6 +2055,7 @@ export interface Translations { smartDescription: string off: string offDescription: string + scopeNote: string } statusbar: { unknown: string diff --git a/ui-desktop/src/i18n/zh-hant.ts b/ui-desktop/src/i18n/zh-hant.ts index 217947e74..809efcb69 100644 --- a/ui-desktop/src/i18n/zh-hant.ts +++ b/ui-desktop/src/i18n/zh-hant.ts @@ -2215,7 +2215,8 @@ export const zhHant = defineLocale({ smart: '智慧', smartDescription: '自動評估操作,並在需要時詢問', off: '關閉', - offDescription: '不顯示核准提示,直接執行' + offDescription: '不顯示核准提示,直接執行', + scopeNote: '將成為新工作階段的預設值,包括命令列' }, statusbar: { unknown: '未知', diff --git a/ui-desktop/src/i18n/zh.ts b/ui-desktop/src/i18n/zh.ts index 86ebebf62..83634e0bb 100644 --- a/ui-desktop/src/i18n/zh.ts +++ b/ui-desktop/src/i18n/zh.ts @@ -2636,7 +2636,8 @@ export const zh: Translations = { smart: '智能', smartDescription: '自动评估操作,并在需要时询问', off: '关闭', - offDescription: '不显示审批提示,直接运行' + offDescription: '不显示审批提示,直接运行', + scopeNote: '将成为新会话的默认设置,包括命令行' }, statusbar: { unknown: '未知',