pi setup

Session

Internals

What every script and definition file in this repo does, when it runs, and what it reads and writes (constitution Principle II). scripts/check-sandbox-docs.sh fails if a file under container/ or scripts/, a compose*.yaml file or one of the files below has no entry here.

Image and definitions#

Dockerfile#

Builds the sandbox image. A first stage pins the official gitleaks image (ghcr.io/gitleaks/gitleaks, tag + digest, updated by Renovate) and only its binary is copied to /usr/local/bin/gitleaks for the memory check. A second stage pins the official uv image (tag + digest); it is only mounted for one RUN that installs AWS’s MCP proxy mcp-proxy-for-aws-cli (ARG MCP_PROXY_FOR_AWS_VERSION, PyPI; the distribution pins every Python dependency exactly) for the image’s Debian python3 into /opt/uv-tools with precompiled bytecode, command on PATH (used by an aws-api MCP server, config.md); uv itself is not in the image. A further RUN installs kubectl (ARG KUBECTL_VERSION, from dl.k8s.io) and talosctl (ARG TALOSCTL_VERSION, Talos GitHub release) as single binaries checked against pinned <TOOL>_SHA256_<ARCH> values. Then stackit (STACKIT CLI), hcloud (Hetzner Cloud CLI) and ghorg are downloaded, together with rtk (RTK; built in a rtk stage: amd64 release archive checked against RTK_SHA256_AMD64, arm64 cross-compiled from the RTK_COMMIT tag commit because the arm64 release needs glibc 2.39 and bookworm has 2.36; command-output compression for the bash tool; ARG RTK_VERSION, its pi extension is in pi-config), from their GitHub releases for the build architecture (dpkg --print-architecture), each archive checked against its pinned SHA-256 (ARG <TOOL>_SHA256_<ARCH>, rewritten by scripts/update-image-checksums.sh) before extraction, installed to /usr/local/bin. Base node:24-bookworm-slim pinned by digest; installs git ripgrep fd-find jq curl ca-certificates less, python3 make g++ for native package builds and micro (terminal editor for pi’s Ctrl+G / externalEditor; host GUI editors such as Zed cannot be started from the sandbox), each pinned to an exact Debian version (ARG <PKG>_VERSION, updated by Renovate); plus python3-yaml and python3-jsonschema (ARG PYTHON3_YAML_VERSION, ARG PYTHON3_JSONSCHEMA_VERSION; PyYAML and jsonschema for the system Python) yamllint (ARG YAMLLINT_VERSION) chromium (ARG CHROMIUM_VERSION, Debian build; the browser for the Playwright MCP server of the design agent, started with --executable-path /usr/bin/chromium --no-sandbox because the session container allows no user namespaces; it is only pointed at the local site, never at untrusted pages) and openssh-client (ARG OPENSSH_VERSION) for the opt-in SSH push of sessions: it does nothing without a key (spec 011). A later RUN makes container/git-hooks/hook-shim executable, links every client hook name to it and installs container/known_hosts as the system-wide /etc/ssh/ssh_known_hosts. Installs pi with npm install -g --ignore-scripts at the pinned ARG PI_VERSION. Renames the base node user to pi (uid/gid 1000). Copies container/ to /usr/local/lib/pi-sandbox/ and tests/isolation.sh to /usr/local/lib/pi-sandbox/selftest/. Sets PI_CODING_AGENT_DIR=/home/pi/.pi/agent, PI_SKIP_VERSION_CHECK=1, PI_SANDBOX_PI_VERSION (the pi version, from ARG PI_VERSION) and PI_SANDBOX_VERSION (the image’s release version, from ARG SANDBOX_VERSION: the release version for published images, <VERSION>-local for local builds). Entrypoint: container/entrypoint.sh. Rebuilt by pi-sandbox rebuild and just gate.

compose.yaml#

The session service pi: hardening (non-root, cap_drop: ALL, no-new-privileges, read-only root, tmpfs, limits, init), the mounts of path-contract rows 1–4 and 6, and the environment. All values come from the wrapper. Every line is commented.

compose.ssh.yaml#

Override for a session with SSH hosts (ssh.json levels): mounts the per-session agent socket volume (tmpfs driver) into the pi container (row 11) and defines the ssh-agent sidecar (profile ssh, no network). Added by compose_run only when PI_SSH_KEYS is set.

compose.other-domain.yaml#

Override for the one extra domain (--with-domain <name>, or --with-other-domain with two domains): adds $OTHER_DOMAIN’s store read-only (row 5).

compose.sessions.yaml, compose.sessions-legacy.yaml, compose.legacy.yaml#

Session history maintenance (pi-config spec 006, path-contract.md): service sessions mounts only pi-sessions-<domain> (same path as in sessions), no network, for pi-sandbox sessions prune|clear|list; the -legacy override adds pi-agent-state at /state for the one-time migration; compose.legacy.yaml (service legacy, pi-agent-state only) serves the legacy line of sessions list and sessions clear --legacy. All run entrypoint.sh sessions ….

compose.missing-core.yaml#

Override when brain/pi-memory-core is missing: /brain/core becomes ./empty, read-only.

compose.missing-domain.yaml#

Override when the domain’s store is missing: /brain/<domain> becomes ./empty, read-only.

compose.workspace.yaml#

Workspace container (pi-config spec 009): session image and hardening, tmpfs /tmp and /home/pi, the only bind ws/<domain> read-write, environment DOMAIN, WS_* and the forge token WS_TOKEN; command workspace. Started by cmd_workspace as project pi-ws-<domain>.

compose.sync.yaml#

The separate sync service for pi-sandbox memory-sync: same image and hardening, mounts only /brain/core and /brain/<domain>, tmpfs home, receives the memory URLs and credentials in the slots BRAIN_CORE_* and BRAIN_DOMAIN_* (no variable is named after a domain, pi-config spec 005).

compose.check.yaml#

The check service for pi-sandbox memory-check: same image and hardening, no network (network_mode: none), /brain/core and /brain/<domain> mounted read-only (or empty/ for a missing store), environment DOMAIN and MISSING_STORES only, no credentials. Runs entrypoint.sh check <domain> (path-contract.md).

VERSION#

The release version of this repo and of its image tag (<PI_SANDBOX_IMAGE_REPO>:<VERSION>). Written only by semantic-release; 0.0.0 before the first release. Read by bin/pi-sandbox, scripts/quickstart.sh and tests/sync-container.bats. See release.md.

.dockerignore#

Keeps the build context to what the Dockerfile copies; excludes .git, .env*, node_modules, docs and host scripts.

.releaserc.json, package.json, package-lock.json#

semantic-release configuration and its pinned tooling (not an npm package). Commit analysis with the conventionalcommits preset (build: and chore(deps): count as patch), changelog, VERSION update, image publish via scripts/release-image.sh, release commit, Codeberg release. See release.md.

renovate.json#

Renovate configuration: config:recommended, semantic commits (build(deps) for the image, ci(deps) for tooling), a regex manager for # renovate: annotations, Debian registries, grouping, the checksum post-upgrade task. See dependencies.md.

.forgejo/workflows/renovate.yml#

Daily and manual Renovate run on the runner pi, scoped to this repository (RENOVATE_REPOSITORIES = github.repository; secrets RENOVATE_TOKEN, RENOVATE_GITHUB_COM_TOKEN). pi-config has its own run.

.forgejo/workflows/check.yml#

Runs on every pull request: just check and an amd64 build of the image via podman --remote.

.forgejo/workflows/publish-image.yml#

Manual recovery (workflow_dispatch, input version): checks out tag v<version> into release-src and runs the current scripts/release-image.sh publish <version> on it (PI_SANDBOX_BUILD_CONTEXT). For releases that were tagged but whose image push failed.

.forgejo/workflows/release.yml#

CI on the self-hosted runner pi: just check, then npx semantic-release with the RELEASE_TOKEN / REGISTRY_USER secrets and a registry login/logout around it.

justfile#

Task runner: just check (static checks, tests, compose and definition lint, docs check) and just gate (rebuild + in-container self-test against the pinned pi version).

A session start#

What pi-sandbox start does, from the host to pi; the sections below describe each part.

A session start. pi-sandbox start reads and checks everything on the host, starts the sidecars the project needs, then the pi container, whose entrypoint prepares the session before pi runs. When the session ends the wrapper removes the sidecars.

Host side#

bin/pi-sandbox#

The only supported entry point (bash 3.2). Parses .env without executing it; resolves and validates the project path; resolves the memory stores (placeholder, opt-in); selects the image <PI_SANDBOX_IMAGE_REPO>:<VERSION> (pulled on every start unless PI_SANDBOX_PULL=never; on failure the local copy, else a local build); scrubs any host ANTHROPIC_API_KEY, every BRAIN_*_TOKEN and any PUSH_* credential slot; for a project below ws/<domain>/<host>/ with a push credential (resolve_push: remote form WS_<ID>_<HOST_ID>_REMOTE beats WS_<HOST_ID>_REMOTE; ssh: WS_<ID>_<HOST_ID>_PUSH_KEY/_KEY_CMD; https: _PUSH_TOKEN_CMD or Keychain ws-<domain>-<host>-push-token, _PUSH_USER) reads it on the Mac, validates it (exit 60/61; ssh-keygen -y -P '' where available) and passes PUSH_MODE, PUSH_HOST and the credential (key base64, plus WS_<HOST_ID>_KNOWN_HOSTS_FILE as PUSH_KNOWN_HOSTS_B64, or token and user) to that session only; reads Keychain items when asked; creates the pi-agent-state volume and builds the image when missing; prints the summary line; runs docker compose run --rm; removes the per-project network afterwards. Commands and exit codes: commands.md. PI_SANDBOX_DOCKER replaces the docker binary in tests.

In the container (/usr/local/lib/pi-sandbox/)#

resolve_project accepts any depth below ws/<domain>/ (PROJECT = relative path), checks every folder name, needs no git but refuses folders inside a repository that are not its root; compose_project keeps flat names and adds a cksum suffix for nested paths (spec 004). Domains (pi-config spec 005): load_domains (called by load_env) validates PI_DOMAINS into KNOWN_DOMAINS (default private work); domain_id maps a name to its settings infix (my-club → MY_CLUB); select_extra_domain turns --with-domain / --with-other-domain into OTHER_DOMAIN (empty without opt-in); cmd_memory_sync resolves the domain’s BRAIN_<ID>_URL and credential and passes them as BRAIN_DOMAIN_URL / BRAIN_DOMAIN_TOKEN; cmd_domain implements domain add (rewrites only the PI_DOMAINS line of .env through a temp file, creates ws/<name>/ and the store scaffold with git init -b main, no commit) and domain list (probes the Keychain item without -w). Layered configuration (pi-config spec 007, config.md): layered_merge_chain reads global and the domain store’s level files (config_level), runs lib/layered-config.jq and stops on its errors; resolve_mcp_secrets resolves the placeholders of enabled servers (mcp_secret: MCP_<ID>_<NAME>_CMD or Keychain mcp-<domain>-<NAME>), disables servers with a missing secret, assigns PI_MCP_SECRET_<n> and rewrites the placeholders (apply_secrets); layered_config exports PI_LAYERED_SETTINGS, PI_LAYERED_MCP, PI_CONFIG_LEVELS; config_level_dirs is the one level walk (global, domain, each prefix of PROJECT) used by the merge, the instructions and the staging (pi-config spec 008): stack_instructions exports PI_LAYERED_AGENTS, PI_INSTRUCTION_LEVELS, PI_DOMAIN_INSTRUCTIONS (64 KiB limit); check_level_resources validates level skills and templates; stage_resources copies them into .run/<run-id>/ (nearest wins, 10 MiB / 2,000 files, EXIT trap removes it), rewrites the settings’ skills / prompts to /opt/pi-layers and exports PI_LAYER_RESOURCES; clean_stale_stages (in prepare_session) removes staging folders older than a day; cmd_workspace implements workspace clone|refresh|list (pi-config spec 009): check_target, forge_type, remote_form, ws_token (exit 51), ws_token_source (no value read), workspace_run (one container per group), ws_groups (the record); scrub_secrets also clears WS_TOKEN; cmd_config implements config show (secret sources only); scaffold_config creates the store in domain add; scrub_secrets also clears PI_MCP_SECRET_*.

lib/ssh-hosts.jq#

Host-side jq module for ssh.json: ssh_file_error (strict validation of one file), ssh_merge (path order, nearer alias replaces, enabled: false removes), ssh_merged_error (proxyJump to an alias on the path, no loops) and ssh_known_hosts_keys (known_hosts text of a hostKeys command → unique <type> <base64> keys). Used by ssh_hosts_merged in the wrapper; tested by tests/ssh-hosts-wrapper.bats.

lib/layered-config.jq#

Host-side jq module for the merge (pi-config spec 007): layered_merge (settings: objects key by key, null removes, pi’s combined resource lists, defaultTools deltas, packages only global; MCP servers by name with pi’s override rule; sources per leaf and server; literal-secret warnings; placeholders of enabled servers) and apply_secrets (placeholders → slots, disabled servers). Tested by tests/layered-config.bats; works with jq 1.6 and 1.7. Session history (pi-config spec 006): retention_days (validated SESSIONS_<ID>_RETENTION_DAYS / SESSIONS_RETENTION_DAYS, default 30) is exported as SESSION_RETENTION_DAYS; ensure_session_volume (in ensure_runtime_objects) creates pi-sessions-<domain> on first use and runs the migration in the sessions container, removing the new volume again if that fails (exit 41); cmd_sessions implements sessions list|prune|clear (sessions_run, legacy_run, confirm).

container/entrypoint.sh#

Runs on every container start. Exports git safe.directory for the mounted repos and user.useConfigOnly via GIT_CONFIG_* env (no config files written); links AGENTS.md, settings.json and mcp.json to this session’s stacked/merged files in /run/pi-session/ (session_file) and mcp-auth.json and the cloud CLIs’ config folders ~/.config/stackit, ~/.config/hcloud, ~/.kube, ~/.talos (link_domain_cli_config) into the domain volume (link_domain_auth, pi-config spec 007); with PI_KUBE_LEVEL writes a credential-free /run/pi-session/kubeconfig for http://kube-proxy:8001 and sets KUBECONFIG (session_kubeconfig); mode kube-proxy (sidecar only) writes PI_KUBECONFIG to its tmpfs, runs kubectl proxy on loopback and kube-filter.py in front of it; with PI_SSH_HOSTS runs ssh-config.sh (session_ssh); mode ssh-agent (sidecar only) runs ssh-agent-sidecar.sh; checks the credential source (exit 21 on the stored-login/keychain conflict; clears any API key otherwise). Mode workspace runs workspace-clone.sh with safe.directory=* (only ws/<domain> is mounted) and prepares nothing else. Modes: pi (also installs packages, renders the context, starts pi with --append-system-prompt), shell, selftest (prints pi <version>, runs the isolation test), sync (runs memory-sync.sh), check (runs memory-check.sh report <domain>). setup_push (from push-setup.sh) runs after the git identity guard in modes pi, shell and selftest.

container/ssh-config.sh#

Called by the entrypoint with PI_SSH_HOSTS: writes /run/pi-session/ssh_config (one Host block per alias: agent socket, own public key with IdentitiesOnly, strict host keys, BatchMode, no agent forwarding), the public keys and the pinned known_hosts under /run/pi-session/ssh/. Included by the image’s /etc/ssh/ssh_config.d/pi-sandbox.conf. Tested by tests/ssh-config.bats.

container/ssh-agent-sidecar.sh#

Mode ssh-agent of the sidecar: starts ssh-agent on /run/pi-ssh/agent.sock, loads every key of PI_SSH_KEYS (a key that fails is logged and skipped), unsets the variable and stays in the foreground; stops the agent on TERM. Tested by tests/ssh-config.bats.

container/kube-filter.py#

Request filter of the kube-proxy sidecar (Python standard library only). Listens on 8001 and forwards to the sidecar’s loopback kubectl proxy (8002) the reads (pod logs and watches streamed) and the writes of the access mode from KUBE_ACCESS (restart: the exact kubectl rollout restart patch; write: POST/PUT/PATCH; admin: plus DELETE); refuses secrets and exec/attach/port-forward/proxy unless KUBE_SECRETS/KUBE_TUNNELS is 1, unusual paths and a foreign Host with a Kubernetes Status 403. Rules: config.md; unit tests tests/kube-filter.bats.

container/push-setup.sh#

Sourced by entrypoint.sh; defines setup_push and git_env_config (appends to the GIT_CONFIG_* env; no config file is written). setup_known_hosts (called first by setup_push) turns PI_KNOWN_HOSTS_B64 into the session known_hosts (pinned keys + extra keys) and a strict GIT_SSH_COMMAND without agent (PI_KNOWN_HOSTS_ENABLED=1); ssh push appends its per-host keys to the same file. Without PUSH_MODE no push is set up and stray credential variables are dropped. Mode ssh: starts an ssh-agent on /run/pi-session/ssh-agent.sock, loads the key from PUSH_SSH_KEY_B64 through ssh-add - (never a file), writes the session known_hosts (image file plus PUSH_KNOWN_HOSTS_B64), exports GIT_SSH_COMMAND (-F /dev/null, BatchMode, StrictHostKeyChecking=yes, own known_hosts only, IdentityAgent, no forwarding) and sets url.git@<host>:.pushInsteadOf for https://<host>/ and ssh://git@<host>/. Mode https: writes PUSH_HTTPS_TOKEN to a 0600 file /run/pi-session/push-token (exported as PUSH_HTTPS_TOKEN_FILE), sets credential.useHttpPath, credential.https://<host>.helper = git-credential-push, url.https://<host>/.pushInsteadOf for git@<host>: and ssh://git@<host>/, and GIT_TERMINAL_PROMPT=0. Both: unset the credential variables, set core.hooksPath to git-hooks/, export PI_PUSH_ENABLED, PI_PUSH_MODE, PI_PUSH_HOST and PI_PUSH_DETAIL (read by render-context.sh). Exit 62 if the agent cannot start, the credential is missing or cannot be loaded, or the mode is unknown.

container/git-credential-push#

git credential helper for push over https from a session: answers get with PUSH_HTTPS_USER and the token from PUSH_HTTPS_TOKEN_FILE only for protocol https, host PUSH_HOST and the project’s own repository path (${PROJECT#*/}, with or without .git); for anything else, or without a host folder in PROJECT, it outputs nothing. Requires credential.useHttpPath=true (set by push-setup.sh). Never logs the token. Tests: tests/push-credential.bats (also with git credential fill).

known_hosts.d/ and pi-sandbox known-hosts#

Host side, next to bin/ and git-ignored: one known_hosts-format file per host (<host>, <host>_<port>), written by known-hosts add after fingerprint confirmation (or by hand; every line needs <host> <key type> <key>, else exit 61). resolve_known_hosts concatenates them into PI_KNOWN_HOSTS_B64 for each session. Tests: tests/known-hosts.bats, tests/push-setup.bats.

container/known_hosts#

Pinned SSH host keys (three types each) of codeberg.org, github.com and gitlab.com, each checked against the fingerprints the forge publishes itself (sources in the file’s header). Used by push-setup.sh (merged with PI_KNOWN_HOSTS_B64) and, in the image, as /etc/ssh/ssh_known_hosts.

container/git-hooks/hook-shim#

The one hook script behind every client hook name (links made in the Dockerfile; core.hooksPath is set by push-setup.sh, which also wins over a repository’s own core.hooksPath). Runs the repository’s own hook of that name first (.git/hooks/<name> of the common dir, or the repository-local core.hooksPath) with the same stdin and arguments, keeping its exit status; then, for pre-push, the guard: refuses deleting remote refs, updating existing tags, pushing the remote’s default branch (refs/remotes/<remote>/HEAD) or a branch matching PUSH_PROTECTED_BRANCHES (default main master trunk develop dev production prod stable release release/* releases/*), and non-fast-forward updates including remote commits unknown locally. Defence in depth only: the agent controls git in the session.

container/install-packages.sh#

Reads packages from the rendered settings.json (an entry is the spec string or an object whose source is the spec, the object form carries pi’s resource filters); for every npm:<name>@<x.y.z> that is not installed at that version under ~/.pi/agent/npm/node_modules, runs pi install <spec>. No-op when everything is present. Fails on unpinned specs. Before installing it copies /opt/pi-config/agent/npmrc (pi-config: the npm install-script allow-list, allow-scripts) to ~/.pi/agent/npm/.npmrc, pi’s npm project, and removes a stale copy when pi-config has none (override for tests: NPMRC_SRC).

container/render-context.sh#

Writes /run/pi-session/context.md: scope table and capped briefings (memory.md); the per-briefing caps come from memory-limits.sh. Inputs: DOMAIN, PROJECT, OTHER_DOMAIN, WITH_OTHER_DOMAIN, MISSING_STORES, the stores under /brain. Never writes env values. Level chain (spec 004): core, domain, every prefix of PROJECT, project; the 16 KiB budget is allocated nearest-first, shortened sections point to their file; a level “has memory” when it has a briefing, an index or notes.

container/sessions.sh#

Session history operations on a mounted tree (pi-config spec 006): prune <dir> <days> (deletes *.jsonl older than <days> by modification time, then empty folders; prints the count), stat <dir> (<count> <size> <oldest date>), clear <dir>, migrate <legacy> <target> &lt;domain&gt; "&lt;domains&gt;" (moves legacy folders whose first session header has cwd under /ws/<domain>/; prints moved/unmatched), legacy-clear <legacy>. The entrypoint runs prune before pi and shell with SESSION_RETENTION_DAYS, and exposes everything as mode sessions.

container/workspace-clone.sh#

The workspace container’s job (pi-config spec 009, workspace.md), bash 3.2-compatible so the tests can run it on the host: snapshots the origin of every repository below ws/<domain>/<host>; runs ghorg (--preserve-dir --no-clean, --skip-archived/--skip-forks unless included, --clone-type=user; GHORG_OUTPUT_DIR = the group for a top-level group or organisation, . for a GitLab subgroup so the full namespace is kept; never --prune/--fetch-all/--backup); restores the recorded origins; checks each new repository’s folder against its remote path and sets the owner’s remote form; refreshes existing repositories of the group (fast-forward of a clean default branch only, token through a command-line credential helper); removes the token from any git config; writes ws/<domain>/.pi-workspace/groups.json atomically; prints a summary. All output passes mask (token → ***). Exit 2 for missing settings, 50 for failures. Tested by tests/workspace-clone.bats with tests/helpers/ghorg-stub.sh and local bare repositories.

container/memory-sync.sh#

The memory sync flow in the sync container (memory.md): allowlisted repo config, hooks off, safe.directory for exactly the synced store (repos are owned by the host user), fetch-and-compare against the store’s URL slot (BRAIN_CORE_URL or BRAIN_DOMAIN_URL; messages name the owner’s BRAIN_<ID>_* setting), review output, memory check per store (blocking findings stop it; possible personal data in lines not yet on the remote must be confirmed one by one, computed from git diff -U0 against the fetched remote state on a temp index), per-store confirmation, commit, push. A store without any commit yet (new from pi-sandbox domain add) gets its first commit here. Accepts any valid domain name; the wrapper checks that it is configured. Exit 30 on any failed or blocked store.

container/memory-check.sh#

The memory check (memory.md). scan <store-dir> <id> prints one record per finding (severity|category|path|line|message, never a matched value) and exits non-zero only if the check itself failed (e.g. the secret scanner) — callers treat that as blocked. report <domain> checks core and the domain under BRAIN_ROOT (default /brain), skipping stores in MISSING_STORES, and exits 0/31/30/32. format turns records into report lines (also used by memory-sync.sh). Secrets: gitleaks dir --redact plus a pattern for credentials in URLs (gitleaks has no rule for them); personal data: grep patterns (IPv4 and IPv6 included), line numbers only; format rules of the memory format. Inputs: ASSESSMENT_WORDS (default: the word list next to the script). Domain stores are checked as a tree (spec 004): every folder prefix before notes/ is a level, expected scope = its relative path (domain at the root); level prefixes are matched literally (dots in host names).

container/memory-limits.sh#

Sourced by render-context.sh and memory-check.sh: BRIEFING_MAX_LINES=60, BRIEFING_MAX_BYTES=4000, so the check warns exactly when the session context would truncate a briefing; MEMORY_MAX_FILE_BYTES=1048576, above which a text file is reported as large-file and not content-scanned.

container/assessment-words.txt#

Curated German/English terms (judgements about people, disciplinary and health terms) for the assessment category of the memory check. Whole-word, case-insensitive; # comments. Words mostly used about systems (e.g. “lazy”, “diagnose”) are deliberately left out.

container/git-credential-brain#

git credential helper used only by memory-sync.sh: returns BRAIN_<SLOT>_TOKEN (slots CORE and DOMAIN) only for the exact BRAIN_<SLOT>_URL (protocol, host and path).

Checks and tests#

scripts/check-sandbox-definition.sh#

Renders every compose combination with placeholder values — once for private/work and once for a synthetic pair of other domain names — and fails on weakened hardening, undeclared mounts (only core, the session’s domain and the extra domain may be bound), published ports, non-bridge networks, or unpinned base image / pi (SC-008).

scripts/release-image.sh#

build <version> (semantic-release prepare, before the tag), push <version> (publish) or publish <version> (both; manual recovery). Builds linux/amd64,linux/arm64 (override: PI_SANDBOX_PLATFORMS), labels it with the version and source, and pushes <PI_SANDBOX_IMAGE_REPO>:<version>. Engine: podman --remote (manifest build + push against the runner host’s podman, CONTAINER_HOST) when podman is installed, else docker buildx; override with PI_SANDBOX_IMAGE_ENGINE; build context override PI_SANDBOX_BUILD_CONTEXT. Runs only in CI. Tests: tests/release-image.bats (stubbed podman/docker, no real build).

scripts/release-retry.sh#

The release job’s entry point (.forgejo/workflows/release.yml): runs npx semantic-release and, when that fails while main moved since the job’s checkout (the multi-minute image build in prepare leaves a window for Renovate or owner merges, so the final push is rejected with fetch first), resets the checkout to the new main (git reset --hard, git clean -fd) and runs it again, up to RELEASE_MAX_ATTEMPTS (default 3). A failure with an unmoved main, an unreachable remote or no attempts left returns semantic-release’s exit status. Environment: RELEASE_REMOTE (origin), RELEASE_BRANCH (main), RELEASE_CMD (replaces npx semantic-release; used by the tests). Runs in CI only. Tests: tests/release-retry.bats (real git with a local bare remote, stubbed release command). See release.md.

scripts/prune-images.sh#

[--keep N] [--dry-run] (default 2). Image retention for Codeberg’s package quota: lists the pi-sandbox container package versions via the Forgejo packages API, keeps the newest N release tags (x.y.z) and the per-architecture digests their multi-arch index references (read from the registry with a pull token), and deletes every other release tag and every unreferenced sha256: version. Anything else is left alone; a failed delete fails the script. Needs RELEASE_TOKEN (package write) and REGISTRY_USER; repo from PI_SANDBOX_IMAGE_REPO. Runs in CI only: semantic-release prepare after the build (--keep 1, so the new release plus one previous remain) and in publish-image before the push.

scripts/ci-install-tools.sh#

Installs the check tools in a CI job: bats, jq, shellcheck from apt; just and gitleaks at pinned versions, verified against pinned SHA-256 checksums. --docker adds the Docker CLI with compose and buildx plugins from Docker’s signed apt repo (key fingerprint checked, versions pinned); --podman adds the pinned Debian podman client for podman --remote.

scripts/update-ci-checksums.sh#

Reads JUST_VERSION and GITLEAKS_VERSION from scripts/ci-install-tools.sh, downloads the releases’ published checksum files and rewrites the four pinned SHA-256 variables. Run by Renovate after it bumps either tool (an allowed post-upgrade command); idempotent.

scripts/update-image-checksums.sh#

The same for the image: reads STACKIT_VERSION, HCLOUD_VERSION, GHORG_VERSION, GH_VERSION, KUBECTL_VERSION and TALOSCTL_VERSION from the Dockerfile, downloads the releases’ published checksum files and rewrites the pinned <TOOL>_SHA256_<ARCH> values. Run by Renovate after it bumps one of these tools; idempotent.

scripts/quickstart.sh#

Run by just quickstart. Automates every non-interactive step of the quickstart (pi-config/specs/001-pi-workspace-setup/quickstart.md): prerequisites, test projects ws/{private,work}/sandbox-test (created if missing), image build, isolation with and without the opt-in, warm-start time (SC-002, < 30 s), rendered settings equal pi-config, refusal exit codes, both repos’ just check, brain secret scans, and parallel sessions. Prints a PASS/FAIL table and the manual steps; exits non-zero on any failure. Temporary folders it creates are removed again.

scripts/check-sandbox-docs.sh#

Fails when an env var, mount, command, flag, exit code or script has no documentation entry (SC-004).

tests/isolation.sh#

The in-container isolation test (SC-003), built into the image; run by pi-sandbox selftest. Includes “only expected memory stores under /brain”: exactly core, the session’s domain and the opted-in extra domain, whatever domains are configured; “configuration is session-local” (links into /run/pi-session/ and the domain volume, spec 007); and “only this domain’s session history”: the sessions folder is its own mount and holds only sessions started under /ws/<domain>/.

tests/*.bats, tests/helpers/#

bats suites for the wrapper and container scripts (domain.bats: PI_DOMAINS, domain add, domain list); helpers/docker-stub.sh records docker invocations and selected env vars (every BRAIN_*_TOKEN / BRAIN_*_URL); sync-container.bats runs only when the local image carries the sync scripts of this checkout; sessions.bats (sessions.sh), sessions-wrapper.bats (retention, volume, migration, sessions commands) and sessions-image.bats (pruning by the entrypoint in the real image, throwaway volumes) cover spec 006; layered-config.bats, layered-config-wrapper.bats and layered-config-image.bats cover spec 007; helpers/setup.bash builds a throwaway PI_ROOT with synthetic values.

empty/#

Versioned empty directory used as the read-only placeholder for a missing memory store.