merge the Godot port's history into the monorepo
Brought in with a subtree merge rather than a copy, so the port's 31 commits survive as history rather than arriving as one anonymous import. Landed under godot-import/ and moved into the final layout in the next commit, which keeps git's rename detection able to follow each file across the move.
This commit is contained in:
100
godot-import/docker/Dockerfile
Normal file
100
godot-import/docker/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
godot-import/docker/bin/build-export
Executable file
23
godot-import/docker/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
godot-import/docker/bin/build-reference-cli
Executable file
80
godot-import/docker/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
godot-import/docker/bin/claude-autonomous
Executable file
72
godot-import/docker/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
godot-import/docker/bin/push-work
Executable file
76
godot-import/docker/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
godot-import/docker/bin/screenshot
Executable file
13
godot-import/docker/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
godot-import/docker/bin/seed-claude-config.py
Executable file
85
godot-import/docker/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
godot-import/docker/entrypoint.sh
Executable file
72
godot-import/docker/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
godot-import/docker/sylph-port
Executable file
180
godot-import/docker/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