From 2cba750ac43a28caeb11345f492e2a8ee20267eb Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Mon, 21 Sep 2026 12:36:38 +0000 Subject: [PATCH] container: give agent runs docker, gh auth and a git identity A headless run started by the scheduler or the Pebble worker had none of the three. GH_TOKEN was exported only inside the Freeshard cycle, which is disabled on the VM (CLAYDE_FS_ENABLED=false), so `gh` was unauthenticated and the `!gh auth git-credential` helper could not push. No git identity was configured anywhere, so any commit failed with "Author identity unknown". And the image carried no docker client, which the app-repository update pass needs for `docker compose pull --dry-run`. bootstrap_process_env() now does the first two at both entry points instead of as a side effect of one loop, and the image gets the Docker CLI plus Compose plugin talking to the host socket through the host's docker group. CLAUDE.md already described the first two as if they were already true. The socket is the full one: an agent in this container can control every container on the host. Deliberate, weighed against running the update pass outside the container entirely. --- .gitignore | 3 +++ CLAUDE.md | 2 +- Dockerfile | 10 ++++++++++ docker-compose.yml | 9 +++++++++ src/clayde/config.py | 31 +++++++++++++++++++++++++++++++ src/clayde/freeshard/entry.py | 3 ++- src/clayde/freeshard/loop.py | 6 ------ src/clayde/orchestrator.py | 3 ++- tests/freeshard/test_loop.py | 18 ------------------ tests/test_config.py | 34 ++++++++++++++++++++++++++++++++++ 10 files changed, 92 insertions(+), 27 deletions(-) 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