diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index b714ce46..e6906500 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -121,6 +121,7 @@ ) from pinky_daemon.autonomy import AgentEvent, AutonomyEngine, EventType from pinky_daemon.broker import BrokerMessage, MessageBroker +from pinky_daemon.claude_config import resolve_agent_claude_config_dir from pinky_daemon.conversation_store import ConversationStore from pinky_daemon.dream_runner import DreamRunner from pinky_daemon.effort import CLI_EFFORT_LEVELS, EFFORT_LEVELS @@ -5853,6 +5854,12 @@ async def transport_transcript_path( (Path(container_config_dir(str(Path(wd).resolve()))) / "projects") .resolve() ) + per_agent_cfg = resolve_agent_claude_config_dir( + agent, + working_dir=agent.working_dir, + ) + if per_agent_cfg is not None: + allowed_roots.append((per_agent_cfg / "projects").resolve()) try: normalised = path.resolve(strict=False) except (OSError, RuntimeError) as e: diff --git a/src/pinky_daemon/claude_config.py b/src/pinky_daemon/claude_config.py new file mode 100644 index 00000000..4b773bdd --- /dev/null +++ b/src/pinky_daemon/claude_config.py @@ -0,0 +1,181 @@ +"""Shared Claude Code config-dir helpers. + +This module is deliberately dependency-light so tmux, SDK streaming, dreams, +and API validation can all use the same path resolver without import cycles. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import tempfile +import threading +from collections.abc import Mapping +from pathlib import Path + +PER_AGENT_CLAUDE_CONFIG_ENV = "PINKY_PER_AGENT_CLAUDE_CONFIG" +PER_AGENT_CLAUDE_CONFIG_DIRNAME = ".claude-config" + +# Serializes read-modify-write of ``.claude.json`` across concurrent local +# launches so two simultaneous seeds cannot drop each other's project entry. +_CLAUDE_JSON_SEED_LOCK = threading.Lock() + + +def env_flag_enabled( + name: str, + env: Mapping[str, str] | None = None, + *, + default: str = "0", +) -> bool: + e = env if env is not None else os.environ + return str(e.get(name, default)).strip().lower() in ("1", "true", "yes", "on") + + +def claude_project_slug(project_dir: str | Path) -> str: + """Return Claude Code's ``projects/`` slug for an absolute cwd.""" + cwd = Path(project_dir).resolve() + return re.sub(r"[^a-zA-Z0-9]", "-", str(cwd)) + + +def resolve_claude_config_path(env: Mapping[str, str] | None = None) -> Path: + """Resolve Claude Code's global ``.claude.json`` path. + + Mirrors the CLI: ``$CLAUDE_CONFIG_DIR/.claude.json`` when set, otherwise + ``$HOME/.claude.json``. + """ + e = env if env is not None else os.environ + cfg_dir = (e.get("CLAUDE_CONFIG_DIR") or "").strip() + base = Path(cfg_dir) if cfg_dir else Path(e.get("HOME") or Path.home()) + return base / ".claude.json" + + +def resolve_claude_settings_path(env: Mapping[str, str] | None = None) -> Path: + """Resolve the settings file that carries first-run prompt suppressors.""" + e = env if env is not None else os.environ + cfg_dir = (e.get("CLAUDE_CONFIG_DIR") or "").strip() + if cfg_dir: + return Path(cfg_dir) / "settings.json" + home = Path(e.get("HOME") or Path.home()) + return home / ".claude" / "settings.json" + + +def resolve_agent_claude_config_dir( + agent: object, + *, + working_dir: str | Path | None = None, + env: Mapping[str, str] | None = None, +) -> Path | None: + """Return this local agent's per-agent ``CLAUDE_CONFIG_DIR`` or ``None``. + + Guardrails: + - flag-gated and default-off; + - local-runtime agents only (container/unix_user provisioning owns its own + config dir); + - static-token fail-safe: without ``CLAUDE_CODE_OAUTH_TOKEN`` present at + resolve time, skip the per-agent dir rather than booting into a login wall. + """ + e = env if env is not None else os.environ + if not env_flag_enabled(PER_AGENT_CLAUDE_CONFIG_ENV, e): + return None + if agent is None: + return None + if getattr(agent, "isolation_mode", "local") not in ("", "local"): + return None + if not (e.get("CLAUDE_CODE_OAUTH_TOKEN") or "").strip(): + return None + + raw_wd = working_dir if working_dir is not None else getattr(agent, "working_dir", "") + wd = str(raw_wd or "").strip() + if not wd: + return None + return Path(wd).resolve() / PER_AGENT_CLAUDE_CONFIG_DIRNAME + + +def migrate_claude_project_history(project_dir: str | Path, config_dir: Path) -> bool: + """Copy the existing global transcript project dir into ``config_dir``. + + Idempotent and non-clobbering: if the destination project dir already + exists, leave it alone. The slug remains derived from the project cwd. + """ + slug = claude_project_slug(project_dir) + src = Path.home() / ".claude" / "projects" / slug + dst = config_dir / "projects" / slug + if not src.exists() or dst.exists(): + return False + dst.parent.mkdir(parents=True, exist_ok=True) + tmp = Path(tempfile.mkdtemp(prefix=f".{dst.name}.pinky-copy.", dir=dst.parent)) + try: + shutil.copytree(src, tmp, dirs_exist_ok=True) + if dst.exists(): + shutil.rmtree(tmp, ignore_errors=True) + return False + os.replace(tmp, dst) + except Exception: + shutil.rmtree(tmp, ignore_errors=True) + raise + return True + + +def seed_claude_trust_file(config_path: Path, project_dir: str | Path) -> bool: + """Idempotently pre-seed Claude Code first-run trust/onboarding flags.""" + proj_key = str(Path(project_dir).resolve()) + with _CLAUDE_JSON_SEED_LOCK: + data: dict = {} + if config_path.exists(): + with config_path.open("r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError( + f"{config_path} root is not a JSON object " + f"(got {type(data).__name__}) -- refusing to overwrite" + ) + + changed = False + for flag in ("bypassPermissionsModeAccepted", "hasCompletedOnboarding"): + if data.get(flag) is not True: + data[flag] = True + changed = True + + projects = data.setdefault("projects", {}) + if not isinstance(projects, dict): + raise ValueError(f"{config_path} 'projects' is not an object") + proj = projects.setdefault(proj_key, {}) + if not isinstance(proj, dict): + raise ValueError(f"{config_path} projects[{proj_key!r}] is not an object") + for flag in ("hasTrustDialogAccepted", "hasCompletedProjectOnboarding"): + if proj.get(flag) is not True: + proj[flag] = True + changed = True + + if changed: + config_path.parent.mkdir(parents=True, exist_ok=True) + tmp = config_path.parent / f".claude.json.pinky-seed.{os.getpid()}.tmp" + with tmp.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp, config_path) + return changed + + +def seed_claude_settings_file(settings_path: Path) -> bool: + """Seed the settings flag that suppresses the dangerous-mode prompt.""" + with _CLAUDE_JSON_SEED_LOCK: + data: dict = {} + if settings_path.exists(): + with settings_path.open("r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError( + f"{settings_path} root is not a JSON object " + f"(got {type(data).__name__}) -- refusing to overwrite" + ) + if data.get("skipDangerousModePermissionPrompt") is True: + return False + data["skipDangerousModePermissionPrompt"] = True + settings_path.parent.mkdir(parents=True, exist_ok=True) + tmp = settings_path.parent / f".claude-settings.pinky-seed.{os.getpid()}.tmp" + with tmp.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + os.replace(tmp, settings_path) + return True diff --git a/src/pinky_daemon/dream_runner.py b/src/pinky_daemon/dream_runner.py index f85d9016..36c869af 100644 --- a/src/pinky_daemon/dream_runner.py +++ b/src/pinky_daemon/dream_runner.py @@ -271,6 +271,7 @@ async def run_dream(self, agent_name: str, agent_config) -> str: system_prompt=system_prompt, # SDK allowlist + Read/Write for the file-passing protocol allowed_tools=["Read", "Write", *_DREAM_ALLOWED_TOOLS], + agent_config=agent_config, ), agent_name=agent_name, ) diff --git a/src/pinky_daemon/streaming_session.py b/src/pinky_daemon/streaming_session.py index 94fb4e0c..384254b2 100644 --- a/src/pinky_daemon/streaming_session.py +++ b/src/pinky_daemon/streaming_session.py @@ -13,11 +13,20 @@ import asyncio import hashlib import json +import os import sys import time from dataclasses import dataclass, field from pathlib import Path +from pinky_daemon.claude_config import ( + migrate_claude_project_history, + resolve_agent_claude_config_dir, + resolve_claude_config_path, + resolve_claude_settings_path, + seed_claude_settings_file, + seed_claude_trust_file, +) from pinky_daemon.effort import CLI_EFFORT_LEVELS, resolve_cli_effort from pinky_daemon.sessions import SessionUsage from pinky_daemon.transport_state import SessionState, StateMachine, Trigger @@ -308,6 +317,53 @@ def __init__( self._turn_seq = 0 # Monotonic turn counter for analytics self._last_user_message = "" # For analytics keyword classification + def _registry_agent(self): + if not self._registry or not self.agent_name: + return None + try: + return self._registry.get(self.agent_name) + except Exception: + return None + + def _per_agent_claude_config_dir(self) -> Path | None: + return resolve_agent_claude_config_dir( + self._registry_agent(), + working_dir=self._config.working_dir, + ) + + def _per_agent_claude_env(self) -> dict[str, str]: + cfg_dir = self._per_agent_claude_config_dir() + if cfg_dir is None: + return {} + env = {"CLAUDE_CONFIG_DIR": str(cfg_dir)} + if not (self._config.provider_url or self._config.provider_key): + token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + if token: + env["CLAUDE_CODE_OAUTH_TOKEN"] = token + return env + + def _prepare_claude_config(self) -> None: + cfg_dir = self._per_agent_claude_config_dir() + if cfg_dir is None: + return + project_dir = Path(self._config.working_dir or ".").resolve() + if migrate_claude_project_history(project_dir, cfg_dir): + _log( + f"streaming[{self.agent_name}]: migrated claude transcript " + f"history into {cfg_dir}" + ) + env = {"CLAUDE_CONFIG_DIR": str(cfg_dir)} + if seed_claude_settings_file(resolve_claude_settings_path(env)): + _log( + f"streaming[{self.agent_name}]: pre-seeded claude settings " + f"in {cfg_dir}" + ) + if seed_claude_trust_file(resolve_claude_config_path(env), project_dir): + _log( + f"streaming[{self.agent_name}]: pre-seeded claude trust flags " + f"in {cfg_dir} for project {project_dir}" + ) + async def connect(self) -> None: """Connect to Claude Code. Starts the reader loop. @@ -414,6 +470,14 @@ async def connect(self) -> None: except Exception as e: _log(f"streaming[{self.agent_name}]: failed to read .mcp.json: {e}") + try: + self._prepare_claude_config() + except Exception as e: + _log( + f"streaming[{self.agent_name}]: claude config prep failed " + f"(non-fatal): {e}" + ) + options = ClaudeAgentOptions( cwd=self._config.working_dir, allowed_tools=self._config.allowed_tools or DEFAULT_STREAMING_ALLOWED_TOOLS, @@ -450,6 +514,7 @@ async def connect(self) -> None: if self._config.provider_key: provider_env["ANTHROPIC_API_KEY"] = self._config.provider_key provider_env["ANTHROPIC_AUTH_TOKEN"] = self._config.provider_key + provider_env.update(self._per_agent_claude_env()) # #429: surface configured effort + agent identity to CLI hooks so # hook_verify_effort.py can detect drift from PINKY_EXPECTED_EFFORT diff --git a/src/pinky_daemon/tmux_dream_runner.py b/src/pinky_daemon/tmux_dream_runner.py index f7c4865c..0d3787fb 100644 --- a/src/pinky_daemon/tmux_dream_runner.py +++ b/src/pinky_daemon/tmux_dream_runner.py @@ -26,16 +26,15 @@ from datetime import datetime from pathlib import Path -from pinky_daemon.claude_runner import RunResult - -# Reuse the rails' first-run gate pre-seed (#112): without it a fresh -# project dir wedges the REPL at the trust dialog / bypass-permissions -# acceptance and the typed instruction is silently eaten (observed in the -# #707 live smoke test). Same-package reuse of tested helpers, on purpose. -from pinky_daemon.tmux_session import ( - _resolve_claude_config_path, - _seed_claude_trust_file, +from pinky_daemon.claude_config import ( + migrate_claude_project_history, + resolve_agent_claude_config_dir, + resolve_claude_config_path, + resolve_claude_settings_path, + seed_claude_settings_file, + seed_claude_trust_file, ) +from pinky_daemon.claude_runner import RunResult _DEFAULT_CLAUDE_BIN = "/opt/homebrew/bin/claude" # cc_autoupdate.sh manages this path @@ -101,6 +100,10 @@ class TmuxDreamConfig: # Delay before checking the instruction actually submitted (re-Enter guard). submit_check_delay_s: float = 5.0 + # Registry Agent row/config object. Used only for shared Claude config-dir + # derivation; optional for direct tests and legacy callers. + agent_config: object = None + class TmuxDreamRunner: """Runs a one-shot dream in a detached tmux session. @@ -153,6 +156,35 @@ def _resolve_binary(self) -> str: return env_bin return shutil.which("claude") or _DEFAULT_CLAUDE_BIN + def _per_agent_claude_config_dir(self) -> Path | None: + return resolve_agent_claude_config_dir( + self._config.agent_config, + working_dir=self._config.working_dir, + ) + + def _per_agent_claude_env(self) -> dict[str, str]: + cfg_dir = self._per_agent_claude_config_dir() + if cfg_dir is None: + return {} + env = {"CLAUDE_CONFIG_DIR": str(cfg_dir)} + agent = self._config.agent_config + if not ( + getattr(agent, "provider_url", "") or getattr(agent, "provider_key", "") + ): + token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + if token: + env["CLAUDE_CODE_OAUTH_TOKEN"] = token + return env + + def _prepare_claude_config(self, project_dir: Path) -> None: + cfg_dir = self._per_agent_claude_config_dir() + if cfg_dir is None: + return + if migrate_claude_project_history(project_dir, cfg_dir): + _log(f"tmux-dream: migrated claude transcript history into {cfg_dir}") + env = {"CLAUDE_CONFIG_DIR": str(cfg_dir)} + seed_claude_settings_file(resolve_claude_settings_path(env)) + # ── run ─────────────────────────────────────────────────── async def run(self, prompt: str, *, system_prompt: str = "") -> RunResult: @@ -175,6 +207,7 @@ async def run(self, prompt: str, *, system_prompt: str = "") -> RunResult: # best-effort; agent dirs are normally already trusted by their main # session, but a first dream on a fresh box must not wedge. try: + self._prepare_claude_config(work_dir) if self._seed_trust(str(work_dir)): _log(f"tmux-dream: seeded claude trust for {work_dir}") except Exception as e: @@ -193,9 +226,12 @@ async def run(self, prompt: str, *, system_prompt: str = "") -> RunResult: if self._config.disallowed_tools: cmd += ["--disallowedTools", ",".join(self._config.disallowed_tools)] - rc, out = await self._tmux( - "new-session", "-d", "-s", self.session_name, "-c", str(work_dir), *cmd - ) + new_session_args = [ + "new-session", "-d", "-s", self.session_name, "-c", str(work_dir), + ] + for key, value in self._per_agent_claude_env().items(): + new_session_args.extend(["-e", f"{key}={value}"]) + rc, out = await self._tmux(*new_session_args, *cmd) if rc != 0: return RunResult( output="", @@ -285,7 +321,8 @@ async def run(self, prompt: str, *, system_prompt: str = "") -> RunResult: def _seed_trust(self, project_dir: str) -> bool: """Seed first-run trust flags; seam for tests.""" - return _seed_claude_trust_file(_resolve_claude_config_path(), project_dir) + env = {**os.environ, **self._per_agent_claude_env()} + return seed_claude_trust_file(resolve_claude_config_path(env), project_dir) # ── waiting ─────────────────────────────────────────────── diff --git a/src/pinky_daemon/tmux_session.py b/src/pinky_daemon/tmux_session.py index 6e489859..ad46ab2a 100644 --- a/src/pinky_daemon/tmux_session.py +++ b/src/pinky_daemon/tmux_session.py @@ -57,11 +57,8 @@ from __future__ import annotations import asyncio -import json import os -import re import shlex -import threading import time from collections import deque from dataclasses import dataclass, field, replace @@ -69,6 +66,19 @@ from pinky_daemon.auth_relay import coordinator as _auth_relay from pinky_daemon.auth_relay import extract_oauth_url, looks_like_login_wall +from pinky_daemon.claude_config import ( + claude_project_slug, + migrate_claude_project_history, + resolve_agent_claude_config_dir, + resolve_claude_settings_path, + seed_claude_settings_file, +) +from pinky_daemon.claude_config import ( + resolve_claude_config_path as _resolve_claude_config_path, +) +from pinky_daemon.claude_config import ( + seed_claude_trust_file as _seed_claude_trust_file, +) from pinky_daemon.command_runner import ( CommandRunner, ContainerCommandRunner, @@ -137,37 +147,6 @@ # relevant flags before launch makes every new tmux agent boot clean on # any box without an operator manually clearing the prompts. -# Serializes read-modify-write of the shared ``.claude.json`` across the -# daemon's concurrent agent launches so two simultaneous seeds can't drop -# each other's ``projects[...]`` entry (last-write-wins clobber). -# -# NOTE (cross-process race, accepted): this lock only serializes seeds -# WITHIN the daemon process. On a box where many agents' ``claude`` -# processes share one ``.claude.json``, an already-running claude could -# write its own per-session keys (``numStartups``, ``lastCost``, ...) -# between our read and our ``os.replace`` — silently dropping that write. -# Window is tiny and severity low (those keys are non-load-bearing -# telemetry), so we accept it for now. A file lock (``fcntl.flock``) -# around the read-modify-write is the proper fix if this ever matters. -_CLAUDE_JSON_SEED_LOCK = threading.Lock() - - -def _resolve_claude_config_path(env: dict[str, str] | None = None) -> Path: - """Resolve the path to Claude Code's global ``.claude.json``. - - Mirrors the CLI's resolution: ``$CLAUDE_CONFIG_DIR/.claude.json`` when - ``CLAUDE_CONFIG_DIR`` is set, else ``$HOME/.claude.json``. ``env`` - defaults to the daemon process environment, which the tmux REPL - inherits (``_build_repl_env`` only adds ``-e`` overrides on top, so - the effective HOME/CLAUDE_CONFIG_DIR the launched ``claude`` sees is - the daemon's unless explicitly overridden). Injectable for tests. - """ - e = env if env is not None else os.environ - cfg_dir = (e.get("CLAUDE_CONFIG_DIR") or "").strip() - base = Path(cfg_dir) if cfg_dir else Path(e.get("HOME") or Path.home()) - return base / ".claude.json" - - # Sentinel distinguishing "caller passed no agent" from "caller passed None # (= local)" in the container-aware helpers below. _UNSET = object() @@ -209,59 +188,6 @@ def _is_dead_runtime_stderr(stderr: str) -> bool: return "container" in low and "is not running" in low -def _seed_claude_trust_file(config_path: Path, project_dir: str) -> bool: - """Idempotently pre-seed first-run trust/bypass flags in - ``config_path`` (Claude Code's ``.claude.json``) for ``project_dir``. - - Sets top-level ``bypassPermissionsModeAccepted`` and, under - ``projects[]``, ``hasTrustDialogAccepted`` + - ``hasCompletedProjectOnboarding`` — all to ``True``. Preserves every - other key (the file also holds oauth creds + per-project history). - - Returns ``True`` if the file was modified, ``False`` if every flag was - already set (no write). Raises on a corrupt/non-object file rather than - clobbering it — callers treat seeding as best-effort and swallow. - - Atomic: writes a sibling temp file and ``os.replace``s it in, so a - concurrent reader never sees a half-written config. Serialized - process-wide via ``_CLAUDE_JSON_SEED_LOCK``. - """ - proj_key = str(Path(project_dir).resolve()) - with _CLAUDE_JSON_SEED_LOCK: - data: dict = {} - if config_path.exists(): - with config_path.open("r", encoding="utf-8") as f: - data = json.load(f) - if not isinstance(data, dict): - raise ValueError( - f"{config_path} root is not a JSON object " - f"(got {type(data).__name__}) — refusing to overwrite" - ) - - changed = False - if data.get("bypassPermissionsModeAccepted") is not True: - data["bypassPermissionsModeAccepted"] = True - changed = True - - projects = data.setdefault("projects", {}) - if not isinstance(projects, dict): - raise ValueError(f"{config_path} 'projects' is not an object") - proj = projects.setdefault(proj_key, {}) - if not isinstance(proj, dict): - raise ValueError(f"{config_path} projects[{proj_key!r}] is not an object") - for flag in ("hasTrustDialogAccepted", "hasCompletedProjectOnboarding"): - if proj.get(flag) is not True: - proj[flag] = True - changed = True - - if changed: - config_path.parent.mkdir(parents=True, exist_ok=True) - tmp = config_path.parent / f".claude.json.pinky-seed.{os.getpid()}.tmp" - with tmp.open("w", encoding="utf-8") as f: - json.dump(data, f, indent=2) - os.replace(tmp, config_path) - return changed - # Transient-failure retry cadence for the worker loop. Fixed (not # exponential) — mirrors pulse-v2's poll cadence and keeps the # semantics simple: "park, sleep, retry the same turn". The worker @@ -2163,6 +2089,7 @@ async def _spawn_tmux_repl(self) -> None: # ``_spawn()`` below (the container is running by now). if container_agent is None: try: + self._prepare_local_claude_config(cwd) effective_env = {**os.environ, **self._build_repl_env()} cfg_path = _resolve_claude_config_path(effective_env) if _seed_claude_trust_file(cfg_path, cwd): @@ -2508,6 +2435,52 @@ def _static_oauth_token(self) -> str: return "" return os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + def _registry_agent(self): + """Best-effort registry row lookup for env/path derivation.""" + if not self._registry or not self.agent_name: + return None + try: + return self._registry.get(self.agent_name) + except Exception: + return None + + def _per_agent_claude_config_dir(self) -> Path | None: + return resolve_agent_claude_config_dir( + self._registry_agent(), + working_dir=self._config.working_dir, + ) + + def _per_agent_claude_env(self) -> dict[str, str]: + cfg_dir = self._per_agent_claude_config_dir() + if cfg_dir is None: + return {} + env = {"CLAUDE_CONFIG_DIR": str(cfg_dir)} + # A fresh per-agent config dir has no .credentials.json. When the + # resolver permits the dir, the static token is present in the daemon + # env; pass it to this child unless this is a custom-provider session. + if not (self._config.provider_url or self._config.provider_key): + token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip() + if token: + env["CLAUDE_CODE_OAUTH_TOKEN"] = token + return env + + def _prepare_local_claude_config(self, project_dir: str) -> None: + """Idempotently prepare a local per-agent config dir before launch.""" + cfg_dir = self._per_agent_claude_config_dir() + if cfg_dir is None: + return + if migrate_claude_project_history(project_dir, cfg_dir): + _log( + f"tmux[{self.agent_name}]: migrated claude transcript history " + f"into {cfg_dir}" + ) + settings_path = resolve_claude_settings_path({"CLAUDE_CONFIG_DIR": str(cfg_dir)}) + if seed_claude_settings_file(settings_path): + _log( + f"tmux[{self.agent_name}]: pre-seeded claude settings in " + f"{settings_path}" + ) + def _build_repl_env(self) -> dict[str, str]: """Env vars injected into the tmux session. @@ -2548,6 +2521,7 @@ def _build_repl_env(self) -> dict[str, str]: oauth_token = self._static_oauth_token() if oauth_token: env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token + env.update(self._per_agent_claude_env()) if self.agent_name: env["PINKY_AGENT_NAME"] = self.agent_name # Surface the RESOLVED effort (#151): the drift hook compares this to @@ -4321,9 +4295,9 @@ def _project_dir(self) -> Path: """ cwd = Path(self._config.working_dir or ".").resolve() # Match Claude Code's encoder exactly: every non-alphanumeric char - # → '-'. For an absolute path the leading '/' yields the leading + # -> '-'. For an absolute path the leading '/' yields the leading # '-' on its own; do NOT prepend an extra dash (that was the bug). - encoded = re.sub(r"[^a-zA-Z0-9]", "-", str(cwd)) + encoded = claude_project_slug(cwd) # Container agents (#638): claude runs with CLAUDE_CONFIG_DIR set to # /.claude-container INSIDE the container — and because # the working_dir is bind-mounted at the SAME absolute path, that @@ -4339,6 +4313,9 @@ def _project_dir(self) -> Path: from pinky_daemon.provisioning import container_config_dir return Path(container_config_dir(wd)) / "projects" / encoded + cfg_dir = self._per_agent_claude_config_dir() + if cfg_dir is not None: + return cfg_dir / "projects" / encoded return Path.home() / ".claude" / "projects" / encoded def _has_prior_transcript(self) -> bool: diff --git a/tests/test_api.py b/tests/test_api.py index fbadf376..a87ddc1d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1131,6 +1131,33 @@ def test_transcript_path_container_root_denied_for_local_agent(self, monkeypatch assert resp.status_code == 403 assert "projects" in resp.json()["detail"].lower() + def test_transcript_path_allows_flagged_per_agent_config_root( + self, monkeypatch + ): + """#797 P2: a flagged local agent's SessionStart hook reports + /.claude-config/projects/...; accept that root only when + the shared resolver would actually inject CLAUDE_CONFIG_DIR.""" + monkeypatch.setenv("PINKY_PER_AGENT_CLAUDE_CONFIG", "1") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-api") + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test.db") + work_dir = os.path.join(tmpdir, "agents", "plain") + app = self._make_app(db_path) + with TestClient(app) as client: + r = client.post("/agents", json={ + "name": "plain", "model": "sonnet", "working_dir": work_dir, + }) + assert r.status_code == 200 + + candidate = os.path.join( + work_dir, ".claude-config", "projects", "x", "s.jsonl" + ) + resp = client.post( + "/agents/plain/transport/transcript-path", + json={"transcript_path": candidate}, + ) + assert resp.status_code == 200 + def test_unix_user_agent_cannot_start_before_provisioner(self): """#149 phase-3 (Murzik #642 P1): an agent labeled isolation_mode= 'unix_user' is accepted at registration but REFUSES to start (501) @@ -2927,6 +2954,7 @@ async def fake_run(self, prompt, **kwargs): long_message = "A" * 620 with tempfile.TemporaryDirectory() as tmpdir, \ + patch.dict(os.environ, {"PINKY_DREAM_TRANSPORT": "sdk"}), \ patch("pinky_daemon.dream_runner.SDKRunner._ensure_sdk", return_value=None), \ patch("pinky_daemon.dream_runner.SDKRunner.run", new=fake_run): db_path = os.path.join(tmpdir, "test.db") diff --git a/tests/test_claude_config.py b/tests/test_claude_config.py new file mode 100644 index 00000000..b3944167 --- /dev/null +++ b/tests/test_claude_config.py @@ -0,0 +1,175 @@ +"""Tests for shared Claude Code config-dir helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import pinky_daemon.claude_config as claude_config +from pinky_daemon.claude_config import ( + claude_project_slug, + migrate_claude_project_history, + resolve_agent_claude_config_dir, + resolve_claude_config_path, + resolve_claude_settings_path, + seed_claude_settings_file, + seed_claude_trust_file, +) + + +class _Agent: + def __init__(self, working_dir: str, isolation_mode: str = "local") -> None: + self.working_dir = working_dir + self.isolation_mode = isolation_mode + + +def _env(**overrides: str) -> dict[str, str]: + env = { + "PINKY_PER_AGENT_CLAUDE_CONFIG": "1", + "CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat01-test", + } + env.update(overrides) + return env + + +def test_resolve_agent_claude_config_dir_default_off(tmp_path: Path) -> None: + agent = _Agent(str(tmp_path / "agent")) + assert resolve_agent_claude_config_dir( + agent, + env={"CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat01-test"}, + ) is None + + +def test_resolve_agent_claude_config_dir_local_flag_on(tmp_path: Path) -> None: + work_dir = tmp_path / "agent" + agent = _Agent(str(work_dir)) + + assert resolve_agent_claude_config_dir(agent, env=_env()) == ( + work_dir / ".claude-config" + ) + + +def test_resolve_agent_claude_config_dir_requires_static_token( + tmp_path: Path, +) -> None: + agent = _Agent(str(tmp_path / "agent")) + + assert resolve_agent_claude_config_dir( + agent, + env={"PINKY_PER_AGENT_CLAUDE_CONFIG": "1"}, + ) is None + + +def test_resolve_agent_claude_config_dir_skips_nonlocal_modes( + tmp_path: Path, +) -> None: + for mode in ("container", "unix_user"): + agent = _Agent(str(tmp_path / mode), isolation_mode=mode) + assert resolve_agent_claude_config_dir(agent, env=_env()) is None + + +def test_migrate_claude_project_history_copy_if_missing( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("HOME", str(tmp_path / "home")) + project_dir = tmp_path / "agents" / "kuzya" + project_dir.mkdir(parents=True) + slug = claude_project_slug(project_dir) + src = Path.home() / ".claude" / "projects" / slug + src.mkdir(parents=True) + (src / "old.jsonl").write_text("history", encoding="utf-8") + + cfg = project_dir / ".claude-config" + assert migrate_claude_project_history(project_dir, cfg) is True + assert (cfg / "projects" / slug / "old.jsonl").read_text() == "history" + + (src / "old.jsonl").write_text("new-history", encoding="utf-8") + assert migrate_claude_project_history(project_dir, cfg) is False + assert (cfg / "projects" / slug / "old.jsonl").read_text() == "history" + + +def test_migrate_claude_project_history_cleans_failed_tmp_copy( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("HOME", str(tmp_path / "home")) + project_dir = tmp_path / "agents" / "kuzya" + project_dir.mkdir(parents=True) + slug = claude_project_slug(project_dir) + src = Path.home() / ".claude" / "projects" / slug + src.mkdir(parents=True) + (src / "old.jsonl").write_text("history", encoding="utf-8") + cfg = project_dir / ".claude-config" + + def fail_copytree(_src: Path, dst: Path, **_kwargs: object) -> None: + dst.mkdir(parents=True, exist_ok=True) + (dst / "partial.jsonl").write_text("partial", encoding="utf-8") + raise RuntimeError("copy interrupted") + + monkeypatch.setattr(claude_config.shutil, "copytree", fail_copytree) + + with pytest.raises(RuntimeError, match="copy interrupted"): + migrate_claude_project_history(project_dir, cfg) + + assert not (cfg / "projects" / slug).exists() + assert list((cfg / "projects").glob("*.pinky-copy.*.tmp")) == [] + + +def test_migrate_claude_project_history_uses_unique_tmp_for_concurrent_call( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("HOME", str(tmp_path / "home")) + project_dir = tmp_path / "agents" / "kuzya" + project_dir.mkdir(parents=True) + slug = claude_project_slug(project_dir) + src = Path.home() / ".claude" / "projects" / slug + src.mkdir(parents=True) + (src / "old.jsonl").write_text("history", encoding="utf-8") + cfg = project_dir / ".claude-config" + dst = cfg / "projects" / slug + copied_to: list[Path] = [] + inner_result: bool | None = None + + def racing_copytree(_src: Path, tmp: Path, **_kwargs: object) -> None: + nonlocal inner_result + copied_to.append(tmp) + tmp.mkdir(parents=True, exist_ok=True) + marker = "outer" if len(copied_to) == 1 else "inner" + (tmp / "old.jsonl").write_text(marker, encoding="utf-8") + if len(copied_to) == 1: + inner_result = migrate_claude_project_history(project_dir, cfg) + + monkeypatch.setattr(claude_config.shutil, "copytree", racing_copytree) + + assert migrate_claude_project_history(project_dir, cfg) is False + assert inner_result is True + assert len(copied_to) == 2 + assert copied_to[0] != copied_to[1] + assert not copied_to[0].exists() + assert not copied_to[1].exists() + assert dst.joinpath("old.jsonl").read_text(encoding="utf-8") == "inner" + assert list((cfg / "projects").glob("*.pinky-copy.*")) == [] + + +def test_seed_trust_and_settings_for_config_dir(tmp_path: Path) -> None: + cfg_dir = tmp_path / ".claude-config" + env = {"CLAUDE_CONFIG_DIR": str(cfg_dir), "HOME": str(tmp_path / "home")} + project_dir = tmp_path / "agent" + project_dir.mkdir() + + assert seed_claude_trust_file(resolve_claude_config_path(env), project_dir) is True + assert seed_claude_settings_file(resolve_claude_settings_path(env)) is True + + claude_json = json.loads((cfg_dir / ".claude.json").read_text()) + assert claude_json["hasCompletedOnboarding"] is True + assert claude_json["bypassPermissionsModeAccepted"] is True + assert claude_json["projects"][str(project_dir.resolve())][ + "hasCompletedProjectOnboarding" + ] is True + + settings = json.loads((cfg_dir / "settings.json").read_text()) + assert settings["skipDangerousModePermissionPrompt"] is True diff --git a/tests/test_streaming_claude_config.py b/tests/test_streaming_claude_config.py new file mode 100644 index 00000000..588a0b47 --- /dev/null +++ b/tests/test_streaming_claude_config.py @@ -0,0 +1,66 @@ +"""Per-agent Claude config-dir wiring for SDK streaming sessions.""" + +from __future__ import annotations + +from pathlib import Path + +from pinky_daemon.streaming_session import StreamingSession, StreamingSessionConfig + + +class _Agent: + def __init__(self, working_dir: str, isolation_mode: str = "local") -> None: + self.working_dir = working_dir + self.isolation_mode = isolation_mode + + +class _Registry: + def __init__(self, agent: _Agent) -> None: + self.agent = agent + + def get(self, name: str): + return self.agent + + +def _session(tmp_path: Path, *, isolation_mode: str = "local") -> StreamingSession: + work_dir = tmp_path / "agent" + cfg = StreamingSessionConfig(agent_name="kuzya", working_dir=str(work_dir)) + return StreamingSession( + cfg, + registry=_Registry(_Agent(str(work_dir), isolation_mode=isolation_mode)), + ) + + +def test_streaming_per_agent_claude_env_flagged_local( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PINKY_PER_AGENT_CLAUDE_CONFIG", "1") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-sdk") + ss = _session(tmp_path) + + env = ss._per_agent_claude_env() + + assert env["CLAUDE_CONFIG_DIR"] == str(tmp_path / "agent" / ".claude-config") + assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "sk-ant-oat01-sdk" + + +def test_streaming_per_agent_claude_env_skips_without_token( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PINKY_PER_AGENT_CLAUDE_CONFIG", "1") + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + ss = _session(tmp_path) + + assert ss._per_agent_claude_env() == {} + + +def test_streaming_per_agent_claude_env_skips_nonlocal_mode( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PINKY_PER_AGENT_CLAUDE_CONFIG", "1") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-sdk") + ss = _session(tmp_path, isolation_mode="unix_user") + + assert ss._per_agent_claude_env() == {} diff --git a/tests/test_tmux_container_isolation.py b/tests/test_tmux_container_isolation.py index f0aa8201..86646170 100644 --- a/tests/test_tmux_container_isolation.py +++ b/tests/test_tmux_container_isolation.py @@ -403,6 +403,50 @@ def test_local_agent_unchanged(self, monkeypatch, tmp_path): ) assert ss._project_dir() == Path.home() / ".claude" / "projects" / _claude_slug(wd) + def test_local_agent_uses_per_agent_config_when_flagged( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv("PINKY_PER_AGENT_CLAUDE_CONFIG", "1") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-local") + wd = str(tmp_path / "proj") + ss = _session( + registry=_FakeRegistry(_FakeAgent("dymok", "local", working_dir=wd)), + working_dir=wd, + ) + + cfg_dir = Path(wd) / ".claude-config" + assert ss._project_dir() == cfg_dir / "projects" / _claude_slug(wd) + env = ss._build_repl_env() + assert env["CLAUDE_CONFIG_DIR"] == str(cfg_dir) + assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "sk-ant-oat01-local" + + def test_per_agent_config_skips_when_token_missing( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv("PINKY_PER_AGENT_CLAUDE_CONFIG", "1") + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + wd = str(tmp_path / "proj") + ss = _session( + registry=_FakeRegistry(_FakeAgent("dymok", "local", working_dir=wd)), + working_dir=wd, + ) + + assert ss._project_dir() == Path.home() / ".claude" / "projects" / _claude_slug(wd) + assert "CLAUDE_CONFIG_DIR" not in ss._build_repl_env() + + def test_per_agent_config_does_not_override_nonlocal_modes( + self, monkeypatch, tmp_path + ): + monkeypatch.setenv("PINKY_PER_AGENT_CLAUDE_CONFIG", "1") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-local") + wd = str(tmp_path / "proj") + for mode in ("container", "unix_user"): + ss = _session( + registry=_FakeRegistry(_FakeAgent("dymok", mode, working_dir=wd)), + working_dir=wd, + ) + assert "CLAUDE_CONFIG_DIR" not in ss._build_repl_env() + class TestBuildReplEnv: """#638 hooks: inside the container netns, localhost is the container, so diff --git a/tests/test_tmux_dream_runner.py b/tests/test_tmux_dream_runner.py index ab1c87c8..b6620aa9 100644 --- a/tests/test_tmux_dream_runner.py +++ b/tests/test_tmux_dream_runner.py @@ -31,6 +31,14 @@ def named(self, cmd: str) -> list[tuple[str, ...]]: return [c for c in self.calls if c[0] == cmd] +class _AgentConfig: + def __init__(self, working_dir: str, isolation_mode: str = "local") -> None: + self.working_dir = working_dir + self.isolation_mode = isolation_mode + self.provider_url = "" + self.provider_key = "" + + def _runner(tmp: str, fake: _FakeTmux, **overrides) -> TmuxDreamRunner: cfg = TmuxDreamConfig( working_dir=tmp, @@ -57,6 +65,23 @@ def _fake_seed(project_dir: str) -> bool: class TestTmuxDreamRunner: + def test_per_agent_claude_env_flagged_local(self, monkeypatch): + with tempfile.TemporaryDirectory() as tmp: + monkeypatch.setenv("PINKY_PER_AGENT_CLAUDE_CONFIG", "1") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-dream") + cfg = TmuxDreamConfig( + working_dir=tmp, + agent_config=_AgentConfig(tmp), + ) + runner = TmuxDreamRunner(cfg, agent_name="ivan") + + env = runner._per_agent_claude_env() + + assert env["CLAUDE_CONFIG_DIR"] == str( + os.path.join(os.path.realpath(tmp), ".claude-config") + ) + assert env["CLAUDE_CODE_OAUTH_TOKEN"] == "sk-ant-oat01-dream" + @pytest.mark.asyncio async def test_happy_path_prompt_file_instruction_result(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_tmux_trust_seed.py b/tests/test_tmux_trust_seed.py index ea46893c..901b3c72 100644 --- a/tests/test_tmux_trust_seed.py +++ b/tests/test_tmux_trust_seed.py @@ -51,6 +51,7 @@ def test_seed_creates_missing_file(tmp_path: Path): assert cfg.exists() data = json.loads(cfg.read_text()) assert data["bypassPermissionsModeAccepted"] is True + assert data["hasCompletedOnboarding"] is True entry = data["projects"][str(proj.resolve())] assert entry["hasTrustDialogAccepted"] is True assert entry["hasCompletedProjectOnboarding"] is True @@ -100,13 +101,15 @@ def test_seed_completes_partial_flags(tmp_path: Path): cfg = tmp_path / ".claude.json" proj = tmp_path / "proj" proj.mkdir() - # Top-level bypass already accepted, but this project never trusted. + # Top-level bypass already accepted, but global onboarding and this + # project trust were missing. cfg.write_text(json.dumps({"bypassPermissionsModeAccepted": True, "projects": {}})) wrote = _seed_claude_trust_file(cfg, str(proj)) assert wrote is True # project flags were missing data = json.loads(cfg.read_text()) + assert data["hasCompletedOnboarding"] is True entry = data["projects"][str(proj.resolve())] assert entry["hasTrustDialogAccepted"] is True assert entry["hasCompletedProjectOnboarding"] is True