monorepo: one repository for the decoders, the port and the corpus
Some checks failed
Some checks failed
Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.
The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.
Canary stays separate: it is a fork tracking upstream.
New structure for the long term:
docs/game/ how the game is NAVIGATED -- menus, modals, prompts, alerts,
and in-game flight. Written so nobody rediscovers it. Mostly
open questions on purpose; the in-game tutorials are the
resource for the flight half.
docs/port/MODDING.md
modding as a constraint on the exporter TODAY, not a later
feature: one logical asset in one file (the disc splits nearly
everything, and resolving that is the exporter's job), names a
person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
re-exporting is always safe, provenance in every file.
data/base + data/mods
generated tree and drop-in overrides, both gitignored
exchange/ transient inter-agent files, deliberately outside history
docs/agents/ the team protocol
Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.
Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
This commit is contained in:
100
docker/port/Dockerfile
Normal file
100
docker/port/Dockerfile
Normal file
@@ -0,0 +1,100 @@
|
||||
# Autonomous port agent for the Sylpheed Godot menu shell.
|
||||
#
|
||||
# DELIBERATELY SMALL. The reverse-engineering container next door is 4.36 GB
|
||||
# because it builds Xenia Canary and drives it under a software Vulkan stack.
|
||||
# This agent has no emulator, no oracle and no C++ build: it converts already-
|
||||
# decoded assets and drives Godot. Keeping it light is what lets both containers
|
||||
# run on one 12-core / 15 GB box without the memory pressure that has crashed it.
|
||||
#
|
||||
# What it needs, and nothing else: Rust (the exporter), Godot 4 (the runtime),
|
||||
# ffmpeg (the transcode), and a headless display to screenshot Godot for
|
||||
# comparison against the reference renderer.
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
LANG=C.UTF-8 \
|
||||
TZ=Etc/UTC
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# toolchain for the exporter and for building sylpheed-cli from /reborn
|
||||
build-essential pkg-config git curl ca-certificates \
|
||||
libssl-dev \
|
||||
# Godot 4 needs these even headless; the windowed run needs the X libs
|
||||
libx11-6 libxcursor1 libxinerama1 libxrandr2 libxi6 libgl1 \
|
||||
libasound2t64 libpulse0 libfontconfig1 \
|
||||
# the transcode target (libtheora + libvorbis ship in Ubuntu's ffmpeg)
|
||||
ffmpeg \
|
||||
# headless display + the screenshot path, for diffing Godot's output
|
||||
# against `sylpheed-cli screen render`
|
||||
xvfb x11-utils openbox imagemagick \
|
||||
# everyday
|
||||
python3 jq ripgrep unzip file less nano tini sudo procps \
|
||||
# expect drives Claude Code's one-time interactive gates
|
||||
expect \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Godot 4 ──────────────────────────────────────────────────────────────────
|
||||
# Pinned. An engine version bump changes rendering, and this project compares
|
||||
# screenshots against a reference renderer — so an upgrade must be a deliberate,
|
||||
# stated act rather than a silent drift.
|
||||
ARG GODOT_VERSION=4.7.2
|
||||
RUN cd /tmp \
|
||||
&& curl -fsSLO "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable/Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \
|
||||
&& unzip -q "Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip" \
|
||||
&& mv "Godot_v${GODOT_VERSION}-stable_linux.x86_64" /usr/local/bin/godot \
|
||||
&& chmod +x /usr/local/bin/godot \
|
||||
&& printf '#!/bin/sh\nexec /usr/local/bin/godot --headless "$@"\n' > /usr/local/bin/godot-headless \
|
||||
&& chmod +x /usr/local/bin/godot-headless \
|
||||
&& rm -f "Godot_v${GODOT_VERSION}-stable_linux.x86_64.zip"
|
||||
|
||||
# ── Node + Claude Code ───────────────────────────────────────────────────────
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& npm install -g @anthropic-ai/claude-code \
|
||||
&& npm cache clean --force \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── The agent user ───────────────────────────────────────────────────────────
|
||||
# NOT root: Claude Code refuses --dangerously-skip-permissions with root
|
||||
# privileges. Ubuntu 24.04 ships its own `ubuntu` account at uid 1000, so the
|
||||
# common case — matching a host user who is also 1000 — collides with it.
|
||||
ARG AGENT_UID=1000
|
||||
ARG AGENT_GID=1000
|
||||
RUN if getent passwd "${AGENT_UID}" >/dev/null; then \
|
||||
userdel -r "$(getent passwd "${AGENT_UID}" | cut -d: -f1)" 2>/dev/null || true; \
|
||||
fi; \
|
||||
if getent group "${AGENT_GID}" >/dev/null; then \
|
||||
groupdel "$(getent group "${AGENT_GID}" | cut -d: -f1)" 2>/dev/null || true; \
|
||||
fi; \
|
||||
groupadd -g "${AGENT_GID}" agent \
|
||||
&& useradd -m -u "${AGENT_UID}" -g "${AGENT_GID}" -s /bin/bash -d /sylph-home/port agent \
|
||||
&& mkdir -p /sylph-home/port /work /reborn \
|
||||
&& chown -R "${AGENT_UID}:${AGENT_GID}" /sylph-home \
|
||||
&& echo 'agent ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/agent
|
||||
|
||||
COPY bin/ /usr/local/bin/
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/* /usr/local/bin/entrypoint.sh
|
||||
|
||||
USER agent
|
||||
WORKDIR /work
|
||||
|
||||
# CARGO_TARGET_DIR points OUTSIDE the bind-mounted repo so the host and the
|
||||
# container do not invalidate each other's incremental state on every switch.
|
||||
ENV RUSTUP_HOME=/sylph-home/port/.rustup \
|
||||
CARGO_HOME=/sylph-home/port/.cargo \
|
||||
CARGO_TARGET_DIR=/sylph-home/port/target-container \
|
||||
PATH=/sylph-home/port/.cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
| sh -s -- -y --default-toolchain stable --profile minimal --component clippy --component rustfmt
|
||||
|
||||
RUN mkdir -p /sylph-home/port/target-container /sylph-home/port/.claude
|
||||
|
||||
ENV HOME=/sylph-home/port \
|
||||
DISPLAY=:97 \
|
||||
SCREEN_GEOMETRY=1280x720x24 \
|
||||
PROJECT_DIR=/work
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["bash"]
|
||||
23
docker/port/bin/build-export
Executable file
23
docker/port/bin/build-export
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and run the exporter against the disc.
|
||||
#
|
||||
# build-export build only
|
||||
# build-export --run build, export to ./export, then validate it
|
||||
#
|
||||
# The validate step is not optional politeness: `export` writes a tree and
|
||||
# `check` is the only thing that says the tree is readable by anything other
|
||||
# than the program that wrote it. A build that exports and does not check has
|
||||
# not shown anything.
|
||||
#
|
||||
# Jobs are capped: this box runs two agent containers and a desktop, and an
|
||||
# unbounded parallel build has crashed it. Do not raise this to "use all cores".
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}"
|
||||
cargo build --release -p sylpheed-export
|
||||
if [ "${1:-}" = "--run" ]; then
|
||||
shift
|
||||
disc="${SYLPHEED_DISC:?set SYLPHEED_DISC to the extracted disc root}"
|
||||
"$CARGO_TARGET_DIR/release/sylpheed-export" export --disc "$disc" --out export "$@"
|
||||
exec "$CARGO_TARGET_DIR/release/sylpheed-export" check --out export
|
||||
fi
|
||||
80
docker/port/bin/build-reference-cli
Executable file
80
docker/port/bin/build-reference-cli
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build `sylpheed-cli` from the SAME revision of sylpheed-formats the exporter
|
||||
# is pinned to, and put it on the persistent target volume.
|
||||
#
|
||||
# build-reference-cli -> $CARGO_TARGET_DIR/release/sylpheed-cli
|
||||
#
|
||||
# Why not just use /reborn/target/release/sylpheed-cli: that binary is built
|
||||
# from whatever /reborn's working tree is at, which is a LIVE mount of the other
|
||||
# agent's checkout and moves under you mid-iteration. `sylpheed-cli screen
|
||||
# render` is the reference the Godot port is diffed against, so if it runs
|
||||
# different decoders than the exporter, a pixel disagreement has a free variable
|
||||
# in it and proves nothing about the port.
|
||||
#
|
||||
# The pinned source lives in CARGO_HOME, which is on the container overlay and
|
||||
# does not survive a fresh container -- cargo re-fetches it. The BINARY goes to
|
||||
# CARGO_TARGET_DIR, which is a volume, so this is a one-off per image.
|
||||
#
|
||||
# Jobs are capped for the same reason as build-export.
|
||||
set -euo pipefail
|
||||
cd "${PROJECT_DIR:-/work}"
|
||||
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}"
|
||||
|
||||
rev=$(sed -n 's/.*Syplheed-Reborn\.git", rev = "\([0-9a-f]*\)".*/\1/p' \
|
||||
crates/sylpheed-export/Cargo.toml | head -1)
|
||||
[ -n "$rev" ] || { echo "build-reference-cli: no rev pin found in Cargo.toml" >&2; exit 1; }
|
||||
|
||||
# The checkout only exists once cargo has fetched it; a fresh container has not.
|
||||
find_checkout() {
|
||||
find "${CARGO_HOME:?}/git/checkouts" -maxdepth 2 -type d -name "${rev}*" 2>/dev/null | head -1
|
||||
}
|
||||
src=$(find_checkout)
|
||||
if [ -z "$src" ]; then
|
||||
echo "build-reference-cli: fetching the pinned decoders ($rev)"
|
||||
cargo fetch
|
||||
src=$(find_checkout)
|
||||
fi
|
||||
[ -n "$src" ] || { echo "build-reference-cli: no checkout for rev $rev" >&2; exit 1; }
|
||||
|
||||
# Build into a target directory KEYED BY THE REVISION.
|
||||
#
|
||||
# This is not tidiness. Sharing one target dir across pins silently served a
|
||||
# stale binary: after the pin moved 8b6dbcf -> 5414db3, cargo reported
|
||||
# "Finished in 0.13s" and left in place a `sylpheed-cli` built from the OLD
|
||||
# decoders. `screen list` still worked, so the old check passed, and the
|
||||
# reference renderer this whole project verifies against was a revision behind
|
||||
# for three consecutive diff runs. A per-rev tree cannot do that: a new pin has
|
||||
# no artifacts to reuse.
|
||||
echo "build-reference-cli: building sylpheed-cli from $rev"
|
||||
tree="$CARGO_TARGET_DIR/reference-cli/$rev"
|
||||
CARGO_TARGET_DIR="$tree" cargo build --release --manifest-path "$src/Cargo.toml" -p sylpheed-cli
|
||||
|
||||
stable="$CARGO_TARGET_DIR/reference-cli/sylpheed-cli"
|
||||
mkdir -p "$(dirname "$stable")"
|
||||
cp -f "$tree/release/sylpheed-cli" "$stable"
|
||||
|
||||
"$stable" screen list "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" >/dev/null \
|
||||
|| { echo "build-reference-cli: built, but 'screen list' failed" >&2; exit 1; }
|
||||
|
||||
# And check the binary is actually the pinned code, not merely a working one.
|
||||
# `screen info` prints each element's resting placement, which is decoder
|
||||
# output; if this disagrees with what the exporter wrote from the same pin, the
|
||||
# two halves of the verification are not the same revision and every diff below
|
||||
# is meaningless. Compare rather than assert a value, so this stays true when
|
||||
# the pin moves again.
|
||||
if [ -f "${PROJECT_DIR:-/work}/export/screens/title/main_menu.json" ]; then
|
||||
cli_rest=$("$stable" screen info "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" --build 5 \
|
||||
| sed -n 's/.*ptframe1\.t32.*rest (\([0-9]*\),\([0-9]*\)).*/\1,\2/p')
|
||||
exp_rest=$(python3 -c '
|
||||
import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
e=next(e for e in d["elements"] if e["id"]=="ptframe1")
|
||||
print("%d,%d" % tuple(e["rest"]["pos"]))' "${PROJECT_DIR:-/work}/export/screens/title/main_menu.json")
|
||||
if [ "$cli_rest" != "$exp_rest" ]; then
|
||||
echo "build-reference-cli: STALE OR MISMATCHED BINARY" >&2
|
||||
echo " the CLI resolves ptframe1 rest to ($cli_rest) but export/ says ($exp_rest)." >&2
|
||||
echo " Both should come from rev $rev. Delete $tree and rebuild." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "build-reference-cli: $stable (rev $rev, agrees with export/ on ptframe1)"
|
||||
72
docker/port/bin/claude-autonomous
Executable file
72
docker/port/bin/claude-autonomous
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/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
|
||||
|
||||
# Give the pty a wide, tall geometry. A detached `docker run -t` defaults to
|
||||
# 80x24, and Claude Code hard-wraps to the terminal width — which truncates the
|
||||
# Remote Control URL to "https://claude.ai/code/session_01…" in the one place
|
||||
# you need to read it, and makes `docker logs` nearly unusable generally.
|
||||
set stty_init "rows 50 cols 200"
|
||||
|
||||
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
|
||||
76
docker/port/bin/push-work
Executable file
76
docker/port/bin/push-work
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Push the current topic branch to origin — the ONLY sanctioned way out of the
|
||||
# container.
|
||||
#
|
||||
# Why a wrapper instead of plain `git push`:
|
||||
#
|
||||
# * **`main` and shared branches are refused.** The agent commits to
|
||||
# `auto/<topic>`; a human merges. A token that can push anywhere is one
|
||||
# confused iteration away from rewriting the consolidated line.
|
||||
# * **Force-push is refused**, always. Nothing here needs it, and history
|
||||
# rewriting is the one mistake that cannot be undone by merging.
|
||||
# * It pushes the CURRENT branch only, by name, so a stray `--all` cannot
|
||||
# publish another agent's worktree branch mid-experiment.
|
||||
#
|
||||
# Credentials come from a file mounted read-only at ~/.git-credentials (see
|
||||
# `sylph-agent`). They are never printed, never logged, and never passed on a
|
||||
# command line.
|
||||
#
|
||||
# push-work push the current branch, and any annotated tags on it
|
||||
# push-work --dry-run say what it would do
|
||||
set -euo pipefail
|
||||
|
||||
DRY=0
|
||||
[ "${1:-}" = "--dry-run" ] && DRY=1
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
||||
echo "push-work: not inside a git repository" >&2; exit 1; }
|
||||
cd "$repo_root"
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$branch" = "HEAD" ]; then
|
||||
echo "push-work: detached HEAD — check out a branch first" >&2; exit 1
|
||||
fi
|
||||
|
||||
case "$branch" in
|
||||
auto/*) ;;
|
||||
*)
|
||||
echo "push-work: refusing to push '$branch'." >&2
|
||||
echo " Only auto/* topic branches may leave the container; a human merges" >&2
|
||||
echo " them into main. Move your work: git switch -c auto/<topic>" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ ! -s "$HOME/.git-credentials" ]; then
|
||||
echo "push-work: no credentials mounted at ~/.git-credentials." >&2
|
||||
echo " The host must start the container with SYLPH_GIT_CREDENTIALS pointing" >&2
|
||||
echo " at a file containing one line:" >&2
|
||||
echo " https://<user>:<token>@git.mc02.dev" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Applied to THIS COMMAND ONLY, via `-c`, never `git config --local`.
|
||||
#
|
||||
# Writing it to --local config persists it in the repository, and this repo is a
|
||||
# bind mount the host also uses -- so the host's git inherited
|
||||
# `store --file=/sylph-home/port/.git-credentials`, a path that exists only
|
||||
# inside the container, and every host push then failed with
|
||||
# `unable to get credential storage lock: No such file or directory`.
|
||||
#
|
||||
# A tool that configures a shared repository to suit itself breaks every other
|
||||
# user of that repository. Keep it to the invocation.
|
||||
CRED_HELPER="store --file=$HOME/.git-credentials"
|
||||
|
||||
ahead=$(git rev-list --count "origin/$branch..$branch" 2>/dev/null || git rev-list --count HEAD)
|
||||
echo "push-work: $branch — $ahead commit(s) to publish"
|
||||
|
||||
if [ "$DRY" = 1 ]; then
|
||||
echo "push-work: --dry-run, stopping here"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --force-with-lease is deliberately NOT offered. If this is rejected as
|
||||
# non-fast-forward, someone else moved the branch: fetch and merge, do not
|
||||
# overwrite.
|
||||
git -c "credential.helper=$CRED_HELPER" push --follow-tags --set-upstream origin "$branch"
|
||||
echo "push-work: pushed $branch"
|
||||
13
docker/port/bin/screenshot
Executable file
13
docker/port/bin/screenshot
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Capture the current display to a PNG.
|
||||
#
|
||||
# screenshot out.png
|
||||
#
|
||||
# Used to diff Godot's rendering against `sylpheed-cli screen render`. Captures
|
||||
# the whole 1280x720 root window, which is exactly the design space the screens
|
||||
# are authored in, so a capture and a composite are directly comparable without
|
||||
# cropping or scaling.
|
||||
set -euo pipefail
|
||||
out="${1:?usage: screenshot OUT.png}"
|
||||
import -display "${DISPLAY:-:97}" -window root "$out"
|
||||
identify -format 'captured %wx%h -> %f\n' "$out"
|
||||
85
docker/port/bin/seed-claude-config.py
Executable file
85
docker/port/bin/seed-claude-config.py
Executable file
@@ -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 <path to .claude.json> <installed version> [workspace...]
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 3:
|
||||
print(f"usage: {sys.argv[0]} <config.json> <version> [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())
|
||||
72
docker/port/entrypoint.sh
Executable file
72
docker/port/entrypoint.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bring up the headless display, then hand over.
|
||||
#
|
||||
# Xvfb and openbox are started as children of PID 1 (tini), NOT of the agent's
|
||||
# shell, so they outlive any single command. The RE container learned this the
|
||||
# hard way: a display owned by a shell gets reaped when that shell exits, which
|
||||
# reads as "Xvfb dies on its own every few minutes".
|
||||
set -euo pipefail
|
||||
|
||||
: "${DISPLAY:=:97}"
|
||||
: "${SCREEN_GEOMETRY:=1280x720x24}"
|
||||
|
||||
if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then
|
||||
Xvfb "$DISPLAY" -screen 0 "$SCREEN_GEOMETRY" -nolisten tcp &
|
||||
for _ in $(seq 50); do
|
||||
xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
openbox >/dev/null 2>&1 &
|
||||
fi
|
||||
echo "[entrypoint] display $DISPLAY ready ($SCREEN_GEOMETRY)"
|
||||
|
||||
if [ -d /reborn ]; then
|
||||
echo "[entrypoint] /reborn mounted read-only — HANDOFF.md is the contract"
|
||||
fi
|
||||
|
||||
# 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 -- no error, 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
|
||||
# Same reason as .claude.json above: `credential.helper=store` rewrites this
|
||||
# file by rename-over-target, which fails with EBUSY on a bind mount. Copy it to
|
||||
# a writable path; nothing is ever written back to the host's file.
|
||||
if [ -f "$HOME/.git-credentials.host" ]; then
|
||||
cp "$HOME/.git-credentials.host" "$HOME/.git-credentials" 2>/dev/null || true
|
||||
chmod 600 "$HOME/.git-credentials" 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 ──────────────────────────────────────────────────────────────
|
||||
# Without this the loop prompt is handed to `exec` as a command, and the whole
|
||||
# markdown file is tried as a filename: exit 126, "File name too long".
|
||||
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
|
||||
# Remote Control registers the session with the account so the agent can be
|
||||
# reached from claude.ai -- the point of a detached run being that nobody is
|
||||
# sitting in front of it. The name is passed EXPLICITLY: the flag's value is
|
||||
# optional, so a bare --remote-control swallows the /loop prompt after it.
|
||||
if [ "${SYLPH_REMOTE:-1}" != "0" ]; then
|
||||
set -- --remote-control "${SYLPH_REMOTE_NAME:-sylpheed-port}" "$@"
|
||||
echo "[entrypoint] Remote Control as '${SYLPH_REMOTE_NAME:-sylpheed-port}'"
|
||||
fi
|
||||
# claude-autonomous wraps `claude --dangerously-skip-permissions` in a pty and
|
||||
# answers the one-time first-run gates. The Bypass Permissions disclaimer has
|
||||
# no config key that skips it, so unattended it hangs forever.
|
||||
set -- claude-autonomous "$@"
|
||||
echo "[entrypoint] starting Claude Code in $(pwd)"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
180
docker/port/sylph-port
Executable file
180
docker/port/sylph-port
Executable file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launcher for the Godot port agent.
|
||||
#
|
||||
# ./sylph-port build build the image
|
||||
# ./sylph-port shell interactive shell
|
||||
# ./sylph-port loose [task] detached, self-running on a fixed interval
|
||||
# ./sylph-port logs -f follow it
|
||||
# ./sylph-port attach chat with it (Ctrl-P Ctrl-Q to leave it running)
|
||||
# ./sylph-port remote a link to chat with it from anywhere
|
||||
# ./sylph-port stop stop it
|
||||
#
|
||||
# Env:
|
||||
# SYLPH_PORT_CPUS / SYLPH_PORT_MEM_GB override the cap (default 3 / 4)
|
||||
# SYLPH_PORT_REPO repo to mount at /work (default: this script's parent)
|
||||
# SYLPH_REBORN path to the Syplheed-Reborn checkout (read-only mount)
|
||||
# SYLPH_DISC extracted disc root
|
||||
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
|
||||
# SYLPH_LOOP_INTERVAL fixed loop cadence (default 45m)
|
||||
#
|
||||
# ── Two hard-won constraints ────────────────────────────────────────────────
|
||||
#
|
||||
# 1. THIS REPO IS ITS OWN CLONE. It is deliberately NOT the tree the RE agent
|
||||
# or a human is working in. Sharing a working tree between two writers means
|
||||
# files change under whoever is mid-edit, and a `git add -A` by one sweeps up
|
||||
# the other's work. That happened; do not re-create it.
|
||||
#
|
||||
# 2. IDENTITY GOES IN THE ENVIRONMENT, NOT `.git/config`. Writing `[user]` into
|
||||
# a repo's config captures every commit made in that tree, including a
|
||||
# human's. GIT_AUTHOR_*/GIT_COMMITTER_* apply to this container's commits and
|
||||
# nobody else's.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# The repo to mount at /work. Overridable so this script can be run from a
|
||||
# worktree -- a human editing on `main` must not repoint the agent's checkout.
|
||||
REPO="${SYLPH_PORT_REPO:-$(cd "$HERE/.." && pwd)}"
|
||||
IMAGE="${SYLPH_PORT_IMAGE:-sylpheed-port:latest}"
|
||||
NAME="${SYLPH_PORT_NAME:-sylpheed-port}"
|
||||
|
||||
# Half of what the RE container takes. That container builds a C++ emulator and
|
||||
# drives it; this one converts assets and runs Godot. Two full-size containers
|
||||
# do not fit on a 12-core / 15 GB box beside a desktop -- memory is the binding
|
||||
# constraint, and an over-committed build has crashed this machine before.
|
||||
CPUS="${SYLPH_PORT_CPUS:-3}"
|
||||
MEM_GB="${SYLPH_PORT_MEM_GB:-4}"
|
||||
|
||||
REBORN="${SYLPH_REBORN:-$(cd "$REPO/../Syplheed-Reborn" 2>/dev/null && pwd || true)}"
|
||||
DISC="${SYLPH_DISC:-$(cd "$REPO/../sylph_extract" 2>/dev/null && pwd || true)}"
|
||||
|
||||
docker_args() {
|
||||
local _out=(
|
||||
--name "$NAME"
|
||||
--hostname sylph-port
|
||||
--cpus "$CPUS"
|
||||
--memory "${MEM_GB}g"
|
||||
--memory-swap "${MEM_GB}g" # no swap escape hatch: a swapping build
|
||||
# thrashes the whole host
|
||||
--pids-limit 2048
|
||||
-v "$REPO:/work"
|
||||
-v "sylpheed-port-target:/sylph-home/port/target-container"
|
||||
# CARGO_HOME on a volume, not the container overlay: without it the pinned
|
||||
# decoder source is re-fetched from the network on every fresh container.
|
||||
-v "sylpheed-port-cargo:/sylph-home/port/.cargo"
|
||||
-v "${SYLPH_CLAUDE_HOME:-$HOME/.claude}:/sylph-home/port/.claude"
|
||||
-v "${SYLPH_CLAUDE_JSON:-$HOME/.claude.json}:/sylph-home/port/.claude.host.json:ro"
|
||||
-e "PROJECT_DIR=/work"
|
||||
)
|
||||
|
||||
# The RE corpus, READ-ONLY. `docs/port/HANDOFF.md` is the contract, and the
|
||||
# agent also builds sylpheed-cli from here for the reference renderer. Mounted
|
||||
# ro so a port iteration cannot edit the other agent's repository.
|
||||
if [ -n "$REBORN" ] && [ -d "$REBORN" ]; then
|
||||
_out+=(-v "$REBORN:/reborn:ro")
|
||||
else
|
||||
echo "==> NOTE: no Syplheed-Reborn checkout found; the agent cannot read" >&2
|
||||
echo " HANDOFF.md or build the reference renderer. Set SYLPH_REBORN." >&2
|
||||
fi
|
||||
|
||||
if [ -n "$DISC" ] && [ -d "$DISC" ]; then
|
||||
_out+=(-v "$DISC:/disc:ro" -e "SYLPHEED_DISC=/disc")
|
||||
else
|
||||
echo "==> NOTE: no extracted disc found; the exporter has nothing to read." >&2
|
||||
echo " Set SYLPH_DISC to the directory holding dat/ and hidden/." >&2
|
||||
fi
|
||||
|
||||
# Commits are attributed to the port agent, via the environment so that
|
||||
# nothing is written into the repository's config. See constraint 2 above.
|
||||
_out+=(
|
||||
-e "GIT_AUTHOR_NAME=Sylpheed port agent"
|
||||
-e "GIT_AUTHOR_EMAIL=port-agent@localhost"
|
||||
-e "GIT_COMMITTER_NAME=Sylpheed port agent"
|
||||
-e "GIT_COMMITTER_EMAIL=port-agent@localhost"
|
||||
)
|
||||
|
||||
# Mounted as `.host` and copied to a writable file by the entrypoint, exactly
|
||||
# like .claude.json. `credential.helper=store` REWRITES its file after a
|
||||
# successful auth -- it writes a temp file and renames over the target, and
|
||||
# renaming onto a bind-mount point gives EBUSY, which surfaces as
|
||||
# `fatal: unable to write credential store: Device or resource busy`.
|
||||
#
|
||||
# The push still succeeds, which is the actual danger: a `fatal:` line that is
|
||||
# routinely wrong teaches the reader to ignore the one that is real. Mounting
|
||||
# rw would also silence it, but then the container can clobber the host's
|
||||
# credential file; copying cannot.
|
||||
local gitcred="${SYLPH_GIT_CREDENTIALS:-$HOME/.sylph-git-credentials}"
|
||||
if [ -f "$gitcred" ]; then
|
||||
_out+=(-v "$gitcred:/sylph-home/port/.git-credentials.host:ro")
|
||||
else
|
||||
echo "==> NOTE: no git credentials at $gitcred — the agent cannot push," >&2
|
||||
echo " so its work dies with the container." >&2
|
||||
fi
|
||||
|
||||
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
|
||||
printf '%s\n' "${_out[@]}"
|
||||
}
|
||||
|
||||
mapfile -t ARGS < <(docker_args)
|
||||
|
||||
case "${1:-}" in
|
||||
build)
|
||||
exec docker build -t "$IMAGE" \
|
||||
--build-arg "AGENT_UID=$(id -u)" --build-arg "AGENT_GID=$(id -g)" "$HERE"
|
||||
;;
|
||||
|
||||
shell)
|
||||
TTY=(-i); [ -t 0 ] && TTY=(-it)
|
||||
exec docker run --rm "${TTY[@]}" "${ARGS[@]}" "$IMAGE" bash
|
||||
;;
|
||||
|
||||
loose)
|
||||
shift
|
||||
TASK="${1:-}"
|
||||
if [ -z "$TASK" ]; then
|
||||
if [ -f "$REPO/docs/loop-task.md" ]; then
|
||||
TASK="$(cat "$REPO/docs/loop-task.md")"
|
||||
else
|
||||
TASK="Work the milestones in docs/MISSION.md."
|
||||
fi
|
||||
fi
|
||||
# A FIXED interval, not self-pacing: the one thing an agent deep in a
|
||||
# milestone reliably forgets is the bookkeeping after it, and a forgotten
|
||||
# wake-up silently ends the loop.
|
||||
INTERVAL="${SYLPH_LOOP_INTERVAL-45m}"
|
||||
echo "==> loose | cpus=$CPUS mem=${MEM_GB}g pacing=${INTERVAL:-self}"
|
||||
echo "==> repo: $REPO"
|
||||
echo "==> reborn: ${REBORN:-<none>} (read-only)"
|
||||
docker run -d -i -t "${ARGS[@]}" -e SYLPH_AUTONOMOUS=1 -w /work "$IMAGE" \
|
||||
"/loop ${INTERVAL:+$INTERVAL }$TASK" >/dev/null
|
||||
echo
|
||||
echo " running detached as '$NAME'."
|
||||
echo " ./sylph-port remote link to chat with it from anywhere"
|
||||
echo " ./sylph-port logs -f follow it"
|
||||
echo " ./sylph-port attach chat with it locally"
|
||||
echo " ./sylph-port stop stop it"
|
||||
;;
|
||||
|
||||
logs) shift; exec docker logs "$@" "$NAME" ;;
|
||||
attach) exec docker attach "$NAME" ;;
|
||||
stop) exec docker rm -f "$NAME" ;;
|
||||
|
||||
remote)
|
||||
echo "waiting for the session to register" >&2
|
||||
for _ in $(seq 60); do
|
||||
# Read the container LOG, not the session transcript. The transcript
|
||||
# records every command run inside the container -- including this
|
||||
# lookup -- so grepping it matched our own pattern string back.
|
||||
url=$(docker logs "$NAME" 2>&1 \
|
||||
| grep -aoE 'https://claude\.ai/code/session_[A-Za-z0-9]+' \
|
||||
| tail -1 || true)
|
||||
[ -n "$url" ] && { echo "$url"; exit 0; }
|
||||
sleep 2
|
||||
done
|
||||
echo "no session link yet — try ./sylph-port logs -f" >&2
|
||||
exit 1
|
||||
;;
|
||||
|
||||
*)
|
||||
sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user