From 82a80e40633f4a0e4b8341e33824984cd6b244e9 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 18 Aug 2026 17:10:09 +0200 Subject: [PATCH] =?UTF-8?q?docker:=20turn=20the=20agent=20loose=20?= =?UTF-8?q?=E2=80=94=20detached=20/loop,=20with=20the=20first-run=20gates?= =?UTF-8?q?=20handled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `./sylph-agent loose [task]` starts Claude Code detached with --dangerously-skip-permissions, running /loop on 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. `logs`/`attach`/`stop` to watch and end it. Runs -d WITHOUT --rm so the transcript survives the container exiting — for an unattended run that is the only record of what happened. Two things had to be fixed for an agent to survive being left alone. MEMORY CONTINUITY. The project is now bind-mounted twice: at /work, and at its own host path. Claude Code derives its per-project state key from the working directory, so running at /work handed the agent an empty project instead of the accumulated one. Verified: a loose run now reports MEMORY=yes and reads back the same branch and backlog as the host. FOUR INTERACTIVE GATES, each a silent permanent hang with nobody at the keyboard -- no error, no log line, just a container that looks healthy and does nothing: theme picker hasCompletedOnboarding + lastOnboardingVersion. Re-fires whenever the container's Claude Code is a different version to the host's, which is the normal case. folder trust projects..hasTrustDialogAccepted bypass disclaimer answered in a pty by bin/claude-autonomous. It has no config key by design -- it wants a person to accept once, and the person did so by launching this. fullscreen upsell fullscreenUpsellSeenCount. This one fires MID-SESSION, after the pty wrapper has already handed over, so it cannot be answered the same way. Config key names were read out of the shipped binary's own strings, not guessed. The pty wrapper matches SINGLE WORDS. Claude Code draws its UI with absolute-column escapes between words, so the prompt arrives as `Yes,\x1b[13GI\x1b[15Gaccept` and a multi-word pattern never matches -- failing in a way indistinguishable from the wrapper not running at all. It stops matching once the session is live so nothing later is answered by accident. ~/.claude.json is now mounted read-only at a staging path and copied in, so the container cannot rewrite the host config. Credentials stay shared read-write in ~/.claude, which is what token refresh and memory continuity need. Co-Authored-By: Claude Opus 5 (1M context) --- docker/agent/Dockerfile | 3 + docker/agent/README.md | 51 ++++++++++++++++ docker/agent/bin/claude-autonomous | 66 ++++++++++++++++++++ docker/agent/bin/seed-claude-config.py | 85 ++++++++++++++++++++++++++ docker/agent/entrypoint.sh | 26 +++++++- docker/agent/loop-task.md | 48 +++++++++++++++ docker/agent/sylph-agent | 67 +++++++++++++++++++- 7 files changed, 343 insertions(+), 3 deletions(-) create mode 100755 docker/agent/bin/claude-autonomous create mode 100755 docker/agent/bin/seed-claude-config.py create mode 100644 docker/agent/loop-task.md 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