docs(ai): move agent rules to ai/skills and add an AI development sandbox (#1498)

The sandbox provides an isolated Docker environment for AI-assisted development
(OpenCode + Claude Code + Qwen Code) against the local AkkudoktorEOS repository.

The sandbox does not protect the repository from harmful changes by the agent.
It limits what a misled agent can reach on the machine, but the agent can still
write any change into the repository.

Primary security defence is to check every change before it is run on the host.
Same holds for opening a pull request or updating one.

Additional changes

- Reduce AGENTS.md to the project summary, baseline rules, completion checklist,
  and an index of the skills.
- Add skills for the Python environment and REST API error responses.
- Add the development guide page docs/develop/ai.md: the AI coding
  workflow, how agents get the project rules from AGENTS.md and ai/skills,
  and how to use the sandbox (start, agent logins, project commands with a
  virtual environment outside the workspace, running EOS and EOSdash,
  GitHub access, persistent data, security model).
- Add a checklist for reviewing agent changes before pushing, starting with
  .git, which stays writable in the sandbox.
- Check out all text files with LF line endings (.gitattributes), also on
  Windows with core.autocrlf, so the sandbox scripts run in the container
  and git in the container agrees with git on the host. Batch files keep CRLF.
- Publish the ports of ai/compose.yaml on request (AI_SANDBOX_PORTS=1).
  docker compose run publishes them only with --service-ports. Off by
  default, so that several sandboxes can run in parallel.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bobby Noelte
2026-11-09 09:15:12 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 628226d3d9
commit 2245976de4
21 changed files with 2041 additions and 150 deletions
+10
View File
@@ -0,0 +1,10 @@
# Text files use LF line endings in every working tree, also on Windows with
# core.autocrlf=true. Shell scripts run in the Linux container of the AI
# sandbox, and git in the container (no autocrlf) must agree with git on the
# host about which files changed.
* text=auto eol=lf
# Windows batch files need CRLF line endings: cmd.exe handles labels and
# `goto` unreliably in batch files with LF line endings.
*.bat text eol=crlf
*.cmd text eol=crlf
+37 -150
View File
@@ -1,163 +1,50 @@
# AGENTS.md — Akkudoktor-EOS # AGENTS.md — Akkudoktor-EOS
## Project Overview Akkudoktor-EOS is a Python-based energy management and optimization system. It
provides energy forecasts, optimization, energy management plans, and
integrations with local energy devices and controllers.
Akkudoktor-EOS is a Python-based energy management and optimization system. It provides energy forecasts, optimization, energy management plans, and integrations with local energy devices and controllers. The project prioritizes reliable local operation, clear interfaces,
maintainable code, testability, and type safety.
The project prioritizes reliable local operation, clear interfaces, maintainable code, testability, and type safety. ## Rules for every change
## General Development Guidelines - Follow existing architecture and coding conventions.
- Prefer small, focused changes that preserve established interfaces and
- Follow the existing architecture and coding conventions. behavior.
- Prefer small, focused changes that preserve established interfaces and behavior.
- Use type annotations for functions, methods, and relevant variables. - Use type annotations for functions, methods, and relevant variables.
- Write Google-style docstrings for modules, classes, and public functions and methods, following the surrounding code. - Write Google-style docstrings for modules, classes, and public
- Keep code compatible with the Python version supported by the project. Consult `pyproject.toml` and the project configuration rather than assuming a particular interpreter version. functions/methods, following surrounding code.
- Keep code compatible with the supported Python version (`pyproject.toml`).
- Avoid introducing unnecessary dependencies. - Avoid introducing unnecessary dependencies.
- Use descriptive names that communicate purpose and domain meaning. - Use descriptive, domain-meaningful names.
- Do not use production `assert` statements for input validation or runtime error handling. Use explicit exceptions or appropriate error handling instead. - Never use production `assert` for input validation or runtime error
- Use the project's existing logging facilities and conventions. handling; use explicit exceptions.
- Follow existing patterns for configuration, validation, error reporting, and resource management. - Use existing logging facilities and conventions.
- Preserve compatibility with the project's existing tests and supported integrations unless a change explicitly requires otherwise. - Follow existing patterns for config, validation, error reporting, resource
management.
- Preserve compatibility with existing tests and supported integrations unless
a change explicitly requires otherwise.
- Never use the workspace `.venv`: run `uv`, `make` and all Python tools in a
virtual environment outside the workspace (`UV_PROJECT_ENVIRONMENT`), see
`ai/skills/python-environment/SKILL.md`.
## Filename Naming Rules ## Skills
**Filename conventions are mandatory. All new files and renamed files must comply with the filename rules configured in `pyproject.toml`.** Detailed rules live in `ai/skills/`. Load the relevant skill for the task:
Before creating, renaming, or proposing a new file: | Skill | Load when |
1. Inspect the `[tool.eos-filename-linter]` configuration in `pyproject.toml`.
2. Identify the first matching rule for the file's repository-relative path.
3. Follow that rule's regular expression, allowed-name exceptions, exclusions, and maximum filename length.
4. Do not invent alternative naming conventions or infer that a filename is acceptable merely because it is valid Python.
5. Do not change the linter configuration or add exceptions simply to accommodate a nonconforming filename without a clear justification.
The custom filename linter is the authoritative source for repository filename conventions. If this document and the linter configuration appear inconsistent, follow the configuration and recommend updating this document when appropriate.
### Python source modules
Python modules under `src/akkudoktoreos/` must follow the naming pattern configured for that directory.
In particular, where the configured rule requires lowercase alphanumeric names, use lowercase letters and digits without underscores or hyphens. Preserve explicitly permitted special filenames, such as `__init__.py`, when listed in `allowed_names`.
Do not rename existing modules merely to impose a personal preference. Follow the configured rule when adding files or changing filenames.
### Pytest modules
Test modules under `tests/` must follow the applicable test filename rule.
Where the configured rule requires the `test_` prefix followed by lowercase letters and digits only:
- Begin the filename with `test_`.
- Use lowercase letters and digits after the prefix.
- Do not use underscores or hyphens after the prefix.
- Keep the `.py` extension.
- Respect any configured exceptions and exclusions.
Examples:
| Valid | Invalid |
|---|---| |---|---|
| `test_adapterdevice.py` | `test_adapter_device.py` | | `ai/skills/application-configuration/SKILL.md` | Config, Pydantic models, defaults |
| `test_geneticforecastcoverage.py` | `test_genetic_forecast_coverage.py` | | `ai/skills/commit-messages/SKILL.md` | Creating/checking commit messages |
| `test_invertercontrol.py` | `test_inverter-control.py` | | `ai/skills/energy-domain/SKILL.md` | Optimization, adapters, units/signs |
| `ai/skills/filename-conventions/SKILL.md` | Creating/renaming files |
| `ai/skills/python-environment/SKILL.md` | Before running uv, make, pytest, mypy, ruff or any Python |
| `ai/skills/python-quality/SKILL.md` | Writing/modifying Python |
| `ai/skills/rest-api/SKILL.md` | Adding/changing REST endpoints, API error responses |
| `ai/skills/testing/SKILL.md` | Adding/updating tests, bug fixes |
If a separate rule applies to files such as `single_test_*.py`, follow that rule instead. ## Completion checklist
### Python scripts
Scripts under `scripts/` must follow the applicable script naming rule.
Where the configured rule requires lowercase snake_case:
- Use lowercase letters and digits.
- Use underscores to separate words where appropriate.
- Do not use hyphens.
- Keep the `.py` extension.
Examples:
- Valid: `lint_filenames.py`
- Invalid: `lint-filenames.py`
- Invalid: `LintFilenames.py`
### Filename length
Respect the maximum filename length configured by the applicable rule. If a rule does not override the global limit, use the global default defined in `pyproject.toml` or by the linter.
The length limit includes the file extension.
### Verify filenames
When adding or renaming files, run the filename linter before completing the change:
```bash
uv run python scripts/lint_filenames.py
```
To check specific files:
```bash
uv run python scripts/lint_filenames.py path/to/file.py
```
When the linter reports a violation:
- Correct the filename where appropriate.
- Review the suggested name as a diagnostic hint; do not assume it is always semantically ideal.
- Update imports, references, test commands, and other affected paths if a file is renamed.
- Never rename files automatically without reviewing the consequences.
Do not weaken filename rules merely to make the check pass.
## Python Quality and Type Checking
- Follow the project's Ruff configuration for linting and formatting.
- Keep code compatible with the project's mypy configuration.
- Avoid introducing new type-checking errors.
- Prefer explicit, narrow types over unnecessary use of `Any`.
- Handle optional values and error cases explicitly.
- Keep abstractions proportionate to the problem; avoid unnecessary complexity.
Use the project's configured tools rather than introducing competing formatting or linting conventions.
## Testing
- Add or update pytest tests for behavior changes and bug fixes.
- **Prefer adding tests to existing test classes in existing test modules whenever possible.** Extend the relevant class or module rather than creating additional test files.
- Create a new test module only when the functionality represents a distinct aspect that does not fit naturally into an existing test module, or when it targets a different component or test target.
- Follow existing test conventions, fixtures, and markers.
- Prefer deterministic, isolated tests; mock external services and devices where appropriate.
- Cover relevant boundary conditions and error paths.
- Do not weaken tests merely to accommodate implementation defects.
Run relevant tests and checks before completing changes. Use the project's configured commands and environments.
## Configuration and Compatibility
- Follow existing Pydantic and configuration-management patterns.
- Validate external input at appropriate boundaries.
- Preserve established configuration behavior and migration compatibility.
- Avoid silently changing defaults, persisted configuration formats, or public API behavior.
- Update tests and documentation when configuration behavior changes.
## Energy Management and Device Integration
- Keep optimization logic separate from device-specific communication where practical.
- Follow existing adapter and controller interfaces.
- Respect the distinction between long-horizon energy optimization and fast local device control.
- Handle communication failures, device availability, and invalid measurements explicitly.
- Avoid introducing cloud dependencies into workflows intended to operate locally.
- Preserve existing energy sign conventions, units, and conversion semantics. Verify them before changing power, energy, or charging behavior.
## Documentation
- Update documentation when changing public interfaces, configuration, command-line behavior, or architectural decisions.
- Keep examples consistent with actual configuration and code.
- Document non-obvious constraints and compatibility requirements.
- Ensure filenames used in documentation match the actual repository paths.
## Completion Checklist
Before considering a change complete: Before considering a change complete:
@@ -167,5 +54,5 @@ Before considering a change complete:
- [ ] Relevant pytest tests pass. - [ ] Relevant pytest tests pass.
- [ ] Ruff checks pass. - [ ] Ruff checks pass.
- [ ] Mypy checks pass where applicable. - [ ] Mypy checks pass where applicable.
- [ ] Configuration and documentation have been updated when necessary. - [ ] Configuration and documentation updated when necessary.
- [ ] No unrelated changes or unnecessary exceptions have been introduced. - [ ] No unrelated changes or unnecessary exceptions introduced.
+9
View File
@@ -0,0 +1,9 @@
# ai/.dockerignore
# Keep the build context small and free of host-specific artifacts.
.git
.venv
__pycache__
*.pyc
node_modules
.env
ai-home
+8
View File
@@ -0,0 +1,8 @@
# ai/.env.example
# Host user/group IDs used for the container's non-root user.
# On Linux/macOS these are set automatically by run.sh.
# On Windows the defaults (1000) are fine.
HOST_UID=1000
HOST_GID=1000
# AI_SANDBOX_* options have no effect here: the launchers read them from the
# shell that starts them.
+75
View File
@@ -0,0 +1,75 @@
# ai/Dockerfile
# AI development sandbox for AkkudoktorEOS.
# Runs as a non-root user whose UID/GID match the host to keep bind-mount ownership sane.
# uv comes from its official image. BuildKit does not expand variables in
# `COPY --from`, so the image is a named stage with a global ARG. Keep
# UV_VERSION in sync with .github/workflows/pre-commit.yml.
ARG UV_VERSION=0.12.9
FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv
FROM debian:bookworm-slim
ARG UID=1000
ARG GID=1000
ARG USERNAME=dev
# Pinned tool versions (UV_VERSION is above). Bump them and rebuild
# (AI_SANDBOX_REBUILD=1) to update.
ARG CLAUDE_CODE_VERSION=2.1.319
ARG OPENCODE_VERSION=1.19.5
ARG QWEN_CODE_VERSION=0.27.0
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
PATH="/home/${USERNAME}/.local/bin:${PATH}"
# Base tooling: git, GitHub CLI and build tools. No sudo: the agent must not
# be able to become root, even if no-new-privileges were removed.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
gh \
gnupg \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Copy the uv binary from the uv stage.
COPY --from=uv /uv /uvx /bin/
# Install Python 3.13 outside /root (mode 0700, unreachable for the dev user)
# and link it into /usr/local/bin, where uv finds it as an interpreter on PATH.
RUN UV_PYTHON_INSTALL_DIR=/opt/uv-python UV_PYTHON_BIN_DIR=/usr/local/bin \
uv python install 3.13 \
&& chmod -R a+rX /opt/uv-python \
&& /usr/local/bin/python3.13 --version
# Install Node.js 22.x (needed for the AI CLIs).
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# AI CLIs as global npm packages (installed under /usr, readable for everyone).
RUN npm install -g \
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
"opencode-ai@${OPENCODE_VERSION}" \
"@qwen-code/qwen-code@${QWEN_CODE_VERSION}" \
&& npm cache clean --force
# Create a non-root user matching the host UID/GID.
RUN groupadd -g ${GID} ${USERNAME} 2>/dev/null || true \
&& useradd -m -u ${UID} -g ${GID} -s /bin/bash ${USERNAME}
# Install the entrypoint into the read-only image layer, owned by root.
# It must NOT live under /home/dev: that path is the persistent `ai-home`
# volume, so a copy there would be shadowed by a stale file from the volume
# and would be writable by the agent.
COPY --chown=root:root --chmod=0755 entrypoint.sh /usr/local/bin/entrypoint.sh
USER ${USERNAME}
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["bash"]
+81
View File
@@ -0,0 +1,81 @@
# AkkudoktorEOS AI Development Sandbox
Isolated Docker environment for AI-assisted development (OpenCode + Claude Code + Qwen Code)
against the local AkkudoktorEOS repository.
**The sandbox does not protect your repository from harmful changes by the agent.** It limits
what a misled agent can reach on your machine, but the agent can still write any change into the
repository. Check every change before you run it on the host, open a pull request or update one.
See the security model in `docs/develop/ai.md`.
## Layout
- `compose.yaml` – service definition (bind-mounts the repo root to `/workspace`, with `ai/`
read-only)
- `Dockerfile` – Debian slim + Python 3.13 + uv + Node.js + OpenCode + Claude Code + Qwen Code + gh,
with pinned versions
- `entrypoint.sh` – container init (exec-bit stripping and reset of the shell startup files in
`$HOME`, warning about active git hooks, uv cache, git config, gh credential helper); installed
to `/usr/local/bin` in the image, outside the `ai-home` volume
- `.env.example` – template, copied to `.env` on first run
- `run.sh` / `run.ps1` / `run.bat` – entry points (prerequisite checks, sanity check of
`compose.yaml`, launch)
- `skills/` – project rules for the agents, indexed in `AGENTS.md`
## Usage
From the repository root:
- Linux/macOS: `chmod +x ai/run.sh && ./ai/run.sh`
- Windows PowerShell: `.\ai\run.ps1`
- Windows CMD: `ai\run.bat`
Options (environment variables):
- `AI_SANDBOX_REBUILD=1` – rebuild the image (e.g. after bumping the agent CLI versions in
`Dockerfile`)
- `AI_SANDBOX_PORTS=1` – publish the ports of `compose.yaml` (8503 EOS, 8504 EOSdash) on
`127.0.0.1` of the host. Off by default, as only one sandbox at a time can publish them.
Start the servers with `--host 0.0.0.0` inside the container to make them reachable.
- `AI_SANDBOX_SKILLS_RW=1` – make `ai/skills` writable in the sandbox, so the agent can update the
skills. The rest of `ai/` stays read-only.
A command after `--` runs instead of the shell, e.g. `./ai/run.sh -- claude`. In PowerShell the
`--` is optional: `.\ai\run.ps1 claude`. On Windows prefer PowerShell 7; `run.bat` cannot pass
`!` and `%` to the container.
On Windows the repository must be checked out with LF line endings, which `.gitattributes`
enforces (see `docs/develop/ai.md`). In the sandbox, run `pre-commit` on the changed files only;
`--all-files` fails on the read-only `ai/` directory. Changes to `ai/` itself (outside `ai/skills`,
or to `ai/skills` without `AI_SANDBOX_SKILLS_RW=1`) must be checked on the host for the same reason.
See `docs/develop/ai.md` for the AI coding workflow.
Inside the container (working dir `/workspace`):
```bash
opencode auth login # or /connect in the TUI
claude # then /login on first run
qwen
gh auth login # then gh auth setup-git
```
All auth state persists in the `akkudoktor-ai-home` Docker volume. The local
repository is bind-mounted at `/workspace`, so AI-made changes are visible on
the host immediately.
## Security notes
- No `privileged` mode, no Docker socket, no host home mount, no `sudo`.
- `ai/` (sandbox definition and launchers) is read-only in the container.
- `.git` is writable, so the agent can commit. It can also plant git configuration and hooks that
run on the host (e.g. through `.git/commondir`). Check `.git` before you run any git command on
the host, see the review checklist in `docs/develop/ai.md`.
- Git worktree checkouts are not supported: their `.git` is a file pointing outside the
repository, so git does not work in the sandbox.
- Container user matches the host UID/GID (Linux/macOS) so file ownership is preserved.
- Credentials are never written to git or to `compose.yaml`; they live only
in the persistent home volume via each tool's own login flow.
- The home volume persists between sessions. Remove it (`docker volume rm akkudoktor-ai-home`)
if a session may have been misled.
- The launchers' checks of `compose.yaml` are a sanity check, not a complete validation.
+134
View File
@@ -0,0 +1,134 @@
# ai/compose.yaml
# Sandbox for the AkkudoktorEOS AI coding agent.
# The launchers (run.sh, run.ps1, run.bat) sanity-check a few of the controls below.
# The real protection of this file is the read-only mount of ai/: the agent
# cannot change the sandbox definition from inside the sandbox.
services:
ai-dev:
build:
context: .
dockerfile: Dockerfile
args:
UID: ${HOST_UID:-1000}
GID: ${HOST_GID:-1000}
image: akkudoktor-eos-ai:latest
# NOTE: no container_name — required for `docker compose run` and parallelism.
working_dir: /workspace
# ------------------------------------------------------------------
# Filesystem isolation
# ------------------------------------------------------------------
read_only: true
volumes:
# Repo, read-write (needed by the agent to edit code).
- ..:/workspace:rw
# Read-only overlays for the sandbox definition the launcher uses.
# Without them the agent could rewrite what the next launch runs.
# - ai/: compose.yaml, Dockerfile, entrypoint.sh and the launchers.
# - ai/skills/: read-only unless the launcher is started with
# AI_SANDBOX_SKILLS_RW=1 (the launcher sets AI_SANDBOX_SKILLS_MODE).
# .git stays writable, so the agent can commit. Read-only overlays of
# .git/hooks or .git/config would not protect the host anyway: git also
# reads config and hooks from the directory named in .git/commondir, and
# .git itself can be renamed. Neither can tracked files the host runs
# (Makefile, .pre-commit-config.yaml, tests, scripts) be protected this
# way: review .git and every change on the host before running git or
# anything else there (docs/develop/ai.md, review checklist).
- ../ai:/workspace/ai:ro
- ../ai/skills:/workspace/ai/skills:${AI_SANDBOX_SKILLS_MODE:-ro}
# Persistent state for CLI auth, caches and Claude memory/transcripts.
# This volume persists between sessions, so a misled session can leave
# state behind that takes effect later (e.g. agent settings and hooks in
# ~/.claude, ~/.gitconfig, the venv in ~/.cache). The entrypoint resets
# the shell startup files on every start; for everything else, remove the
# volume (docker volume rm akkudoktor-ai-home) if a session may have been
# misled. entrypoint.sh lives in the image (/usr/local/bin), not here.
# No `nocopy`: an empty volume is populated from the image's /home/dev,
# which also gives it the owner of /home/dev (the dev user). With
# `nocopy`, a fresh volume is owned by root and the container cannot start.
- type: volume
source: ai-home
target: /home/dev
tmpfs:
# /tmp is needed by many tools but must never be executable.
- /tmp:rw,nosuid,nodev,noexec,size=512m,mode=1777
# /run and /var/tmp kept in-memory, noexec.
- /run:rw,nosuid,nodev,noexec,size=64m
- /var/tmp:rw,nosuid,nodev,noexec,size=256m
# ------------------------------------------------------------------
# Privilege & capability isolation
# ------------------------------------------------------------------
privileged: false
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# ------------------------------------------------------------------
# Network
# ------------------------------------------------------------------
networks:
- ai-net
# ------------------------------------------------------------------
# Testing ports — loopback-only on the host.
# 127.0.0.1:... prevents exposing these services to the LAN/VPN.
# Add new ports here only when you have a concrete test need.
# ------------------------------------------------------------------
ports:
- "127.0.0.1:8503:8503"
- "127.0.0.1:8504:8504"
# ------------------------------------------------------------------
# Resource limits (defense in depth against runaway agents)
# ------------------------------------------------------------------
mem_limit: 4g
memswap_limit: 4g # == mem_limit: disables swap for this cgroup
pids_limit: 512 # fork-bomb protection
cpus: 2.0
ulimits:
nofile:
soft: 4096
hard: 8192
# ------------------------------------------------------------------
# Environment
# ------------------------------------------------------------------
environment:
- TERM=xterm-256color
- CLAUDE_CONFIG_DIR=/home/dev/.claude
- HOME=/home/dev
# Keep the agent's virtual environment out of the host's .venv in every
# shell and tool call (see ai/skills/python-environment/SKILL.md).
- UV_PROJECT_ENVIRONMENT=/home/dev/.cache/eos-venv
# Disable shell history writing so a hijacked home doesn't retain commands.
- HISTFILE=/dev/null
- HISTSIZE=0
# Trust the repository: Docker Desktop (Windows, macOS) shows the bind
# mount as owned by root, and git refuses a repository owned by another
# user ("dubious ownership"). Set here, not in ~/.gitconfig, so it does
# not depend on the home volume.
- GIT_CONFIG_COUNT=1
- GIT_CONFIG_KEY_0=safe.directory
- GIT_CONFIG_VALUE_0=/workspace
# Optional git identity, passed through from the host shell
# (export GIT_USER_NAME / GIT_USER_EMAIL before ./ai/run.sh).
- GIT_USER_NAME
- GIT_USER_EMAIL
stdin_open: true
tty: true
networks:
ai-net:
driver: bridge
volumes:
ai-home:
name: akkudoktor-ai-home
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# ai/entrypoint.sh
# Initializes the AI sandbox container on startup, then execs the command.
# Installed to /usr/local/bin (image layer, root-owned) so it cannot be
# shadowed or modified via the persistent /home/dev volume.
set -euo pipefail
# ---------------------------------------------------------------------------
# Hardening
# ---------------------------------------------------------------------------
# The home directory is a persistent volume, so files written in one session
# are still there in the next. The steps below remove the most direct ways to
# make such a file run automatically. They do not cover everything (agent
# settings and hooks in ~/.claude, ~/.gitconfig, the venv in ~/.cache):
# reset the volume (docker volume rm akkudoktor-ai-home) if a session may have
# been misled.
# Strip exec bits from $HOME, except from the uv cache and the virtual
# environment in ~/.cache, which must stay executable. A program planted in
# e.g. ~/.local/bin (first on PATH) then does not run in the next session.
# Runs as the non-root `dev` user, so it can only touch its own files.
strip_exec_bits() {
find /home/dev \
-path /home/dev/.cache -prune -o \
-path /home/dev/.local/share/uv -prune -o \
-type f -perm /111 -exec chmod a-x -- {} + 2>/dev/null || true
}
strip_exec_bits
# Reset the shell startup files on every start, so a startup file written in
# a previous session cannot inject commands into the next shell.
# Customizations of these files do not persist.
reset_shell_startup_files() {
local f
for f in .bashrc .profile .bash_logout; do
# Remove first: cp onto a planted symlink would write to its target.
rm -f -- "$HOME/$f"
if [[ -f "/etc/skel/$f" ]]; then
cp -- "/etc/skel/$f" "$HOME/$f"
fi
done
rm -f -- "$HOME/.bash_profile" "$HOME/.bash_login" \
"$HOME/.zshrc" "$HOME/.zprofile" "$HOME/.zshenv" "$HOME/.zlogin"
}
reset_shell_startup_files
# NOTE: /workspace is the user's own repository. The entrypoint must never
# modify it — in particular .git/hooks is left untouched (pre-commit etc.).
# .git is writable in the sandbox, so a previous session may have added hooks.
# Warn about the active hooks, so they get noticed. This is no protection: the
# review checklist in docs/develop/ai.md checks .git on the host.
warn_git_hooks() {
local hooks_dir
# Honors core.hooksPath and worktrees; falls back to the default location.
hooks_dir="$(git -C /workspace rev-parse --path-format=absolute --git-path hooks 2>/dev/null)" \
|| hooks_dir="/workspace/.git/hooks"
[[ -d "$hooks_dir" ]] || return 0
local hooks=()
while IFS= read -r -d '' f; do
hooks+=("$f")
done < <(find "$hooks_dir" -maxdepth 1 -type f ! -name '*.sample' -print0 2>/dev/null)
[[ ${#hooks[@]} -gt 0 ]] || return 0
{
echo "WARNING: active git hooks found in $hooks_dir"
echo " They run on the host too. Verify they are expected:"
local f
for f in "${hooks[@]}"; do
printf ' %s (modified %s)\n' \
"$(basename -- "$f")" \
"$(date -r "$f" '+%Y-%m-%d %H:%M' 2>/dev/null || echo '?')"
done
} >&2
}
warn_git_hooks || true
# ---------------------------------------------------------------------------
# Initialization
# ---------------------------------------------------------------------------
# Prepare a persistent uv cache inside the home volume.
export UV_CACHE_DIR="/home/dev/.cache/uv"
mkdir -p "$UV_CACHE_DIR"
# Configure git identity if provided via environment.
if [[ -n "${GIT_USER_NAME:-}" ]]; then
git config --global user.name "$GIT_USER_NAME"
fi
if [[ -n "${GIT_USER_EMAIL:-}" ]]; then
git config --global user.email "$GIT_USER_EMAIL"
fi
# If gh is already authenticated, wire git to use it as a credential helper.
if gh auth status &>/dev/null; then
gh auth setup-git || true
fi
exec "$@"
+299
View File
@@ -0,0 +1,299 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
:: ==========================================================================
:: ai\run.bat
:: Entry point for the AkkudoktorEOS AI development sandbox (Windows CMD).
::
:: Launcher:
:: - Sanity-checks ai\compose.yaml for the main sandbox controls. This is not
:: a complete validation; the protection of the sandbox definition is the
:: read-only mount of ai/ in the container.
:: - Only forwards arguments after `--` as the in-container command, never
:: as docker compose options. Each argument is forwarded with its own
:: quotes, so cmd does not interpret |, &, < or > inside a quoted
:: argument. `!` and `%` cannot be passed through; use run.ps1 for them.
:: - Optional write access to ai/skills (AI_SANDBOX_SKILLS_RW=1).
:: - Requires every published port to bind to 127.0.0.1 (loopback on the host).
:: - Optional rebuild (AI_SANDBOX_REBUILD=1); cached image otherwise.
:: - Windows: UID/GID bind-mount mapping does not apply, HOST_UID/HOST_GID
:: fall back to the defaults from ai\.env.
:: ==========================================================================
:: Resolve script directory and repo root.
set "SCRIPT_DIR=%~dp0"
if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
for %%I in ("%SCRIPT_DIR%\..") do set "REPO_ROOT=%%~fI"
pushd "%REPO_ROOT%" >nul 2>&1
if errorlevel 1 (
echo ERROR: cannot cd into "%REPO_ROOT%". 1>&2
exit /b 1
)
set "COMPOSE_FILE=ai\compose.yaml"
set "ENV_FILE=ai\.env"
set "ENV_EXAMPLE=ai\.env.example"
:: ==========================================================================
:: 0. Arguments. Docker compose options cannot be passed; everything after
:: `--` is the command to run in the container.
:: ==========================================================================
set "EXTRA_CMD="
set "SAW_DASHDASH=0"
:parse_args
if "%~1"=="" goto end_parse_args
:: Everything after `--` is forwarded to the container as given. %1 keeps
:: the quotes of the argument, which protect |, &, < and > inside it from
:: cmd; "%~1" inside quotes would expose them to cmd.
if "%SAW_DASHDASH%"=="1" (
rem Forward the argument with its own quotes.
if defined EXTRA_CMD (
set EXTRA_CMD=!EXTRA_CMD! %1
) else (
set EXTRA_CMD=%1
)
shift
goto parse_args
)
if "%~1"=="--" (
set "SAW_DASHDASH=1"
shift
goto parse_args
)
echo ERROR: extra arguments must be supplied after '--' as the in-container command. 1>&2
echo Example: ai\run.bat -- bash -lc "pytest -q" 1>&2
popd >nul
exit /b 2
:end_parse_args
:: ==========================================================================
:: 1. Prerequisites.
:: ==========================================================================
echo ==^> Checking prerequisites...
where docker >nul 2>&1
if errorlevel 1 (
echo ERROR: docker is not installed. 1>&2
popd >nul
exit /b 1
)
docker compose version >nul 2>&1
if errorlevel 1 (
echo ERROR: docker compose v2 is not available. 1>&2
popd >nul
exit /b 1
)
docker info >nul 2>&1
if errorlevel 1 (
echo ERROR: docker daemon is not reachable. 1>&2
echo - Is Docker Desktop running? 1>&2
echo - Try 'docker info' manually. 1>&2
popd >nul
exit /b 1
)
if not exist "pyproject.toml" (
echo ERROR: pyproject.toml not found; run from inside the AkkudoktorEOS repository. 1>&2
popd >nul
exit /b 1
)
if not exist "%COMPOSE_FILE%" (
echo ERROR: %COMPOSE_FILE% missing. 1>&2
popd >nul
exit /b 1
)
:: ==========================================================================
:: 2. Sanity-check the sandbox controls in compose.yaml.
:: This catches accidental edits, not every weakening edit. The agent
:: cannot edit compose.yaml from inside the sandbox (ai/ is read-only
:: there); review changes to ai/ before you start the sandbox.
:: CMD cannot span lines, so multi-line YAML values are checked
:: line-by-line with findstr. Patterns must not use `$`: findstr only
:: matches it before CRLF, and the file may have LF line endings.
:: ==========================================================================
echo ==^> Checking sandbox controls in %COMPOSE_FILE% ...
call :require_line "read_only:[ ]*true" "Missing read_only:true."
if errorlevel 1 goto verify_failed
call :require_line "no-new-privileges:[ ]*true" "Missing no-new-privileges:true."
if errorlevel 1 goto verify_failed
:: cap_drop must be present and must include ALL.
call :require_line "cap_drop:" "Missing cap_drop."
if errorlevel 1 goto verify_failed
:: No caret anchor: call doubles carets in quoted arguments.
call :require_line "[ ]-[ ]*ALL" "cap_drop must include ALL."
if errorlevel 1 goto verify_failed
:: ai/ must be mounted read-only.
call :require_text "../ai:/workspace/ai:ro" "ai/ must be mounted read-only."
if errorlevel 1 goto verify_failed
:: Docker socket / named pipe must never be mounted.
findstr /R /C:"/var/run/docker\.sock" "%COMPOSE_FILE%" >nul 2>&1
if not errorlevel 1 (
echo ERROR: compose.yaml mounts the Docker socket. This is an escape vector. 1>&2
goto verify_failed
)
findstr /R /C:"//\./pipe/docker_engine" "%COMPOSE_FILE%" >nul 2>&1
if not errorlevel 1 (
echo ERROR: compose.yaml mounts the Docker named pipe. This is an escape vector. 1>&2
goto verify_failed
)
:: privileged:true is forbidden.
findstr /R /C:"privileged:[ ]*true" "%COMPOSE_FILE%" >nul 2>&1
if not errorlevel 1 (
echo ERROR: compose.yaml sets privileged:true; refusing to start. 1>&2
goto verify_failed
)
:: network_mode: host is forbidden.
findstr /R /C:"network_mode:[ ]*host" "%COMPOSE_FILE%" >nul 2>&1
if not errorlevel 1 (
echo ERROR: compose.yaml sets network_mode: host; refusing to start. 1>&2
goto verify_failed
)
:: Every port entry under `ports:` must start with 127.0.0.1:
call :check_ports
if errorlevel 1 goto verify_failed
goto verify_ok
:verify_failed
popd >nul
exit /b 3
:verify_ok
:: ==========================================================================
:: 3. Ensure ai\.env exists. HOST_UID/HOST_GID do not apply on Windows;
:: the defaults already present in ai\.env stand.
:: ==========================================================================
if not exist "%ENV_FILE%" (
if exist "%ENV_EXAMPLE%" (
echo ==^> Creating %ENV_FILE% from %ENV_EXAMPLE%
copy /Y "%ENV_EXAMPLE%" "%ENV_FILE%" >nul
) else (
echo ==^> Creating empty %ENV_FILE%
type nul > "%ENV_FILE%"
)
)
:: ==========================================================================
:: 4. Rebuild is opt-in.
:: ==========================================================================
set "BUILD_FLAG="
if /I "%AI_SANDBOX_REBUILD%"=="1" (
echo ==^> Rebuilding image ^(AI_SANDBOX_REBUILD=1^)
set "BUILD_FLAG=--build"
) else (
echo ==^> Using cached image ^(set AI_SANDBOX_REBUILD=1 to rebuild^)
)
:: ==========================================================================
:: 5. Port publishing is opt-in. `docker compose run` publishes the ports of
:: compose.yaml (loopback-only, verified above) only with --service-ports.
:: Off by default, so several sandboxes can run in parallel.
:: ==========================================================================
set "PORTS_FLAG="
if /I "%AI_SANDBOX_PORTS%"=="1" (
echo ==^> Publishing the compose ports on 127.0.0.1 ^(AI_SANDBOX_PORTS=1^)
set "PORTS_FLAG=--service-ports"
) else (
echo ==^> Ports not published ^(set AI_SANDBOX_PORTS=1 to publish them^)
)
:: ==========================================================================
:: 6. ai/skills is read-only in the container unless AI_SANDBOX_SKILLS_RW=1.
:: Always set the mode explicitly, so a value in ai\.env cannot change it.
:: setlocal keeps the variable out of the calling CMD session.
:: ==========================================================================
if "%AI_SANDBOX_SKILLS_RW%"=="1" (
echo ==^> ai/skills is writable in the sandbox ^(AI_SANDBOX_SKILLS_RW=1^)
echo Review every change to ai/skills before you commit or push it.
set "AI_SANDBOX_SKILLS_MODE=rw"
) else (
set "AI_SANDBOX_SKILLS_MODE=ro"
)
:: ==========================================================================
:: 7. Run. The forwarded command runs in the Linux container as given;
:: each argument in EXTRA_CMD keeps its own quotes.
:: ==========================================================================
echo ==^> Starting AI dev sandbox...
call docker compose -f "%COMPOSE_FILE%" run --rm %BUILD_FLAG% %PORTS_FLAG% ai-dev %EXTRA_CMD%
set "RC=%ERRORLEVEL%"
popd >nul
exit /b %RC%
:: ==========================================================================
:: Subroutines
:: ==========================================================================
:: :require_line <regex> <message>
:: Returns errorlevel 1 if the pattern is not found in %COMPOSE_FILE%.
:require_line
findstr /R /C:"%~1" "%COMPOSE_FILE%" >nul 2>&1
if errorlevel 1 (
echo ERROR: compose.yaml does not contain a line matching: %~1 1>&2
echo %~2 1>&2
exit /b 1
)
exit /b 0
:: :require_text <literal> <message>
:: Returns errorlevel 1 if the literal text is not found in %COMPOSE_FILE%.
:require_text
findstr /L /C:"%~1" "%COMPOSE_FILE%" >nul 2>&1
if errorlevel 1 (
echo ERROR: compose.yaml does not contain: %~1 1>&2
echo %~2 1>&2
exit /b 1
)
exit /b 0
:: :check_ports
:: Walks each line of compose.yaml. When inside the `ports:` block, every
:: `- "..."` entry must start with 127.0.0.1:.
:check_ports
setlocal EnableDelayedExpansion
set "IN_PORTS=0"
for /f "usebackq delims=" %%L in ("%COMPOSE_FILE%") do (
set "LINE=%%L"
set "STRIPPED=!LINE: =!"
if "!STRIPPED!"=="ports:" (
set "IN_PORTS=1"
) else (
if "!IN_PORTS!"=="1" (
if "!STRIPPED:~0,1!"=="-" (
set "ENTRY=!STRIPPED:~1!"
rem strip all double and single quotes
set "ENTRY=!ENTRY:"=!"
set "ENTRY=!ENTRY:'=!"
if "!ENTRY:~0,10!" NEQ "127.0.0.1:" (
echo ERROR: compose.yaml publishes a port on a non-loopback interface: !ENTRY! 1>&2
echo Every entry under 'ports:' must start with '127.0.0.1:'. 1>&2
endlocal & exit /b 1
)
) else if not "!STRIPPED!"=="" (
set "IN_PORTS=0"
)
)
)
)
endlocal & exit /b 0
+221
View File
@@ -0,0 +1,221 @@
# ai/run.ps1
# Entry point for the AkkudoktorEOS AI development sandbox (Windows PowerShell).
#
# Launcher:
# - Sanity-checks ai/compose.yaml for the main sandbox controls. This is not
# a complete validation; the protection of the sandbox definition is the
# read-only mount of ai/ in the container.
# - Forwards all arguments as the in-container command, never as docker
# compose options. PowerShell consumes an unquoted `--` itself, so `--`
# is optional: .\ai\run.ps1 claude or .\ai\run.ps1 -- claude
# - Optional write access to ai/skills (AI_SANDBOX_SKILLS_RW=1).
# - Requires every published port to bind to 127.0.0.1 (loopback on the host).
# - Optional rebuild (AI_SANDBOX_REBUILD=1); cached image otherwise.
# - Optional publishing of the compose ports (AI_SANDBOX_PORTS=1).
# - Windows: UID/GID bind-mount mapping does not apply, HOST_UID/HOST_GID
# fall back to the defaults from ai/.env.
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent $ScriptDir
Set-Location $RepoRoot
$ComposeFile = "ai/compose.yaml"
$EnvFile = "ai/.env"
$EnvExample = "ai/.env.example"
# ---------------------------------------------------------------------------
# Helper: hard exit with a message on stderr.
# ---------------------------------------------------------------------------
function Fail([string]$Message, [int]$Code = 1) {
[Console]::Error.WriteLine("ERROR: $Message")
exit $Code
}
# ---------------------------------------------------------------------------
# 0. Arguments: all of them form the in-container command. A leading '--'
# (passed quoted, or kept by some PowerShell versions) is dropped.
# Cast to string: PowerShell passes numbers (e.g. `-n 4`) as Int32.
# ---------------------------------------------------------------------------
$ExtraCmd = @($args | ForEach-Object { [string]$_ })
if ($ExtraCmd.Count -gt 0 -and $ExtraCmd[0] -eq '--') {
$ExtraCmd = @($ExtraCmd | Select-Object -Skip 1)
}
# A command never starts with '-': such an argument is a docker compose option
# (e.g. --build), which the launcher does not forward.
if ($ExtraCmd.Count -gt 0 -and $ExtraCmd[0].StartsWith('-')) {
Fail ("'$($ExtraCmd[0])' is not a command. Docker compose options cannot be passed; " +
"all arguments form the in-container command.`n " +
"Use AI_SANDBOX_REBUILD=1 to rebuild and AI_SANDBOX_PORTS=1 to publish the ports.") 2
}
# ---------------------------------------------------------------------------
# 1. Prerequisites.
# ---------------------------------------------------------------------------
Write-Host "==> Checking prerequisites..."
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
Fail "docker is not installed."
}
docker compose version *> $null
if ($LASTEXITCODE -ne 0) {
Fail "docker compose v2 is not available."
}
docker info *> $null
if ($LASTEXITCODE -ne 0) {
Fail "docker daemon is not reachable.`n - Is Docker Desktop running?`n - Try 'docker info' manually."
}
if (-not (Test-Path "pyproject.toml")) {
Fail "pyproject.toml not found; run from inside the AkkudoktorEOS repository."
}
if (-not (Test-Path $ComposeFile)) {
Fail "$ComposeFile missing."
}
# ---------------------------------------------------------------------------
# 2. Sanity-check the sandbox controls in compose.yaml.
# This catches accidental edits, not every weakening edit. The agent
# cannot edit compose.yaml from inside the sandbox (ai/ is read-only there);
# review changes to ai/ before you start the sandbox.
# ---------------------------------------------------------------------------
Write-Host "==> Checking sandbox controls in $ComposeFile ..."
$composeText = Get-Content -Raw -LiteralPath $ComposeFile
function Require-InCompose([string]$Pattern, [string]$Why) {
if ($composeText -notmatch $Pattern) {
Fail "compose.yaml does not contain: $Pattern`n $Why" 3
}
}
Require-InCompose '(?m)^\s*cap_drop:\s*$|(?m)^\s*cap_drop:\s*\[' 'Missing cap_drop (must drop ALL).'
Require-InCompose '(?m)^\s*-\s*"?ALL"?\s*$' 'cap_drop must include ALL.'
Require-InCompose 'no-new-privileges:\s*true' 'Missing no-new-privileges:true.'
Require-InCompose 'read_only:\s*true' 'Missing read_only:true.'
Require-InCompose '\.\./ai:/workspace/ai:ro' 'ai/ must be mounted read-only.'
# The docker socket (Linux path or Windows named pipe) must never be mounted.
if ($composeText -match '/var/run/docker\.sock') {
Fail "compose.yaml mounts the Docker socket. This is an escape vector." 3
}
if ($composeText -match '//\./pipe/docker_engine|\\\\\.\\pipe\\docker_engine') {
Fail "compose.yaml mounts the Docker named pipe. This is an escape vector." 3
}
# privileged must be false or absent.
if ($composeText -match 'privileged:\s*true') {
Fail "compose.yaml sets privileged:true; refusing to start." 3
}
# network_mode: host must never be used.
if ($composeText -match 'network_mode:\s*"?host"?') {
Fail "compose.yaml sets network_mode: host; refusing to start." 3
}
# Every published port MUST bind to 127.0.0.1.
if ($composeText -match '(?m)^\s*ports:\s*$') {
$portBlock = [regex]::Match(
$composeText,
'(?m)^[ \t]*ports:[ \t]*\r?\n((?:^[ \t]*-[^\r\n]*(?:\r?\n|\z))+)'
)
if ($portBlock.Success) {
$entries = [regex]::Matches($portBlock.Groups[1].Value, '(?m)^\s*-\s*(.+?)\s*$')
foreach ($m in $entries) {
$raw = $m.Groups[1].Value.Trim().Trim('"').Trim("'")
if (-not $raw.StartsWith('127.0.0.1:')) {
Fail "compose.yaml publishes a port on a non-loopback interface: '$raw'.`n Every entry under 'ports:' must start with '127.0.0.1:'." 3
}
}
}
}
# ---------------------------------------------------------------------------
# 3. Ensure ai/.env exists. HOST_UID/HOST_GID do not apply on Windows (bind
# mounts via Docker Desktop ignore them); the defaults in ai/.env stand.
# ---------------------------------------------------------------------------
if (-not (Test-Path $EnvFile)) {
if (Test-Path $EnvExample) {
Write-Host "==> Creating $EnvFile from $EnvExample"
Copy-Item -LiteralPath $EnvExample -Destination $EnvFile -Force
} else {
Write-Host "==> Creating empty $EnvFile"
New-Item -ItemType File -Path $EnvFile -Force | Out-Null
}
}
# ---------------------------------------------------------------------------
# 4. Rebuild is opt-in.
# ---------------------------------------------------------------------------
$BuildFlag = @()
if ($env:AI_SANDBOX_REBUILD -eq '1') {
Write-Host "==> Rebuilding image (AI_SANDBOX_REBUILD=1)"
$BuildFlag = @('--build')
} else {
Write-Host "==> Using cached image (set AI_SANDBOX_REBUILD=1 to rebuild)"
}
# ---------------------------------------------------------------------------
# 5. Port publishing is opt-in. `docker compose run` publishes the ports of
# compose.yaml (loopback-only, verified above) only with --service-ports.
# Off by default, so several sandboxes can run in parallel.
# ---------------------------------------------------------------------------
$PortsFlag = @()
if ($env:AI_SANDBOX_PORTS -eq '1') {
Write-Host "==> Publishing the compose ports on 127.0.0.1 (AI_SANDBOX_PORTS=1)"
$PortsFlag = @('--service-ports')
} else {
Write-Host "==> Ports not published (set AI_SANDBOX_PORTS=1 to publish them)"
}
# ---------------------------------------------------------------------------
# 6. ai/skills is read-only in the container unless AI_SANDBOX_SKILLS_RW=1.
# Always set the mode explicitly, so a value in ai/.env cannot change it.
# ---------------------------------------------------------------------------
# $env: changes outlive the script in the calling session; restored below.
$PrevSkillsMode = $env:AI_SANDBOX_SKILLS_MODE
if ($env:AI_SANDBOX_SKILLS_RW -eq '1') {
Write-Host "==> ai/skills is writable in the sandbox (AI_SANDBOX_SKILLS_RW=1)"
Write-Host " Review every change to ai/skills before you commit or push it."
$env:AI_SANDBOX_SKILLS_MODE = 'rw'
} else {
$env:AI_SANDBOX_SKILLS_MODE = 'ro'
}
# ---------------------------------------------------------------------------
# 7. Run.
# ---------------------------------------------------------------------------
Write-Host "==> Starting AI dev sandbox..."
$dockerArgs = @(
'compose',
'-f', $ComposeFile,
'run', '--rm'
) + $BuildFlag + $PortsFlag + @('ai-dev') + $ExtraCmd
$rc = $null
$PrevErrorAction = $ErrorActionPreference
try {
# Windows PowerShell 5.1 turns native stderr into error records when the
# script's output is redirected (e.g. .\ai\run.ps1 2>&1 | Tee-Object log.txt);
# with "Stop" the first status line of docker compose would abort the script.
$ErrorActionPreference = "Continue"
& docker @dockerArgs
$rc = $LASTEXITCODE
} finally {
$ErrorActionPreference = $PrevErrorAction
$env:AI_SANDBOX_SKILLS_MODE = $PrevSkillsMode
}
# Preserve docker compose's exit code, but if docker itself wasn't invoked
# (e.g. Parse error) we still exit non-zero.
if ($null -eq $rc) { $rc = 1 }
exit $rc
Executable
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env bash
# ai/run.sh
# Entry point for the AkkudoktorEOS AI development sandbox (Linux/macOS).
#
# Launcher:
# - Sanity-checks ai/compose.yaml for the main sandbox controls. This is not
# a complete validation; the protection of the sandbox definition is the
# read-only mount of ai/ in the container.
# - Only forwards arguments after `--` as the in-container command, never
# as docker compose options.
# - Requires every published port to bind to 127.0.0.1 (loopback on the host).
# - Optional rebuild (AI_SANDBOX_REBUILD=1); cached image otherwise.
# - Optional publishing of the compose ports (AI_SANDBOX_PORTS=1).
# - Optional write access to ai/skills (AI_SANDBOX_SKILLS_RW=1).
# - Regenerates ai/.env atomically with HOST_UID/HOST_GID and mode 0600.
# - Works with bash 3.2 (macOS /bin/bash).
set -Eeuo pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
COMPOSE_FILE="ai/compose.yaml"
ENV_FILE="ai/.env"
ENV_EXAMPLE="ai/.env.example"
trap 'echo "ERROR: ai/run.sh failed at line $LINENO (exit $?)." >&2' ERR
# ---------------------------------------------------------------------------
# 0. Arguments. Docker compose options cannot be passed; everything after
# `--` is the command to run in the container.
# ---------------------------------------------------------------------------
EXTRA_CMD=()
if [[ $# -gt 0 ]]; then
if [[ "$1" != "--" ]]; then
echo "ERROR: extra arguments must be supplied after '--' as the in-container command." >&2
echo " Example: ./ai/run.sh -- bash -lc 'pytest -q'" >&2
exit 2
fi
shift
EXTRA_CMD=("$@")
fi
# ---------------------------------------------------------------------------
# 1. Prerequisites.
# ---------------------------------------------------------------------------
echo "==> Checking prerequisites..."
command -v docker >/dev/null || { echo "ERROR: docker not installed." >&2; exit 1; }
docker compose version >/dev/null 2>&1 \
|| { echo "ERROR: docker compose v2 not available." >&2; exit 1; }
if ! docker info >/dev/null 2>&1; then
echo "ERROR: docker daemon not reachable." >&2
echo " - Is the daemon running? (systemctl status docker)" >&2
echo " - Is your user in 'docker' group? (groups)" >&2
exit 1
fi
[[ -f pyproject.toml ]] \
|| { echo "ERROR: pyproject.toml not found; run from inside the repo." >&2; exit 1; }
[[ -f "$COMPOSE_FILE" ]] \
|| { echo "ERROR: $COMPOSE_FILE missing." >&2; exit 1; }
# ---------------------------------------------------------------------------
# 2. Sanity-check the sandbox controls in compose.yaml.
# This catches accidental edits, not every weakening edit. The agent
# cannot edit compose.yaml from inside the sandbox (ai/ is read-only there);
# review changes to ai/ before you start the sandbox.
# ---------------------------------------------------------------------------
echo "==> Checking sandbox controls in $COMPOSE_FILE ..."
compose_text="$(cat "$COMPOSE_FILE")"
require_in_compose() {
local needle="$1" why="$2"
if ! grep -Eq -- "$needle" <<<"$compose_text"; then
echo "ERROR: compose.yaml does not contain: $needle" >&2
echo " $why" >&2
exit 3
fi
}
require_in_compose 'cap_drop:[[:space:]]*$|cap_drop:[[:space:]]*\[' 'Missing cap_drop (must drop ALL).'
require_in_compose '-\s*"?ALL"?|\[[[:space:]]*"?ALL"?[[:space:]]*\]' 'cap_drop must include ALL.'
require_in_compose 'no-new-privileges:true|no-new-privileges:[[:space:]]*true' 'Missing no-new-privileges:true.'
require_in_compose 'read_only:[[:space:]]*true' 'Missing read_only:true.'
require_in_compose '\.\./ai:/workspace/ai:ro' 'ai/ must be mounted read-only.'
# The docker socket must never be mounted.
if grep -Eq '/var/run/docker\.sock' <<<"$compose_text"; then
echo "ERROR: compose.yaml mounts the Docker socket. This is an escape vector." >&2
exit 3
fi
# privileged must be false or absent.
if grep -Eq 'privileged:[[:space:]]*true' <<<"$compose_text"; then
echo "ERROR: compose.yaml sets privileged:true; refusing to start." >&2
exit 3
fi
# network_mode: host must never be used.
if grep -Eq 'network_mode:[[:space:]]*"?host"?' <<<"$compose_text"; then
echo "ERROR: compose.yaml sets network_mode: host; refusing to start." >&2
exit 3
fi
# Every published port MUST bind to 127.0.0.1. Ports are allowed here because
# the sandbox is used for local testing; the constraint is that nothing inside
# becomes reachable from the LAN/VPN.
if grep -Eq '^[[:space:]]*ports:' <<<"$compose_text"; then
if ! awk '
/^[[:space:]]*ports:[[:space:]]*$/ { inports=1; next }
inports && /^[[:space:]]*-/ {
line=$0
sub(/^[[:space:]]*-[[:space:]]*/, "", line)
gsub(/["'"'"']/, "", line)
if (line !~ /^127\.0\.0\.1:/) {
print "OFFENDING: " line > "/dev/stderr"
bad=1
}
next
}
inports && !/^[[:space:]]*-/ && !/^[[:space:]]*$/ { inports=0 }
END { exit bad ? 1 : 0 }
' <<<"$compose_text"; then
echo "ERROR: compose.yaml publishes a port on a non-loopback interface." >&2
echo " Every entry under 'ports:' must start with '127.0.0.1:'." >&2
echo " Change e.g. \"8503:8503\" to \"127.0.0.1:8503:8503\"." >&2
exit 3
fi
fi
# ---------------------------------------------------------------------------
# 3. Regenerate ai/.env atomically (mode 0600). Do not leak .bak files.
# ---------------------------------------------------------------------------
if [[ ! -f "$ENV_FILE" ]]; then
if [[ -f "$ENV_EXAMPLE" ]]; then
echo "==> Creating $ENV_FILE from $ENV_EXAMPLE"
install -m 0600 "$ENV_EXAMPLE" "$ENV_FILE"
else
echo "==> Creating empty $ENV_FILE"
: >"$ENV_FILE"
chmod 0600 "$ENV_FILE"
fi
fi
chmod 0600 "$ENV_FILE"
HOST_UID="$(id -u)"
HOST_GID="$(id -g)"
tmp_env="$(mktemp "${ENV_FILE}.XXXXXX")"
trap 'rm -f "$tmp_env"' EXIT
{
grep -Ev '^HOST_(UID|GID)=' "$ENV_FILE" || true
printf 'HOST_UID=%s\n' "$HOST_UID"
printf 'HOST_GID=%s\n' "$HOST_GID"
} >"$tmp_env"
mv -f "$tmp_env" "$ENV_FILE"
chmod 0600 "$ENV_FILE"
trap 'echo "ERROR: ai/run.sh failed at line $LINENO (exit $?)." >&2' ERR
# ---------------------------------------------------------------------------
# 4. Rebuild is opt-in. A tampered Dockerfile/entrypoint cannot be silently
# rebuilt by a wrapper invocation.
# ---------------------------------------------------------------------------
BUILD_FLAG=()
if [[ "${AI_SANDBOX_REBUILD:-0}" == "1" ]]; then
echo "==> Rebuilding image (AI_SANDBOX_REBUILD=1)"
BUILD_FLAG=(--build)
else
echo "==> Using cached image (set AI_SANDBOX_REBUILD=1 to rebuild)"
fi
# ---------------------------------------------------------------------------
# 5. Port publishing is opt-in. `docker compose run` publishes the ports of
# compose.yaml (loopback-only, verified above) only with --service-ports.
# Off by default, so several sandboxes can run in parallel.
# ---------------------------------------------------------------------------
PORTS_FLAG=()
if [[ "${AI_SANDBOX_PORTS:-0}" == "1" ]]; then
echo "==> Publishing the compose ports on 127.0.0.1 (AI_SANDBOX_PORTS=1)"
PORTS_FLAG=(--service-ports)
else
echo "==> Ports not published (set AI_SANDBOX_PORTS=1 to publish them)"
fi
# ---------------------------------------------------------------------------
# 6. ai/skills is read-only in the container unless AI_SANDBOX_SKILLS_RW=1.
# Always set the mode explicitly, so a value in ai/.env cannot change it.
# ---------------------------------------------------------------------------
if [[ "${AI_SANDBOX_SKILLS_RW:-0}" == "1" ]]; then
echo "==> ai/skills is writable in the sandbox (AI_SANDBOX_SKILLS_RW=1)"
echo " Review every change to ai/skills before you commit or push it."
export AI_SANDBOX_SKILLS_MODE=rw
else
export AI_SANDBOX_SKILLS_MODE=ro
fi
# ---------------------------------------------------------------------------
# 7. Run. Use exec so signals reach docker compose directly.
# ${arr[@]+"${arr[@]}"} expands an empty array safely under `set -u` in
# bash < 4.4 (macOS /bin/bash).
# ---------------------------------------------------------------------------
echo "==> Starting AI dev sandbox (UID=$HOST_UID, GID=$HOST_GID)..."
exec docker compose -f "$COMPOSE_FILE" run --rm \
${BUILD_FLAG[@]+"${BUILD_FLAG[@]}"} \
${PORTS_FLAG[@]+"${PORTS_FLAG[@]}"} \
ai-dev \
${EXTRA_CMD[@]+"${EXTRA_CMD[@]}"}
@@ -0,0 +1,13 @@
---
name: application-configuration
description: Use when changing configuration models, defaults, persisted formats, external input validation, or migration behavior.
---
# Configuration and Compatibility
- Follow existing Pydantic and configuration-management patterns.
- Validate external input at appropriate boundaries.
- Preserve established configuration behavior and migration compatibility.
- Do not silently change defaults, persisted configuration formats, or public
API behavior.
- Update tests and documentation when configuration behavior changes.
+195
View File
@@ -0,0 +1,195 @@
---
name: commit-messages
description: Use when writing, amending, or reviewing a git commit message in this repository. Enforces Conventional Commits (required by the project) with an optional body section for secondary changes/fixes.
---
# Commit Messages
## When to use
- Writing a new commit message.
- Amending an existing commit message.
- Reviewing or proposing commit messages in a PR description.
- Any agent-generated commit.
## Format
The project uses **Conventional Commits** and enforces them via `commitizen`
(`cz check`). Every commit message must follow this structure:
```text
<type>[(scope)][!]: <description>
[optional body]
[optional footer(s)]
```
The subject line is mandatory. The body is optional and is the place
for secondary changes, fixes, or improvements that are not the primary point
of the commit.
1. Subject (mandatory)
Format: <type>[(scope)][!]: <description>
- type — one of the project-accepted types (see table below).
- scope — optional, in parentheses, e.g. (config), (forecast).
- ! — append after type/scope only for a breaking change.
- description — short, imperative, present tense ("add …", "fix …").
No trailing period. Aim for ≤ 72 characters; hard limit 100.
| Type | Use for | SemVer bump |
|---|---|---|
| `feat` | User-visible feature | minor |
| `fix` | Bug fix in shipped behavior | patch |
| `perf` | Performance improvement | patch |
| `refactor` | Internal restructure, no behavior change | patch |
| `docs` | Documentation only | none |
| `test` | Tests only, no production code change | none |
| `build` | Build system / dependency pins | none |
| `ci` | CI configuration | none |
| `chore` | Maintenance not covered above | none |
| `style` | Pure formatting/whitespace | none |
| `revert` | Revert a previous commit | none |
Pick the type that matches the primary intent of the commit. Split commits
that mix unrelated concerns.
2. Body (optional — for secondary changes)
Use the body when the commit also contains secondary bug fixes, required
extensions, or small improvements that are not part of the primary change
described in the subject.
Rules:
- Separate the subject and body with exactly one blank line.
- Start the body with a short heading line:
```text
Additional changes
```
or, when more accurate:
```text
Additional fixes — secondary bug fixes.
Additional improvements — secondary refactors/cleanups.
```
- Pick the heading that best reflects the majority of secondary changes. Do
not stack multiple headings.
- Follow the heading with a short description of each secondary change.
- One short line (or bullet) per distinct secondary change.
- Do not restate the primary change in the body.
- Keep the body free of other headings, emoji, or issue-closing keywords
(Closes #…) unless the footer is intentionally used for that purpose.
3. Footer (optional)
Use only for breaking changes (BREAKING CHANGE: …) or issue references
(Refs: #123). Conventional Commits allows this; it is not a replacement for
the body section.
## Layout
```text
<type>[(scope)][!]: <description>
[Additional changes]
<short description of secondary change>
[<another short description>]
[optional footer]
```
- Exactly one blank line between the subject and the body heading.
- No blank line between the body heading and its items.
- Exactly one blank line between the body and any footer.
## Examples
### Subject only
```text
feat(config): add filename linter check to pre-commit hook
```
### Subject + additional fixes in body
```text
feat(config): add filename linter check to pre-commit hook
Additional fixes
Corrected import path in scripts/lint_filenames.py
Removed duplicate entry from .pre-commit-config.yaml
```
### Subject + additional improvements in body
```text
refactor(forecast): share HTTP session across adapter instances
Additional improvements
Extracted shared retry helper
Tightened docstrings on public forecast methods
```
### Subject + breaking change footer
```text
feat(config)!: rename surface_azimuth to azimuth for all surfaces
BREAKING CHANGE: north=0, east=90, south=180, west=270.
```
### Incorrect
```text
Add filename linter
```
(Missing required <type>: prefix — cz check will reject it.)
```text
feat: add filename linter and fix imports and bump deps
```
(Multiple unrelated changes in the subject; move secondary changes to the body
and use a subject that describes the primary intent.)
```text
feat: add filename linter
* Additional fixes
Corrected import path
```
(Asterisk-prefixed heading is not Conventional Commits style and will look
inconsistent with the rest of the log. Use a plain heading line in the body,
as shown above.)
## Decision rules
- If everything in the commit serves the subject → omit the body.
- If the commit contains an unrelated bug fix or a necessary-but-secondary
extension → add the body section.
- If the body would need two or more different headings → the commit is
probably too broad; recommend splitting it into focused commits.
- If unsure whether a change is "primary" or "secondary": ask "would a reviewer
describe this commit primarily in terms of change X?" If yes, X is the
subject and everything else goes into the body.
## Verify before committing
□ Subject matches <type>[(scope)][!]: <description> and uses an accepted type.
□ Subject is imperative, no trailing period, ≤ 72 chars (hard limit 100).
□ Exactly one blank line between subject and body.
□ At most one body heading (Additional changes / Additional fixes / Additional improvements).
□ Each secondary item is a short single-line description.
□ No emoji, no asterisk-prefixed headings.
□ If more than one body heading would be needed, split the commit instead.
□ Run uv run cz check --rev-range main..HEAD (or rely on the pre-commit hook) to confirm the
message passes the project's check.
+19
View File
@@ -0,0 +1,19 @@
---
name: energy-domain
description: Use when working on optimization logic, adapters, controllers, device communication, or power/energy units and sign conventions.
---
# Energy Management and Device Integration
- Keep optimization logic separate from device-specific communication where
practical.
- Follow existing adapter and controller interfaces.
- Respect the distinction between long-horizon energy optimization and fast
local device control.
- Handle communication failures, device availability, and invalid measurements
explicitly.
- Avoid introducing cloud dependencies into workflows intended to operate
locally.
- **Preserve existing energy sign conventions, units, and conversion
semantics.** Verify them before changing power, energy, or charging
behavior.
+62
View File
@@ -0,0 +1,62 @@
---
name: filename-conventions
description: Use when creating, renaming, or proposing any new file in this repository. Mandatory filename rules derived from [tool.eos-filename-linter] in pyproject.toml.
---
# Filename Conventions
**Filename conventions are mandatory.** All new and renamed files must comply
with the rules configured in `pyproject.toml`. The custom filename linter is the
authoritative source; if this document and the configuration appear
inconsistent, follow the configuration.
## Procedure
1. Inspect `[tool.eos-filename-linter]` in `pyproject.toml`.
2. Find the first matching rule for the file's repository-relative path.
3. Apply that rule's regex, `allowed_names`, exclusions, and max length.
4. Do not invent alternative conventions or infer acceptability from valid Python.
5. Do not weaken the linter configuration to accommodate a filename.
## Python source modules (`src/akkudoktoreos/`)
- Follow the configured pattern for that directory.
- Where lowercase-alphanumeric is required: lowercase letters and digits only,
no underscores or hyphens.
- Preserve listed `allowed_names` (e.g. `__init__.py`).
- Do not rename existing modules for stylistic preference.
## Pytest modules (`tests/`)
Where the rule requires `test_` + lowercase alphanumerics:
- Start with `test_`, then lowercase letters/digits only.
- No underscores or hyphens after the prefix.
- Keep `.py`.
- Respect configured exceptions/exclusions.
- If a separate rule applies (e.g. `single_test_*.py`), follow that rule.
| Valid | Invalid |
|---|---|
| `test_adapterdevice.py` | `test_adapter_device.py` |
| `test_geneticforecastcoverage.py` | `test_genetic_forecast_coverage.py` |
| `test_invertercontrol.py` | `test_inverter-control.py` |
## Python scripts (`scripts/`)
Where lowercase snake_case is required:
- Lowercase letters and digits, underscores to separate words, no hyphens, `.py`.
- Valid: `lint_filenames.py`
- Invalid: `lint-filenames.py`, `LintFilenames.py`
## Filename length
Respect the max length from the applicable rule, else the global default in
`pyproject.toml` / linter. The limit includes the extension.
## Verify
```bash
uv run python scripts/lint_filenames.py
uv run python scripts/lint_filenames.py path/to/file.py
+59
View File
@@ -0,0 +1,59 @@
---
name: python-environment
description: Use before running any Python, uv, pytest, mypy, ruff, pre-commit or make command. Keeps the agent's virtual environment out of the workspace .venv.
---
# Python Environment
## Why
The workspace may be shared with the developer's machine (e.g. a mounted
directory or a container). The workspace `.venv` then belongs to the developer
and is bound to an interpreter on their machine, which does not exist for the
agent. `uv` treats such a `.venv` as broken: it deletes and recreates it for
the agent's interpreter, which breaks the developer's environment, and the
developer's next `uv` call breaks the agent's in turn (e.g. dev tools such as
pytest or mypy go missing).
## Rules
- **Never use, create, modify or delete the workspace `.venv`.**
- Use a virtual environment outside the workspace, selected by
`UV_PROJECT_ENVIRONMENT`. In the AI development sandbox (`ai/`) it is
already set for every shell and tool call; check with
`echo "$UV_PROJECT_ENVIRONMENT"`.
- If it is not set, set it in **every** shell command that runs `uv` or
`make`, e.g.
`export UV_PROJECT_ENVIRONMENT="$HOME/.cache/eos-venv" && <command>`.
Exported variables do not carry over between separate tool calls. Put the
environment on a disk with enough space (it needs several hundred MB), not
on a small tmpfs such as `/tmp`.
- It applies to all commands that run Python through `uv`:
`uv sync`, `uv run ...` (pytest, mypy, python scripts, `pre-commit`), and the
`make` targets that use `uv run` (`make mypy`, `make gen-docs`, `make test`,
...).
- Create or update the environment with the dev tools:
```bash
uv sync --extra dev
```
A plain `uv sync` (without `--extra dev`) removes pytest, mypy and the other
dev tools from the environment.
- Do not let a newer local `uv` rewrite `uv.lock`: use `uv sync --frozen` /
`uv run --frozen`. When the lock must change (dependencies changed), lock with
the `uv` version CI uses (`.github/workflows/pre-commit.yml`), e.g.
`uv tool run --from uv==<version> uv lock`, and check that `revision` in
`uv.lock` did not change.
- Do not run commands that remove the workspace `.venv`, e.g. `make clean`
(`rm -rf build .venv`) or `rm -rf .venv`.
## Verify
- `uv run python -c "import sys; print(sys.prefix)"` prints the environment
outside the workspace, not `<workspace>/.venv`.
- If `uv` reports `Removed virtual environment at: .venv` or
`Ignoring existing virtual environment linked to non-existent Python
interpreter`, the variable was not set: the workspace `.venv` was rebuilt.
Do not try to repair it; tell the developer to run `uv sync --extra dev` on
their machine.
+17
View File
@@ -0,0 +1,17 @@
---
name: python-quality
description: Use when writing or modifying Python code. Enforces Ruff, mypy, typing and tooling conventions of this project.
---
# Python Quality and Type Checking
These rules add to the rules for every change in `AGENTS.md` (typing,
docstrings, naming, `assert`, logging, dependencies).
- Follow the project's Ruff configuration for lint and format.
- Keep compatible with the project's mypy configuration; do not add new type errors.
- Prefer explicit, narrow types over `Any`.
- Handle optional values and error cases explicitly.
- Keep abstractions proportionate; avoid unnecessary complexity.
- Use the project's configured tools; do not introduce competing formatters or
linters.
+60
View File
@@ -0,0 +1,60 @@
---
name: rest-api
description: Use when adding or changing EOS REST endpoints (FastAPI, `src/akkudoktoreos/server/eos.py` and `src/akkudoktoreos/server/rest/`). Enforces RFC 7807 problem details via EOSProblem for all errors.
---
# REST API
## When to use
- Adding or changing an endpoint in `server/eos.py` or a router in
`server/rest/`.
- Changing how an endpoint reports errors.
## Errors
EOS answers errors as RFC 7807 problem details (`application/problem+json`
with `type`, `title`, `status`, `detail`, `instance`), see
`src/akkudoktoreos/server/rest/error.py`.
- Raise `EOSProblem`, **never** FastAPI's `HTTPException`:
```python
from akkudoktoreos.server.rest.error import EOSProblem
try:
value = get_config().get_nested_value(path)
except KeyError as exc:
raise EOSProblem(status=404, title="Config value not found", detail=str(exc)) from exc
```
- `title`: short, stable summary of the kind of problem, the same for all
occurrences (e.g. "Error on cache clear", "Total load prediction failed"). `detail`:
the specific message for this occurrence.
- Chain the original exception (`raise ... from exc`).
- Pass `cause=exc` only for server-side failures (5xx, unexpected errors in
EOS): the handler logs the cause with its traceback. Leave it out for client
errors (bad input, unknown names) so they do not flood the log.
- Do not catch broad exceptions just to convert them; unexpected exceptions are
already answered as 500 problem details by the registered handler.
- `HTTPException` is only still handled for errors raised by FastAPI and
Starlette themselves (e.g. unknown routes).
## Tests
- Tests that build their own app around a router must register the handlers,
otherwise an `EOSProblem` surfaces as an exception in the test client:
```python
app = FastAPI()
register_problem_handlers(app)
app.include_router(router)
```
- Check the status code of error responses; check the problem body where the
`title` or `detail` matters to clients.
## Verify
- `docs/_generated/openapi.md` and `openapi.json` are generated from the
endpoints; regenerate them (`make gen-docs`) after endpoint changes.
+30
View File
@@ -0,0 +1,30 @@
---
name: testing
description: Use when adding or updating pytest tests, fixing bugs, or changing behavior that needs test coverage.
---
# Testing
## When to use
- Any behavior change or bug fix.
## Rules
- **Prefer adding tests to existing test classes in existing test modules.**
Extend the relevant class/module rather than creating new files.
- Create a new test module only when:
- functionality is a distinct aspect that does not fit any existing module, or
- it targets a different component/test target.
- Follow existing conventions, fixtures, and markers.
- Prefer deterministic, isolated tests; mock external services/devices.
- Cover boundary conditions and error paths.
- Do not weaken tests to accommodate implementation defects.
## Verify
- Run the relevant pytest tests and checks with the project's configured
commands and environment before declaring the change complete.
- Run a full test run:
- Only one run at a time. Stop any running full test run before start of a new.
- Run the equivalent of make gen-docs before a full run.
+395
View File
@@ -0,0 +1,395 @@
% SPDX-License-Identifier: Apache-2.0
(ai-page)=
# AI-Assisted Development
EOS can be developed together with an AI coding agent such as
[Claude Code](https://docs.anthropic.com/en/docs/claude-code),
[OpenCode](https://opencode.ai/) or [Qwen Code](https://github.com/QwenLM/qwen-code).
The agent reads and edits the code, runs the tests and checks, and writes commits. You stay in
charge: you choose the task, review every change and decide what is pushed and proposed upstream.
This page describes the workflow and how to run it in the AI development sandbox that ships with
the repository in the `ai/` directory.
(ai-coding-workflow)=
## The AI coding workflow
### Overview
```text
host sandbox container (ai/run.sh)
───────────────────────────────── ─────────────────────────────────────────
1. create a feature branch
2. start the sandbox ───────▶ 3. start an agent (claude, opencode, qwen)
4. describe the task, agree on a plan
5. watch the changes in your ◀─────── 6. agent edits code, adds tests,
IDE, answer questions runs pytest, ruff, mypy, linters
7. review the diff ◀─────── 8. agent commits (Conventional Commits)
9. push, open a pull request
```
The repository on your machine is mounted into the container. Every change the agent makes is
visible on the host immediately, and every change you make on the host is visible to the agent.
There is no copy to synchronize.
### Project rules for agents
Agents get their project knowledge from two places in the repository:
- `AGENTS.md` in the repository root is the entry point. It gives a short project summary, the
rules for every change, the index of the skills and the completion checklist. Claude Code and
OpenCode read it automatically when they start in the repository. If an agent does not, ask it
to read `AGENTS.md` first.
- `ai/skills/<skill>/SKILL.md` holds the detailed rules, one skill per topic. The agent loads a
skill when the task needs it, as listed in `AGENTS.md`:
| Skill | Content |
|---|---|
| `python-environment` | How the agent runs `uv`, `make`, pytest, mypy and ruff without breaking your `.venv` |
| `python-quality` | Ruff, mypy, typing and docstring rules |
| `testing` | Where and how to add tests |
| `filename-conventions` | File name rules enforced by `scripts/lint_filenames.py` |
| `application-configuration` | Configuration models, defaults and migration |
| `energy-domain` | Optimization, adapters, units and sign conventions |
| `rest-api` | REST endpoints and error responses |
| `commit-messages` | Conventional Commits as checked by `commitizen` |
The skills are not registered as agent-specific skills (e.g. in `.claude/skills/`). The agent reads
them because `AGENTS.md` tells it to, which works the same for every agent.
When you notice that an agent repeatedly gets something wrong, add or sharpen the rule in the
matching skill instead of correcting the agent again in every session. Changes to the skills are
reviewed and committed like code. `ai/skills` is read-only in the sandbox. To let the agent edit
the skills, start the sandbox with `AI_SANDBOX_SKILLS_RW=1`.
### Working with the agent
1. **Start from a clean branch.** Create a branch for the task on the host, e.g.
`git switch -c feat/my-feature eos/main`. Commit or stash unrelated work first, so the agent's
changes are easy to tell apart.
2. **Describe the task precisely.** Name the goal, the affected component and the constraints,
and point the agent to relevant files, issues or error messages. Paste tracebacks in full.
3. **Plan before larger changes.** For anything beyond a small fix, ask the agent for a short
design note or plan first and agree on it before code is written. Claude Code has a plan mode
(`Shift+Tab`) for this.
4. **Let the agent verify its work.** The skills require the agent to add tests and to run
pytest, ruff, mypy and the filename linter. Ask for the results if they are not reported.
5. **Watch and review.** Open the repository in your IDE on the host. You can follow the edits
live, answer the agent's questions with full knowledge of the code, run the result yourself and
change things by hand. Review the complete diff before you open or update a pull request, as
described in [](#ai-review-checklist). You are the author of what goes upstream.
6. **Commit in small steps.** Let the agent commit each logical step with a Conventional Commits
message (see the `commit-messages` skill), or commit yourself. Check the messages with
`make gitlint`.
7. **Push and open the pull request from the host.** Then the sandbox needs no GitHub
credentials at all. See [](#ai-sandbox-github) if you want the agent to push.
## The AI development sandbox
An AI agent runs commands on your behalf, and it can be misled, for example by instructions hidden
in a downloaded file or web page. The sandbox limits what such a command can reach. It is a Docker
container that sees only the repository and its own home volume.
:::{warning}
The sandbox does not protect your repository from harmful changes by the agent. The repository is
mounted read-write, so the agent can write any change into it, and such a change takes effect when
you run tests, `make`, pre-commit or EOS on the host, or when it is pushed. **Check every change
the agent made before you run it on the host, open a pull request or update one.** See
[](#ai-review-checklist).
:::
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) with Docker Compose v2 (`docker compose version`).
On Linux, your user must be allowed to use the Docker daemon (member of the `docker` group).
- A normal clone of the repository. Git worktree checkouts are not supported: their `.git` is a
file that points outside the repository, so git does not work in the sandbox.
- On Windows: a checkout with LF line endings. The repository's `.gitattributes` checks out all
text files with LF (only `*.bat` and `*.cmd` with CRLF), whatever `core.autocrlf` says, so the
shell scripts run in the container and git in the container sees the same changes as git on
the host. A clone made before this setting still has CRLF files; commit or stash your work and
check out all files again: `git rm -r -q --cached .` and then `git reset -q --hard`.
- An account for at least one agent: an Anthropic subscription or API key for Claude Code, an
account with any provider supported by OpenCode (including local models), or a Qwen account.
### Start the sandbox
Run the launcher from the repository root:
| Host | Command |
|---|---|
| Linux, macOS | `./ai/run.sh` (once before: `chmod +x ai/run.sh`) |
| Windows PowerShell | `.\ai\run.ps1` |
| Windows CMD | `ai\run.bat` |
The launcher
1. checks that Docker and Docker Compose are available,
2. sanity-checks that `ai/compose.yaml` contains the main sandbox controls and refuses to start
otherwise,
3. writes `ai/.env` (from `ai/.env.example`) with your user and group id, so files the agent
creates belong to you,
4. starts a new container and opens a `bash` shell in `/workspace`, the repository.
The first start builds the image. Later starts reuse it. Rebuild the image to get new versions of
the agent CLIs or after changing `ai/Dockerfile`:
```bash
AI_SANDBOX_REBUILD=1 ./ai/run.sh
```
To run a single command instead of a shell, put it after `--`:
```bash
./ai/run.sh -- claude
./ai/run.sh -- bash -lc 'git status'
```
PowerShell consumes an unquoted `--` itself, so there it is optional: `.\ai\run.ps1 claude`.
The launchers forward all arguments as the command; they do not take Docker Compose options such
as `--build` (use the variables described here instead).
On Windows, prefer PowerShell 7 (`pwsh`): it passes quotes and the characters `|`, `&`, `!` and
`%` to the container unchanged. Windows PowerShell 5.1 does not pass embedded double quotes on to
native commands, so put double quotes outside and single quotes inside:
`.\ai\run.ps1 bash -lc "pytest -k 'a and b'"`. `ai\run.bat` passes each argument with its own
quotes, so `|`, `&`, `<` and `>` inside a quoted argument reach the container, but `!` and `%`
cannot be passed through `run.bat` at all; use `run.ps1` for commands that contain them.
To let the agent edit the skills in `ai/skills`, which are read-only in the sandbox by default,
start it with `AI_SANDBOX_SKILLS_RW=1`. The rest of `ai/` stays read-only.
Every call starts a fresh container that is removed on exit, so you can run several sandboxes in
parallel, e.g. one agent per terminal. Note that they all work on the same repository and branch.
To reach servers in the sandbox from the host, start it with `AI_SANDBOX_PORTS=1` (see
[](#ai-sandbox-servers)).
To give commits made in the sandbox your identity, export it before starting the sandbox:
```bash
export GIT_USER_NAME="Your Name"
export GIT_USER_EMAIL="you@example.com"
./ai/run.sh
```
### Log in to the agents
The image contains Claude Code (`claude`), OpenCode (`opencode`), Qwen Code (`qwen`) and the GitHub
CLI (`gh`). Log in once, the login is kept in the sandbox home volume (see
[](#ai-sandbox-persistence)).
**Claude Code**: start `claude`. On the first start a setup assistant asks for the color theme and
the login method. With a Claude subscription choose "Claude account with subscription", with an
Anthropic API key choose "Anthropic Console account". Claude Code shows a login link. Press `c` to
copy it, open it in a browser on the host, log in, and paste the code the browser shows back into
the terminal. When the login has expired, run `/login` in a Claude Code session.
**OpenCode**: start `opencode` and run `/connect` in it, or run `opencode auth login` in the shell.
Choose the provider, e.g. OpenAI, Anthropic with an API key, or a local model.
**Qwen Code**: start `qwen` and follow its login prompt.
### Start the agent
Start the agent in `/workspace`, the repository root, so it finds `AGENTS.md`:
```bash
claude # or: opencode, qwen
```
Then work with it as described in [](#ai-coding-workflow).
### Commands in the sandbox
The sandbox contains Python 3.13, `uv`, Node.js, `git`, `gh` and `make`. Run the project's commands
as you would on the host, with one difference: **use a virtual environment outside the
repository**.
The repository's `.venv` belongs to the host. It is bound to the Python interpreter of your
machine, which does not exist in the container. If `uv` used it, it would delete and recreate
`.venv` for the container's interpreter and break your environment on the host. The sandbox
therefore sets `UV_PROJECT_ENVIRONMENT=/home/dev/.cache/eos-venv` for every shell and every agent
tool call. Create the environment once:
```bash
uv sync --frozen --extra dev
```
The first sync downloads and installs all packages; on Docker Desktop (Windows, macOS) it can take
many minutes. Later syncs take a moment. `/home/dev` is the persistent home volume, so the
environment survives restarts. Do not change the variable to a path on `/tmp`: `/tmp` is small and
does not allow running programs.
The usual commands work:
| Task | Command |
|---|---|
| Run all tests | `uv run --frozen pytest` |
| Run the tests of one module | `uv run --frozen pytest tests/test_config.py` |
| Run the tests as CI does | `make test-ci` |
| Check types | `make mypy` |
| Pre-commit hooks on committed changes | `uv run --frozen pre-commit run --from-ref main --to-ref HEAD` |
| Pre-commit hooks on changed files | `uv run --frozen pre-commit run --files <file> ...` |
| Check file names | `uv run --frozen python scripts/lint_filenames.py` |
| Check commit messages since `main` | `make gitlint` |
| Generate the documentation | `make gen-docs` |
| Start the EOS server for development | `make run-dev` (see [](#ai-sandbox-servers)) |
| Start EOSdash for development | `make run-dash-dev` (see [](#ai-sandbox-servers)) |
Do not run `pre-commit run --all-files` in the sandbox: some hooks open every file for writing,
which fails on the read-only `ai/` directory. Run it on the host. For the same reason
`--from-ref`/`--files` fail with `Errno 30` when the changes include files in `ai/` outside
`ai/skills`, or in `ai/skills` without `AI_SANDBOX_SKILLS_RW=1`; check those changes on the host
too. Do not run `make clean` in the sandbox either. It deletes the host's `.venv`.
(ai-sandbox-servers)=
### Run EOS and EOSdash in the sandbox
A server started in the sandbox can always be tested from within the same container, e.g. let the
agent start it in the background and query it with `curl http://localhost:8503/v1/health`. A second
`./ai/run.sh` starts a separate container, which cannot reach it.
To open EOS and EOSdash in the browser on the host:
1. Start the sandbox with published ports:
```bash
AI_SANDBOX_PORTS=1 ./ai/run.sh
```
On Windows, set the variable first: `$env:AI_SANDBOX_PORTS = "1"` in PowerShell or
`set AI_SANDBOX_PORTS=1` in CMD. The ports 8503 (EOS) and 8504 (EOSdash) are published on the
host's loopback interface only, so they are not reachable from the network. Only one sandbox at a
time can publish them. That's why publishing is off by default.
2. Start the servers so that they listen on all interfaces of the container. `make run-dev` and
`make run-dash-dev` listen on `localhost` only, which is not reachable through the published
ports:
```bash
uv run --frozen python -m akkudoktoreos.server.eos \
--host 0.0.0.0 --port 8503 --log_level DEBUG \
--startup_eosdash false --reload true &
uv run --frozen python -m akkudoktoreos.server.eosdash \
--host 0.0.0.0 --port 8504 --eos-host localhost --eos-port 8503 \
--log_level DEBUG --reload true
```
3. Open <http://localhost:8504> (EOSdash) or <http://localhost:8503/docs> (EOS REST API) in the
browser on the host.
(ai-sandbox-github)=
### GitHub access
The recommended way is to push and open pull requests from the host, after your review. The
sandbox then needs no GitHub credentials.
If the agent shall push or create pull requests itself, log in to GitHub in the sandbox:
```bash
gh auth login
gh auth setup-git
```
`gh auth setup-git` lets `git push` use the `gh` login. Later sandbox starts set this up
automatically. Prefer a
[fine-grained personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)
restricted to your fork over a full login.
(ai-sandbox-persistence)=
### Persistent data
Everything in the sandbox is temporary, except
- `/workspace`: the repository on your machine,
- `/home/dev`: the Docker volume `akkudoktor-ai-home`. It holds the logins of the agents and of
`gh`, the agents' settings, memory and session transcripts, the `uv` cache and the virtual
environment. The shell startup files (`~/.bashrc`, `~/.profile`) are reset on every start, so
changes to them do not persist.
To log out of everything and start over, remove the volume while no sandbox is running:
```bash
docker volume rm akkudoktor-ai-home
```
### Security model
The sandbox reduces what a misled agent can do on your machine. It does not make the agent's output
trustworthy, and it does not protect the repository: the agent can write any change into it. Your
review of every change is the main control.
The container
- sees only the repository and its own home volume, no other host directories and no Docker
socket,
- mounts `ai/` (the sandbox definition and the launchers) read-only, so the agent cannot change
the next sandbox start,
- runs as your user id, not as root, with all Linux capabilities dropped, `no-new-privileges` and
no `sudo`, so the agent cannot gain privileges,
- has a read-only root file system. `/tmp`, `/run` and `/var/tmp` are in-memory and do not allow
running programs,
- is limited to 2 CPUs, 4 GB of memory and 512 processes,
- has network access to the internet, which the agents and `uv` need,
- publishes no ports, unless started with `AI_SANDBOX_PORTS=1`, and then only on the host's
loopback interface.
The launchers check `ai/compose.yaml` for the main controls before they start the sandbox. This is
a sanity check against accidental edits, not a complete validation. Review changes to `ai/` like
any other change.
Keep in mind what the agent can still reach:
- **The repository, read and write.** Everything outside `ai/`. Many files in it are run on the
host: `Makefile`, `.pre-commit-config.yaml` (local hooks run on your next commit),
`pyproject.toml`, tests and `conftest.py`, scripts, the CI workflows.
- **`.git`, read and write.** The agent commits through it, and can create commits that change
`ai/`. It can also plant git configuration that runs commands on the host, e.g. a
`core.fsmonitor` that runs on the next `git status`, or hooks that run on the next commit. It
does not need to touch `.git/config` or `.git/hooks` for that: git also reads configuration and
hooks from the directory named in `.git/commondir`. Read-only mounts of single files in `.git`
cannot prevent this, so the sandbox does not pretend to. Check `.git` before you run any git
command on the host (see [](#ai-review-checklist)).
- **The home volume.** It persists between sessions, so a misled session can leave state behind
that takes effect later: agent settings and hooks in `~/.claude`, `~/.gitconfig`, the virtual
environment in `~/.cache`. If a session may have been misled, remove the volume
(`docker volume rm akkudoktor-ai-home`) and log in again.
- **The internet.** The agent can download code and send data. Do not put secrets into the
repository directory.
- **The logins in the home volume.** A GitHub login in the sandbox lets the agent push to every
repository the login can write to. That's why pushing from the host is recommended.
- **The skills, if enabled.** With `AI_SANDBOX_SKILLS_RW=1` the agent can change the rules that
later sessions follow.
(ai-review-checklist)=
### Check the changes before you push
Do this on the host, before you run the agent's changes there and before you open or update a pull
request. Start with `.git`, before you run any git command, because a planted configuration
runs on the first one, even on `git status` or `git diff`:
- Look at `.git` with plain file tools: `ls -la .git`. There must be no `commondir` file and no
unexpected files or directories (a normal `.git` holds `HEAD`, `config`, `description`, `index`,
`hooks/`, `info/`, `logs/`, `objects/`, `refs/` and some `*_HEAD`, `COMMIT_EDITMSG`,
`packed-refs`, `modules/` or `worktrees/`). Read `.git/config` with a text viewer and look for
`core.hooksPath`, `core.fsmonitor`, `core.sshCommand`, `filter.*` and `include*` entries you did
not add. List the active hooks: `ls .git/hooks` (anything not ending in `.sample`). If anything
looks wrong, do not run git; remove the planted files first.
- Review the full diff against the target branch, including every commit:
`git diff main...HEAD` and `git log -p main..HEAD`. The agent may have committed changes that
are no longer visible in the working tree.
- Check `git status` for unexpected modified and untracked files, and `git status --ignored` for
unexpected ignored ones.
- Look closely at changes to files the host runs: `Makefile`, `.pre-commit-config.yaml`,
`pyproject.toml`, `conftest.py` and tests, `scripts/`, `.github/`, `ai/` and `ai/skills/`.
- Cross-check the effective local git configuration: `git config --local --list --show-origin`.
Every entry must come from `.git/config`.
- If you suspect that a session was misled, discard its changes and reset the home volume.
See `ai/README.md` for the files of the sandbox.
+1
View File
@@ -65,6 +65,7 @@ akkudoktoreos/api.rst
:caption: Development :caption: Development
develop/develop.md develop/develop.md
develop/ai.md
develop/release.md develop/release.md
develop/async.md develop/async.md
develop/CHANGELOG.md develop/CHANGELOG.md