From 76a92523af295e77d0228c4dd9ba9e981af66843 Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Tue, 23 Jun 2026 15:11:20 +0530 Subject: [PATCH 01/21] WEB-4882: honor CLAUDE_CONFIG_DIR in Claude Code hooks install Resolve the Claude config dir from --config-dir / CLAUDE_CONFIG_DIR (fallback ~/.claude) in setup.py and at hook runtime in unbound.py, so hooks, settings, the baked command path, audit log, cache, and backfill transcripts all live where Claude reads them when a custom dir is set. Co-Authored-By: Claude Opus 4.8 --- claude-code/hooks/setup.py | 98 ++++++++++++++++----------- claude-code/hooks/test_setup.py | 116 +++++++++++++++++++++++++++----- claude-code/hooks/unbound.py | 16 +++-- 3 files changed, 170 insertions(+), 60 deletions(-) diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index c031cc2..2174b20 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -61,6 +61,19 @@ def normalize_url(domain: str) -> str: return url.rstrip('/') +def _resolve_claude_config_dir(argv) -> Path: + value = None + for i, arg in enumerate(argv): + if arg == "--config-dir" and i + 1 < len(argv): + value = argv[i + 1] + break + if not value: + value = os.environ.get("CLAUDE_CONFIG_DIR") + if not value: + return Path.home() / ".claude" + return Path(value).expanduser().resolve() + + def get_shell_rc_file() -> Path: system = platform.system().lower() shell = os.environ.get("SHELL", "").lower() @@ -308,9 +321,10 @@ def write_unbound_config(api_key: str, urls: dict = None) -> bool: return False -def remove_gateway_artifacts() -> None: - """Remove ~/.claude/anthropic_key.sh if present (leftover from gateway setup).""" - key_helper_path = Path.home() / ".claude" / "anthropic_key.sh" +def remove_gateway_artifacts(config_dir: Path = None) -> None: + """Remove anthropic_key.sh if present (leftover from gateway setup).""" + config_dir = config_dir or (Path.home() / ".claude") + key_helper_path = config_dir / "anthropic_key.sh" if key_helper_path.exists(): try: key_helper_path.unlink() @@ -350,8 +364,9 @@ def rewrite_gateway_url_in_file(path: Path, gateway_url: str) -> None: debug_print(f"Could not rewrite gateway URL in {path}: {e}") -def setup_hooks(gateway_url: str = DEFAULT_GATEWAY_URL): - hooks_dir = Path.home() / ".claude" / "hooks" +def setup_hooks(gateway_url: str = DEFAULT_GATEWAY_URL, config_dir: Path = None): + config_dir = config_dir or (Path.home() / ".claude") + hooks_dir = config_dir / "hooks" script_path = hooks_dir / "unbound.py" # print("\nšŸ“„ Downloading unbound.py script...") @@ -371,9 +386,10 @@ def setup_hooks(gateway_url: str = DEFAULT_GATEWAY_URL): return True -def configure_claude_settings() -> bool: - settings_path = Path.home() / ".claude" / "settings.json" - +def configure_claude_settings(config_dir: Path = None) -> bool: + config_dir = config_dir or (Path.home() / ".claude") + settings_path = config_dir / "settings.json" + try: if settings_path.exists(): with open(settings_path, 'r', encoding='utf-8') as f: @@ -386,7 +402,7 @@ def configure_claude_settings() -> bool: if "apiKeyHelper" in settings: del settings["apiKeyHelper"] - script_path = Path.home() / ".claude" / "hooks" / "unbound.py" + script_path = config_dir / "hooks" / "unbound.py" # On Windows, invoke via the launcher and quote the path (handles spaces # like C:\Users\Jane Doe\ or C:\Program Files\). Use `py -3` if present, @@ -520,13 +536,14 @@ def _hook(entry: dict) -> dict: return False -def remove_hooks_from_settings() -> str: +def remove_hooks_from_settings(config_dir: Path = None) -> str: """Remove the unbound hooks from settings.json. Returns "cleared", "not_found", or "failed". """ - settings_path = Path.home() / ".claude" / "settings.json" - hook_command = str(Path.home() / ".claude" / "hooks" / "unbound.py") + config_dir = config_dir or (Path.home() / ".claude") + settings_path = config_dir / "settings.json" + hook_command = str(config_dir / "hooks" / "unbound.py") is_windows = platform.system().lower() == "windows" if not settings_path.exists(): @@ -591,8 +608,9 @@ def _clear_path(path: Path, label: str) -> str: return "failed" -def clear_setup() -> None: +def clear_setup(config_dir: Path = None) -> None: """Undo all changes made by the setup script.""" + config_dir = config_dir or (Path.home() / ".claude") print("=" * 60) print("Claude Code Hooks - Clearing Setup") print("=" * 60) @@ -607,15 +625,15 @@ def clear_setup() -> None: print("Failed to clear API_KEY") any_failed = True - _r = _clear_path(Path.home() / ".claude" / "hooks" / "unbound.py", "Claude unbound.py hook") + _r = _clear_path(config_dir / "hooks" / "unbound.py", "Claude unbound.py hook") if _r == "cleared": any_cleared = True elif _r == "failed": any_failed = True for extra in ( - Path.home() / ".claude" / "hooks" / "unbound-setup.py", - Path.home() / ".claude" / "hooks" / ".last_updated", + config_dir / "hooks" / "unbound-setup.py", + config_dir / "hooks" / ".last_updated", ): _r = _clear_path(extra, str(extra)) if _r == "cleared": @@ -623,7 +641,7 @@ def clear_setup() -> None: elif _r == "failed": any_failed = True - settings_status = remove_hooks_from_settings() + settings_status = remove_hooks_from_settings(config_dir) if settings_status == "cleared": any_cleared = True elif settings_status == "failed": @@ -740,12 +758,13 @@ def get_device_identifier() -> Optional[str]: return None -def detect_install_state() -> str: +def detect_install_state(config_dir: Path = None) -> str: """User-level install state (informational): 'persisted' if this tool's Unbound setup already exists on this device, else 'fresh'. User-level setups are never tamper-eligible, so 'tampered' is never reported.""" + config_dir = config_dir or (Path.home() / ".claude") try: - return "persisted" if (Path.home() / ".claude" / "hooks" / "unbound.py").exists() else "fresh" + return "persisted" if (config_dir / "hooks" / "unbound.py").exists() else "fresh" except Exception as e: debug_print(f"detect_install_state failed: {e}") return "fresh" @@ -943,16 +962,16 @@ def _backfill_upload_chunk(api_key: str, backend_url: str, sessions: List[Dict]) return True -def _backfill_state_path(home: Path) -> Path: - return home / '.claude' / 'hooks' / BACKFILL_STATE_FILE +def _backfill_state_path(config_dir: Path) -> Path: + return config_dir / 'hooks' / BACKFILL_STATE_FILE -def _backfill_read_cutoff(home: Path) -> float: +def _backfill_read_cutoff(config_dir: Path) -> float: """mtime cutoff for transcript selection: the last successful backfill when cached (so cron reruns only seed sessions touched since), else 30 days ago.""" default_cutoff = time.time() - (BACKFILL_MAX_AGE_DAYS * 86400) try: - last = float(_backfill_state_path(home).read_text().strip()) + last = float(_backfill_state_path(config_dir).read_text().strip()) except (OSError, ValueError): return default_cutoff # Ignore corrupt or future timestamps (clock skew). @@ -961,11 +980,11 @@ def _backfill_read_cutoff(home: Path) -> float: return last -def _backfill_write_cutoff(home: Path, ts: float) -> None: +def _backfill_write_cutoff(config_dir: Path, ts: float) -> None: # Write via temp + atomic replace so an overlapping cron run never reads a # half-written timestamp. try: - path = _backfill_state_path(home) + path = _backfill_state_path(config_dir) path.parent.mkdir(parents=True, exist_ok=True) tmp = path.parent / f'{path.name}.{os.getpid()}.tmp' tmp.write_text(str(ts)) @@ -1085,17 +1104,18 @@ def _backfill_slice_session(session: Dict, max_chunk_bytes: int): start_idx = last_fit_end -def run_backfill(api_key: str, backend_url: str) -> None: - """Walk ~/.claude/projects and seed historical sessions. Never raises.""" +def run_backfill(api_key: str, backend_url: str, config_dir: Path = None) -> None: + """Walk config_dir/projects and seed historical sessions. Never raises.""" if os.environ.get('UNBOUND_BACKFILL_DISABLED') == '1': debug_print("UNBOUND_BACKFILL_DISABLED=1 — skipping backfill") return try: - home = Path.home() + if config_dir is None: + config_dir = Path.home() / '.claude' started_at = time.time() - cutoff_mtime = _backfill_read_cutoff(home) - projects_root = home / '.claude' / 'projects' + cutoff_mtime = _backfill_read_cutoff(config_dir) + projects_root = config_dir / 'projects' sessions: List[Dict] = [] capped = False if projects_root.exists(): @@ -1110,7 +1130,7 @@ def run_backfill(api_key: str, backend_url: str) -> None: if session: sessions.append(session) if not sessions: - _backfill_write_cutoff(home, started_at) + _backfill_write_cutoff(config_dir, started_at) print("[backfill] No past sessions found.") return @@ -1157,7 +1177,7 @@ def _flush(): print(f"[backfill] Done — queued {sessions_sent} past sessions ({failed} chunks failed).") else: if not capped: - _backfill_write_cutoff(home, started_at) + _backfill_write_cutoff(config_dir, started_at) print(f"[backfill] Done — queued {sessions_sent} past sessions for processing.") except Exception as e: print(f"[backfill] Skipped due to error: {e}", file=sys.stderr) @@ -1175,8 +1195,10 @@ def main(): DEBUG = True debug_print("Debug mode enabled") + config_dir = _resolve_claude_config_dir(sys.argv) + if clear_mode: - clear_setup() + clear_setup(config_dir) return if check_enterprise_hooks_conflict(): @@ -1248,7 +1270,7 @@ def main(): remove_env_var(var_name) except Exception: pass - remove_gateway_artifacts() + remove_gateway_artifacts(config_dir) debug_print("Setting UNBOUND_CLAUDE_API_KEY environment variable...") success, message = set_env_var("UNBOUND_CLAUDE_API_KEY", api_key) @@ -1257,19 +1279,19 @@ def main(): return debug_print("UNBOUND_CLAUDE_API_KEY set successfully") - _install_state = detect_install_state() + _install_state = detect_install_state(config_dir) _device_id = get_device_identifier() write_unbound_config(api_key, urls={"base_url": backend_url, "gateway_url": gateway_url, "frontend_url": normalize_url(domain) if domain else None}) debug_print("Setting up hooks...") - if not setup_hooks(gateway_url=gateway_url): + if not setup_hooks(gateway_url=gateway_url, config_dir=config_dir): print("āŒ Failed to setup hooks") return debug_print("Hooks downloaded successfully") debug_print("Configuring Claude settings...") - if not configure_claude_settings(): + if not configure_claude_settings(config_dir=config_dir): print("āŒ Failed to configure Claude settings") return debug_print("Claude settings configured successfully") @@ -1281,7 +1303,7 @@ def main(): notify_setup_complete(api_key, "claude-code", backend_url=backend_url, install_state=_install_state, serial_number=_device_id) if backfill_mode: - run_backfill(api_key, backend_url) + run_backfill(api_key, backend_url, config_dir) rc_path = get_shell_rc_file() if rc_path is not None: diff --git a/claude-code/hooks/test_setup.py b/claude-code/hooks/test_setup.py index 34ca382..af98d83 100644 --- a/claude-code/hooks/test_setup.py +++ b/claude-code/hooks/test_setup.py @@ -146,12 +146,13 @@ class TestBackfillCutoffCache(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.home = Path(self._tmp.name) + self.config_dir = self.home / ".claude" self.addCleanup(self._tmp.cleanup) def test_read_cutoff_defaults_to_max_age_when_no_file(self): """No cache file -> fall back to BACKFILL_MAX_AGE_DAYS ago (first run).""" import setup - cutoff = setup._backfill_read_cutoff(self.home) + cutoff = setup._backfill_read_cutoff(self.config_dir) expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) self.assertAlmostEqual(cutoff, expected, delta=5) @@ -159,25 +160,25 @@ def test_write_then_read_roundtrip(self): """A persisted timestamp is read back as the cutoff on the next run.""" import setup ts = time.time() - 3600 - setup._backfill_write_cutoff(self.home, ts) - self.assertTrue(setup._backfill_state_path(self.home).exists()) - self.assertAlmostEqual(setup._backfill_read_cutoff(self.home), ts, delta=0.01) + setup._backfill_write_cutoff(self.config_dir, ts) + self.assertTrue(setup._backfill_state_path(self.config_dir).exists()) + self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), ts, delta=0.01) def test_read_cutoff_ignores_corrupt_value(self): """A non-numeric cache file falls back to the default window.""" import setup - path = setup._backfill_state_path(self.home) + path = setup._backfill_state_path(self.config_dir) path.parent.mkdir(parents=True, exist_ok=True) path.write_text("not-a-number") expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) - self.assertAlmostEqual(setup._backfill_read_cutoff(self.home), expected, delta=5) + self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), expected, delta=5) def test_read_cutoff_ignores_future_timestamp(self): """A future timestamp (clock skew) is rejected for the default window.""" import setup - setup._backfill_write_cutoff(self.home, time.time() + 10000) + setup._backfill_write_cutoff(self.config_dir, time.time() + 10000) expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) - self.assertAlmostEqual(setup._backfill_read_cutoff(self.home), expected, delta=5) + self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), expected, delta=5) def test_iter_transcripts_respects_cutoff(self): """Only transcripts modified at/after the cutoff are yielded.""" @@ -199,8 +200,8 @@ def test_iter_transcripts_respects_cutoff(self): def test_write_is_atomic_and_leaves_no_temp(self): """The atomic write produces the final file and no leftover .tmp.""" import setup - setup._backfill_write_cutoff(self.home, 123.0) - path = setup._backfill_state_path(self.home) + setup._backfill_write_cutoff(self.config_dir, 123.0) + path = setup._backfill_state_path(self.config_dir) self.assertEqual(path.read_text(), "123.0") self.assertEqual(list(path.parent.glob("*.tmp")), []) @@ -208,15 +209,27 @@ def test_cutoff_not_advanced_when_session_cap_fires(self): """When the per-run session cap is hit, the cutoff must NOT advance, or the unprocessed older files would be skipped forever next run.""" import setup - root = self.home / ".claude" / "projects" + root = self.config_dir / "projects" root.mkdir(parents=True) for i in range(3): (root / f"s{i}.jsonl").write_text('{"sessionId":"x%d"}\n' % i) with patch.object(setup, "BACKFILL_MAX_SESSIONS_PER_RUN", 2), \ - patch.object(setup, "_backfill_upload_chunk", return_value=True), \ - patch.object(Path, "home", return_value=self.home): - setup.run_backfill("key", "https://backend") - self.assertFalse(setup._backfill_state_path(self.home).exists()) + patch.object(setup, "_backfill_upload_chunk", return_value=True): + setup.run_backfill("key", "https://backend", self.config_dir) + self.assertFalse(setup._backfill_state_path(self.config_dir).exists()) + + def test_run_backfill_reads_custom_config_dir_projects(self): + """With a custom config_dir, backfill walks config_dir/projects and writes + the cutoff there — not under ~/.claude.""" + import setup + custom = self.home / "custom-cc" + root = custom / "projects" + root.mkdir(parents=True) + (root / "s.jsonl").write_text('{"sessionId":"x"}\n') + with patch.object(setup, "_backfill_upload_chunk", return_value=True): + setup.run_backfill("key", "https://backend", custom) + self.assertTrue(setup._backfill_state_path(custom).exists()) + self.assertFalse(setup._backfill_state_path(self.config_dir).exists()) class TestMdmBackfillCutoff(unittest.TestCase): @@ -359,5 +372,78 @@ def fake_run_as_user(username, fn, *args, **kwargs): ) +class TestResolveClaudeConfigDir(unittest.TestCase): + """WEB-4882: --config-dir arg > CLAUDE_CONFIG_DIR env > ~/.claude.""" + + def test_arg_beats_env_and_home(self): + import setup + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) + self.assertEqual(result, Path("/arg/cc").resolve()) + + def test_env_used_when_no_arg(self): + import setup + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path("/env/cc").resolve()) + + def test_home_default_when_arg_and_env_absent(self): + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path.home() / ".claude") + + def test_relative_value_is_absolutized(self): + import setup + result = setup._resolve_claude_config_dir(["x", "--config-dir", "rel/cc"]) + self.assertEqual(result, Path("rel/cc").resolve()) + + +class TestInstallUnderResolvedDir(unittest.TestCase): + """Hooks + settings + baked command must land under the resolved config dir.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.home = Path(self.tmp) / "home" + self.home.mkdir(parents=True) + self.config_dir = Path(self.tmp) / "custom-cc" + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_settings_and_hook_command_under_config_dir(self): + import setup + with patch.object(Path, "home", staticmethod(lambda: self.home)), \ + patch.object(setup, "download_file", lambda url, dest: dest.parent.mkdir(parents=True, exist_ok=True) or dest.write_text("# hook") or True): + self.assertTrue(setup.setup_hooks(config_dir=self.config_dir)) + self.assertTrue(setup.configure_claude_settings(config_dir=self.config_dir)) + + hook_path = self.config_dir / "hooks" / "unbound.py" + settings_path = self.config_dir / "settings.json" + self.assertTrue(hook_path.exists()) + self.assertTrue(settings_path.exists()) + settings = json.loads(settings_path.read_text()) + cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + self.assertEqual(cmd, str(hook_path)) + self.assertNotIn(str(self.home / ".claude"), cmd) + + def test_backward_compat_no_env_uses_home_claude(self): + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True), \ + patch.object(Path, "home", staticmethod(lambda: self.home)), \ + patch.object(setup, "download_file", lambda url, dest: dest.parent.mkdir(parents=True, exist_ok=True) or dest.write_text("# hook") or True): + config_dir = setup._resolve_claude_config_dir(["x"]) + self.assertTrue(setup.setup_hooks(config_dir=config_dir)) + self.assertTrue(setup.configure_claude_settings(config_dir=config_dir)) + + hook_path = self.home / ".claude" / "hooks" / "unbound.py" + self.assertTrue(hook_path.exists()) + settings = json.loads((self.home / ".claude" / "settings.json").read_text()) + cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + self.assertEqual(cmd, str(hook_path)) + + if __name__ == "__main__": unittest.main() diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 84829d3..5f25ce5 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -17,14 +17,16 @@ UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") -AUDIT_LOG = Path.home() / ".claude" / "hooks" / "agent-audit.log" -ERROR_LOG = Path.home() / ".claude" / "hooks" / "error.log" -LAST_REPORT_FILE = Path.home() / ".claude" / "hooks" / ".last_error_report" +_config_dir_is_default = not (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() +_CONFIG_DIR = Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude")).expanduser().resolve() +AUDIT_LOG = _CONFIG_DIR / "hooks" / "agent-audit.log" +ERROR_LOG = _CONFIG_DIR / "hooks" / "error.log" +LAST_REPORT_FILE = _CONFIG_DIR / "hooks" / ".last_error_report" ALLOWED_NON_MCP_HOOK_NAMES = ['Bash', 'Read', 'Write', 'Edit'] # MCP tools (mcp__*) are always checked separately NATIVE_FILE_TOOLS = {'Read', 'Write', 'Edit'} MCP_TOOL_PREFIX = 'mcp__' -CLAUDE_MCP_CONFIG_PATH = Path.home() / ".claude.json" -POLICY_CACHE_FILE = Path.home() / ".claude" / "hooks" / ".policy_cache.json" +CLAUDE_MCP_CONFIG_PATH = Path.home() / ".claude.json" if _config_dir_is_default else _CONFIG_DIR / ".claude.json" +POLICY_CACHE_FILE = _CONFIG_DIR / "hooks" / ".policy_cache.json" CACHE_TTL_SECONDS = 300 POLICY_CHECK_FAILURE_DEFAULT = 'allow' POLICY_CHECK_FAILURE_BLOCK_REASON = 'policy engine unavailable — please retry' @@ -53,7 +55,7 @@ SELF_UPDATE_INTERVAL_SECONDS = 2 * 3600 SELF_UPDATE_LOCK_TTL_SECONDS = 30 SELF_UPDATE_CURL_TIMEOUT = 10 -SELF_SCRIPT_PATH = Path.home() / ".claude" / "hooks" / "unbound.py" +SELF_SCRIPT_PATH = _CONFIG_DIR / "hooks" / "unbound.py" SELF_UPDATE_STATE_PATH = SELF_SCRIPT_PATH.parent / ".self_update_check" SELF_UPDATE_LOCK_PATH = SELF_SCRIPT_PATH.parent / ".self_update.lock" @@ -238,7 +240,7 @@ def append_to_audit_log(event_data: Dict): pass -_APPROVAL_MARKER_FILE = Path.home() / ".claude" / "hooks" / ".approval_pending" +_APPROVAL_MARKER_FILE = _CONFIG_DIR / "hooks" / ".approval_pending" def _is_approval_retry(command: str) -> bool: From a4d59d4d244c61ffc873a70b55a984d25091b5c3 Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Tue, 23 Jun 2026 15:31:35 +0530 Subject: [PATCH 02/21] WEB-4882: address review feedback - unbound.py: resolve the config dir from the hook's own install location (__file__), so runtime paths always match where the installer wrote them, regardless of how CLAUDE_CONFIG_DIR is propagated into the hook env. - setup.py: strip whitespace-only CLAUDE_CONFIG_DIR, and don't let --config-dir swallow a following flag as its value. Co-Authored-By: Claude Opus 4.8 --- claude-code/hooks/setup.py | 4 ++-- claude-code/hooks/unbound.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 2174b20..705db92 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -64,11 +64,11 @@ def normalize_url(domain: str) -> str: def _resolve_claude_config_dir(argv) -> Path: value = None for i, arg in enumerate(argv): - if arg == "--config-dir" and i + 1 < len(argv): + if arg == "--config-dir" and i + 1 < len(argv) and not argv[i + 1].startswith("--"): value = argv[i + 1] break if not value: - value = os.environ.get("CLAUDE_CONFIG_DIR") + value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None if not value: return Path.home() / ".claude" return Path(value).expanduser().resolve() diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 5f25ce5..4b7f7e3 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -17,8 +17,8 @@ UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") -_config_dir_is_default = not (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() -_CONFIG_DIR = Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude")).expanduser().resolve() +_CONFIG_DIR = Path(__file__).resolve().parents[1] +_config_dir_is_default = _CONFIG_DIR == (Path.home() / ".claude").resolve() AUDIT_LOG = _CONFIG_DIR / "hooks" / "agent-audit.log" ERROR_LOG = _CONFIG_DIR / "hooks" / "error.log" LAST_REPORT_FILE = _CONFIG_DIR / "hooks" / ".last_error_report" From 3cdefe12d971a90aa152fd3082de9d1c46b0b55e Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Tue, 23 Jun 2026 15:40:46 +0530 Subject: [PATCH 03/21] WEB-4882: keep env-based runtime resolution; fix whitespace + .claude.json - unbound.py: resolve _CONFIG_DIR from CLAUDE_CONFIG_DIR (stripped) again, not __file__. Deriving from __file__ made SELF_SCRIPT_PATH always equal the running script, defeating the MDM self-update guard that must skip admin-managed installs. Strip the env value so whitespace-only falls back to ~/.claude consistently for both _CONFIG_DIR and _config_dir_is_default. - unbound.py: CLAUDE_MCP_CONFIG_PATH probes $CONFIG_DIR/.claude.json and falls back to ~/.claude.json, so account-identity reads never break if Claude keeps the OAuth config at the home sibling. - setup.py: strip the --config-dir value too, matching the env handling. Co-Authored-By: Claude Opus 4.8 --- claude-code/hooks/setup.py | 2 +- claude-code/hooks/unbound.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 705db92..092d943 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -65,7 +65,7 @@ def _resolve_claude_config_dir(argv) -> Path: value = None for i, arg in enumerate(argv): if arg == "--config-dir" and i + 1 < len(argv) and not argv[i + 1].startswith("--"): - value = argv[i + 1] + value = argv[i + 1].strip() or None break if not value: value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 4b7f7e3..18e40ba 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -17,15 +17,16 @@ UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") -_CONFIG_DIR = Path(__file__).resolve().parents[1] -_config_dir_is_default = _CONFIG_DIR == (Path.home() / ".claude").resolve() +_env_config_dir = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() +_config_dir_is_default = not _env_config_dir +_CONFIG_DIR = Path(_env_config_dir or (Path.home() / ".claude")).expanduser().resolve() AUDIT_LOG = _CONFIG_DIR / "hooks" / "agent-audit.log" ERROR_LOG = _CONFIG_DIR / "hooks" / "error.log" LAST_REPORT_FILE = _CONFIG_DIR / "hooks" / ".last_error_report" ALLOWED_NON_MCP_HOOK_NAMES = ['Bash', 'Read', 'Write', 'Edit'] # MCP tools (mcp__*) are always checked separately NATIVE_FILE_TOOLS = {'Read', 'Write', 'Edit'} MCP_TOOL_PREFIX = 'mcp__' -CLAUDE_MCP_CONFIG_PATH = Path.home() / ".claude.json" if _config_dir_is_default else _CONFIG_DIR / ".claude.json" +CLAUDE_MCP_CONFIG_PATH = (_CONFIG_DIR / ".claude.json") if (not _config_dir_is_default and (_CONFIG_DIR / ".claude.json").exists()) else (Path.home() / ".claude.json") POLICY_CACHE_FILE = _CONFIG_DIR / "hooks" / ".policy_cache.json" CACHE_TTL_SECONDS = 300 POLICY_CHECK_FAILURE_DEFAULT = 'allow' From aa7f55839b385c102afcdc334bedddd59ab5bec1 Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Tue, 23 Jun 2026 15:51:10 +0530 Subject: [PATCH 04/21] WEB-4882: resolve config dir env-first so install matches runtime Make setup.py prioritize CLAUDE_CONFIG_DIR (env) over --config-dir, with the CLI arg as fallback. unbound.py resolves runtime paths from the same env, so install-time placement and runtime resolution now agree by the same precedence instead of diverging. The CLI passes --config-dir derived from CLAUDE_CONFIG_DIR, so the gated real flow is unchanged. Co-Authored-By: Claude Opus 4.8 --- claude-code/hooks/setup.py | 11 +++++------ claude-code/hooks/test_setup.py | 11 +++++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 092d943..ea2b28b 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -62,13 +62,12 @@ def normalize_url(domain: str) -> str: def _resolve_claude_config_dir(argv) -> Path: - value = None - for i, arg in enumerate(argv): - if arg == "--config-dir" and i + 1 < len(argv) and not argv[i + 1].startswith("--"): - value = argv[i + 1].strip() or None - break + value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None if not value: - value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None + for i, arg in enumerate(argv): + if arg == "--config-dir" and i + 1 < len(argv) and not argv[i + 1].startswith("--"): + value = argv[i + 1].strip() or None + break if not value: return Path.home() / ".claude" return Path(value).expanduser().resolve() diff --git a/claude-code/hooks/test_setup.py b/claude-code/hooks/test_setup.py index af98d83..c3a2486 100644 --- a/claude-code/hooks/test_setup.py +++ b/claude-code/hooks/test_setup.py @@ -373,12 +373,19 @@ def fake_run_as_user(username, fn, *args, **kwargs): class TestResolveClaudeConfigDir(unittest.TestCase): - """WEB-4882: --config-dir arg > CLAUDE_CONFIG_DIR env > ~/.claude.""" + """WEB-4882: CLAUDE_CONFIG_DIR env > --config-dir arg > ~/.claude.""" - def test_arg_beats_env_and_home(self): + def test_env_beats_arg_and_home(self): import setup with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) + self.assertEqual(result, Path("/env/cc").resolve()) + + def test_arg_used_when_no_env(self): + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) self.assertEqual(result, Path("/arg/cc").resolve()) def test_env_used_when_no_arg(self): From 9c58392a45878863aa73ca0af7b2faf147b20f45 Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Mon, 29 Jun 2026 14:49:56 +0530 Subject: [PATCH 05/21] WEB-4882: gateway-mode Claude install honors CLAUDE_CONFIG_DIR Mirror the hooks installer: resolve the config dir from CLAUDE_CONFIG_DIR / the --config-dir arg the CLI forwards (else ~/.claude), and thread it through the key-helper writer, settings, install-state detection, and clear. apiKeyHelper keeps the portable ~/.claude form for the default dir and uses the absolute path when relocated so Claude resolves it under the active dir. Adds gateway test_setup. Co-Authored-By: Claude Opus 4.8 --- claude-code/gateway/setup.py | 64 ++++++++++++++++++--------- claude-code/gateway/test_setup.py | 73 +++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 claude-code/gateway/test_setup.py diff --git a/claude-code/gateway/setup.py b/claude-code/gateway/setup.py index 33b56f6..9fbba5f 100644 --- a/claude-code/gateway/setup.py +++ b/claude-code/gateway/setup.py @@ -282,9 +282,22 @@ def write_unbound_config(api_key: str, urls: dict = None) -> bool: return False -def remove_hooks_unbound_script() -> None: - """Remove ~/.claude/hooks/unbound.py if present (leftover from hooks setup).""" - script_path = Path.home() / ".claude" / "hooks" / "unbound.py" +def _resolve_claude_config_dir(config_dir_arg: Optional[str] = None) -> Path: + """Resolve Claude Code's config dir: $CLAUDE_CONFIG_DIR (env wins), else the + --config-dir arg the CLI forwards, else the default ~/.claude. Mirrors the + hooks installer so gateway mode honors a relocated config dir too (WEB-4882).""" + value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None + if not value and config_dir_arg: + value = config_dir_arg.strip() or None + if not value: + return Path.home() / ".claude" + return Path(value).expanduser().resolve() + + +def remove_hooks_unbound_script(config_dir: Path = None) -> None: + """Remove /hooks/unbound.py if present (leftover from hooks setup).""" + config_dir = config_dir or (Path.home() / ".claude") + script_path = config_dir / "hooks" / "unbound.py" if script_path.exists(): try: script_path.unlink() @@ -293,12 +306,12 @@ def remove_hooks_unbound_script() -> None: debug_print(f"Failed to remove {script_path}: {e}") -def setup_claude_key_helper() -> None: +def setup_claude_key_helper(config_dir: Path = None) -> None: """ - Create ~/.claude/anthropic_key.sh that echoes UNBOUND_API_KEY and - update ~/.claude/settings.json with apiKeyHelper pointing to that script. + Create /anthropic_key.sh that echoes UNBOUND_API_KEY and + update /settings.json with apiKeyHelper pointing to that script. """ - claude_dir = Path.home() / ".claude" + claude_dir = config_dir or (Path.home() / ".claude") settings_path = claude_dir / "settings.json" key_helper_path = claude_dir / "anthropic_key.sh" @@ -325,8 +338,13 @@ def setup_claude_key_helper() -> None: if "hooks" in settings: del settings["hooks"] - # Update apiKeyHelper - settings["apiKeyHelper"] = "~/.claude/anthropic_key.sh" + # Update apiKeyHelper. Keep the portable ~/.claude form for the default dir + # (unchanged for existing installs); use the absolute path when the config + # dir is relocated so Claude resolves the helper under the active dir. + if claude_dir == (Path.home() / ".claude"): + settings["apiKeyHelper"] = "~/.claude/anthropic_key.sh" + else: + settings["apiKeyHelper"] = str(key_helper_path) settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") except Exception as e: @@ -425,12 +443,13 @@ def _clear_path(path: Path, label: str) -> str: return "failed" -def remove_api_key_helper_setting() -> str: +def remove_api_key_helper_setting(config_dir: Path = None) -> str: """Remove apiKeyHelper from settings.json. Returns "cleared", "not_found", or "failed". """ - settings_path = Path.home() / ".claude" / "settings.json" + config_dir = config_dir or (Path.home() / ".claude") + settings_path = config_dir / "settings.json" if not settings_path.exists(): return "not_found" try: @@ -448,8 +467,9 @@ def remove_api_key_helper_setting() -> str: return "failed" -def clear_setup() -> None: +def clear_setup(config_dir: Path = None) -> None: """Undo all changes made by the setup script.""" + config_dir = config_dir or (Path.home() / ".claude") print("=" * 60) print("Claude Code - Clearing Setup") print("=" * 60) @@ -465,13 +485,13 @@ def clear_setup() -> None: print(f"Failed to clear {label}") any_failed = True - _r = _clear_path(Path.home() / ".claude" / "anthropic_key.sh", "Claude anthropic_key.sh") + _r = _clear_path(config_dir / "anthropic_key.sh", "Claude anthropic_key.sh") if _r == "cleared": any_cleared = True elif _r == "failed": any_failed = True - settings_status = remove_api_key_helper_setting() + settings_status = remove_api_key_helper_setting(config_dir) if settings_status == "cleared": any_cleared = True elif settings_status == "failed": @@ -588,12 +608,13 @@ def get_device_identifier() -> Optional[str]: return None -def detect_install_state() -> str: +def detect_install_state(config_dir: Path = None) -> str: """User-level install state (informational): 'persisted' if this tool's Unbound setup already exists on this device, else 'fresh'. User-level setups are never tamper-eligible, so 'tampered' is never reported.""" + config_dir = config_dir or (Path.home() / ".claude") try: - return "persisted" if (Path.home() / ".claude" / "anthropic_key.sh").exists() else "fresh" + return "persisted" if (config_dir / "anthropic_key.sh").exists() else "fresh" except Exception as e: debug_print(f"detect_install_state failed: {e}") return "fresh" @@ -666,16 +687,19 @@ def main(): parser.add_argument("--clear", action="store_true", help="Undo all changes made by the setup script") parser.add_argument("--debug", action="store_true", help="Show detailed debug information") parser.add_argument("--api-key", dest="api_key", help="API key (skip browser auth)") + parser.add_argument("--config-dir", dest="config_dir", help="Claude Code config dir (defaults to $CLAUDE_CONFIG_DIR or ~/.claude)") args, _ = parser.parse_known_args() args.gateway_url = normalize_url(args.gateway_url) args.backend_url = normalize_url(args.backend_url) + config_dir = _resolve_claude_config_dir(args.config_dir) + if args.debug: DEBUG = True debug_print("Debug mode enabled") if args.clear: - clear_setup() + clear_setup(config_dir) return if check_enterprise_hooks_conflict(): @@ -698,7 +722,7 @@ def main(): pass # Remove leftover hooks setup artifacts - remove_hooks_unbound_script() + remove_hooks_unbound_script(config_dir) api_key = args.api_key if not api_key: @@ -735,14 +759,14 @@ def main(): success, message = set_env_var("ANTHROPIC_BASE_URL", args.gateway_url) debug_print("ANTHROPIC_BASE_URL set successfully") - _install_state = detect_install_state() + _install_state = detect_install_state(config_dir) _device_id = get_device_identifier() write_unbound_config(api_key, urls={"base_url": args.backend_url, "gateway_url": args.gateway_url, "frontend_url": normalize_url(args.domain) if args.domain else None}) # Configure Claude Code helper files debug_print("Setting up Claude key helper...") - setup_claude_key_helper() + setup_claude_key_helper(config_dir) debug_print("Claude key helper configured") # Final instructions diff --git a/claude-code/gateway/test_setup.py b/claude-code/gateway/test_setup.py new file mode 100644 index 0000000..e7aff54 --- /dev/null +++ b/claude-code/gateway/test_setup.py @@ -0,0 +1,73 @@ +import importlib.util +import json +import os +import tempfile +import unittest +from unittest import mock +from pathlib import Path + +# Load gateway/setup.py under a unique module name so it can't collide with the +# hooks-mode setup.py when both test suites run in one pytest session. +_SPEC = importlib.util.spec_from_file_location( + "gateway_setup", os.path.join(os.path.dirname(__file__), "setup.py") +) +gw = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(gw) + + +class TestResolveClaudeConfigDir(unittest.TestCase): + """WEB-4882: gateway mode honors CLAUDE_CONFIG_DIR like the hooks installer.""" + + def test_env_wins(self): + with tempfile.TemporaryDirectory() as d: + with mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": d}): + self.assertEqual(gw._resolve_claude_config_dir(None), Path(d).resolve()) + + def test_env_takes_precedence_over_arg(self): + with tempfile.TemporaryDirectory() as d: + with mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": d}): + self.assertEqual(gw._resolve_claude_config_dir("/other/dir"), Path(d).resolve()) + + def test_arg_used_when_env_absent(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("CLAUDE_CONFIG_DIR", None) + self.assertEqual(gw._resolve_claude_config_dir("/opt/cc"), Path("/opt/cc").resolve()) + + def test_default_fallback(self): + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("CLAUDE_CONFIG_DIR", None) + self.assertEqual(gw._resolve_claude_config_dir(None), Path.home() / ".claude") + + def test_blank_env_falls_back(self): + with mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": " "}): + self.assertEqual(gw._resolve_claude_config_dir(None), Path.home() / ".claude") + + +class TestKeyHelperUnderConfigDir(unittest.TestCase): + def test_custom_dir_writes_there_with_absolute_helper(self): + with tempfile.TemporaryDirectory() as d: + cc = Path(d) / "cc" + gw.setup_claude_key_helper(cc) + self.assertTrue((cc / "anthropic_key.sh").exists()) + settings = json.loads((cc / "settings.json").read_text()) + # Relocated dir → absolute helper path so Claude resolves it under the active dir. + self.assertEqual(settings["apiKeyHelper"], str(cc / "anthropic_key.sh")) + + def test_default_dir_keeps_portable_helper(self): + with tempfile.TemporaryDirectory() as home: + with mock.patch.object(gw.Path, "home", staticmethod(lambda: Path(home))): + default_dir = Path(home) / ".claude" + gw.setup_claude_key_helper(default_dir) + settings = json.loads((default_dir / "settings.json").read_text()) + self.assertEqual(settings["apiKeyHelper"], "~/.claude/anthropic_key.sh") + + def test_detect_install_state_honors_config_dir(self): + with tempfile.TemporaryDirectory() as d: + cc = Path(d) / "cc" + self.assertEqual(gw.detect_install_state(cc), "fresh") + gw.setup_claude_key_helper(cc) + self.assertEqual(gw.detect_install_state(cc), "persisted") + + +if __name__ == "__main__": + unittest.main() From 713638896cdd16a585184527727e2143771701d9 Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Mon, 29 Jun 2026 15:12:04 +0530 Subject: [PATCH 06/21] Remove ticket-id and explanatory comments from Claude config-dir code Strip WEB-4882 references and redundant inline comments; behavior unchanged. --- claude-code/gateway/setup.py | 6 +----- claude-code/gateway/test_setup.py | 4 ---- claude-code/hooks/test_setup.py | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/claude-code/gateway/setup.py b/claude-code/gateway/setup.py index 9fbba5f..9b2fff2 100644 --- a/claude-code/gateway/setup.py +++ b/claude-code/gateway/setup.py @@ -284,8 +284,7 @@ def write_unbound_config(api_key: str, urls: dict = None) -> bool: def _resolve_claude_config_dir(config_dir_arg: Optional[str] = None) -> Path: """Resolve Claude Code's config dir: $CLAUDE_CONFIG_DIR (env wins), else the - --config-dir arg the CLI forwards, else the default ~/.claude. Mirrors the - hooks installer so gateway mode honors a relocated config dir too (WEB-4882).""" + --config-dir arg the CLI forwards, else the default ~/.claude.""" value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None if not value and config_dir_arg: value = config_dir_arg.strip() or None @@ -338,9 +337,6 @@ def setup_claude_key_helper(config_dir: Path = None) -> None: if "hooks" in settings: del settings["hooks"] - # Update apiKeyHelper. Keep the portable ~/.claude form for the default dir - # (unchanged for existing installs); use the absolute path when the config - # dir is relocated so Claude resolves the helper under the active dir. if claude_dir == (Path.home() / ".claude"): settings["apiKeyHelper"] = "~/.claude/anthropic_key.sh" else: diff --git a/claude-code/gateway/test_setup.py b/claude-code/gateway/test_setup.py index e7aff54..e202f22 100644 --- a/claude-code/gateway/test_setup.py +++ b/claude-code/gateway/test_setup.py @@ -6,8 +6,6 @@ from unittest import mock from pathlib import Path -# Load gateway/setup.py under a unique module name so it can't collide with the -# hooks-mode setup.py when both test suites run in one pytest session. _SPEC = importlib.util.spec_from_file_location( "gateway_setup", os.path.join(os.path.dirname(__file__), "setup.py") ) @@ -16,7 +14,6 @@ class TestResolveClaudeConfigDir(unittest.TestCase): - """WEB-4882: gateway mode honors CLAUDE_CONFIG_DIR like the hooks installer.""" def test_env_wins(self): with tempfile.TemporaryDirectory() as d: @@ -50,7 +47,6 @@ def test_custom_dir_writes_there_with_absolute_helper(self): gw.setup_claude_key_helper(cc) self.assertTrue((cc / "anthropic_key.sh").exists()) settings = json.loads((cc / "settings.json").read_text()) - # Relocated dir → absolute helper path so Claude resolves it under the active dir. self.assertEqual(settings["apiKeyHelper"], str(cc / "anthropic_key.sh")) def test_default_dir_keeps_portable_helper(self): diff --git a/claude-code/hooks/test_setup.py b/claude-code/hooks/test_setup.py index 2e4d5aa..6c255d2 100644 --- a/claude-code/hooks/test_setup.py +++ b/claude-code/hooks/test_setup.py @@ -374,7 +374,7 @@ def fake_run_as_user(username, fn, *args, **kwargs): class TestResolveClaudeConfigDir(unittest.TestCase): - """WEB-4882: CLAUDE_CONFIG_DIR env > --config-dir arg > ~/.claude.""" + """CLAUDE_CONFIG_DIR env > --config-dir arg > ~/.claude.""" def test_env_beats_arg_and_home(self): import setup From 870240e639a8c317b9ee3f0369c82170dd5050bc Mon Sep 17 00:00:00 2001 From: MohamedAklamaash Date: Mon, 29 Jun 2026 17:53:12 +0530 Subject: [PATCH 07/21] Harden Claude config-dir handling: consistent .claude.json/plugins, clear sweep, portable apiKeyHelper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unbound.py: resolve .claude.json AND plugins/cache the same way — prefer the relocated dir when it has the artifact, else the legacy ~/.claude location — so MCP/plugin policy reads from wherever Claude actually stores them. - setup --clear (hooks + gateway): when the config dir is relocated, also strip enforcement left behind in the default ~/.claude so nothing fires if Claude later runs without CLAUDE_CONFIG_DIR. - gateway apiKeyHelper: compare resolved paths so the portable ~/.claude form is kept even on symlinked HOME / when CLAUDE_CONFIG_DIR equals the default dir. Co-Authored-By: Claude Opus 4.8 --- claude-code/gateway/setup.py | 12 ++++++++++- claude-code/gateway/test_setup.py | 36 +++++++++++++++++++++++++++++++ claude-code/hooks/setup.py | 10 +++++++++ claude-code/hooks/unbound.py | 15 +++++++++++-- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/claude-code/gateway/setup.py b/claude-code/gateway/setup.py index 9b2fff2..27af389 100644 --- a/claude-code/gateway/setup.py +++ b/claude-code/gateway/setup.py @@ -337,7 +337,7 @@ def setup_claude_key_helper(config_dir: Path = None) -> None: if "hooks" in settings: del settings["hooks"] - if claude_dir == (Path.home() / ".claude"): + if claude_dir.resolve() == (Path.home() / ".claude").resolve(): settings["apiKeyHelper"] = "~/.claude/anthropic_key.sh" else: settings["apiKeyHelper"] = str(key_helper_path) @@ -494,6 +494,16 @@ def clear_setup(config_dir: Path = None) -> None: print("Failed to clear apiKeyHelper in settings.json") any_failed = True + # When the config dir was relocated, also strip enforcement left behind in the + # default ~/.claude so clearing leaves nothing that fires if Claude later runs + # without CLAUDE_CONFIG_DIR set. + default_dir = Path.home() / ".claude" + if config_dir.resolve() != default_dir.resolve(): + if _clear_path(default_dir / "anthropic_key.sh", "Claude anthropic_key.sh (~/.claude)") == "cleared": + any_cleared = True + if remove_api_key_helper_setting(default_dir) == "cleared": + any_cleared = True + if any_cleared: print("Cleared") elif not any_failed: diff --git a/claude-code/gateway/test_setup.py b/claude-code/gateway/test_setup.py index e202f22..6052579 100644 --- a/claude-code/gateway/test_setup.py +++ b/claude-code/gateway/test_setup.py @@ -64,6 +64,42 @@ def test_detect_install_state_honors_config_dir(self): gw.setup_claude_key_helper(cc) self.assertEqual(gw.detect_install_state(cc), "persisted") + def test_apikeyhelper_portable_when_dir_equals_default_via_realpath(self): + # Passing the default dir (even pre-resolution) must still yield the + # portable ~/.claude form, not an absolute realpath. + with tempfile.TemporaryDirectory() as home: + with mock.patch.object(gw.Path, "home", staticmethod(lambda: Path(home))): + gw.setup_claude_key_helper(Path(home) / ".claude") + settings = json.loads((Path(home) / ".claude" / "settings.json").read_text()) + self.assertEqual(settings["apiKeyHelper"], "~/.claude/anthropic_key.sh") + + +class TestClearSweepsLegacyDir(unittest.TestCase): + def test_clear_relocated_also_clears_default_claude(self): + with tempfile.TemporaryDirectory() as home: + home = Path(home) + with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): + legacy = home / ".claude" + legacy.mkdir(parents=True) + (legacy / "anthropic_key.sh").write_text("echo x") + (legacy / "settings.json").write_text(json.dumps({"apiKeyHelper": "~/.claude/anthropic_key.sh"})) + cc = home / "cc" + gw.setup_claude_key_helper(cc) + gw.clear_setup(cc) + # active dir cleared + self.assertFalse((cc / "anthropic_key.sh").exists()) + # legacy ~/.claude swept too + self.assertFalse((legacy / "anthropic_key.sh").exists()) + self.assertNotIn("apiKeyHelper", json.loads((legacy / "settings.json").read_text())) + + def test_clear_default_dir_does_not_double_sweep(self): + with tempfile.TemporaryDirectory() as home: + home = Path(home) + with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): + gw.setup_claude_key_helper(home / ".claude") + gw.clear_setup(home / ".claude") + self.assertFalse((home / ".claude" / "anthropic_key.sh").exists()) + if __name__ == "__main__": unittest.main() diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 975c74d..071b99e 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -668,6 +668,16 @@ def clear_setup(config_dir: Path = None) -> None: print("Failed to clear Unbound hooks in settings.json") any_failed = True + # When the config dir was relocated, also strip enforcement left behind in the + # default ~/.claude so clearing leaves nothing that fires if Claude later runs + # without CLAUDE_CONFIG_DIR set. + default_dir = Path.home() / ".claude" + if config_dir.resolve() != default_dir.resolve(): + if _clear_path(default_dir / "hooks" / "unbound.py", "Claude unbound.py hook (~/.claude)") == "cleared": + any_cleared = True + if remove_hooks_from_settings(default_dir) == "cleared": + any_cleared = True + if any_cleared: print("Cleared") elif not any_failed: diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 5a76dc3..d217539 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -26,8 +26,19 @@ ALLOWED_NON_MCP_HOOK_NAMES = ['Bash', 'Read', 'Write', 'Edit'] # MCP tools (mcp__*) are always checked separately NATIVE_FILE_TOOLS = {'Read', 'Write', 'Edit'} MCP_TOOL_PREFIX = 'mcp__' -CLAUDE_MCP_CONFIG_PATH = (_CONFIG_DIR / ".claude.json") if (not _config_dir_is_default and (_CONFIG_DIR / ".claude.json").exists()) else (Path.home() / ".claude.json") -CLAUDE_PLUGIN_CACHE_DIR = _CONFIG_DIR / "plugins" / "cache" + + +def _relocated_or_legacy(relocated: Path, legacy: Path) -> Path: + # Whether Claude relocates .claude.json / plugins under CLAUDE_CONFIG_DIR is + # version-dependent, so read from the relocated dir when it actually has the + # artifact, else the default ~/.claude location. Keeps both paths consistent. + if not _config_dir_is_default and relocated.exists(): + return relocated + return legacy + + +CLAUDE_MCP_CONFIG_PATH = _relocated_or_legacy(_CONFIG_DIR / ".claude.json", Path.home() / ".claude.json") +CLAUDE_PLUGIN_CACHE_DIR = _relocated_or_legacy(_CONFIG_DIR / "plugins" / "cache", Path.home() / ".claude" / "plugins" / "cache") POLICY_CACHE_FILE = _CONFIG_DIR / "hooks" / ".policy_cache.json" CACHE_TTL_SECONDS = 300 POLICY_CHECK_FAILURE_DEFAULT = 'allow' From bb10ed383f8afb22c848d119c45e9e161e6418d8 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Sun, 23 Aug 2026 13:44:19 +0530 Subject: [PATCH 08/21] fix: match Claude Code's own config-dir resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-verified against Claude Code 2.1.177 rather than the behavior assumed when this branch was written in June, by reading the resolver out of the shipped bundle and probing a real install. Claude Code reads CLAUDE_CONFIG_DIR verbatim and does not expand a leading "~" — probing 2.1.177 with CLAUDE_CONFIG_DIR='~/ccdir' made it create a directory literally named "~" under the cwd. The installers and the runtime hook expanded it, so a value carrying a real tilde (a Dockerfile ENV, an MDM profile, a quoted export) installed to $HOME/ccdir while Claude read ./~/ccdir — the silent enforcement gap this branch exists to close, reintroduced by our own normalization. Removing the expansion deletes code and fixes the bug together. Also switched to a lexical abspath instead of resolve(), matching Node's path.resolve, which Claude Code uses. resolve() followed symlinks and turned /var into /private/var on macOS, so paths we report back no longer match what the user actually set. Dropped _relocated_or_legacy. It probed for an existing .claude.json before deciding between the relocated and default location, on the theory that relocation was version-dependent. The bundle shows it is not: .claude.json is CLAUDE_CONFIG_DIR || homedir(), plugins are join(configDir, "plugins"), and user skills are join(configDir, "skills"), all unconditional. Worse, the probe could pick the wrong file — on a fresh relocated install the hook would fall back to reading ~/.claude.json, which Claude was not using. The deterministic rule is both shorter and correct. Confirmed end to end: installing with a relocated dir writes settings.json and hooks/ there, and Claude Code 2.1.177 launched with the same env picks that same directory, leaving nothing in ~/.claude. With the variable unset every path is byte-identical to before, including .claude.json sitting at ~/.claude.json rather than nested under ~/.claude. Tests: hooks 42 passed, gateway 11 passed. The one hooks failure (TestMatcherParityAcrossTrees) reproduces byte-identically on untouched staging and concerns the two MDM trees this branch does not touch. py_compile and pyflakes clean across every *.py. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/gateway/setup.py | 3 +- claude-code/gateway/test_setup.py | 6 +- claude-code/hooks/setup.py | 3 +- claude-code/hooks/test_setup.py | 1219 +++++++++++++++-------------- claude-code/hooks/unbound.py | 22 +- 5 files changed, 633 insertions(+), 620 deletions(-) diff --git a/claude-code/gateway/setup.py b/claude-code/gateway/setup.py index 660d72f..ab5f5f0 100644 --- a/claude-code/gateway/setup.py +++ b/claude-code/gateway/setup.py @@ -291,7 +291,8 @@ def _resolve_claude_config_dir(config_dir_arg: Optional[str] = None) -> Path: value = config_dir_arg.strip() or None if not value: return Path.home() / ".claude" - return Path(value).expanduser().resolve() + # Verbatim, as Claude Code reads it: a literal "~" must not be expanded. + return Path(os.path.abspath(value)) def remove_hooks_unbound_script(config_dir: Path = None) -> None: diff --git a/claude-code/gateway/test_setup.py b/claude-code/gateway/test_setup.py index 6052579..ea742e7 100644 --- a/claude-code/gateway/test_setup.py +++ b/claude-code/gateway/test_setup.py @@ -18,17 +18,17 @@ class TestResolveClaudeConfigDir(unittest.TestCase): def test_env_wins(self): with tempfile.TemporaryDirectory() as d: with mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": d}): - self.assertEqual(gw._resolve_claude_config_dir(None), Path(d).resolve()) + self.assertEqual(gw._resolve_claude_config_dir(None), Path(os.path.abspath(d))) def test_env_takes_precedence_over_arg(self): with tempfile.TemporaryDirectory() as d: with mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": d}): - self.assertEqual(gw._resolve_claude_config_dir("/other/dir"), Path(d).resolve()) + self.assertEqual(gw._resolve_claude_config_dir("/other/dir"), Path(os.path.abspath(d))) def test_arg_used_when_env_absent(self): with mock.patch.dict(os.environ, {}, clear=False): os.environ.pop("CLAUDE_CONFIG_DIR", None) - self.assertEqual(gw._resolve_claude_config_dir("/opt/cc"), Path("/opt/cc").resolve()) + self.assertEqual(gw._resolve_claude_config_dir("/opt/cc"), Path(os.path.abspath("/opt/cc"))) def test_default_fallback(self): with mock.patch.dict(os.environ, {}, clear=False): diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 65f2770..debfdae 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -71,7 +71,8 @@ def _resolve_claude_config_dir(argv) -> Path: break if not value: return Path.home() / ".claude" - return Path(value).expanduser().resolve() + # Verbatim, as Claude Code reads it: a literal "~" must not be expanded. + return Path(os.path.abspath(value)) def get_shell_rc_file() -> Path: diff --git a/claude-code/hooks/test_setup.py b/claude-code/hooks/test_setup.py index 6c255d2..580d5cc 100644 --- a/claude-code/hooks/test_setup.py +++ b/claude-code/hooks/test_setup.py @@ -1,602 +1,617 @@ -import unittest -from unittest.mock import patch -import json -import os -import shutil -import socket -import tempfile -import threading -import urllib.request -import urllib.error -import urllib.parse -import time -from pathlib import Path - - -class TestCallbackHandler(unittest.TestCase): - """Tests for the CallbackHandler inside run_callback_server. - - These tests exercise the real run_callback_server function by mocking - webbrowser.open to intercept the URL, then sending an HTTP request - to the actual server it spins up. - """ - - def _run_server_with_query(self, query_string): - """Call run_callback_server, intercept its URL, hit it with query_string. - - Returns (http_status, response_body, result_dict). - """ - from setup import run_callback_server - - captured_url = {} - http_response = {} - - def fake_browser_open(url): - """Instead of opening a browser, parse the callback_url and hit it.""" - parsed = urllib.parse.urlparse(url) - qs = dict(urllib.parse.parse_qsl(parsed.query)) - callback_url = qs.get("callback_url", "") - target = f"{callback_url}?{query_string}" - captured_url["target"] = target - - # Small delay to let the server finish binding - time.sleep(0.05) - - try: - resp = urllib.request.urlopen(target) - http_response["code"] = resp.getcode() - http_response["body"] = resp.read().decode() - except urllib.error.HTTPError as e: - http_response["code"] = e.code - http_response["body"] = e.read().decode() - - with patch("webbrowser.open", side_effect=fake_browser_open): - result = run_callback_server("https://example.com") - - return http_response.get("code"), http_response.get("body", ""), result - - def test_success_returns_200(self): - """CallbackHandler returns 200 on success (no error param).""" - code, body, result = self._run_server_with_query("api_key=abc123") - self.assertEqual(code, 200) - self.assertIn("Logged in successfully", body) - self.assertEqual(result["query"]["api_key"], "abc123") - - def test_error_returns_400(self): - """CallbackHandler returns 400 with error message when error param present.""" - code, body, result = self._run_server_with_query("error=something+went+wrong") - self.assertEqual(code, 400) - self.assertIn("Setup failed: something went wrong", body) - - def test_error_truncated_to_200_chars(self): - """Error message in HTTP response is truncated to 200 characters.""" - long_error = "x" * 300 - code, body, _ = self._run_server_with_query(f"error={long_error}") - self.assertEqual(code, 400) - self.assertIn("x" * 200, body) - self.assertNotIn("x" * 201, body) - - -class TestMainErrorHandling(unittest.TestCase): - """Tests for error display in main().""" - - def _run_main_with_callback(self, query): - """Run main() with a mocked callback response and capture stdout.""" - import setup - import sys - from io import StringIO - - with patch("setup.run_callback_server") as mock_server, \ - patch("setup.install_macos_certificates"): - mock_server.return_value = { - "method": "GET", - "path": "/callback", - "query": query, - "headers": {}, - "body": None, - } - - old_argv = sys.argv - sys.argv = ["setup.py", "--domain", "example.com"] - captured = StringIO() - old_stdout = sys.stdout - sys.stdout = captured - try: - setup.main() - finally: - sys.stdout = old_stdout - sys.argv = old_argv - - return captured.getvalue() - - def test_main_prints_specific_error(self): - """main() prints specific error when callback has error param.""" - output = self._run_main_with_callback({"error": "token expired"}) - self.assertIn("Setup failed: token expired", output) - - def test_ansi_stripped_from_terminal_output(self): - """ANSI escape sequences are stripped from terminal error output.""" - output = self._run_main_with_callback({"error": "\x1b[31mred error\x1b[0m"}) - self.assertNotIn("\x1b", output) - self.assertIn("red error", output) - - def test_error_truncated_in_terminal(self): - """Error message displayed in terminal is truncated to 200 chars.""" - long_error = "A" * 300 - output = self._run_main_with_callback({"error": long_error}) - self.assertIn("A" * 200, output) - self.assertNotIn("A" * 201, output) - - def test_cb_response_error_without_guard(self): - """Error path works when cb_response is non-None with no api_key. - - Validates that removing the redundant 'if cb_response else None' - guard does not break error extraction -- cb_response is guaranteed - non-None at that point because line 543-545 returns early if None. - """ - output = self._run_main_with_callback({"error": "access denied"}) - self.assertIn("Setup failed: access denied", output) - self.assertNotIn("No API key received", output) - - -class TestBackfillCutoffCache(unittest.TestCase): - """Tests for the per-tool last-backfill cache that lets cron reruns seed only - sessions touched since the previous run instead of the full 30-day window.""" - - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.home = Path(self._tmp.name) - self.config_dir = self.home / ".claude" - self.addCleanup(self._tmp.cleanup) - - def test_read_cutoff_defaults_to_max_age_when_no_file(self): - """No cache file -> fall back to BACKFILL_MAX_AGE_DAYS ago (first run).""" - import setup - cutoff = setup._backfill_read_cutoff(self.config_dir) - expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) - self.assertAlmostEqual(cutoff, expected, delta=5) - - def test_write_then_read_roundtrip(self): - """A persisted timestamp is read back as the cutoff on the next run.""" - import setup - ts = time.time() - 3600 - setup._backfill_write_cutoff(self.config_dir, ts) - self.assertTrue(setup._backfill_state_path(self.config_dir).exists()) - self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), ts, delta=0.01) - - def test_read_cutoff_ignores_corrupt_value(self): - """A non-numeric cache file falls back to the default window.""" - import setup - path = setup._backfill_state_path(self.config_dir) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("not-a-number") - expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) - self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), expected, delta=5) - - def test_read_cutoff_ignores_future_timestamp(self): - """A future timestamp (clock skew) is rejected for the default window.""" - import setup - setup._backfill_write_cutoff(self.config_dir, time.time() + 10000) - expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) - self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), expected, delta=5) - - def test_iter_transcripts_respects_cutoff(self): - """Only transcripts modified at/after the cutoff are yielded.""" - import setup - root = self.home / ".claude" / "projects" - root.mkdir(parents=True) - old = root / "old.jsonl" - new = root / "new.jsonl" - old.write_text("{}\n") - new.write_text("{}\n") - now = time.time() - os.utime(old, (now - 10 * 86400, now - 10 * 86400)) - os.utime(new, (now - 1 * 86400, now - 1 * 86400)) - - cutoff = now - (5 * 86400) - found = {p.name for p in setup._backfill_iter_transcripts(root, cutoff)} - self.assertEqual(found, {"new.jsonl"}) - - def test_write_is_atomic_and_leaves_no_temp(self): - """The atomic write produces the final file and no leftover .tmp.""" - import setup - setup._backfill_write_cutoff(self.config_dir, 123.0) - path = setup._backfill_state_path(self.config_dir) - self.assertEqual(path.read_text(), "123.0") - self.assertEqual(list(path.parent.glob("*.tmp")), []) - - def test_cutoff_not_advanced_when_session_cap_fires(self): - """When the per-run session cap is hit, the cutoff must NOT advance, or - the unprocessed older files would be skipped forever next run.""" - import setup - root = self.config_dir / "projects" - root.mkdir(parents=True) - for i in range(3): - (root / f"s{i}.jsonl").write_text('{"sessionId":"x%d"}\n' % i) - with patch.object(setup, "BACKFILL_MAX_SESSIONS_PER_RUN", 2), \ - patch.object(setup, "_backfill_upload_chunk", return_value=True): - setup.run_backfill("key", "https://backend", self.config_dir) - self.assertFalse(setup._backfill_state_path(self.config_dir).exists()) - - def test_run_backfill_reads_custom_config_dir_projects(self): - """With a custom config_dir, backfill walks config_dir/projects and writes - the cutoff there — not under ~/.claude.""" - import setup - custom = self.home / "custom-cc" - root = custom / "projects" - root.mkdir(parents=True) - (root / "s.jsonl").write_text('{"sessionId":"x"}\n') - with patch.object(setup, "_backfill_upload_chunk", return_value=True): - setup.run_backfill("key", "https://backend", custom) - self.assertTrue(setup._backfill_state_path(custom).exists()) - self.assertFalse(setup._backfill_state_path(self.config_dir).exists()) - - -class TestMdmBackfillCutoff(unittest.TestCase): - """Tests for the multi-user MDM run_backfill: a user's cutoff must advance - only when that user's transcripts were actually collected, so a failed - privilege-drop never strands their history behind an advanced cutoff.""" - - @staticmethod - def _load_mdm(): - import importlib.util - spec = importlib.util.spec_from_file_location( - "mdm_setup", str(Path(__file__).parent / "mdm" / "setup.py") - ) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - def _run(self, mdm, collect_by_home, send_result): - """Run run_backfill with _run_as_user mocked; return list of homes - whose cutoff was written.""" - writes = [] - - def fake_run_as_user(username, fn, *args): - if fn is mdm._backfill_collect_sessions: - return collect_by_home[args[0]] - if fn is mdm._backfill_write_cutoff: - writes.append(args[0]) - return None - - homes = [(f"u{i}", home) for i, home in enumerate(collect_by_home)] - with patch.object(mdm, "_run_as_user", side_effect=fake_run_as_user), \ - patch.object(mdm, "_backfill_send_sessions", return_value=send_result): - mdm.run_backfill("key", "https://backend", homes) - return writes - - def test_failed_home_cutoff_not_advanced(self): - """Collection returning None (fork/perms failure) -> no cutoff write.""" - mdm = self._load_mdm() - good, bad = Path("/home/good"), Path("/home/bad") - # good: collected, empty, not capped; bad: collection failed (None) - writes = self._run(mdm, {good: ([], False), bad: None}, send_result=(0, 0, 0)) - self.assertIn(good, writes) - self.assertNotIn(bad, writes) - - def test_collected_homes_advanced_on_success(self): - """Full upload success -> cutoff written for every collected home.""" - mdm = self._load_mdm() - home = Path("/home/alice") - writes = self._run( - mdm, - {home: ([{"session_id": "s1", "entries": [{}]}], False)}, - send_result=(1, 1, 0), - ) - self.assertEqual(writes, [home]) - - def test_partial_upload_failure_does_not_advance(self): - """A failed chunk -> no cutoff write, so the next cron retries.""" - mdm = self._load_mdm() - home = Path("/home/alice") - writes = self._run( - mdm, - {home: ([{"session_id": "s1", "entries": [{}]}], False)}, - send_result=(1, 0, 1), # one chunk failed - ) - self.assertEqual(writes, []) - - def test_capped_home_not_advanced(self): - """A home that hit the per-run cap -> its cutoff is not advanced even on - a fully successful upload, so its overflow stays eligible next run.""" - mdm = self._load_mdm() - capped_home, ok_home = Path("/home/heavy"), Path("/home/light") - writes = self._run( - mdm, - { - capped_home: ([{"session_id": "s1", "entries": [{}]}], True), - ok_home: ([{"session_id": "s2", "entries": [{}]}], False), - }, - send_result=(2, 1, 0), - ) - self.assertEqual(writes, [ok_home]) - - -class TestMdmWriteConfigReportsSuccess(unittest.TestCase): - """A successful per-user config write must NOT be logged as a failure. - - Regression for the missing ``return True`` in the privilege-dropped - ``_write`` closure of ``write_unbound_config_for_user``: without it the - closure returned None on success, ``_run_as_user`` relayed that None, and - the caller misreported every successful write as - ``Could not write config for ``. The same closure ships verbatim in - claude-code, codex, copilot and augment, so all four are checked here. - """ - - _REPO_ROOT = Path(__file__).resolve().parents[2] - TOOLS = { - "claude-code": _REPO_ROOT / "claude-code" / "hooks" / "mdm" / "setup.py", - "codex": _REPO_ROOT / "codex" / "hooks" / "mdm" / "setup.py", - "copilot": _REPO_ROOT / "copilot" / "hooks" / "mdm" / "setup.py", - "augment": _REPO_ROOT / "augment" / "hooks" / "mdm" / "setup.py", - } - - @staticmethod - def _load(name, path): - import importlib.util - spec = importlib.util.spec_from_file_location( - f"mdm_setup_{name.replace('-', '_')}", str(path) - ) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - def test_successful_write_is_not_reported_as_failure(self): - for name, path in self.TOOLS.items(): - with self.subTest(tool=name): - mdm = self._load(name, path) - logs = [] - home = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, home, ignore_errors=True) - - # Mimic a successful privilege drop: _run_as_user runs the - # callback in-process and relays its return value verbatim. - def fake_run_as_user(username, fn, *args, **kwargs): - return fn(*args, **kwargs) - - with patch.object(mdm, "_run_as_user", side_effect=fake_run_as_user), \ - patch.object(mdm, "_repair_user_ownership", lambda *a, **k: None), \ - patch.object(mdm, "debug_print", side_effect=logs.append): - mdm.write_unbound_config_for_user( - "tester", home, "sk-test-key", - urls={"base_url": "https://backend", "gateway_url": "https://gw"}, - ) - - config_file = home / ".unbound" / "config.json" - self.assertTrue(config_file.exists(), f"{name}: config.json was not written") - data = json.loads(config_file.read_text()) - self.assertEqual(data["api_key"], "sk-test-key") - self.assertEqual(data["base_url"], "https://backend") - self.assertFalse( - any("Could not write config" in m for m in logs), - f"{name}: success path falsely logged a failure: {logs}", - ) - - -class TestResolveClaudeConfigDir(unittest.TestCase): - """CLAUDE_CONFIG_DIR env > --config-dir arg > ~/.claude.""" - - def test_env_beats_arg_and_home(self): - import setup - with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): - result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) - self.assertEqual(result, Path("/env/cc").resolve()) - - def test_arg_used_when_no_env(self): - import setup - env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} - with patch.dict(os.environ, env, clear=True): - result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) - self.assertEqual(result, Path("/arg/cc").resolve()) - - def test_env_used_when_no_arg(self): - import setup - with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): - result = setup._resolve_claude_config_dir(["x"]) - self.assertEqual(result, Path("/env/cc").resolve()) - - def test_home_default_when_arg_and_env_absent(self): - import setup - env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} - with patch.dict(os.environ, env, clear=True): - result = setup._resolve_claude_config_dir(["x"]) - self.assertEqual(result, Path.home() / ".claude") - - def test_relative_value_is_absolutized(self): - import setup - result = setup._resolve_claude_config_dir(["x", "--config-dir", "rel/cc"]) - self.assertEqual(result, Path("rel/cc").resolve()) - - -class TestInstallUnderResolvedDir(unittest.TestCase): - """Hooks + settings + baked command must land under the resolved config dir.""" - - def setUp(self): - self.tmp = tempfile.mkdtemp() - self.home = Path(self.tmp) / "home" - self.home.mkdir(parents=True) - self.config_dir = Path(self.tmp) / "custom-cc" - - def tearDown(self): - shutil.rmtree(self.tmp, ignore_errors=True) - - def test_settings_and_hook_command_under_config_dir(self): - import setup - with patch.object(Path, "home", staticmethod(lambda: self.home)), \ - patch.object(setup, "download_file", lambda url, dest: dest.parent.mkdir(parents=True, exist_ok=True) or dest.write_text("# hook") or True): - self.assertTrue(setup.setup_hooks(config_dir=self.config_dir)) - self.assertTrue(setup.configure_claude_settings(config_dir=self.config_dir)) - - hook_path = self.config_dir / "hooks" / "unbound.py" - settings_path = self.config_dir / "settings.json" - self.assertTrue(hook_path.exists()) - self.assertTrue(settings_path.exists()) - settings = json.loads(settings_path.read_text()) - cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] - self.assertEqual(cmd, str(hook_path)) - self.assertNotIn(str(self.home / ".claude"), cmd) - - def test_backward_compat_no_env_uses_home_claude(self): - import setup - env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} - with patch.dict(os.environ, env, clear=True), \ - patch.object(Path, "home", staticmethod(lambda: self.home)), \ - patch.object(setup, "download_file", lambda url, dest: dest.parent.mkdir(parents=True, exist_ok=True) or dest.write_text("# hook") or True): - config_dir = setup._resolve_claude_config_dir(["x"]) - self.assertTrue(setup.setup_hooks(config_dir=config_dir)) - self.assertTrue(setup.configure_claude_settings(config_dir=config_dir)) - - hook_path = self.home / ".claude" / "hooks" / "unbound.py" - self.assertTrue(hook_path.exists()) - settings = json.loads((self.home / ".claude" / "settings.json").read_text()) - cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] - self.assertEqual(cmd, str(hook_path)) - - -class TestCommandTargetsHook(unittest.TestCase): - def setUp(self): - from setup import _command_targets_hook - self.match = _command_targets_hook - self.target = Path("/Users/jane/.claude/hooks/unbound.py") - - def test_bare_path_matches(self): - self.assertTrue(self.match(str(self.target), self.target)) - - def test_double_quoted_matches(self): - self.assertTrue(self.match(f'"{self.target}"', self.target)) - - def test_single_quoted_matches(self): - self.assertTrue(self.match(f"'{self.target}'", self.target)) - - def test_launcher_prefixed_matches(self): - self.assertTrue(self.match(f'py -3 "{self.target}"', self.target)) - self.assertTrue(self.match(f'python "{self.target}"', self.target)) - - def test_exe_launcher_prefixed_matches(self): - self.assertTrue(self.match(f'py.exe -3 "{self.target}"', self.target)) - self.assertTrue(self.match(f'python.exe "{self.target}"', self.target)) - self.assertTrue(self.match(f'python3.exe "{self.target}"', self.target)) - - def test_path_with_spaces_matches(self): - target = Path("/Users/Jane Doe/.claude/hooks/unbound.py") - self.assertTrue(self.match(f'"{target}"', target)) - self.assertTrue(self.match(f'py -3 "{target}"', target)) - - def test_foreign_command_does_not_match(self): - self.assertFalse(self.match("/opt/other/hook.py", self.target)) - self.assertFalse(self.match('echo "hello world"', self.target)) - - def test_target_as_argument_does_not_match(self): - self.assertFalse(self.match(f'/opt/other/hook.py --config "{self.target}"', self.target)) - - def test_sibling_path_does_not_match(self): - self.assertFalse(self.match(f"{self.target}.backup", self.target)) - self.assertFalse(self.match(f"/opt/mirror{self.target}", self.target)) - - def test_empty_command_does_not_match(self): - self.assertFalse(self.match("", self.target)) - - -class TestRemoveHooksFromSettings(unittest.TestCase): - def setUp(self): - self.home = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, self.home, ignore_errors=True) - patcher = patch("setup.Path.home", return_value=self.home) - patcher.start() - self.addCleanup(patcher.stop) - self.settings_path = self.home / ".claude" / "settings.json" - self.settings_path.parent.mkdir(parents=True, exist_ok=True) - self.script = str(self.home / ".claude" / "hooks" / "unbound.py") - - def _write(self, settings): - self.settings_path.write_text(json.dumps(settings)) - - def _read(self): - return json.loads(self.settings_path.read_text()) - - def test_removes_quoted_bare_and_launcher_forms_preserving_foreign(self): - from setup import remove_hooks_from_settings - self._write({"hooks": { - "PreToolUse": [ - {"matcher": "*", "hooks": [ - {"type": "command", "command": f'"{self.script}"'}, - {"type": "command", "command": "/opt/other/hook.py"}, - ]}, - ], - "Stop": [ - {"hooks": [{"type": "command", "command": self.script}]}, - ], - "SessionStart": [ - {"hooks": [{"type": "command", "command": f'py -3 "{self.script}"'}]}, - ], - }}) - self.assertEqual(remove_hooks_from_settings(), "cleared") - result = self._read() - self.assertEqual( - result["hooks"]["PreToolUse"][0]["hooks"], - [{"type": "command", "command": "/opt/other/hook.py"}], - ) - self.assertNotIn("Stop", result["hooks"]) - self.assertNotIn("SessionStart", result["hooks"]) - - def test_mixed_quoted_and_bare_both_removed(self): - from setup import remove_hooks_from_settings - self._write({"hooks": {"PreToolUse": [ - {"matcher": "*", "hooks": [ - {"type": "command", "command": f'"{self.script}"'}, - {"type": "command", "command": self.script}, - ]}, - ]}}) - self.assertEqual(remove_hooks_from_settings(), "cleared") - self.assertNotIn("hooks", self._read()) - - def test_install_dedup_skips_when_quoted_entry_exists(self): - from setup import configure_claude_settings - self._write({"hooks": {"PreToolUse": [ - {"matcher": "*", "hooks": [ - {"type": "command", "command": f'"{self.script}"', "timeout": 15000}, - ]}, - ]}}) - self.assertTrue(configure_claude_settings()) - result = self._read() - commands = [ - h["command"] - for item in result["hooks"]["PreToolUse"] - for h in item["hooks"] - ] - self.assertEqual(commands.count(f'"{self.script}"'), 1) - self.assertNotIn(self.script, commands) - - -class TestMatcherParityAcrossTrees(unittest.TestCase): - SENTINEL = "return os.path.normcase(os.path.normpath(tokens[0])) == normalized_target" - - def _extract(self, path): - captured = [] - capturing = False - for line in path.read_text().splitlines(): - if line.startswith("def _command_targets_hook"): - capturing = True - if capturing: - captured.append(line) - if line.strip() == self.SENTINEL: - break - return "\n".join(captured) - - def test_helper_is_byte_identical_across_trees(self): - root = Path(__file__).resolve().parents[2] - files = [ - root / "claude-code" / "hooks" / "setup.py", - root / "claude-code" / "hooks" / "mdm" / "setup.py", - root / "codex" / "hooks" / "setup.py", - root / "codex" / "hooks" / "mdm" / "setup.py", - root / "binary" / "src" / "unbound_hook" / "setup_cmd.py", - ] - bodies = [self._extract(f) for f in files] - for path, body in zip(files, bodies): - self.assertTrue(body.strip(), f"{path}: matcher not found") - self.assertEqual(len(set(bodies)), 1, "matcher drifted across trees") - - -if __name__ == "__main__": - unittest.main() +import unittest +from unittest.mock import patch +import json +import os +import shutil +import socket +import tempfile +import threading +import urllib.request +import urllib.error +import urllib.parse +import time +from pathlib import Path + + +class TestCallbackHandler(unittest.TestCase): + """Tests for the CallbackHandler inside run_callback_server. + + These tests exercise the real run_callback_server function by mocking + webbrowser.open to intercept the URL, then sending an HTTP request + to the actual server it spins up. + """ + + def _run_server_with_query(self, query_string): + """Call run_callback_server, intercept its URL, hit it with query_string. + + Returns (http_status, response_body, result_dict). + """ + from setup import run_callback_server + + captured_url = {} + http_response = {} + + def fake_browser_open(url): + """Instead of opening a browser, parse the callback_url and hit it.""" + parsed = urllib.parse.urlparse(url) + qs = dict(urllib.parse.parse_qsl(parsed.query)) + callback_url = qs.get("callback_url", "") + target = f"{callback_url}?{query_string}" + captured_url["target"] = target + + # Small delay to let the server finish binding + time.sleep(0.05) + + try: + resp = urllib.request.urlopen(target) + http_response["code"] = resp.getcode() + http_response["body"] = resp.read().decode() + except urllib.error.HTTPError as e: + http_response["code"] = e.code + http_response["body"] = e.read().decode() + + with patch("webbrowser.open", side_effect=fake_browser_open): + result = run_callback_server("https://example.com") + + return http_response.get("code"), http_response.get("body", ""), result + + def test_success_returns_200(self): + """CallbackHandler returns 200 on success (no error param).""" + code, body, result = self._run_server_with_query("api_key=abc123") + self.assertEqual(code, 200) + self.assertIn("Logged in successfully", body) + self.assertEqual(result["query"]["api_key"], "abc123") + + def test_error_returns_400(self): + """CallbackHandler returns 400 with error message when error param present.""" + code, body, result = self._run_server_with_query("error=something+went+wrong") + self.assertEqual(code, 400) + self.assertIn("Setup failed: something went wrong", body) + + def test_error_truncated_to_200_chars(self): + """Error message in HTTP response is truncated to 200 characters.""" + long_error = "x" * 300 + code, body, _ = self._run_server_with_query(f"error={long_error}") + self.assertEqual(code, 400) + self.assertIn("x" * 200, body) + self.assertNotIn("x" * 201, body) + + +class TestMainErrorHandling(unittest.TestCase): + """Tests for error display in main().""" + + def _run_main_with_callback(self, query): + """Run main() with a mocked callback response and capture stdout.""" + import setup + import sys + from io import StringIO + + with patch("setup.run_callback_server") as mock_server, \ + patch("setup.install_macos_certificates"): + mock_server.return_value = { + "method": "GET", + "path": "/callback", + "query": query, + "headers": {}, + "body": None, + } + + old_argv = sys.argv + sys.argv = ["setup.py", "--domain", "example.com"] + captured = StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + setup.main() + finally: + sys.stdout = old_stdout + sys.argv = old_argv + + return captured.getvalue() + + def test_main_prints_specific_error(self): + """main() prints specific error when callback has error param.""" + output = self._run_main_with_callback({"error": "token expired"}) + self.assertIn("Setup failed: token expired", output) + + def test_ansi_stripped_from_terminal_output(self): + """ANSI escape sequences are stripped from terminal error output.""" + output = self._run_main_with_callback({"error": "\x1b[31mred error\x1b[0m"}) + self.assertNotIn("\x1b", output) + self.assertIn("red error", output) + + def test_error_truncated_in_terminal(self): + """Error message displayed in terminal is truncated to 200 chars.""" + long_error = "A" * 300 + output = self._run_main_with_callback({"error": long_error}) + self.assertIn("A" * 200, output) + self.assertNotIn("A" * 201, output) + + def test_cb_response_error_without_guard(self): + """Error path works when cb_response is non-None with no api_key. + + Validates that removing the redundant 'if cb_response else None' + guard does not break error extraction -- cb_response is guaranteed + non-None at that point because line 543-545 returns early if None. + """ + output = self._run_main_with_callback({"error": "access denied"}) + self.assertIn("Setup failed: access denied", output) + self.assertNotIn("No API key received", output) + + +class TestBackfillCutoffCache(unittest.TestCase): + """Tests for the per-tool last-backfill cache that lets cron reruns seed only + sessions touched since the previous run instead of the full 30-day window.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.home = Path(self._tmp.name) + self.config_dir = self.home / ".claude" + self.addCleanup(self._tmp.cleanup) + + def test_read_cutoff_defaults_to_max_age_when_no_file(self): + """No cache file -> fall back to BACKFILL_MAX_AGE_DAYS ago (first run).""" + import setup + cutoff = setup._backfill_read_cutoff(self.config_dir) + expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) + self.assertAlmostEqual(cutoff, expected, delta=5) + + def test_write_then_read_roundtrip(self): + """A persisted timestamp is read back as the cutoff on the next run.""" + import setup + ts = time.time() - 3600 + setup._backfill_write_cutoff(self.config_dir, ts) + self.assertTrue(setup._backfill_state_path(self.config_dir).exists()) + self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), ts, delta=0.01) + + def test_read_cutoff_ignores_corrupt_value(self): + """A non-numeric cache file falls back to the default window.""" + import setup + path = setup._backfill_state_path(self.config_dir) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("not-a-number") + expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) + self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), expected, delta=5) + + def test_read_cutoff_ignores_future_timestamp(self): + """A future timestamp (clock skew) is rejected for the default window.""" + import setup + setup._backfill_write_cutoff(self.config_dir, time.time() + 10000) + expected = time.time() - (setup.BACKFILL_MAX_AGE_DAYS * 86400) + self.assertAlmostEqual(setup._backfill_read_cutoff(self.config_dir), expected, delta=5) + + def test_iter_transcripts_respects_cutoff(self): + """Only transcripts modified at/after the cutoff are yielded.""" + import setup + root = self.home / ".claude" / "projects" + root.mkdir(parents=True) + old = root / "old.jsonl" + new = root / "new.jsonl" + old.write_text("{}\n") + new.write_text("{}\n") + now = time.time() + os.utime(old, (now - 10 * 86400, now - 10 * 86400)) + os.utime(new, (now - 1 * 86400, now - 1 * 86400)) + + cutoff = now - (5 * 86400) + found = {p.name for p in setup._backfill_iter_transcripts(root, cutoff)} + self.assertEqual(found, {"new.jsonl"}) + + def test_write_is_atomic_and_leaves_no_temp(self): + """The atomic write produces the final file and no leftover .tmp.""" + import setup + setup._backfill_write_cutoff(self.config_dir, 123.0) + path = setup._backfill_state_path(self.config_dir) + self.assertEqual(path.read_text(), "123.0") + self.assertEqual(list(path.parent.glob("*.tmp")), []) + + def test_cutoff_not_advanced_when_session_cap_fires(self): + """When the per-run session cap is hit, the cutoff must NOT advance, or + the unprocessed older files would be skipped forever next run.""" + import setup + root = self.config_dir / "projects" + root.mkdir(parents=True) + for i in range(3): + (root / f"s{i}.jsonl").write_text('{"sessionId":"x%d"}\n' % i) + with patch.object(setup, "BACKFILL_MAX_SESSIONS_PER_RUN", 2), \ + patch.object(setup, "_backfill_upload_chunk", return_value=True): + setup.run_backfill("key", "https://backend", self.config_dir) + self.assertFalse(setup._backfill_state_path(self.config_dir).exists()) + + def test_run_backfill_reads_custom_config_dir_projects(self): + """With a custom config_dir, backfill walks config_dir/projects and writes + the cutoff there — not under ~/.claude.""" + import setup + custom = self.home / "custom-cc" + root = custom / "projects" + root.mkdir(parents=True) + (root / "s.jsonl").write_text('{"sessionId":"x"}\n') + with patch.object(setup, "_backfill_upload_chunk", return_value=True): + setup.run_backfill("key", "https://backend", custom) + self.assertTrue(setup._backfill_state_path(custom).exists()) + self.assertFalse(setup._backfill_state_path(self.config_dir).exists()) + + +class TestMdmBackfillCutoff(unittest.TestCase): + """Tests for the multi-user MDM run_backfill: a user's cutoff must advance + only when that user's transcripts were actually collected, so a failed + privilege-drop never strands their history behind an advanced cutoff.""" + + @staticmethod + def _load_mdm(): + import importlib.util + spec = importlib.util.spec_from_file_location( + "mdm_setup", str(Path(__file__).parent / "mdm" / "setup.py") + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def _run(self, mdm, collect_by_home, send_result): + """Run run_backfill with _run_as_user mocked; return list of homes + whose cutoff was written.""" + writes = [] + + def fake_run_as_user(username, fn, *args): + if fn is mdm._backfill_collect_sessions: + return collect_by_home[args[0]] + if fn is mdm._backfill_write_cutoff: + writes.append(args[0]) + return None + + homes = [(f"u{i}", home) for i, home in enumerate(collect_by_home)] + with patch.object(mdm, "_run_as_user", side_effect=fake_run_as_user), \ + patch.object(mdm, "_backfill_send_sessions", return_value=send_result): + mdm.run_backfill("key", "https://backend", homes) + return writes + + def test_failed_home_cutoff_not_advanced(self): + """Collection returning None (fork/perms failure) -> no cutoff write.""" + mdm = self._load_mdm() + good, bad = Path("/home/good"), Path("/home/bad") + # good: collected, empty, not capped; bad: collection failed (None) + writes = self._run(mdm, {good: ([], False), bad: None}, send_result=(0, 0, 0)) + self.assertIn(good, writes) + self.assertNotIn(bad, writes) + + def test_collected_homes_advanced_on_success(self): + """Full upload success -> cutoff written for every collected home.""" + mdm = self._load_mdm() + home = Path("/home/alice") + writes = self._run( + mdm, + {home: ([{"session_id": "s1", "entries": [{}]}], False)}, + send_result=(1, 1, 0), + ) + self.assertEqual(writes, [home]) + + def test_partial_upload_failure_does_not_advance(self): + """A failed chunk -> no cutoff write, so the next cron retries.""" + mdm = self._load_mdm() + home = Path("/home/alice") + writes = self._run( + mdm, + {home: ([{"session_id": "s1", "entries": [{}]}], False)}, + send_result=(1, 0, 1), # one chunk failed + ) + self.assertEqual(writes, []) + + def test_capped_home_not_advanced(self): + """A home that hit the per-run cap -> its cutoff is not advanced even on + a fully successful upload, so its overflow stays eligible next run.""" + mdm = self._load_mdm() + capped_home, ok_home = Path("/home/heavy"), Path("/home/light") + writes = self._run( + mdm, + { + capped_home: ([{"session_id": "s1", "entries": [{}]}], True), + ok_home: ([{"session_id": "s2", "entries": [{}]}], False), + }, + send_result=(2, 1, 0), + ) + self.assertEqual(writes, [ok_home]) + + +class TestMdmWriteConfigReportsSuccess(unittest.TestCase): + """A successful per-user config write must NOT be logged as a failure. + + Regression for the missing ``return True`` in the privilege-dropped + ``_write`` closure of ``write_unbound_config_for_user``: without it the + closure returned None on success, ``_run_as_user`` relayed that None, and + the caller misreported every successful write as + ``Could not write config for ``. The same closure ships verbatim in + claude-code, codex, copilot and augment, so all four are checked here. + """ + + _REPO_ROOT = Path(__file__).resolve().parents[2] + TOOLS = { + "claude-code": _REPO_ROOT / "claude-code" / "hooks" / "mdm" / "setup.py", + "codex": _REPO_ROOT / "codex" / "hooks" / "mdm" / "setup.py", + "copilot": _REPO_ROOT / "copilot" / "hooks" / "mdm" / "setup.py", + "augment": _REPO_ROOT / "augment" / "hooks" / "mdm" / "setup.py", + } + + @staticmethod + def _load(name, path): + import importlib.util + spec = importlib.util.spec_from_file_location( + f"mdm_setup_{name.replace('-', '_')}", str(path) + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def test_successful_write_is_not_reported_as_failure(self): + for name, path in self.TOOLS.items(): + with self.subTest(tool=name): + mdm = self._load(name, path) + logs = [] + home = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, home, ignore_errors=True) + + # Mimic a successful privilege drop: _run_as_user runs the + # callback in-process and relays its return value verbatim. + def fake_run_as_user(username, fn, *args, **kwargs): + return fn(*args, **kwargs) + + with patch.object(mdm, "_run_as_user", side_effect=fake_run_as_user), \ + patch.object(mdm, "_repair_user_ownership", lambda *a, **k: None), \ + patch.object(mdm, "debug_print", side_effect=logs.append): + mdm.write_unbound_config_for_user( + "tester", home, "sk-test-key", + urls={"base_url": "https://backend", "gateway_url": "https://gw"}, + ) + + config_file = home / ".unbound" / "config.json" + self.assertTrue(config_file.exists(), f"{name}: config.json was not written") + data = json.loads(config_file.read_text()) + self.assertEqual(data["api_key"], "sk-test-key") + self.assertEqual(data["base_url"], "https://backend") + self.assertFalse( + any("Could not write config" in m for m in logs), + f"{name}: success path falsely logged a failure: {logs}", + ) + + +class TestResolveClaudeConfigDir(unittest.TestCase): + """CLAUDE_CONFIG_DIR env > --config-dir arg > ~/.claude.""" + + def test_env_beats_arg_and_home(self): + import setup + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) + self.assertEqual(result, Path(os.path.abspath("/env/cc"))) + + def test_arg_used_when_no_env(self): + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) + self.assertEqual(result, Path(os.path.abspath("/arg/cc"))) + + def test_env_used_when_no_arg(self): + import setup + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/env/cc"}): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path(os.path.abspath("/env/cc"))) + + def test_home_default_when_arg_and_env_absent(self): + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path.home() / ".claude") + + def test_relative_value_is_absolutized(self): + import setup + result = setup._resolve_claude_config_dir(["x", "--config-dir", "rel/cc"]) + self.assertEqual(result, Path(os.path.abspath("rel/cc"))) + + def test_leading_tilde_stays_literal(self): + # Claude Code creates a literal "~" directory rather than expanding it, + # so expanding here would install where Claude never looks. + import setup + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "~/cc"}): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path(os.path.abspath("~/cc"))) + self.assertNotEqual(result, Path.home() / "cc") + + def test_blank_env_falls_back_to_home(self): + import setup + with patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": " "}): + result = setup._resolve_claude_config_dir(["x"]) + self.assertEqual(result, Path.home() / ".claude") + + +class TestInstallUnderResolvedDir(unittest.TestCase): + """Hooks + settings + baked command must land under the resolved config dir.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + self.home = Path(self.tmp) / "home" + self.home.mkdir(parents=True) + self.config_dir = Path(self.tmp) / "custom-cc" + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_settings_and_hook_command_under_config_dir(self): + import setup + with patch.object(Path, "home", staticmethod(lambda: self.home)), \ + patch.object(setup, "download_file", lambda url, dest: dest.parent.mkdir(parents=True, exist_ok=True) or dest.write_text("# hook") or True): + self.assertTrue(setup.setup_hooks(config_dir=self.config_dir)) + self.assertTrue(setup.configure_claude_settings(config_dir=self.config_dir)) + + hook_path = self.config_dir / "hooks" / "unbound.py" + settings_path = self.config_dir / "settings.json" + self.assertTrue(hook_path.exists()) + self.assertTrue(settings_path.exists()) + settings = json.loads(settings_path.read_text()) + cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + self.assertEqual(cmd, str(hook_path)) + self.assertNotIn(str(self.home / ".claude"), cmd) + + def test_backward_compat_no_env_uses_home_claude(self): + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True), \ + patch.object(Path, "home", staticmethod(lambda: self.home)), \ + patch.object(setup, "download_file", lambda url, dest: dest.parent.mkdir(parents=True, exist_ok=True) or dest.write_text("# hook") or True): + config_dir = setup._resolve_claude_config_dir(["x"]) + self.assertTrue(setup.setup_hooks(config_dir=config_dir)) + self.assertTrue(setup.configure_claude_settings(config_dir=config_dir)) + + hook_path = self.home / ".claude" / "hooks" / "unbound.py" + self.assertTrue(hook_path.exists()) + settings = json.loads((self.home / ".claude" / "settings.json").read_text()) + cmd = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + self.assertEqual(cmd, str(hook_path)) + + +class TestCommandTargetsHook(unittest.TestCase): + def setUp(self): + from setup import _command_targets_hook + self.match = _command_targets_hook + self.target = Path("/Users/jane/.claude/hooks/unbound.py") + + def test_bare_path_matches(self): + self.assertTrue(self.match(str(self.target), self.target)) + + def test_double_quoted_matches(self): + self.assertTrue(self.match(f'"{self.target}"', self.target)) + + def test_single_quoted_matches(self): + self.assertTrue(self.match(f"'{self.target}'", self.target)) + + def test_launcher_prefixed_matches(self): + self.assertTrue(self.match(f'py -3 "{self.target}"', self.target)) + self.assertTrue(self.match(f'python "{self.target}"', self.target)) + + def test_exe_launcher_prefixed_matches(self): + self.assertTrue(self.match(f'py.exe -3 "{self.target}"', self.target)) + self.assertTrue(self.match(f'python.exe "{self.target}"', self.target)) + self.assertTrue(self.match(f'python3.exe "{self.target}"', self.target)) + + def test_path_with_spaces_matches(self): + target = Path("/Users/Jane Doe/.claude/hooks/unbound.py") + self.assertTrue(self.match(f'"{target}"', target)) + self.assertTrue(self.match(f'py -3 "{target}"', target)) + + def test_foreign_command_does_not_match(self): + self.assertFalse(self.match("/opt/other/hook.py", self.target)) + self.assertFalse(self.match('echo "hello world"', self.target)) + + def test_target_as_argument_does_not_match(self): + self.assertFalse(self.match(f'/opt/other/hook.py --config "{self.target}"', self.target)) + + def test_sibling_path_does_not_match(self): + self.assertFalse(self.match(f"{self.target}.backup", self.target)) + self.assertFalse(self.match(f"/opt/mirror{self.target}", self.target)) + + def test_empty_command_does_not_match(self): + self.assertFalse(self.match("", self.target)) + + +class TestRemoveHooksFromSettings(unittest.TestCase): + def setUp(self): + self.home = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.home, ignore_errors=True) + patcher = patch("setup.Path.home", return_value=self.home) + patcher.start() + self.addCleanup(patcher.stop) + self.settings_path = self.home / ".claude" / "settings.json" + self.settings_path.parent.mkdir(parents=True, exist_ok=True) + self.script = str(self.home / ".claude" / "hooks" / "unbound.py") + + def _write(self, settings): + self.settings_path.write_text(json.dumps(settings)) + + def _read(self): + return json.loads(self.settings_path.read_text()) + + def test_removes_quoted_bare_and_launcher_forms_preserving_foreign(self): + from setup import remove_hooks_from_settings + self._write({"hooks": { + "PreToolUse": [ + {"matcher": "*", "hooks": [ + {"type": "command", "command": f'"{self.script}"'}, + {"type": "command", "command": "/opt/other/hook.py"}, + ]}, + ], + "Stop": [ + {"hooks": [{"type": "command", "command": self.script}]}, + ], + "SessionStart": [ + {"hooks": [{"type": "command", "command": f'py -3 "{self.script}"'}]}, + ], + }}) + self.assertEqual(remove_hooks_from_settings(), "cleared") + result = self._read() + self.assertEqual( + result["hooks"]["PreToolUse"][0]["hooks"], + [{"type": "command", "command": "/opt/other/hook.py"}], + ) + self.assertNotIn("Stop", result["hooks"]) + self.assertNotIn("SessionStart", result["hooks"]) + + def test_mixed_quoted_and_bare_both_removed(self): + from setup import remove_hooks_from_settings + self._write({"hooks": {"PreToolUse": [ + {"matcher": "*", "hooks": [ + {"type": "command", "command": f'"{self.script}"'}, + {"type": "command", "command": self.script}, + ]}, + ]}}) + self.assertEqual(remove_hooks_from_settings(), "cleared") + self.assertNotIn("hooks", self._read()) + + def test_install_dedup_skips_when_quoted_entry_exists(self): + from setup import configure_claude_settings + self._write({"hooks": {"PreToolUse": [ + {"matcher": "*", "hooks": [ + {"type": "command", "command": f'"{self.script}"', "timeout": 15000}, + ]}, + ]}}) + self.assertTrue(configure_claude_settings()) + result = self._read() + commands = [ + h["command"] + for item in result["hooks"]["PreToolUse"] + for h in item["hooks"] + ] + self.assertEqual(commands.count(f'"{self.script}"'), 1) + self.assertNotIn(self.script, commands) + + +class TestMatcherParityAcrossTrees(unittest.TestCase): + SENTINEL = "return os.path.normcase(os.path.normpath(tokens[0])) == normalized_target" + + def _extract(self, path): + captured = [] + capturing = False + for line in path.read_text().splitlines(): + if line.startswith("def _command_targets_hook"): + capturing = True + if capturing: + captured.append(line) + if line.strip() == self.SENTINEL: + break + return "\n".join(captured) + + def test_helper_is_byte_identical_across_trees(self): + root = Path(__file__).resolve().parents[2] + files = [ + root / "claude-code" / "hooks" / "setup.py", + root / "claude-code" / "hooks" / "mdm" / "setup.py", + root / "codex" / "hooks" / "setup.py", + root / "codex" / "hooks" / "mdm" / "setup.py", + root / "binary" / "src" / "unbound_hook" / "setup_cmd.py", + ] + bodies = [self._extract(f) for f in files] + for path, body in zip(files, bodies): + self.assertTrue(body.strip(), f"{path}: matcher not found") + self.assertEqual(len(set(bodies)), 1, "matcher drifted across trees") + + +if __name__ == "__main__": + unittest.main() diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 37ea4ca..175fa02 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -20,9 +20,12 @@ UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") +# Claude Code reads CLAUDE_CONFIG_DIR verbatim and resolves it against the cwd; +# it does not expand a leading "~". Expanding it here would put the hook's files +# under $HOME while Claude read a literal "~" directory, so keep it literal. +# A blank value is treated as unset so nothing lands in the cwd. _env_config_dir = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() -_config_dir_is_default = not _env_config_dir -_CONFIG_DIR = Path(_env_config_dir or (Path.home() / ".claude")).expanduser().resolve() +_CONFIG_DIR = Path(os.path.abspath(_env_config_dir)) if _env_config_dir else Path.home() / ".claude" AUDIT_LOG = _CONFIG_DIR / "hooks" / "agent-audit.log" ERROR_LOG = _CONFIG_DIR / "hooks" / "error.log" LAST_REPORT_FILE = _CONFIG_DIR / "hooks" / ".last_error_report" @@ -48,23 +51,16 @@ _SLUG_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$') -def _relocated_or_legacy(relocated: Path, legacy: Path) -> Path: - # Whether Claude relocates .claude.json / plugins under CLAUDE_CONFIG_DIR is - # version-dependent, so read from the relocated dir when it actually has the - # artifact, else the default ~/.claude location. Keeps both paths consistent. - if not _config_dir_is_default and relocated.exists(): - return relocated - return legacy - - # CoWork built-in tools that are exposed under mcp__ COWORK_BUILTIN_MCP_SERVERS = frozenset({ 'workspace', 'cowork', 'cowork-onboarding', 'visualize', 'scheduled-tasks', 'plugins', 'mcp-registry', 'session_info', 'skills', }) -CLAUDE_MCP_CONFIG_PATH = _relocated_or_legacy(_CONFIG_DIR / ".claude.json", Path.home() / ".claude.json") -CLAUDE_PLUGIN_CACHE_DIR = _relocated_or_legacy(_CONFIG_DIR / "plugins" / "cache", Path.home() / ".claude" / "plugins" / "cache") +# Claude keeps .claude.json beside the config dir when relocated, and directly +# in the home dir otherwise — it is not nested under the default ~/.claude. +CLAUDE_MCP_CONFIG_PATH = (_CONFIG_DIR / ".claude.json") if _env_config_dir else (Path.home() / ".claude.json") +CLAUDE_PLUGIN_CACHE_DIR = _CONFIG_DIR / "plugins" / "cache" POLICY_CACHE_FILE = _CONFIG_DIR / "hooks" / ".policy_cache.json" CACHE_TTL_SECONDS = 300 # Repo-scope gate. Straying outside the allowed org is blocked on the first From cba0181fd94fbbfb1562cddebd2136ae9a4115e8 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Sun, 23 Aug 2026 13:59:25 +0530 Subject: [PATCH 09/21] fix: warn when --config-dir is set without CLAUDE_CONFIG_DIR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor flagged that setup.py honours --config-dir while the runtime hook resolves only from the environment, so an install driven by the arg alone lands somewhere the hook never looks. The deeper issue is that Claude Code itself keys off the environment variable alone, so such an install is one Claude never reads at all — nothing the hook does could rescue it. The CLI never produces this state; it forwards --config-dir only when CLAUDE_CONFIG_DIR is already set. It is reachable by invoking setup.py directly, and it used to report success over hooks that could not fire. It now says so plainly, which is the same fail-loud rule the rest of this branch follows. Tests: hooks 43 passed, gateway 11 passed, with the pre-existing MDM matcher-parity failure unchanged. py_compile and pyflakes clean. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/hooks/setup.py | 7 +++++++ claude-code/hooks/test_setup.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index debfdae..3321afd 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -1228,6 +1228,13 @@ def main(): debug_print("Debug mode enabled") config_dir = _resolve_claude_config_dir(sys.argv) + # Claude Code picks its config dir from the environment alone. An install + # aimed somewhere else by --config-dir is one it will never read, so say so + # rather than reporting success over a set of hooks that cannot fire. + if "--config-dir" in sys.argv and not (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip(): + print(f"\nāš ļø --config-dir set to {config_dir} but CLAUDE_CONFIG_DIR is not set in the " + "environment. Claude Code reads only the environment variable, so it will not load " + "these hooks. Export CLAUDE_CONFIG_DIR to the same path.") if clear_mode: return clear_setup(config_dir) diff --git a/claude-code/hooks/test_setup.py b/claude-code/hooks/test_setup.py index 580d5cc..83954ab 100644 --- a/claude-code/hooks/test_setup.py +++ b/claude-code/hooks/test_setup.py @@ -422,6 +422,15 @@ def test_blank_env_falls_back_to_home(self): result = setup._resolve_claude_config_dir(["x"]) self.assertEqual(result, Path.home() / ".claude") + def test_arg_without_env_still_resolves_to_the_arg(self): + # The install honours --config-dir, but Claude Code keys off the env var + # alone, so main() warns that these hooks will not be read. + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x", "--config-dir", "/arg/cc"]) + self.assertEqual(result, Path(os.path.abspath("/arg/cc"))) + class TestInstallUnderResolvedDir(unittest.TestCase): """Hooks + settings + baked command must land under the resolved config dir.""" From 489bdbce7db8ebec287d19c458cecb769cccc454 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Sun, 23 Aug 2026 14:16:01 +0530 Subject: [PATCH 10/21] fix: accept --config-dir=VALUE in the hooks installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled argv scan only matched the space-separated spelling, so --config-dir=/path was skipped and the install quietly fell back to ~/.claude — the silent wrong-directory install this branch exists to stop. The gateway installer uses argparse and already handled both. The unbound-cli flow always emits the space form, so this is reachable by invoking setup.py directly, which is exactly where a person types the equals form by habit. The warning added for --config-dir without CLAUDE_CONFIG_DIR now recognises both spellings too, so it cannot be sidestepped by the same typo. Tests: hooks 44 passed, gateway 11 passed, with the pre-existing MDM matcher-parity failure unchanged. py_compile and pyflakes clean. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/hooks/setup.py | 9 ++++++++- claude-code/hooks/test_setup.py | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 3321afd..c449aeb 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -66,6 +66,12 @@ def _resolve_claude_config_dir(argv) -> Path: value = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() or None if not value: for i, arg in enumerate(argv): + # Both spellings: --config-dir VALUE and --config-dir=VALUE. Missing + # the second would silently fall back to ~/.claude, which is the very + # wrong-directory install this is meant to prevent. + if arg.startswith("--config-dir="): + value = arg.split("=", 1)[1].strip() or None + break if arg == "--config-dir" and i + 1 < len(argv) and not argv[i + 1].startswith("--"): value = argv[i + 1].strip() or None break @@ -1231,7 +1237,8 @@ def main(): # Claude Code picks its config dir from the environment alone. An install # aimed somewhere else by --config-dir is one it will never read, so say so # rather than reporting success over a set of hooks that cannot fire. - if "--config-dir" in sys.argv and not (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip(): + _arg_dir_given = any(a == "--config-dir" or a.startswith("--config-dir=") for a in sys.argv) + if _arg_dir_given and not (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip(): print(f"\nāš ļø --config-dir set to {config_dir} but CLAUDE_CONFIG_DIR is not set in the " "environment. Claude Code reads only the environment variable, so it will not load " "these hooks. Export CLAUDE_CONFIG_DIR to the same path.") diff --git a/claude-code/hooks/test_setup.py b/claude-code/hooks/test_setup.py index 83954ab..fd664c1 100644 --- a/claude-code/hooks/test_setup.py +++ b/claude-code/hooks/test_setup.py @@ -422,6 +422,13 @@ def test_blank_env_falls_back_to_home(self): result = setup._resolve_claude_config_dir(["x"]) self.assertEqual(result, Path.home() / ".claude") + def test_equals_form_of_config_dir_arg(self): + import setup + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + with patch.dict(os.environ, env, clear=True): + result = setup._resolve_claude_config_dir(["x", "--config-dir=/arg/cc"]) + self.assertEqual(result, Path(os.path.abspath("/arg/cc"))) + def test_arg_without_env_still_resolves_to_the_arg(self): # The install honours --config-dir, but Claude Code keys off the env var # alone, so main() warns that these hooks will not be read. From d7049139189a5dd2b45b6b494fe4eaafd902d894 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Sun, 23 Aug 2026 14:37:50 +0530 Subject: [PATCH 11/21] fix: honor CLAUDE_CODE_PLUGIN_CACHE_DIR like Claude Code does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading the resolver out of the 2.1.177 bundle settled where Claude Code actually keeps these: .claude.json CLAUDE_CONFIG_DIR || homedir(), then + "/.claude.json" plugins CLAUDE_CODE_PLUGIN_CACHE_DIR, else join(configDir, "plugins") user skills join(configDir, "skills") user settings join(resolve(configDir), "settings.json") Everything the hook derives already matched except the plugin root, which ignored the override entirely. Following it is a one-line change and makes the constant faithful rather than merely assumed. One deliberate gap left: Claude prefers /.config.json over .claude.json when that file exists. Matching it would put a filesystem probe back at import time on every hook invocation, and the only consequence of missing it is unresolved MCP server names in attribution — no policy weight. Recorded on the PR rather than papered over. Tests: hooks 44 passed, gateway 11 passed, MDM matcher-parity failure unchanged. py_compile and pyflakes clean. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/hooks/unbound.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 175fa02..3417c9b 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -60,7 +60,11 @@ # Claude keeps .claude.json beside the config dir when relocated, and directly # in the home dir otherwise — it is not nested under the default ~/.claude. CLAUDE_MCP_CONFIG_PATH = (_CONFIG_DIR / ".claude.json") if _env_config_dir else (Path.home() / ".claude.json") -CLAUDE_PLUGIN_CACHE_DIR = _CONFIG_DIR / "plugins" / "cache" +# Claude Code lets CLAUDE_CODE_PLUGIN_CACHE_DIR override the plugin root outright, +# and falls back to /plugins otherwise. +_env_plugin_dir = (os.environ.get("CLAUDE_CODE_PLUGIN_CACHE_DIR") or "").strip() +_PLUGIN_ROOT = Path(os.path.abspath(_env_plugin_dir)) if _env_plugin_dir else _CONFIG_DIR / "plugins" +CLAUDE_PLUGIN_CACHE_DIR = _PLUGIN_ROOT / "cache" POLICY_CACHE_FILE = _CONFIG_DIR / "hooks" / ".policy_cache.json" CACHE_TTL_SECONDS = 300 # Repo-scope gate. Straying outside the allowed org is blocked on the first From 248adf0623972c6b6ffdf52be0871670668e0085 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Sun, 23 Aug 2026 14:48:03 +0530 Subject: [PATCH 12/21] test: cover the runtime path constants in unbound.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin-cache override went in without a test, and more to the point none of the runtime path constants had one — they are computed at import from the environment, so nothing was pinning them. These are the paths that decide whether the hook reads and writes where Claude Code looks, and a disagreement there fails silently, which is the whole subject of this PR. Ten cases over the resolver, the enforcement paths (audit log, policy cache, self-update target, skills root), .claude.json placement in both the default and relocated layouts, and the plugin cache including its env override and a blank value. Reloading the module under a patched environment is the only way to exercise import-time constants; each case restores the default afterwards. Verified it does not disturb the rest of the suite: the full hooks directory goes from 440 to 450 passing with the same two failures before and after, both of which reproduce on untouched staging. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/hooks/test_config_dir.py | 92 ++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 claude-code/hooks/test_config_dir.py diff --git a/claude-code/hooks/test_config_dir.py b/claude-code/hooks/test_config_dir.py new file mode 100644 index 0000000..219cc9d --- /dev/null +++ b/claude-code/hooks/test_config_dir.py @@ -0,0 +1,92 @@ +"""Runtime path resolution in unbound.py, checked against what Claude Code does. + +Every constant here is a path Claude Code also computes. If the two disagree the +hook reads or writes somewhere Claude never looks, which fails silently — so +these assertions mirror the resolver in the shipped Claude Code bundle. +""" + +import importlib +import os +import unittest +from pathlib import Path +from unittest.mock import patch + +import unbound + + +def _reload(**env): + """Reload unbound with `env` applied, since the paths resolve at import.""" + base = {k: v for k, v in os.environ.items() + if k not in ('CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_PLUGIN_CACHE_DIR')} + base.update({k: v for k, v in env.items() if v is not None}) + with patch.dict(os.environ, base, clear=True): + return importlib.reload(unbound) + + +class TestConfigDirResolution(unittest.TestCase): + def tearDown(self): + _reload() # leave the module as the rest of the suite expects it + + def test_default_when_env_unset(self): + m = _reload(HOME='/home/jane') + self.assertEqual(m._CONFIG_DIR, Path('/home/jane/.claude')) + + def test_relocated_when_env_set(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') + self.assertEqual(m._CONFIG_DIR, Path('/opt/cc')) + + def test_leading_tilde_is_not_expanded(self): + # Claude Code reads the value verbatim and makes a literal "~" directory. + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='~/cc') + self.assertNotEqual(m._CONFIG_DIR, Path('/home/jane/cc')) + self.assertEqual(m._CONFIG_DIR, Path(os.path.abspath('~/cc'))) + + def test_blank_env_falls_back_to_home(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR=' ') + self.assertEqual(m._CONFIG_DIR, Path('/home/jane/.claude')) + + def test_enforcement_paths_follow_the_relocated_dir(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') + self.assertEqual(m.AUDIT_LOG, Path('/opt/cc/hooks/agent-audit.log')) + self.assertEqual(m.POLICY_CACHE_FILE, Path('/opt/cc/hooks/.policy_cache.json')) + self.assertEqual(m.SELF_SCRIPT_PATH, Path('/opt/cc/hooks/unbound.py')) + self.assertEqual(m.CLAUDE_SKILLS_ROOT, Path('/opt/cc/skills')) + + +class TestClaudeJsonLocation(unittest.TestCase): + """Claude resolves it as (CLAUDE_CONFIG_DIR or homedir) + '/.claude.json' — + beside the config dir when relocated, never nested under ~/.claude.""" + + def tearDown(self): + _reload() + + def test_sits_in_home_not_under_dot_claude_by_default(self): + m = _reload(HOME='/home/jane') + self.assertEqual(m.CLAUDE_MCP_CONFIG_PATH, Path('/home/jane/.claude.json')) + + def test_moves_into_the_relocated_dir(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') + self.assertEqual(m.CLAUDE_MCP_CONFIG_PATH, Path('/opt/cc/.claude.json')) + + +class TestPluginCacheDir(unittest.TestCase): + def tearDown(self): + _reload() + + def test_defaults_under_the_config_dir(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') + self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, Path('/opt/cc/plugins/cache')) + + def test_env_override_wins_over_the_config_dir(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc', + CLAUDE_CODE_PLUGIN_CACHE_DIR='/var/pcache') + self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, Path('/var/pcache/cache')) + + def test_blank_override_is_ignored(self): + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc', + CLAUDE_CODE_PLUGIN_CACHE_DIR=' ') + self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, Path('/opt/cc/plugins/cache')) + + +if __name__ == '__main__': + unittest.main() From 4333f5909c61f9ada20cced68ce14955be903083 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Mon, 24 Aug 2026 19:31:54 +0530 Subject: [PATCH 13/21] fix: flag a set-but-empty CLAUDE_CONFIG_DIR instead of installing blind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the install against real Claude Code on macOS and Linux. When the variable is set but empty, Claude Code does not fall back to ~/.claude — it resolves the value against the current directory. An empty string lands on the cwd itself, and whitespace on a directory named after that whitespace, which Claude will happily create. Observed directly: with CLAUDE_CONFIG_DIR= it wrote backups/ into the cwd, and with " " it created a directory named three spaces. Both reproduce on 2.1.177 (macOS) and 2.1.179 (Linux), so this is not version-specific. We keep resolving blank to ~/.claude rather than following it into the cwd. Matching Claude here would scatter hook scripts through whatever directory setup happened to run in, and Claude itself is inconsistent about it — the config dir uses ?? while .claude.json uses ||, so an empty value sends the two to different places. That reads as an accident on their side, not a contract to mirror. What is not acceptable is doing it silently, since the result is an install Claude never reads. Both installers now say so plainly, which is the same fail-loud rule the rest of this branch follows. Tests: hooks 56 passed, gateway 11 passed, with the pre-existing MDM matcher-parity failure unchanged. py_compile and pyflakes clean. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/gateway/setup.py | 8 ++++++++ claude-code/hooks/setup.py | 8 ++++++++ claude-code/hooks/test_config_dir.py | 6 ++++++ 3 files changed, 22 insertions(+) diff --git a/claude-code/gateway/setup.py b/claude-code/gateway/setup.py index ab5f5f0..2e42fd6 100644 --- a/claude-code/gateway/setup.py +++ b/claude-code/gateway/setup.py @@ -705,6 +705,14 @@ def main(): args.backend_url = normalize_url(args.backend_url) config_dir = _resolve_claude_config_dir(args.config_dir) + # Claude Code resolves CLAUDE_CONFIG_DIR against the current directory when it + # is set but empty, instead of falling back to ~/.claude, so installing to the + # default would be invisible to it. + _raw_cfg = os.environ.get("CLAUDE_CONFIG_DIR") + if _raw_cfg is not None and not _raw_cfg.strip(): + print("\n\u26a0\ufe0f CLAUDE_CONFIG_DIR is set but empty. Claude Code reads that as a path " + "relative to the current directory, not as ~/.claude, so it will not load this " + "install. Unset the variable, or point it at a real directory.") if args.debug: DEBUG = True diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index c449aeb..469c644 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -1234,6 +1234,14 @@ def main(): debug_print("Debug mode enabled") config_dir = _resolve_claude_config_dir(sys.argv) + # Claude Code resolves CLAUDE_CONFIG_DIR against the current directory when it + # is set but empty, instead of falling back to ~/.claude. Installing to the + # default would then be invisible to it, so refuse to do that quietly. + _raw_cfg = os.environ.get("CLAUDE_CONFIG_DIR") + if _raw_cfg is not None and not _raw_cfg.strip(): + print("\n\u26a0\ufe0f CLAUDE_CONFIG_DIR is set but empty. Claude Code reads that as a path " + "relative to the current directory, not as ~/.claude, so it will not load hooks " + "installed here. Unset the variable, or point it at a real directory.") # Claude Code picks its config dir from the environment alone. An install # aimed somewhere else by --config-dir is one it will never read, so say so # rather than reporting success over a set of hooks that cannot fire. diff --git a/claude-code/hooks/test_config_dir.py b/claude-code/hooks/test_config_dir.py index 219cc9d..2e43696 100644 --- a/claude-code/hooks/test_config_dir.py +++ b/claude-code/hooks/test_config_dir.py @@ -45,6 +45,12 @@ def test_blank_env_falls_back_to_home(self): m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR=' ') self.assertEqual(m._CONFIG_DIR, Path('/home/jane/.claude')) + def test_empty_env_also_falls_back_to_home(self): + # Claude Code would use the cwd here; we deliberately do not follow it + # there, and the installer warns instead. See the note in setup.py main(). + m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='') + self.assertEqual(m._CONFIG_DIR, Path('/home/jane/.claude')) + def test_enforcement_paths_follow_the_relocated_dir(self): m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') self.assertEqual(m.AUDIT_LOG, Path('/opt/cc/hooks/agent-audit.log')) From a116048aa1fc103fd0da9a19e817641cbb510b3e Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Mon, 24 Aug 2026 21:14:04 +0530 Subject: [PATCH 14/21] fix: do not delete a foreign key helper in the legacy sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging staging's ownership rule left one path uncovered, and it was the destructive one. WEB-5526 stopped clear from removing an anthropic_key.sh we did not write, but this branch's legacy ~/.claude sweep — which only runs when the config dir is relocated — deleted that file unconditionally. Anyone keeping their own helper of that name would have lost it to a --clear under a custom CLAUDE_CONFIG_DIR. The sweep now applies the same content check as the primary path, so it removes only the script the gateway writer emitted. The existing sweep test wrote "echo x" as the legacy helper and asserted it was deleted, which predates the ownership rule and would now be asserting the wrong thing; it writes the real helper body instead. A new test covers the case that matters, that a foreign helper survives while our own install is still cleared. Left the hooks sweep alone deliberately: it removes unbound.py, which nothing else writes, and the primary clear path deletes it unguarded for the same reason. Guarding one and not the other would be the inconsistency, not the fix. Tests: hooks 56 passed, gateway 12 passed, with the pre-existing MDM matcher-parity failure unchanged. py_compile and pyflakes clean. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/gateway/setup.py | 6 +++++- claude-code/gateway/test_setup.py | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/claude-code/gateway/setup.py b/claude-code/gateway/setup.py index f7324c0..8e77fca 100644 --- a/claude-code/gateway/setup.py +++ b/claude-code/gateway/setup.py @@ -673,7 +673,11 @@ def clear_setup(config_dir: Path = None) -> bool: # without CLAUDE_CONFIG_DIR set. default_dir = Path.home() / ".claude" if config_dir.resolve() != default_dir.resolve(): - if _clear_path(default_dir / "anthropic_key.sh", "Claude anthropic_key.sh (~/.claude)") == "cleared": + # Same ownership check the primary path uses: a helper of this name that we + # did not write belongs to whoever did, and clearing must not delete it. + legacy_helper = default_dir / "anthropic_key.sh" + if (_is_unbound_key_helper_file(legacy_helper) + and _clear_path(legacy_helper, "Claude anthropic_key.sh (~/.claude)") == "cleared"): any_cleared = True if remove_api_key_helper_setting(default_dir) == "cleared": any_cleared = True diff --git a/claude-code/gateway/test_setup.py b/claude-code/gateway/test_setup.py index ea742e7..6ee8d13 100644 --- a/claude-code/gateway/test_setup.py +++ b/claude-code/gateway/test_setup.py @@ -81,7 +81,7 @@ def test_clear_relocated_also_clears_default_claude(self): with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): legacy = home / ".claude" legacy.mkdir(parents=True) - (legacy / "anthropic_key.sh").write_text("echo x") + (legacy / "anthropic_key.sh").write_text(gw.UNBOUND_KEY_HELPER_BODY) (legacy / "settings.json").write_text(json.dumps({"apiKeyHelper": "~/.claude/anthropic_key.sh"})) cc = home / "cc" gw.setup_claude_key_helper(cc) @@ -92,6 +92,23 @@ def test_clear_relocated_also_clears_default_claude(self): self.assertFalse((legacy / "anthropic_key.sh").exists()) self.assertNotIn("apiKeyHelper", json.loads((legacy / "settings.json").read_text())) + def test_clear_relocated_leaves_a_foreign_legacy_helper_alone(self): + """A helper of the same name that we did not write is not ours to delete, + and the legacy sweep must honour that as the primary path does.""" + with tempfile.TemporaryDirectory() as home: + home = Path(home) + with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): + legacy = home / ".claude" + legacy.mkdir(parents=True) + foreign = legacy / "anthropic_key.sh" + foreign.write_text("echo $MY_COMPANY_KEY") + cc = home / "cc" + gw.setup_claude_key_helper(cc) + gw.clear_setup(cc) + self.assertFalse((cc / "anthropic_key.sh").exists(), "our own install is cleared") + self.assertTrue(foreign.exists(), "someone else's helper survives the sweep") + self.assertEqual(foreign.read_text(), "echo $MY_COMPANY_KEY") + def test_clear_default_dir_does_not_double_sweep(self): with tempfile.TemporaryDirectory() as home: home = Path(home) From b1bde0308e5e7cd52b299755bc57d03953b9445b Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Tue, 25 Aug 2026 01:44:33 +0530 Subject: [PATCH 15/21] chore: trim the resolver comment to the rule itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropped the fourth line, which restated the consequence already implied by the two above it. The rule a maintainer needs — verbatim, no tilde expansion, blank means unset — is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/hooks/unbound.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 3417c9b..345ea3d 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -20,10 +20,9 @@ UNBOUND_GATEWAY_URL = os.environ.get( "UNBOUND_GATEWAY_URL", "https://api.getunbound.ai" ).rstrip("/") -# Claude Code reads CLAUDE_CONFIG_DIR verbatim and resolves it against the cwd; -# it does not expand a leading "~". Expanding it here would put the hook's files -# under $HOME while Claude read a literal "~" directory, so keep it literal. -# A blank value is treated as unset so nothing lands in the cwd. +# Claude Code reads CLAUDE_CONFIG_DIR verbatim and resolves it against the cwd, +# without expanding a leading "~". Expanding it here would put the hook's files +# under $HOME while Claude read a literal "~" dir. Blank is treated as unset. _env_config_dir = (os.environ.get("CLAUDE_CONFIG_DIR") or "").strip() _CONFIG_DIR = Path(os.path.abspath(_env_config_dir)) if _env_config_dir else Path.home() / ".claude" AUDIT_LOG = _CONFIG_DIR / "hooks" / "agent-audit.log" From 976ffb92b523adb58aea0be860f4211eac50fab3 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Tue, 25 Aug 2026 02:07:18 +0530 Subject: [PATCH 16/21] fix: ownership predicates must follow the relocated dir too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three predicates still asked "is this ours" against a hardcoded ~/.claude while the install itself had moved. Each is the same bug this branch exists to fix, one level down. The damaging one is the gateway's apiKeyHelper. setup_claude_key_helper writes an absolute path when the dir is relocated, but _is_unbound_key_helper_setting only recognised the ~ form, so clear deleted the script and left the setting pointing at it. Claude Code then holds a dangling apiKeyHelper and every API call fails — a worse state than before clearing. _is_unbound_hook_command was pinned to ~/.claude/hooks/unbound.py, so moving from hooks mode to gateway mode under a custom dir would not strip the existing hooks and both enforcement paths would fire at once. The hooks installer had the same blind spot in reverse: it drops the gateway's apiKeyHelper before installing, and could not see the absolute form either, so the helper would have survived and driven Claude alongside the hooks. All three now take the resolved dir and still accept the portable ~ form, so a default install behaves exactly as before. Tests cover the dangling-setting case and the hooks-to-gateway transition, which are the two that reach a user. Tests: hooks 58 passed, gateway 14 passed, with the pre-existing MDM matcher-parity failure unchanged. py_compile and pyflakes clean. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/gateway/setup.py | 29 +++++++++++++----------- claude-code/gateway/test_setup.py | 34 ++++++++++++++++++++++++++++ claude-code/hooks/setup.py | 15 ++++++++---- claude-code/hooks/test_config_dir.py | 24 ++++++++++++++++++++ 4 files changed, 84 insertions(+), 18 deletions(-) diff --git a/claude-code/gateway/setup.py b/claude-code/gateway/setup.py index 8e77fca..fad27c6 100644 --- a/claude-code/gateway/setup.py +++ b/claude-code/gateway/setup.py @@ -270,15 +270,16 @@ def _command_targets_hook(command: str, target: Path) -> bool: return os.path.normcase(os.path.normpath(tokens[0])) == normalized_target -def _is_unbound_hook_command(command) -> bool: +def _is_unbound_hook_command(command, config_dir: Path = None) -> bool: """Whether a settings.json hook entry runs the Unbound hook. The hooks installer writes the interpreter, quoting and separators of the platform it ran on, so the command is tokenised and the path compared rather than matched as a substring.""" + config_dir = config_dir or (Path.home() / ".claude") return isinstance(command, str) and _command_targets_hook( - command, Path.home() / ".claude" / "hooks" / "unbound.py") + command, config_dir / "hooks" / "unbound.py") -def _strip_unbound_hooks(settings: dict) -> None: +def _strip_unbound_hooks(settings: dict, config_dir: Path = None) -> None: """Drop the Unbound entries from settings["hooks"], leaving every other hook in place and removing only the groups and the block our entries emptied.""" hooks = settings.get("hooks") @@ -297,7 +298,7 @@ def _strip_unbound_hooks(settings: dict) -> None: kept_groups.append(group) continue kept = [e for e in entries - if not (isinstance(e, dict) and _is_unbound_hook_command(e.get("command")))] + if not (isinstance(e, dict) and _is_unbound_hook_command(e.get("command"), config_dir))] if not kept: continue group["hooks"] = kept @@ -310,20 +311,22 @@ def _strip_unbound_hooks(settings: dict) -> None: del settings["hooks"] -def _is_unbound_key_helper_setting(value) -> bool: - """Whether settings.json's apiKeyHelper is the one this setup writes. The expanded - form counts too: the setup writes the ~ form, but a device may already carry the - expanded one.""" +def _is_unbound_key_helper_setting(value, config_dir: Path = None) -> bool: + """Whether settings.json's apiKeyHelper is the one this setup writes. The setup + writes the portable ~ form for the default dir and an absolute path for a relocated + one, and a device may already carry the expanded default; all three count.""" + config_dir = config_dir or (Path.home() / ".claude") if not isinstance(value, str): return False + helper = config_dir / "anthropic_key.sh" candidate = value.strip() if candidate not in (UNBOUND_KEY_HELPER_SETTING, - str(Path.home() / ".claude" / "anthropic_key.sh")): + str(Path.home() / ".claude" / "anthropic_key.sh"), + str(helper)): return False # The path is a name anyone could choose, so the script there decides. Nothing there # means our own removal already ran; a dangling helper is broken either way. - path = Path.home() / ".claude" / "anthropic_key.sh" - return not path.exists() or _is_unbound_key_helper_file(path) + return not helper.exists() or _is_unbound_key_helper_file(helper) def _is_unbound_key_helper_file(path: Path) -> bool: @@ -503,7 +506,7 @@ def setup_claude_key_helper(config_dir: Path = None) -> bool: # Our hook and the gateway cannot both drive Claude Code, so ours goes before # apiKeyHelper is added. Only ours: a hook the user installed is not ours to drop. - _strip_unbound_hooks(settings) + _strip_unbound_hooks(settings, claude_dir) if claude_dir.resolve() == (Path.home() / ".claude").resolve(): settings["apiKeyHelper"] = "~/.claude/anthropic_key.sh" @@ -621,7 +624,7 @@ def remove_api_key_helper_setting(config_dir: Path = None) -> str: try: with open(settings_path, "r", encoding="utf-8") as f: settings = json.load(f) - if not _is_unbound_key_helper_setting(settings.get("apiKeyHelper")): + if not _is_unbound_key_helper_setting(settings.get("apiKeyHelper"), config_dir): return "not_found" del settings["apiKeyHelper"] with open(settings_path, "w", encoding="utf-8") as f: diff --git a/claude-code/gateway/test_setup.py b/claude-code/gateway/test_setup.py index 6ee8d13..756963c 100644 --- a/claude-code/gateway/test_setup.py +++ b/claude-code/gateway/test_setup.py @@ -74,6 +74,40 @@ def test_apikeyhelper_portable_when_dir_equals_default_via_realpath(self): self.assertEqual(settings["apiKeyHelper"], "~/.claude/anthropic_key.sh") +class TestRelocatedClearRemovesTheSetting(unittest.TestCase): + def test_clear_removes_apikeyhelper_written_for_a_custom_dir(self): + """A relocated install writes apiKeyHelper as an absolute path. If clear does + not recognise that form it leaves Claude pointing at a deleted script, and + every API call then fails.""" + with tempfile.TemporaryDirectory() as home: + home = Path(home) + with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): + cc = home / "cc" + gw.setup_claude_key_helper(cc) + settings = json.loads((cc / "settings.json").read_text()) + self.assertEqual(settings["apiKeyHelper"], str(cc / "anthropic_key.sh")) + gw.clear_setup(cc) + after = json.loads((cc / "settings.json").read_text()) + self.assertNotIn("apiKeyHelper", after, "no dangling pointer left behind") + self.assertFalse((cc / "anthropic_key.sh").exists()) + + def test_strip_hooks_finds_hooks_under_a_custom_dir(self): + """Switching from hooks mode to gateway mode must drop the hook entries, or + both enforcement paths fire at once.""" + with tempfile.TemporaryDirectory() as home: + home = Path(home) + with mock.patch.object(gw.Path, "home", staticmethod(lambda: home)): + cc = home / "cc" + hook = cc / "hooks" / "unbound.py" + hook.parent.mkdir(parents=True) + hook.write_text("# unbound") + (cc / "settings.json").write_text(json.dumps( + {"hooks": {"PreToolUse": [{"hooks": [{"command": str(hook)}]}]}})) + gw.setup_claude_key_helper(cc) + after = json.loads((cc / "settings.json").read_text()) + self.assertNotIn("PreToolUse", after.get("hooks", {})) + + class TestClearSweepsLegacyDir(unittest.TestCase): def test_clear_relocated_also_clears_default_claude(self): with tempfile.TemporaryDirectory() as home: diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index ec99b2e..2a34469 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -214,17 +214,22 @@ def _export_value(line: str, prefix: str) -> str: return line.strip()[len(prefix):].strip().strip('"').strip("'") -def _is_unbound_key_helper_setting(value) -> bool: - """Whether settings.json's apiKeyHelper is the one the gateway setup writes.""" +def _is_unbound_key_helper_setting(value, config_dir: Path = None) -> bool: + """Whether settings.json's apiKeyHelper is the one the gateway setup writes. It + writes the portable ~ form for the default dir and an absolute path for a relocated + one, so both count — otherwise the helper survives here and drives Claude alongside + the hooks we are about to install.""" + config_dir = config_dir or (Path.home() / ".claude") if not isinstance(value, str): return False + path = config_dir / "anthropic_key.sh" candidate = value.strip() if candidate not in (UNBOUND_KEY_HELPER_SETTING, - str(Path.home() / ".claude" / "anthropic_key.sh")): + str(Path.home() / ".claude" / "anthropic_key.sh"), + str(path)): return False # The path is a name anyone could choose, so the script there decides. Nothing there # means our own removal already ran; a dangling helper is broken either way. - path = Path.home() / ".claude" / "anthropic_key.sh" return not path.exists() or _is_unbound_key_helper_file(path) @@ -526,7 +531,7 @@ def configure_claude_settings(config_dir: Path = None) -> bool: # Our hook and the gateway's key helper cannot both drive Claude Code, so ours # goes before the hooks are added. Only ours: an org's own helper stays. - if _is_unbound_key_helper_setting(settings.get("apiKeyHelper")): + if _is_unbound_key_helper_setting(settings.get("apiKeyHelper"), config_dir): del settings["apiKeyHelper"] script_path = config_dir / "hooks" / "unbound.py" diff --git a/claude-code/hooks/test_config_dir.py b/claude-code/hooks/test_config_dir.py index 2e43696..a32a40f 100644 --- a/claude-code/hooks/test_config_dir.py +++ b/claude-code/hooks/test_config_dir.py @@ -7,6 +7,7 @@ import importlib import os +import tempfile import unittest from pathlib import Path from unittest.mock import patch @@ -96,3 +97,26 @@ def test_blank_override_is_ignored(self): if __name__ == '__main__': unittest.main() + + +class TestGatewayHelperRemovedUnderCustomDir(unittest.TestCase): + """Installing hooks must drop the gateway's apiKeyHelper, including the absolute + form it writes for a relocated dir — otherwise both drive Claude at once.""" + + def test_absolute_helper_setting_is_recognised(self): + import setup as hooks_setup + with tempfile.TemporaryDirectory() as home: + cc = Path(home) / "cc" + cc.mkdir(parents=True) + (cc / "anthropic_key.sh").write_text(hooks_setup.UNBOUND_KEY_HELPER_BODY) + self.assertTrue(hooks_setup._is_unbound_key_helper_setting( + str(cc / "anthropic_key.sh"), cc)) + + def test_a_foreign_helper_setting_is_not_ours(self): + import setup as hooks_setup + with tempfile.TemporaryDirectory() as home: + cc = Path(home) / "cc" + cc.mkdir(parents=True) + self.assertFalse(hooks_setup._is_unbound_key_helper_setting( + str(cc / "somebody_else.sh"), cc)) + From a126453a7aa1faa1d4e840f08ee9eb593a040553 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Tue, 25 Aug 2026 02:31:16 +0530 Subject: [PATCH 17/21] fix: resolve user skills from the config dir, not ~/.claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync writes managed skills to /skills, which is also where Claude Code reads user-level skills, but _resolve_skill_path only ever looked under ~/.claude/skills. On a relocated install we therefore wrote skills to one directory and searched another, so every Unbound-managed skill failed to resolve and the backend got no path to join against. The search list now includes the resolved skills root alongside the existing roots rather than replacing any of them, so project-scoped and directory- scoped lookups are untouched and the default dir resolves exactly as before — it simply checks the same directory twice there. Tests cover both directions: a skill resolving under a relocated dir, and the default dir still resolving as it did. Tests: hooks 60 passed, gateway 14 passed, with the pre-existing MDM matcher-parity failure unchanged. py_compile and pyflakes clean. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/hooks/test_config_dir.py | 23 +++++++++++++++++++ claude-code/hooks/unbound.py | 33 ++++++++++++++++------------ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/claude-code/hooks/test_config_dir.py b/claude-code/hooks/test_config_dir.py index a32a40f..694236f 100644 --- a/claude-code/hooks/test_config_dir.py +++ b/claude-code/hooks/test_config_dir.py @@ -120,3 +120,26 @@ def test_a_foreign_helper_setting_is_not_ours(self): self.assertFalse(hooks_setup._is_unbound_key_helper_setting( str(cc / "somebody_else.sh"), cc)) + +class TestSkillResolutionFollowsConfigDir(unittest.TestCase): + """The sync writes managed skills to /skills, so resolution has to + look there — otherwise every Unbound skill silently fails to resolve on a + relocated install.""" + + def test_user_skill_resolves_under_a_relocated_dir(self): + with tempfile.TemporaryDirectory() as home: + cc = Path(home) / "cc" + skill = cc / "skills" / "unbound-review" / "SKILL.md" + skill.parent.mkdir(parents=True) + skill.write_text("# review") + m = _reload(HOME=home, CLAUDE_CONFIG_DIR=str(cc)) + self.assertEqual(m._resolve_skill_path("unbound-review", None), str(skill)) + + def test_default_dir_resolution_is_unchanged(self): + with tempfile.TemporaryDirectory() as home: + skill = Path(home) / ".claude" / "skills" / "unbound-review" / "SKILL.md" + skill.parent.mkdir(parents=True) + skill.write_text("# review") + m = _reload(HOME=home) + self.assertEqual(m._resolve_skill_path("unbound-review", None), str(skill)) + diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 345ea3d..f5e5609 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -3494,20 +3494,25 @@ def _resolve_skill_path(skill: Optional[str], cwd: Optional[str]) -> Optional[st roots = _trusted_ancestors(Path(cwd)) roots.append(Path.home()) - for root in roots: - for skill_dir in SKILL_SEARCH_DIRS: - base = root.joinpath(*nested, *skill_dir) - candidate = base / name / 'SKILL.md' - if candidate.is_file(): - return str(candidate) - # Bundled skills sit one level deeper (skills//). - # Several bundles sharing a name is ambiguous, so resolve - # nothing rather than attach the wrong path to a join key. - matches = sorted(base.glob('*/%s/SKILL.md' % name)) - if len(matches) > 1: - return None - if matches: - return str(matches[0]) + bases = [root.joinpath(*nested, *skill_dir) + for root in roots for skill_dir in SKILL_SEARCH_DIRS] + # User-level skills live in /skills, which is where the sync + # writes them and where Claude Code reads them; the home-rooted entry above + # only finds them while the config dir is still the default one. + bases.append(CLAUDE_SKILLS_ROOT.joinpath(*nested)) + + for base in bases: + candidate = base / name / 'SKILL.md' + if candidate.is_file(): + return str(candidate) + # Bundled skills sit one level deeper (skills//). + # Several bundles sharing a name is ambiguous, so resolve + # nothing rather than attach the wrong path to a join key. + matches = sorted(base.glob('*/%s/SKILL.md' % name)) + if len(matches) > 1: + return None + if matches: + return str(matches[0]) return None except Exception: return None From de1fad4fd6992dc6ac0fe4747b2e40040e356043 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Tue, 25 Aug 2026 02:50:32 +0530 Subject: [PATCH 18/21] fix: judge the helper the setting names, and let every test run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two from review, both mine. _is_unbound_key_helper_setting checked ownership of the active dir's anthropic_key.sh whatever the setting said. A portable ~ form left over from a default install names ~/.claude, so a script sitting in the relocated dir was vouching for a file it has nothing to do with — it could refuse to clear a stale default-dir setting, or clear one that is not ours. It now picks the target from the candidate and judges that file. Both installers carried the same shape, so both are fixed. The two test classes I appended to test_config_dir.py landed after the unittest.main() guard, so running the file directly defined them too late to be collected — 11 of 15 tests ran, and the four covering this branch's own behaviour were among the silent four. The guard now sits at the end. Tests: hooks 62 passed via unittest and 16 via direct execution, gateway 14 passed, with the pre-existing MDM matcher-parity failure unchanged. py_compile and pyflakes clean. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/gateway/setup.py | 15 ++++++++++----- claude-code/hooks/setup.py | 15 ++++++++++----- claude-code/hooks/test_config_dir.py | 22 ++++++++++++++++++++-- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/claude-code/gateway/setup.py b/claude-code/gateway/setup.py index fad27c6..8ffd50e 100644 --- a/claude-code/gateway/setup.py +++ b/claude-code/gateway/setup.py @@ -318,15 +318,20 @@ def _is_unbound_key_helper_setting(value, config_dir: Path = None) -> bool: config_dir = config_dir or (Path.home() / ".claude") if not isinstance(value, str): return False - helper = config_dir / "anthropic_key.sh" + default_helper = Path.home() / ".claude" / "anthropic_key.sh" + active_helper = config_dir / "anthropic_key.sh" candidate = value.strip() - if candidate not in (UNBOUND_KEY_HELPER_SETTING, - str(Path.home() / ".claude" / "anthropic_key.sh"), - str(helper)): + # Judge the file the setting actually names, not the active dir's — a portable + # ~ form left over from a default install points at ~/.claude either way. + if candidate in (UNBOUND_KEY_HELPER_SETTING, str(default_helper)): + target = default_helper + elif candidate == str(active_helper): + target = active_helper + else: return False # The path is a name anyone could choose, so the script there decides. Nothing there # means our own removal already ran; a dangling helper is broken either way. - return not helper.exists() or _is_unbound_key_helper_file(helper) + return not target.exists() or _is_unbound_key_helper_file(target) def _is_unbound_key_helper_file(path: Path) -> bool: diff --git a/claude-code/hooks/setup.py b/claude-code/hooks/setup.py index 2a34469..80bc748 100644 --- a/claude-code/hooks/setup.py +++ b/claude-code/hooks/setup.py @@ -222,15 +222,20 @@ def _is_unbound_key_helper_setting(value, config_dir: Path = None) -> bool: config_dir = config_dir or (Path.home() / ".claude") if not isinstance(value, str): return False - path = config_dir / "anthropic_key.sh" + default_helper = Path.home() / ".claude" / "anthropic_key.sh" + active_helper = config_dir / "anthropic_key.sh" candidate = value.strip() - if candidate not in (UNBOUND_KEY_HELPER_SETTING, - str(Path.home() / ".claude" / "anthropic_key.sh"), - str(path)): + # Judge the file the setting actually names, not the active dir's — a portable + # ~ form left over from a default install points at ~/.claude either way. + if candidate in (UNBOUND_KEY_HELPER_SETTING, str(default_helper)): + target = default_helper + elif candidate == str(active_helper): + target = active_helper + else: return False # The path is a name anyone could choose, so the script there decides. Nothing there # means our own removal already ran; a dangling helper is broken either way. - return not path.exists() or _is_unbound_key_helper_file(path) + return not target.exists() or _is_unbound_key_helper_file(target) def _is_unbound_key_helper_file(path: Path) -> bool: diff --git a/claude-code/hooks/test_config_dir.py b/claude-code/hooks/test_config_dir.py index 694236f..3356eaf 100644 --- a/claude-code/hooks/test_config_dir.py +++ b/claude-code/hooks/test_config_dir.py @@ -95,8 +95,6 @@ def test_blank_override_is_ignored(self): self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, Path('/opt/cc/plugins/cache')) -if __name__ == '__main__': - unittest.main() class TestGatewayHelperRemovedUnderCustomDir(unittest.TestCase): @@ -112,6 +110,23 @@ def test_absolute_helper_setting_is_recognised(self): self.assertTrue(hooks_setup._is_unbound_key_helper_setting( str(cc / "anthropic_key.sh"), cc)) + def test_portable_form_is_judged_against_the_default_dir(self): + # A ~ form left over from a default install names ~/.claude, so the script + # there decides — not whatever sits in the relocated dir. + import setup as hooks_setup + with tempfile.TemporaryDirectory() as home: + with patch.object(hooks_setup.Path, "home", staticmethod(lambda: Path(home))): + default = Path(home) / ".claude" + default.mkdir(parents=True) + (default / "anthropic_key.sh").write_text("echo $SOMEONE_ELSES_KEY") + cc = Path(home) / "cc" + cc.mkdir(parents=True) + (cc / "anthropic_key.sh").write_text(hooks_setup.UNBOUND_KEY_HELPER_BODY) + self.assertFalse( + hooks_setup._is_unbound_key_helper_setting( + hooks_setup.UNBOUND_KEY_HELPER_SETTING, cc), + "the relocated dir's helper must not vouch for the default one") + def test_a_foreign_helper_setting_is_not_ours(self): import setup as hooks_setup with tempfile.TemporaryDirectory() as home: @@ -143,3 +158,6 @@ def test_default_dir_resolution_is_unchanged(self): m = _reload(HOME=home) self.assertEqual(m._resolve_skill_path("unbound-review", None), str(skill)) + +if __name__ == '__main__': + unittest.main() From a0819d5f7f39a0261b3599780a8c565280b3c601 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Tue, 25 Aug 2026 19:59:25 +0530 Subject: [PATCH 19/21] test: make the config-dir tests pass on Windows, not just POSIX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running them on Windows Server surfaced 13 failures, all in the tests rather than the code — the resolver was right every time and the expectations were POSIX-only. Two causes. The fake home set HOME alone, but expanduser reads USERPROFILE on Windows, so Path.home() kept returning the real profile and every default-dir assertion compared against the wrong directory; _reload now sets both. And a root-relative literal like /opt/cc is stored as C:\opt\cc on Windows, since abspath prepends the current drive — the expectations now go through the same call rather than hardcoding the POSIX spelling. A suite that only passes on POSIX gives false confidence about the platform whose path rules differ most, which is the one this branch had to reason about hardest. It now runs green on macOS, Linux and Windows, under both `python -m unittest` and direct execution. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/hooks/test_config_dir.py | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/claude-code/hooks/test_config_dir.py b/claude-code/hooks/test_config_dir.py index 3356eaf..f7ecee5 100644 --- a/claude-code/hooks/test_config_dir.py +++ b/claude-code/hooks/test_config_dir.py @@ -15,11 +15,23 @@ import unbound +def _abs(p): + """The absolute form of a test path. Windows prepends the current drive to a + root-relative path, which is what the code under test stores, so expectations + have to go through the same call to compare on either platform.""" + return Path(os.path.abspath(p)) + + def _reload(**env): """Reload unbound with `env` applied, since the paths resolve at import.""" base = {k: v for k, v in os.environ.items() if k not in ('CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_PLUGIN_CACHE_DIR')} base.update({k: v for k, v in env.items() if v is not None}) + # expanduser reads USERPROFILE on Windows and HOME on POSIX; set both so a + # fake home takes effect either way. + if 'HOME' in base: + base.setdefault('USERPROFILE', base['HOME']) + base['USERPROFILE'] = base['HOME'] with patch.dict(os.environ, base, clear=True): return importlib.reload(unbound) @@ -34,7 +46,7 @@ def test_default_when_env_unset(self): def test_relocated_when_env_set(self): m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') - self.assertEqual(m._CONFIG_DIR, Path('/opt/cc')) + self.assertEqual(m._CONFIG_DIR, _abs('/opt/cc')) def test_leading_tilde_is_not_expanded(self): # Claude Code reads the value verbatim and makes a literal "~" directory. @@ -54,10 +66,10 @@ def test_empty_env_also_falls_back_to_home(self): def test_enforcement_paths_follow_the_relocated_dir(self): m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') - self.assertEqual(m.AUDIT_LOG, Path('/opt/cc/hooks/agent-audit.log')) - self.assertEqual(m.POLICY_CACHE_FILE, Path('/opt/cc/hooks/.policy_cache.json')) - self.assertEqual(m.SELF_SCRIPT_PATH, Path('/opt/cc/hooks/unbound.py')) - self.assertEqual(m.CLAUDE_SKILLS_ROOT, Path('/opt/cc/skills')) + self.assertEqual(m.AUDIT_LOG, _abs('/opt/cc/hooks/agent-audit.log')) + self.assertEqual(m.POLICY_CACHE_FILE, _abs('/opt/cc/hooks/.policy_cache.json')) + self.assertEqual(m.SELF_SCRIPT_PATH, _abs('/opt/cc/hooks/unbound.py')) + self.assertEqual(m.CLAUDE_SKILLS_ROOT, _abs('/opt/cc/skills')) class TestClaudeJsonLocation(unittest.TestCase): @@ -73,7 +85,7 @@ def test_sits_in_home_not_under_dot_claude_by_default(self): def test_moves_into_the_relocated_dir(self): m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') - self.assertEqual(m.CLAUDE_MCP_CONFIG_PATH, Path('/opt/cc/.claude.json')) + self.assertEqual(m.CLAUDE_MCP_CONFIG_PATH, _abs('/opt/cc/.claude.json')) class TestPluginCacheDir(unittest.TestCase): @@ -82,17 +94,17 @@ def tearDown(self): def test_defaults_under_the_config_dir(self): m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc') - self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, Path('/opt/cc/plugins/cache')) + self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, _abs('/opt/cc/plugins/cache')) def test_env_override_wins_over_the_config_dir(self): m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc', CLAUDE_CODE_PLUGIN_CACHE_DIR='/var/pcache') - self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, Path('/var/pcache/cache')) + self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, _abs('/var/pcache/cache')) def test_blank_override_is_ignored(self): m = _reload(HOME='/home/jane', CLAUDE_CONFIG_DIR='/opt/cc', CLAUDE_CODE_PLUGIN_CACHE_DIR=' ') - self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, Path('/opt/cc/plugins/cache')) + self.assertEqual(m.CLAUDE_PLUGIN_CACHE_DIR, _abs('/opt/cc/plugins/cache')) From 828016c9dfb0b93698d7120a5f1fdd25afa4914f Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Tue, 25 Aug 2026 21:13:15 +0530 Subject: [PATCH 20/21] fix: warn in the gateway installer too when --config-dir has no env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hooks installer says so when --config-dir is passed without CLAUDE_CONFIG_DIR in the environment; the gateway did not. Claude Code picks its config dir from the environment alone, so an install aimed elsewhere by the flag writes a key helper Claude never finds, then reports success. The user is left with no working apiKeyHelper and failing API calls, with nothing having said anything. This is the fourth time on this branch a guard landed on one side of a pair, so rather than add the one line I compared every config-dir guard across both installers: resolver, blank-env warning, arg-without-env warning, legacy sweep, and the two ownership predicates. They match now. The one remaining difference is deliberate — the gateway clears the key helper in two places because it owns it, while hooks only removes a leftover one. pytest: 1910 passed, 39 skipped. py_compile and pyflakes clean. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/gateway/setup.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/claude-code/gateway/setup.py b/claude-code/gateway/setup.py index 8ffd50e..de08ac9 100644 --- a/claude-code/gateway/setup.py +++ b/claude-code/gateway/setup.py @@ -895,6 +895,13 @@ def main(): print("\n\u26a0\ufe0f CLAUDE_CONFIG_DIR is set but empty. Claude Code reads that as a path " "relative to the current directory, not as ~/.claude, so it will not load this " "install. Unset the variable, or point it at a real directory.") + # Claude Code picks its config dir from the environment alone. An install aimed + # somewhere else by --config-dir is one it will never read, so say so rather than + # reporting success over a key helper that cannot be found. + if args.config_dir and not (_raw_cfg or "").strip(): + print(f"\n\u26a0\ufe0f --config-dir set to {config_dir} but CLAUDE_CONFIG_DIR is not set in the " + "environment. Claude Code reads only the environment variable, so it will not load " + "this install. Export CLAUDE_CONFIG_DIR to the same path.") if args.debug: DEBUG = True From a1480ca73aedc5ff9095e75af1a1d866b7273ba3 Mon Sep 17 00:00:00 2001 From: Aakash Velusamy Date: Tue, 25 Aug 2026 21:39:05 +0530 Subject: [PATCH 21/21] fix: a stale skill in the old home dir must not outrank the live one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier skill-resolution fix appended the config dir's skills root to the search list to avoid disturbing the existing entries. That left ~/.claude/skills ahead of it, and after a move the sync only refreshes the relocated dir — so a copy left behind in the old location would win every lookup and stay frozen at whatever it last held. Being non-destructive about the ordering made the result wrong in the case the fix existed for. The config dir's skills root now stands in for the home-anchored user entry rather than queuing behind it, which is also what Claude Code does: user scope is join(configDir, "skills"), project scope is /.claude/skills. Project and directory-scoped lookups are untouched, and with the variable unset the two are the same path, so nothing changes for a default install. The previous test passed only because it never put a competing skill in the old location. There is now one that does, and it fails against the old ordering. pytest: 1911 passed, 39 skipped. py_compile and pyflakes clean. Co-Authored-By: Claude Opus 5 (1M context) --- claude-code/hooks/unbound.py | 12 +++++------- tests/claude_code/hooks/test_config_dir.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/claude-code/hooks/unbound.py b/claude-code/hooks/unbound.py index 3b60de2..e5be530 100644 --- a/claude-code/hooks/unbound.py +++ b/claude-code/hooks/unbound.py @@ -3490,16 +3490,14 @@ def _resolve_skill_path(skill: Optional[str], cwd: Optional[str]) -> Optional[st # A prefixed skill never falls back to the bare name — "slack:standup" # and a personal "standup" are different skills. nested = segments - roots = [] - if cwd: - roots = _trusted_ancestors(Path(cwd)) - roots.append(Path.home()) + roots = _trusted_ancestors(Path(cwd)) if cwd else [] bases = [root.joinpath(*nested, *skill_dir) for root in roots for skill_dir in SKILL_SEARCH_DIRS] - # User-level skills live in /skills, which is where the sync - # writes them and where Claude Code reads them; the home-rooted entry above - # only finds them while the config dir is still the default one. + # User-level skills live in /skills — where the sync writes them + # and where Claude Code reads them. It stands in for ~/.claude/skills rather + # than following it: after a move the sync stops refreshing the old copy, so + # searching there first would pin resolution to whatever it last held. bases.append(CLAUDE_SKILLS_ROOT.joinpath(*nested)) for base in bases: diff --git a/tests/claude_code/hooks/test_config_dir.py b/tests/claude_code/hooks/test_config_dir.py index c03cc02..05d2e19 100644 --- a/tests/claude_code/hooks/test_config_dir.py +++ b/tests/claude_code/hooks/test_config_dir.py @@ -171,6 +171,20 @@ def test_user_skill_resolves_under_a_relocated_dir(self): m = _reload(HOME=home, CLAUDE_CONFIG_DIR=str(cc)) self.assertEqual(m._resolve_skill_path("unbound-review", None), str(skill)) + def test_a_stale_copy_in_the_old_home_dir_does_not_win(self): + """After a move the sync stops refreshing ~/.claude/skills, so anything left + there is frozen at whatever it last held. Resolution must not prefer it.""" + with tempfile.TemporaryDirectory() as home: + stale = Path(home) / ".claude" / "skills" / "unbound-review" / "SKILL.md" + stale.parent.mkdir(parents=True) + stale.write_text("# stale") + cc = Path(home) / "cc" + fresh = cc / "skills" / "unbound-review" / "SKILL.md" + fresh.parent.mkdir(parents=True) + fresh.write_text("# fresh") + m = _reload(HOME=home, CLAUDE_CONFIG_DIR=str(cc)) + self.assertEqual(m._resolve_skill_path("unbound-review", None), str(fresh)) + def test_default_dir_resolution_is_unchanged(self): with tempfile.TemporaryDirectory() as home: skill = Path(home) / ".claude" / "skills" / "unbound-review" / "SKILL.md"