diff --git a/claude_bottle/backend/docker/backend.py b/claude_bottle/backend/docker/backend.py index 023a0d3..f6c2071 100644 --- a/claude_bottle/backend/docker/backend.py +++ b/claude_bottle/backend/docker/backend.py @@ -24,6 +24,7 @@ from ...log import die, info from ...manifest import SshEntry from ...util import expand_tilde from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec +from ..util import host_skill_dir from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle @@ -307,19 +308,13 @@ class DockerBottleBackend(BottleBackend): user doesn't get a launch prompt for a plan that's already known to break.""" for name in skills: - path = self._host_skill_dir(name) + path = host_skill_dir(name) if not os.path.isdir(path): die( f"skill '{name}' not found on host at {path}. " f"Create it under ~/.claude/skills/, then re-run." ) - def _host_skill_dir(self, name: str) -> str: - home = os.environ.get("HOME") - if not home: - die("HOME not set") - return f"{home}/.claude/skills/{name}" - def provision_skills(self, plan: BottlePlan, target: str) -> None: """Copy each of the agent's named skills from the host's ~/.claude/skills// into the container's equivalent path. @@ -345,7 +340,7 @@ class DockerBottleBackend(BottleBackend): ) for n in agent.skills: - src = self._host_skill_dir(n) + src = host_skill_dir(n) if not os.path.isdir(src): die(f"skill '{n}' disappeared from host between validation and copy at {src}.") dst = f"{skills_dir}/{n}" @@ -423,14 +418,14 @@ class DockerBottleBackend(BottleBackend): keys_dir = "/root/.claude-bottle-keys" # ~/.ssh for node (700, owned by node). - self._docker_exec_root(container, ["mkdir", "-p", container_ssh]) - self._docker_exec_root(container, ["chown", "node:node", container_ssh]) - self._docker_exec_root(container, ["chmod", "700", container_ssh]) + docker_mod.docker_exec_root(container, ["mkdir", "-p", container_ssh]) + docker_mod.docker_exec_root(container, ["chown", "node:node", container_ssh]) + docker_mod.docker_exec_root(container, ["chmod", "700", container_ssh]) # /root/.claude-bottle-keys for root (700, root-owned). - self._docker_exec_root(container, ["mkdir", "-p", keys_dir]) - self._docker_exec_root(container, ["chown", "root:root", keys_dir]) - self._docker_exec_root(container, ["chmod", "700", keys_dir]) + docker_mod.docker_exec_root(container, ["mkdir", "-p", keys_dir]) + docker_mod.docker_exec_root(container, ["chown", "root:root", keys_dir]) + docker_mod.docker_exec_root(container, ["chmod", "700", keys_dir]) config_file = plan.stage_dir / "ssh_config" known_hosts_file = plan.stage_dir / "ssh_known_hosts" @@ -459,8 +454,8 @@ class DockerBottleBackend(BottleBackend): stdout=subprocess.DEVNULL, check=True, ) - self._docker_exec_root(container, ["chown", "root:root", container_key_path]) - self._docker_exec_root(container, ["chmod", "600", container_key_path]) + docker_mod.docker_exec_root(container, ["chown", "root:root", container_key_path]) + docker_mod.docker_exec_root(container, ["chmod", "600", container_key_path]) container_key_paths.append(container_key_path) @@ -533,8 +528,8 @@ class DockerBottleBackend(BottleBackend): stdout=subprocess.DEVNULL, check=True, ) - self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"]) - self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"]) + docker_mod.docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"]) + docker_mod.docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"]) if known_hosts_file.stat().st_size > 0: info(f"writing {container_ssh}/known_hosts") @@ -543,15 +538,8 @@ class DockerBottleBackend(BottleBackend): stdout=subprocess.DEVNULL, check=True, ) - self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"]) - self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"]) - - def _docker_exec_root(self, container: str, argv: list[str]) -> None: - subprocess.run( - ["docker", "exec", "-u", "0", container, *argv], - stdout=subprocess.DEVNULL, - check=True, - ) + docker_mod.docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"]) + docker_mod.docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"]) def provision_git(self, plan: BottlePlan, target: str) -> None: """If --cwd was set and the host cwd has a .git directory, copy diff --git a/claude_bottle/backend/docker/util.py b/claude_bottle/backend/docker/util.py index 75318e8..532635a 100644 --- a/claude_bottle/backend/docker/util.py +++ b/claude_bottle/backend/docker/util.py @@ -48,6 +48,16 @@ def container_exists(name: str) -> bool: return bool(result.stdout.strip()) +def docker_exec_root(container: str, argv: list[str]) -> None: + """Run `docker exec -u 0` in the named container, check=True. Used + by SSH provisioning to chown/chmod files that need root.""" + subprocess.run( + ["docker", "exec", "-u", "0", container, *argv], + stdout=subprocess.DEVNULL, + check=True, + ) + + _SLUG_RE = re.compile(r"[^a-z0-9]+") diff --git a/claude_bottle/backend/util.py b/claude_bottle/backend/util.py new file mode 100644 index 0000000..bb26f49 --- /dev/null +++ b/claude_bottle/backend/util.py @@ -0,0 +1,18 @@ +"""Cross-backend utility helpers — host-side primitives shared by +every backend implementation. Backend-specific helpers live one level +deeper (e.g. claude_bottle/backend/docker/util.py).""" + +from __future__ import annotations + +import os + +from ..log import die + + +def host_skill_dir(name: str) -> str: + """Return the host-side path for a named skill: + `$HOME/.claude/skills/`. Dies if HOME is unset.""" + home = os.environ.get("HOME") + if not home: + die("HOME not set") + return f"{home}/.claude/skills/{name}"