diff --git a/docker/agent/Dockerfile b/docker/agent/Dockerfile index 25ac0d9..e4b320a 100644 --- a/docker/agent/Dockerfile +++ b/docker/agent/Dockerfile @@ -51,6 +51,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 python3-numpy python3-pil python3-pip \ gdb strace ltrace binutils file xxd ripgrep jq unzip zip p7zip-full \ procps psmisc lsof less nano tini sudo \ + # expect drives Claude Code's one-time interactive gates for an + # unattended run — see bin/claude-autonomous. + expect \ && rm -rf /var/lib/apt/lists/* # Pin the unversioned tool names to 19 so CMake, and anything that shells out to diff --git a/docker/agent/README.md b/docker/agent/README.md index 21c04cf..8a5cc4a 100644 --- a/docker/agent/README.md +++ b/docker/agent/README.md @@ -10,8 +10,49 @@ the emulator, reads its guest memory and photographs its screen. ./sylph-agent doctor # prove it can do the four things it exists for ./sylph-agent shell # poke around ./sylph-agent agent # Claude Code, --dangerously-skip-permissions +./sylph-agent loose # turn it loose: detached, /loop, self-paced +./sylph-agent logs -f # watch it +./sylph-agent stop # stop it ``` +## On the loose + +`./sylph-agent loose` starts Claude Code **detached**, with +`--dangerously-skip-permissions`, running `/loop` on the task in +[`loop-task.md`](loop-task.md) — work the RE backlog one item at a time, commit +to `auto/*` branches, never push, record withdrawn results rather than deleting +them. Pass your own task as an argument, or set `SYLPH_LOOP_INTERVAL=30m` for a +fixed cadence instead of letting it self-pace. + +It runs `-d` **without** `--rm`, so the transcript survives the container +exiting — for an unattended run that is the only record of what happened. +`./sylph-agent attach` joins the live session (Ctrl-P Ctrl-Q to leave it +running). + +**It cannot push.** No git credentials are mounted, deliberately: a human +reviews before anything leaves the box. Review with +`git -C /Syplheed-Reborn log --oneline main..auto/`. + +### The four gates + +Claude Code has four one-time prompts, and each one is a silent, permanent hang +for an agent with nobody at the keyboard — no error, no log line, just a +container that looks healthy and does nothing. All four are handled: + +| gate | how | +|---|---| +| theme picker | `hasCompletedOnboarding` + `lastOnboardingVersion` in `~/.claude.json` | +| "do you trust this folder?" | `projects..hasTrustDialogAccepted` | +| Bypass Permissions disclaimer | answered in a pty by [`bin/claude-autonomous`](bin/claude-autonomous) — it has no config key, by design | +| fullscreen-renderer upsell | `fullscreenUpsellSeenCount`, because it fires *mid-session*, after the pty wrapper has handed over | + +Config keys were read out of the shipped binary's own strings rather than +guessed. The pty wrapper matches **single words**: Claude Code draws its UI with +absolute-column escapes between words, so `Yes, I accept` arrives as +`Yes,\x1b[13GI\x1b[15Gaccept` and a multi-word pattern never matches — which +looks exactly like the wrapper not running at all. It stops matching once the +session is live, so nothing later can be answered by accident. + ## The resource cap The container gets **half the machine**, computed at launch so it stays half on @@ -137,6 +178,16 @@ needs to write). **That directory also holds your memory and project state**, so the container agent and you share it. Point `SYLPH_CLAUDE_HOME` at a separate directory to isolate it, or set `ANTHROPIC_API_KEY` instead. +`~/.claude.json` is different: mounted **read-only** at a staging path and +copied in, so the container cannot rewrite your host config — and so a version +skew between the container's Claude Code and yours cannot re-trigger onboarding. + +The project is bind-mounted **twice**, at `/work` and at its own host path. The +host path is what makes memory carry over: Claude Code derives its per-project +state key from the working directory, so running at `/work` would hand the agent +an empty project instead of the accumulated one. Verified — a loose run reports +`MEMORY=yes` and reads back the same branch and backlog you see. + ## Host prerequisites * **A Vulkan SDK** (LunarG), for *building* only. Canary's shader step calls diff --git a/docker/agent/bin/claude-autonomous b/docker/agent/bin/claude-autonomous new file mode 100755 index 0000000..c871018 --- /dev/null +++ b/docker/agent/bin/claude-autonomous @@ -0,0 +1,66 @@ +#!/usr/bin/expect -f +# Start Claude Code for an unattended run, answering the first-run gates. +# +# Claude Code has three one-time interactive prompts, and every one of them is a +# silent, permanent hang for an agent with nobody at the keyboard — no error, no +# log line, just a container that looks healthy and does nothing: +# +# 1. the theme picker (first run, or whenever the installed version is +# newer than lastOnboardingVersion) +# 2. "do you trust this folder?" (per workspace) +# 3. the Bypass Permissions disclaimer (for --dangerously-skip-permissions) +# +# `seed-claude-config.py` pre-sets the config keys for 1 and 2. The disclaimer +# has no such key — it is meant to be accepted by a person once — so it is +# answered here instead. That is the honest reading of `sylph-agent loose`: the +# operator accepted it by choosing to run this, and the container is exactly the +# sandbox the warning asks for. +# +# ── Why the patterns are single words ── +# Claude Code draws its UI with ABSOLUTE COLUMN escapes between words, so the +# prompt arrives on the wire as +# +# 2.\x1b[8GYes,\x1b[13GI\x1b[15Gaccept +# +# A multi-word pattern like {Yes, I accept} therefore never matches, and the +# wrapper sits there looking like it is not running at all. Match one word. + +set timeout 90 +log_user 1 + +set answered_theme 0 +set answered_trust 0 +set answered_bypass 0 + +spawn -noecho claude --dangerously-skip-permissions {*}$argv + +expect { + -re {Choose} { + if {!$answered_theme} { set answered_theme 1; send "\r" } + exp_continue + } + -re {trust} { + if {!$answered_trust} { + set answered_trust 1 + send_user "\n\[claude-autonomous] accepting the workspace trust prompt\n" + send "1\r" + } + exp_continue + } + -re {accept} { + if {!$answered_bypass} { + set answered_bypass 1 + send_user "\n\[claude-autonomous] accepting the Bypass Permissions disclaimer\n" + send "2\r" + } + exp_continue + } + timeout { + # No new gate for a while: the session is up (or never had one). Stop + # matching so nothing later in the run can be answered by accident. + } + eof { exit } +} + +# Hand the terminal over for the rest of the run. +interact diff --git a/docker/agent/bin/seed-claude-config.py b/docker/agent/bin/seed-claude-config.py new file mode 100755 index 0000000..53a6de0 --- /dev/null +++ b/docker/agent/bin/seed-claude-config.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Mark Claude Code's onboarding as complete in ~/.claude.json. + +Claude Code re-runs its first-run wizard whenever `lastOnboardingVersion` does +not match the installed version. In a terminal that is a one-key prompt; for an +unattended agent it is a silent, permanent hang on the theme picker — no error, +no log line, and the container looks like it started fine. + +It also pre-accepts the workspace's trust prompt. That is a SECOND, separate +first-run gate: even past onboarding, Claude Code asks "is this a project you +trust?" per directory, and this repo's settings pre-approve 442 tool permissions +so the prompt is emphatic about it. Unattended, it is another silent hang. + +Pre-accepting is safe here precisely because the trust question is being +answered by the person who built and launched the container, for their own +repository — it is not a judgement being made on their behalf about someone +else's code. + +Usage: seed-claude-config.py [workspace...] +""" +import json +import os +import sys + + +def main() -> int: + if len(sys.argv) < 3: + print(f"usage: {sys.argv[0]} [workspace...]", + file=sys.stderr) + return 2 + path, version = sys.argv[1], sys.argv[2] + workspaces = sys.argv[3:] + + cfg = {} + if os.path.exists(path) and os.path.getsize(path): + try: + with open(path) as f: + cfg = json.load(f) + except (OSError, ValueError): + # A corrupt or partial config is not worth failing the container + # over — start from empty rather than block the run. + cfg = {} + if not isinstance(cfg, dict): + cfg = {} + + cfg["hasCompletedOnboarding"] = True + cfg["lastOnboardingVersion"] = version + cfg.setdefault("theme", "dark") + # The THIRD interactive gate: --dangerously-skip-permissions shows a + # "Bypass Permissions mode / you accept all responsibility" confirmation on + # first use. Key name taken from the shipped binary's own strings, not + # guessed. Accepting it here is the whole point of `sylph-agent loose` — + # the container is the sandbox that warning asks you to provide. + cfg["bypassPermissionsModeAccepted"] = True + # A FOURTH gate, and this one fires mid-session rather than at startup, so + # the pty wrapper has already handed over by then: an upsell asking whether + # to try the fullscreen renderer. It is shown while + # `fullscreenUpsellSeenCount` is below an internal threshold, so park it far + # above. Found by reading the shipped binary's strings, same as the others. + cfg["fullscreenUpsellSeenCount"] = 9999 + # An auto-update mid-run would restart the process and lose the loop's + # scheduled wake-up, so pin the version the container was built with. + cfg["autoUpdates"] = False + + projects = cfg.setdefault("projects", {}) + if not isinstance(projects, dict): + projects = cfg["projects"] = {} + for ws in workspaces: + entry = projects.setdefault(ws, {}) + if not isinstance(entry, dict): + entry = projects[ws] = {} + entry["hasTrustDialogAccepted"] = True + entry.setdefault("projectOnboardingSeenCount", 1) + entry["hasClaudeMdExternalIncludesApproved"] = True + entry["hasClaudeMdExternalIncludesWarningShown"] = True + + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(cfg, f, indent=2) + os.replace(tmp, path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docker/agent/entrypoint.sh b/docker/agent/entrypoint.sh index a2c9742..da22398 100755 --- a/docker/agent/entrypoint.sh +++ b/docker/agent/entrypoint.sh @@ -92,12 +92,34 @@ log "build parallelism: $jobs (cpus=$cpus, mem=${mem_gib}GiB avail)" mkdir -p "$HOME/shots" "$HOME/logs" +# ── Claude Code config ─────────────────────────────────────────────────────── +# Seed ~/.claude.json from the host's read-only copy, then stamp onboarding as +# complete. Claude Code re-runs its first-run wizard whenever +# lastOnboardingVersion differs from the installed version — so a container with +# a newer Claude than the host stops on the theme picker, with no error and no +# log line, and an unattended agent sits there forever. +if [ -f "$HOME/.claude.host.json" ] && [ ! -s "$HOME/.claude.json" ]; then + cp "$HOME/.claude.host.json" "$HOME/.claude.json" 2>/dev/null || true +fi +CLAUDE_VER=$(claude --version 2>/dev/null | grep -oE '^[0-9][0-9.]*' || echo 0.0.0) +python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER" \ + "$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true +chmod 600 "$HOME/.claude.json" 2>/dev/null || true + # ── Claude Code ────────────────────────────────────────────────────────────── if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then + # Drop the image's default CMD first, or `claude` is handed the literal string + # "bash" as its prompt and answers a question nobody asked. + if [ "$#" -eq 1 ] && [ "$1" = "bash" ]; then + set -- + fi # The flag the user asked for. It is refused under root, which is why this # image runs as `agent`. - set -- claude --dangerously-skip-permissions "$@" - log "starting Claude Code with --dangerously-skip-permissions" + # claude-autonomous wraps `claude --dangerously-skip-permissions` in a pty and + # answers the one-time first-run gates. The Bypass Permissions disclaimer in + # particular has no config key that skips it, so unattended it hangs forever. + set -- claude-autonomous "$@" + log "starting Claude Code with --dangerously-skip-permissions in $(pwd)" fi exec "$@" diff --git a/docker/agent/loop-task.md b/docker/agent/loop-task.md new file mode 100644 index 0000000..3e03533 --- /dev/null +++ b/docker/agent/loop-task.md @@ -0,0 +1,48 @@ +Work the Project Sylpheed reverse-engineering backlog, one item at a time. + +Read `Syplheed-Reborn/docker/agent/AGENT.md` first — it has the container's +tooling and, more importantly, the method rules this corpus is built on. + +## Each iteration + +1. **Pick one item.** Take the next open entry from + `Syplheed-Reborn/docs/re/BACKLOG.md`, preferring the one whose "first step" + is cheapest and most decisive. If you are mid-item from a previous + iteration, continue it rather than starting another. +2. **Do the smallest experiment that could settle it**, and try to *refute* + your hypothesis before you believe it. +3. **Write the result down** in `docs/re/` under the ✅/🟡/❔ convention, with + the evidence. A withdrawn or refuted result is a real result — record it, + with the reasoning, rather than deleting it. +4. **Commit** to a topic branch (below), one logical change per commit. +5. **Say plainly what you did not settle**, and stop the iteration. + +## Hard rules + +* **Never commit to `main`.** Work on `auto/` in whichever repo you are + touching, branched from the current `main`. Create it if it does not exist. +* **Never push.** No push credentials are mounted, and that is deliberate — a + human reviews before anything leaves the box. +* **One emulator at a time.** `run-canary` enforces this with a lockfile; do not + work around it. +* **Do not edit `main`'s history**, do not rebase shared branches, and do not + delete branches. +* **Measure the oracle; never infer it.** An iteration that reasons about the + game without running it is a red flag unless it is a pure static-format task. +* **Verify with an artifact**, not with "it compiles": `build-reborn test` (it + wires up `SYLPHEED_DISC` — without it the disc tests silently self-skip and a + green run means almost nothing), `sylpheed-cli mesh render`, `screen render`, + `save info`, a screenshot. + +## When you are blocked + +If an item needs something the container cannot do — hardware Vulkan for a +rendering question, a push, a decision only the user can make — **do not +improvise around it**. Write what you found, note the blocker in `BACKLOG.md`, +and move to the next item. + +## Pacing + +Self-pace. A useful iteration is one experiment plus its write-up, not a +marathon; stopping with a clean commit and an honest "here is what is still +open" is the goal every time. diff --git a/docker/agent/sylph-agent b/docker/agent/sylph-agent index f6e2934..5224bf7 100755 --- a/docker/agent/sylph-agent +++ b/docker/agent/sylph-agent @@ -7,8 +7,11 @@ # ./sylph-agent build build (or rebuild) the image # ./sylph-agent shell interactive shell in the container # ./sylph-agent agent [prompt] Claude Code, --dangerously-skip-permissions +# ./sylph-agent loose [task] turn it loose: detached, /loop, self-paced +# ./sylph-agent logs [-f] what the loose agent is doing +# ./sylph-agent attach attach to the loose agent's session # ./sylph-agent run one-shot command -# ./sylph-agent stop stop a detached container +# ./sylph-agent stop stop it # # Environment: # SYLPH_PROJECT host project root (default: three levels up from this file) @@ -38,6 +41,14 @@ MEM_GB="${SYLPH_MEM_GB:-$(LC_ALL=C awk -v m="$HOST_MEM_KB" 'BEGIN{printf "%d", m # pages count against the memory cap, so take a third of it and no more. SHM_GB=$(( MEM_GB / 3 )); [ "$SHM_GB" -lt 1 ] && SHM_GB=1 +CLAUDE_JSON="${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}" +if [ ! -f "$CLAUDE_JSON" ]; then + echo "==> WARNING: $CLAUDE_JSON does not exist." >&2 + echo " Docker would create a DIRECTORY at that path inside the container," >&2 + echo " and Claude Code would fail confusingly. Run \`claude\` once on the" >&2 + echo " host first, or set SYLPH_CLAUDE_JSON." >&2 +fi + usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } docker_args() { @@ -67,10 +78,27 @@ docker_args() { --security-opt seccomp=unconfined --security-opt apparmor=unconfined # ── project ── + # Mounted TWICE, at the same path the host uses and at /work. The host path + # is what makes Claude Code's memory carry over: its per-project state key is + # derived from the working directory, so running at /work would give the + # agent an empty `-work` project instead of the accumulated + # `-home-fabi-RE-Project-Sylpheed` one. /work stays because the toolkit + # scripts and every doc here refer to it. + -v "$PROJECT:$PROJECT" -v "$PROJECT:/work" -e "PROJECT_DIR=/work" # ── claude ── + # The state dir is shared read-write: credentials live in + # .claude/.credentials.json, so token refresh needs to write, and this is + # also what carries the project memory across. -v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/re/.claude" + # ~/.claude.json sits BESIDE that directory and holds `hasCompletedOnboarding`. + # Mounted READ-ONLY at a staging path: the entrypoint copies it to + # ~/.claude.json and stamps onboarding as done. Sharing the file directly + # would (a) re-run the first-run theme wizard whenever the container's + # Claude Code version differs from the host's — a silent hang an unattended + # agent never gets past — and (b) let the container rewrite your host config. + -v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/re/.claude.host.json:ro" # persistent build caches, so a container restart is not a rebuild -v sylph-agent-cargo:/sylph-home/re/.cargo -v sylph-agent-target:/sylph-home/re/target-container @@ -142,6 +170,43 @@ case "${1:-}" in "$@" "$HERE" ;; + loose) + shift + # "On the loose": detached, self-paced, working the RE backlog until stopped. + # + # -d WITHOUT --rm so the transcript survives the container exiting; that is + # the only record of what an unattended run did. -t because /loop keeps the + # session alive and schedules its own wake-ups — a `-p`/print-mode run would + # answer once and exit, killing the loop on its first iteration. + TASK="${1:-}" + if [ -z "$TASK" ]; then + if [ -f "$HERE/loop-task.md" ]; then + TASK="$(cat "$HERE/loop-task.md")" + else + TASK="Work the RE backlog in Syplheed-Reborn/docs/re/BACKLOG.md." + fi + fi + INTERVAL="${SYLPH_LOOP_INTERVAL:-}" # empty = let the model self-pace + declare -a ARGS; docker_args ARGS + ARGS+=(-e SYLPH_AUTONOMOUS=1 -w "$PROJECT") + echo "==> loose | cpus=$CPUS mem=${MEM_GB}g shm=${SHM_GB}g" + echo "==> project: $PROJECT (mounted at its own path, so memory carries over)" + echo "==> pacing: ${INTERVAL:-self-paced}" + docker rm -f "$NAME" >/dev/null 2>&1 || true + docker run -d -i -t "${ARGS[@]}" "$IMAGE" "/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null + echo + echo " running detached as '$NAME'." + echo " ./sylph-agent logs -f follow it" + echo " ./sylph-agent attach attach to the session (Ctrl-P Ctrl-Q to detach)" + echo " ./sylph-agent stop stop it" + echo + echo " It commits to auto/* branches and cannot push — no git credentials" + echo " are mounted, so review its work with: git -C '$PROJECT/Syplheed-Reborn' log --oneline auto/..." + ;; + + logs) shift; exec docker logs "$@" "$NAME" ;; + attach) exec docker attach "$NAME" ;; + shell|agent|run) mode=$1; shift declare -a ARGS; docker_args ARGS