diff --git a/.gitignore b/.gitignore index 4027d21..2e69a31 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ docker-compose.override.yml # SDD scratch .superpowers/ + +# Worktrees +.worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index cd0696b..dfa0e12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ The `gh` CLI is authenticated as the configured bot GitHub account and git is co # Source repository pyproject.toml # hatchling build; console scripts: clayde, clayde-once CLAUDE.md # this file — identity + project context -Dockerfile # Python 3.13-slim image with git, gh, uv +Dockerfile # Python 3.13-slim image with git, gh, uv, docker CLI docker-compose.yml # container deployment config uv.lock src/clayde/ diff --git a/Dockerfile b/Dockerfile index 8d303a2..5671ed6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,16 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \ apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/* +# Install Docker CLI + Compose plugin (no daemon in the image — the host's +# socket is mounted in, see docker-compose.yml) +RUN install -m 0755 -d /etc/apt/keyrings && \ + curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \ + chmod a+r /etc/apt/keyrings/docker.asc && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + > /etc/apt/sources.list.d/docker.list && \ + apt-get update && apt-get install -y --no-install-recommends \ + docker-ce-cli docker-compose-plugin && rm -rf /var/lib/apt/lists/* + # Install Node.js (required for Claude Code CLI) RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get install -y nodejs && rm -rf /var/lib/apt/lists/* diff --git a/docker-compose.yml b/docker-compose.yml index 1ac054e..cd572b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,10 @@ services: image: ghcr.io/claydecode/me:main restart: unless-stopped user: "1000:1000" + # The host's docker group, so the mounted socket below is usable as uid + # 1000. Must match `getent group docker` on the host. + group_add: + - "${DOCKER_GID:-111}" networks: [internal] expose: - "8080" @@ -57,6 +61,11 @@ services: # Scheduled-task markdown files (cron/at frontmatter). Read-write: the # scheduler moves fired one-off files into a done/ subdirectory. - ~/clayde-tasks:/tasks + # Docker CLI access for agent runs that need to resolve images, e.g. the + # app-repository update pass and its `docker compose pull --dry-run`. + # Full socket: an agent in this container can control every container on + # the host. Deliberate, and the reason the whitelist above matters. + - /var/run/docker.sock:/var/run/docker.sock labels: - "traefik.enable=true" - "traefik.http.routers.clayde.rule=Host(`${CLAYDE_PEBBLE_HOST}`) && PathPrefix(`/webhook`)" diff --git a/src/clayde/config.py b/src/clayde/config.py index 4f4e308..36e970d 100644 --- a/src/clayde/config.py +++ b/src/clayde/config.py @@ -1,6 +1,8 @@ """Configuration via pydantic-settings.""" import logging +import os +import subprocess from pathlib import Path from github import Auth, Github @@ -121,3 +123,32 @@ def setup_logging() -> None: root = logging.getLogger("clayde") root.setLevel(logging.INFO) root.addHandler(handler) + + +log = logging.getLogger("clayde.config") + + +def bootstrap_process_env() -> None: + """Give subprocesses of this process a usable git and `gh`. + + `gh` reads GH_TOKEN from the environment and the container's git credential + helper is `!gh auth git-credential`, so without the token every push fails; + without an identity every commit fails. Both apply to the headless Claude + runs the scheduler and the Pebble worker start, not just to Freeshard work. + """ + settings = get_settings() + if settings.github_token: + os.environ["GH_TOKEN"] = settings.github_token + if settings.effective_git_name: + _git_config("user.name", settings.effective_git_name) + if settings.git_email: + _git_config("user.email", settings.git_email) + + +def _git_config(key: str, value: str) -> None: + r = subprocess.run( + ["git", "config", "--global", key, value], + capture_output=True, text=True, + ) + if r.returncode != 0: + log.warning("git config --global %s failed: %s", key, r.stderr.strip()) diff --git a/src/clayde/freeshard/entry.py b/src/clayde/freeshard/entry.py index b450e79..71dcb2b 100644 --- a/src/clayde/freeshard/entry.py +++ b/src/clayde/freeshard/entry.py @@ -12,7 +12,7 @@ import signal import time -from clayde.config import get_settings, setup_logging +from clayde.config import bootstrap_process_env, get_settings, setup_logging from clayde.freeshard.loop import run_cycle log = logging.getLogger("clayde.freeshard.entry") @@ -36,6 +36,7 @@ def run_loop() -> None: signal.signal(signal.SIGINT, _handle_signal) setup_logging() + bootstrap_process_env() settings = get_settings() log.info("Starting Freeshard loop (interval=%ds)", settings.fs_loop_interval_s) diff --git a/src/clayde/freeshard/loop.py b/src/clayde/freeshard/loop.py index e662453..13d52f4 100644 --- a/src/clayde/freeshard/loop.py +++ b/src/clayde/freeshard/loop.py @@ -6,7 +6,6 @@ """ import concurrent.futures import logging -import os from clayde.claude import is_claude_available from clayde.config import get_github_client @@ -176,11 +175,6 @@ def run_cycle(settings) -> int: check_disk_and_alert(settings) except Exception: log.warning("Disk guard check failed — continuing") - if settings.github_token: - # The container's git credential helper is `!gh auth git-credential`, - # which reads GH_TOKEN from the environment; without it, branch pushes - # in steps._push_branch fail with exit 128. - os.environ["GH_TOKEN"] = settings.github_token if is_claude_available(): g = get_github_client() return tick(g, settings) diff --git a/src/clayde/orchestrator.py b/src/clayde/orchestrator.py index c26f09e..d01e6bb 100644 --- a/src/clayde/orchestrator.py +++ b/src/clayde/orchestrator.py @@ -6,7 +6,7 @@ import uvicorn -from clayde.config import DATA_DIR, get_settings, setup_logging +from clayde.config import DATA_DIR, bootstrap_process_env, get_settings, setup_logging from clayde.freeshard.loop import run_cycle from clayde.scheduler.loop import scheduler_loop from clayde.webhook import JobQueue, create_app, worker_loop @@ -42,6 +42,7 @@ async def _freeshard_loop(settings) -> None: async def _run_with_pebble() -> None: """Async entry point that runs the Pebble webhook and worker.""" setup_logging() + bootstrap_process_env() settings = get_settings() log.info( "Starting Clayde with Pebble webhook (port=%d, queue_max=%d)", diff --git a/tests/freeshard/test_loop.py b/tests/freeshard/test_loop.py index 45127e1..84db43d 100644 --- a/tests/freeshard/test_loop.py +++ b/tests/freeshard/test_loop.py @@ -308,21 +308,3 @@ def test_malformed_issue_url_does_not_abort_tick( n = loop.tick(MagicMock(), _settings()) assert n == 1 mock_impl.assert_called_once() - - -def test_run_cycle_sets_gh_token_for_git_credential_helper(): - """run_cycle must export GH_TOKEN so the container's `!gh auth git-credential` - helper can authenticate branch pushes (else push fails with exit 128).""" - import os - from unittest.mock import MagicMock, patch - from clayde.freeshard import loop - - settings = MagicMock(github_token="ghp_testtoken123") - os.environ.pop("GH_TOKEN", None) - with patch("clayde.freeshard.loop.check_disk_and_alert"), \ - patch("clayde.freeshard.loop.is_claude_available", return_value=True), \ - patch("clayde.freeshard.loop.get_github_client", return_value=MagicMock()), \ - patch("clayde.freeshard.loop.tick", return_value=0): - loop.run_cycle(settings) - assert os.environ.get("GH_TOKEN") == "ghp_testtoken123" - os.environ.pop("GH_TOKEN", None) diff --git a/tests/test_config.py b/tests/test_config.py index 35d1d4e..3af1468 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ """Tests for clayde.config.""" import logging +import os from pathlib import Path from unittest.mock import patch @@ -175,3 +176,36 @@ def test_scheduler_settings_defaults(monkeypatch): assert s.scheduler_interval_s == 30 assert s.scheduler_tz == "Europe/Berlin" assert s.scheduler_timeout == 300 + + +class TestBootstrapProcessEnv: + def _settings(self, **kw): + base = dict(github_token="ghp_tok", git_name="ClaydeCode", + git_email="clayde@example.net", github_username="ClaydeCode") + base.update(kw) + return Settings(_env_file=None, **base) + + def test_exports_gh_token(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + with patch("clayde.config.get_settings", return_value=self._settings()), \ + patch("clayde.config.subprocess.run") as run: + clayde.config.bootstrap_process_env() + assert os.environ["GH_TOKEN"] == "ghp_tok" + assert run.called + + def test_configures_git_identity(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + with patch("clayde.config.get_settings", return_value=self._settings()), \ + patch("clayde.config.subprocess.run") as run: + clayde.config.bootstrap_process_env() + configured = {c.args[0][3]: c.args[0][4] for c in run.call_args_list} + assert configured == {"user.name": "ClaydeCode", "user.email": "clayde@example.net"} + + def test_skips_what_is_not_configured(self, monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + empty = self._settings(github_token="", git_name="", git_email="", github_username="") + with patch("clayde.config.get_settings", return_value=empty), \ + patch("clayde.config.subprocess.run") as run: + clayde.config.bootstrap_process_env() + assert "GH_TOKEN" not in os.environ + assert not run.called