Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/permissions/settings_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions tests/test_permission_settings_paths.py
Original file line number Diff line number Diff line change
@@ -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"
)
11 changes: 11 additions & 0 deletions ui-desktop/src/app/shell/approval-mode-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Harness requestGateway={vi.fn(async () => ({ 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<never>(() => undefined)
render(
Expand Down
7 changes: 7 additions & 0 deletions ui-desktop/src/app/shell/approval-mode-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export function useApprovalModeStatusbarItem(profile: string, requestGateway: Ap
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{/* 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. */}
<p className="px-2 py-1.5 text-[0.6875rem] leading-snug text-(--ui-text-tertiary)">{copy.scopeNote}</p>
</>
),
title: copy.ariaLabel(labels[mode]),
Expand Down
3 changes: 2 additions & 1 deletion ui-desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion ui-desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2292,7 +2292,8 @@ export const ja = defineLocale({
smart: 'スマート',
smartDescription: '必要な場合にのみ確認します',
off: 'オフ',
offDescription: '承認プロンプトなしで実行します'
offDescription: '承認プロンプトなしで実行します',
scopeNote: 'CLI を含む新しいセッションの既定値になります'
},
statusbar: {
unknown: '不明',
Expand Down
1 change: 1 addition & 0 deletions ui-desktop/src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2055,6 +2055,7 @@ export interface Translations {
smartDescription: string
off: string
offDescription: string
scopeNote: string
}
statusbar: {
unknown: string
Expand Down
3 changes: 2 additions & 1 deletion ui-desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2215,7 +2215,8 @@ export const zhHant = defineLocale({
smart: '智慧',
smartDescription: '自動評估操作,並在需要時詢問',
off: '關閉',
offDescription: '不顯示核准提示,直接執行'
offDescription: '不顯示核准提示,直接執行',
scopeNote: '將成為新工作階段的預設值,包括命令列'
},
statusbar: {
unknown: '未知',
Expand Down
3 changes: 2 additions & 1 deletion ui-desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2636,7 +2636,8 @@ export const zh: Translations = {
smart: '智能',
smartDescription: '自动评估操作,并在需要时询问',
off: '关闭',
offDescription: '不显示审批提示,直接运行'
offDescription: '不显示审批提示,直接运行',
scopeNote: '将成为新会话的默认设置,包括命令行'
},
statusbar: {
unknown: '未知',
Expand Down
Loading