Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ docker-compose.override.yml

# SDD scratch
.superpowers/

# Worktrees
.worktrees/
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
10 changes: 10 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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`)"
Expand Down
31 changes: 31 additions & 0 deletions src/clayde/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Configuration via pydantic-settings."""

import logging
import os
import subprocess
from pathlib import Path

from github import Auth, Github
Expand Down Expand Up @@ -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())
3 changes: 2 additions & 1 deletion src/clayde/freeshard/entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down
6 changes: 0 additions & 6 deletions src/clayde/freeshard/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion src/clayde/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)",
Expand Down
18 changes: 0 additions & 18 deletions tests/freeshard/test_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
34 changes: 34 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for clayde.config."""

import logging
import os
from pathlib import Path
from unittest.mock import patch

Expand Down Expand Up @@ -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
Loading