@stixxert/pi-docker-sandbox 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ # Lightweight sandbox template
2
+
3
+ A minimal base image for sandboxes, built automatically. **Measured**, not
4
+ estimated — `docker build` + `du` on this machine:
5
+
6
+ | Profile | On disk | docker reports | What's in it |
7
+ |---|---|---|---|
8
+ | `core` | **383 MB** | 547 MB | git, curl, ripgrep, jq, node 22, pnpm, vim-tiny |
9
+ | `core + build` *(default)* | **648 MB** | 920 MB | the above **+** `build-essential`, `python3` (native node modules) |
10
+ | *the existing pi template* | ~2.2 GB `/usr` | — | that, **plus** pi, a global `opencode-ai`, JVM, Go, Python, Rust |
11
+
12
+ So the default profile is **~3× smaller**, and the two ingredients that account
13
+ for most of the difference are simply *not installing pi* (136 MB) and *not
14
+ shipping a second coding agent* (353 MB, `opencode-ai`), a JVM (332 MB), Go
15
+ (72 MB), Python (49 MB) and Rust (11 MB).
16
+
17
+ ## Files
18
+
19
+ | File | Purpose |
20
+ |---|---|
21
+ | `install.sh` | the recipe. Contains **no `sbx` commands**, so the exact same script runs in a docker build (for measuring/CI) and inside a sandbox (for the real template) |
22
+ | `Dockerfile` | `install.sh` in docker-buildable form: `docker build -f template/Dockerfile -t sbx-lite .` |
23
+ | `build.sh` | builds the sbx template from `install.sh`, hashes the recipe into the tag, saves, **verifies**, and records the ref |
24
+
25
+ ## The user does nothing
26
+
27
+ That is a hard requirement, and it drives the design:
28
+
29
+ 1. **The tag is a hash of the recipe.** Editing `install.sh` produces a *new*
30
+ template tag. A stale snapshot can never be silently reused, and no version
31
+ bookkeeping is needed.
32
+ 2. **`build.sh` is idempotent and cheap when current** — one `sbx template ls`
33
+ decides whether there is any work, so it is safe to call on every launch
34
+ (`bash template/build.sh`, or a launcher doing it for you). No-op: ~1 s.
35
+ 3. **The builder records the ref** in
36
+ `${XDG_CACHE_HOME:-~/.cache}/pi-sbx-lite/template-ref`.
37
+ 4. **The extension adopts it automatically** — it reads that file at session
38
+ start, *verifies the template still exists*, and uses it. No configuration.
39
+ 5. **Nothing about this can fail a session.** Missing file, unreadable file,
40
+ template deleted since it was recorded, sbx not answering, the build itself
41
+ failing — every one of those degrades to the stock base image. The sandbox
42
+ still works; it is simply bigger. The user is never blocked and never sees an
43
+ error they have to act on.
44
+ 6. **An explicit `DOCKER_SANDBOX_TEMPLATE` always wins**, so this is an opinion,
45
+ not a policy.
46
+
47
+ ```
48
+ first launch → stock base (works, bigger) [no template recorded yet]
49
+ build.sh runs → builds + verifies, records the ref [once, ~2 min]
50
+ later launches → lightweight template, automatically
51
+ ```
52
+
53
+ ## Build it
54
+
55
+ ```bash
56
+ npm run template # build if missing/stale, else no-op
57
+ npm run template:check # report status, change nothing
58
+ bash template/build.sh --force
59
+ ```
60
+
61
+ Tunables: `SBX_LITE_BASE` (base image), `SBX_LITE_TAG` (override the tag),
62
+ `NODE_VERSION`, `SBX_LITE_BUILD=off` (drop `build-essential`+`python3`, −265 MB),
63
+ `SBX_LITE_PNPM=off`.
64
+
65
+ ## Verifying without `sbx`
66
+
67
+ `build.sh` needs a host that can run `sbx`. The recipe does not: it is plain
68
+ apt + curl + npm, so it can be built and **measured** anywhere docker runs.
69
+
70
+ ```bash
71
+ docker build -f template/Dockerfile -t sbx-lite .
72
+ docker run --rm sbx-lite sh -c 'node -v; pnpm -v; git --version; rg --version'
73
+ docker run --rm sbx-lite du -sh /
74
+ ```
75
+
76
+ ## What is deliberately not here
77
+
78
+ - **pi.** With the sandbox execution backend, pi runs on the host and routes its
79
+ tools in. Baking it into the template was 136 MB *and* a maintenance
80
+ treadmill: every pi release invalidated the template for every project.
81
+ - **A browser.** Playwright's Chromium headless shell is ~270 MB. It is genuinely
82
+ needed for UI work and genuinely wasted for everything else, so it belongs in a
83
+ separate `webdev` variant, not in every sandbox.
84
+ - **Language toolchains nobody asked for.** The default profile is what an
85
+ ordinary project needs; `build-essential` + `python3` are the one concession,
86
+ because a failing `pnpm install` of a native module would break the
87
+ "user does nothing" guarantee. Turn them off if your projects don't need them.
88
+
89
+ ## See also
90
+
91
+ `sandbox/README.md` for the execution backend, and the repo README for how this
92
+ fits the two modes.
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # build.sh — build the LIGHTWEIGHT sandbox template, automatically.
4
+ #
5
+ # bash template/build.sh # build if missing/stale, else no-op (fast)
6
+ # bash template/build.sh --check # report status, change nothing
7
+ # bash template/build.sh --ref # print the template ref, or nothing
8
+ # bash template/build.sh --force # rebuild even if the current one exists
9
+ #
10
+ # Design goal: **the user never runs anything and never sees a failure.**
11
+ #
12
+ # * Idempotent and cheap when current: a single `sbx template ls` decides
13
+ # whether to do anything, so it is safe to call on every launch.
14
+ # * The tag embeds a hash of the recipe, so editing install.sh produces a NEW
15
+ # template instead of silently reusing a stale snapshot.
16
+ # * Self-verifying, like the pi template builder it replaces: after saving, it
17
+ # boots a throwaway sandbox FROM THE SAVED TEMPLATE and asserts the tools are
18
+ # really there. A template that silently lacks node/pnpm/git can't be
19
+ # produced.
20
+ # * Records the resolved ref where the extension looks for it, so the sandbox
21
+ # starts using it with no configuration. If that file is missing or the
22
+ # template is gone, the extension simply creates sandboxes from the stock
23
+ # base — degraded, never broken.
24
+ #
25
+ # What it does NOT contain: pi (pi runs on the HOST now and routes its tools in),
26
+ # editors for pi, or language toolchains nobody asked for. See install.sh.
27
+
28
+ set -euo pipefail
29
+
30
+ HERE="$(cd "$(dirname "$0")" && pwd)"
31
+ BASE="${SBX_LITE_BASE:-docker.io/docker/sandbox-templates:shell}"
32
+
33
+ # Portability: macOS has shasum, Linux has sha256sum.
34
+ if command -v sha256sum >/dev/null 2>&1; then
35
+ HASHER="sha256sum"
36
+ else
37
+ HASHER="shasum -a 256"
38
+ fi
39
+
40
+ # NOTE the braces: `cat f; echo x | hash` would pipe ONLY the echo and let the
41
+ # cat output leak into the captured value (giving a tag containing the entire
42
+ # recipe). The whole group must be the pipeline's input.
43
+ RECIPE_HASH="$( { cat "$HERE/install.sh"; printf 'base=%s\n' "$BASE"; } | $HASHER | cut -c1-8 )"
44
+
45
+ # A malformed tag would produce a template nobody can ever match, so fail loudly
46
+ # here rather than writing a nonsense ref that the extension then ignores.
47
+ case "$RECIPE_HASH" in
48
+ [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
49
+ *)
50
+ echo "build: could not compute a recipe hash (got '${RECIPE_HASH:0:40}…')" >&2
51
+ exit 1
52
+ ;;
53
+ esac
54
+
55
+ TAG="${SBX_LITE_TAG:-pi-sbx-lite:${RECIPE_HASH}}"
56
+ REF="docker.io/library/${TAG}"
57
+ STATE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/pi-sbx-lite"
58
+ STATE_FILE="$STATE_DIR/template-ref"
59
+
60
+ TPL_SANDBOX="pi-sbx-lite-build-$$"
61
+ VERIFY_SANDBOX="pi-sbx-lite-verify-$$"
62
+ # Build in a scratch dir, never the caller's cwd: mounting the caller's project
63
+ # into the snapshot would bake project files into the shared image.
64
+ SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/pi-sbx-lite.XXXXXX")"
65
+
66
+ MODE="ensure"
67
+ case "${1:-}" in
68
+ --check) MODE="check" ;;
69
+ --ref) MODE="ref" ;;
70
+ --force) MODE="force" ;;
71
+ "") ;;
72
+ *)
73
+ echo "usage: $0 [--check|--ref|--force]" >&2
74
+ exit 2
75
+ ;;
76
+ esac
77
+
78
+ cleanup() {
79
+ rm -rf "$SCRATCH"
80
+ sbx rm --force "$TPL_SANDBOX" >/dev/null 2>&1 || true
81
+ sbx rm --force "$VERIFY_SANDBOX" >/dev/null 2>&1 || true
82
+ }
83
+ trap cleanup EXIT
84
+
85
+ # `sbx template ls` differs between sbx versions (table vs flat), so match on the
86
+ # repository basename + version rather than on a column position — the same
87
+ # approach the existing pi-template detector needed.
88
+ template_present() {
89
+ sbx template ls 2>/dev/null | awk -v tag="${TAG}" '
90
+ {
91
+ sub(/^[[:space:]]+/, "")
92
+ if ($0 == "" || $0 ~ /^REPOSITORY/) next
93
+ r = $1; sub(/^.*\//, "", r)
94
+ if (r == tag && $2 != "") found = 1 # table row: <repo> <version>
95
+ if ($1 == "docker.io/library/" tag) found = 1
96
+ if (r == tag) found = 1
97
+ }
98
+ END { exit(found ? 0 : 1) }'
99
+ }
100
+
101
+ record_ref() {
102
+ mkdir -p "$STATE_DIR"
103
+ printf '%s\n' "$REF" >"$STATE_FILE"
104
+ }
105
+
106
+ case "$MODE" in
107
+ ref)
108
+ [ -f "$STATE_FILE" ] && cat "$STATE_FILE"
109
+ exit 0
110
+ ;;
111
+ check)
112
+ if template_present; then
113
+ echo "current: $REF"
114
+ exit 0
115
+ fi
116
+ echo "missing: $REF (would build from $BASE)"
117
+ exit 1
118
+ ;;
119
+ ensure)
120
+ if template_present; then
121
+ record_ref
122
+ echo "template up to date: $REF"
123
+ exit 0
124
+ fi
125
+ ;;
126
+ force) : ;;
127
+ esac
128
+
129
+ echo "building lightweight sandbox template"
130
+ echo " base: $BASE"
131
+ echo " tag: $TAG (recipe hash ${RECIPE_HASH})"
132
+ echo " recipe: $HERE/install.sh"
133
+ echo
134
+
135
+ # Leftovers from an interrupted build would make `sbx create` fail.
136
+ sbx rm --force "$TPL_SANDBOX" >/dev/null 2>&1 || true
137
+ sbx template rm "$TAG" >/dev/null 2>&1 || true
138
+
139
+ echo "creating throwaway sandbox…"
140
+ sbx create -q --name "$TPL_SANDBOX" -t "$BASE" shell "$SCRATCH"
141
+
142
+ # The recipe is passed as a single argv element; no output-masking pipe, so a
143
+ # failure inside the install surfaces as a non-zero exit and `set -e` aborts.
144
+ echo "installing baseline (this is the part that takes a couple of minutes)…"
145
+ sbx exec "$TPL_SANDBOX" -- bash -lc "$(cat "$HERE/install.sh")"
146
+
147
+ echo "saving template…"
148
+ sbx template save "$TPL_SANDBOX" "$TAG" # no `|| true` — a failed save must abort
149
+ sbx rm --force "$TPL_SANDBOX" >/dev/null 2>&1 || true
150
+
151
+ # The definitive check: boot FROM THE SAVED TEMPLATE and assert the toolchain.
152
+ # Asserting in the build sandbox would prove nothing about the saved image.
153
+ echo "verifying the saved template boots with a working toolchain…"
154
+ sbx create -q --name "$VERIFY_SANDBOX" -t "$REF" shell "$SCRATCH"
155
+ sbx exec "$VERIFY_SANDBOX" -- bash -lc '
156
+ set -e
157
+ for c in node pnpm git rg; do
158
+ command -v "$c" >/dev/null || { echo "verify: missing $c" >&2; exit 1; }
159
+ done
160
+ node -e "require(\"child_process\")" 2>/dev/null || { echo "verify: node is broken" >&2; exit 1; }
161
+ printf "verify: node %s, pnpm %s, git %s, rg %s\n" \
162
+ "$(node -v)" "$(pnpm -v)" "$(git --version | awk "{print \$3}")" "$(rg --version | head -1 | awk "{print \$2}")"'
163
+ sbx rm --force "$VERIFY_SANDBOX" >/dev/null 2>&1 || true
164
+
165
+ record_ref
166
+ echo
167
+ echo "done. recorded $REF in $STATE_FILE"
168
+ echo "the sandbox extension picks this up automatically; override with DOCKER_SANDBOX_TEMPLATE."
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # install.sh — the lightweight sandbox baseline.
4
+ #
5
+ # Runs INSIDE the sandbox (or inside a docker build). Deliberately contains no
6
+ # `sbx` commands so the exact same recipe can be validated with plain docker
7
+ # (`docker build -f template/Dockerfile …`) on machines where sbx cannot run.
8
+ #
9
+ # What it is: a general Linux + git + node/pnpm + search box. That is all a
10
+ # sandbox needs once pi runs on the HOST and merely routes its tools here —
11
+ # pi itself, an editor stack for pi, and every language toolchain somebody
12
+ # might want are all gone.
13
+ #
14
+ # Tunables (env):
15
+ # NODE_VERSION=22.22.1 node LTS to install
16
+ # SBX_LITE_BUILD=off drop build-essential + python3 (~250 MB, needed
17
+ # for native node modules / node-gyp)
18
+ # SBX_LITE_PNPM=off drop the pnpm global install
19
+
20
+ set -euo pipefail
21
+
22
+ NODE_VERSION="${NODE_VERSION:-22.22.1}"
23
+ export DEBIAN_FRONTEND=noninteractive
24
+
25
+ SUDO=""
26
+ if [ "$(id -u)" != "0" ]; then SUDO="sudo"; fi
27
+
28
+ # ── base packages ─────────────────────────────────────────────────────────
29
+ # git/curl/ca-certificates: cloning and fetching over TLS.
30
+ # ripgrep: the agent's search tool, and much faster than grep -r.
31
+ # build-essential + python3: native node modules (node-gyp) — without these a
32
+ # `pnpm install` in an ordinary project can fail, which would break the
33
+ # "the user does nothing" guarantee. Opt out with SBX_LITE_BUILD=off.
34
+ CORE="ca-certificates curl git gnupg ripgrep jq unzip xz-utils procps less vim-tiny"
35
+ EXTRA=""
36
+ if [ "${SBX_LITE_BUILD:-on}" != "off" ]; then EXTRA="build-essential python3"; fi
37
+
38
+ echo "install: apt packages (core${EXTRA:+ + $EXTRA})"
39
+ # shellcheck disable=SC2086
40
+ $SUDO apt-get update -qq
41
+ # shellcheck disable=SC2086
42
+ $SUDO apt-get install -y -qq --no-install-recommends $CORE $EXTRA
43
+
44
+ # ── apt hygiene ───────────────────────────────────────────────────────────
45
+ # ~55 MB in a typical Ubuntu image, and pure waste in a snapshot: the lists are
46
+ # stale the moment the image is saved and every sandbox re-downloads them.
47
+ $SUDO rm -rf /var/lib/apt/lists/* /usr/share/doc/* /usr/share/man/* /var/cache/apt/*
48
+
49
+ # ── node + pnpm ───────────────────────────────────────────────────────────
50
+ # The official tarball into /usr/local rather than a distro/node image layer:
51
+ # no apt repository to configure, no extra base layers to carry.
52
+ case "$(uname -m)" in
53
+ aarch64 | arm64) NODE_ARCH="arm64" ;;
54
+ x86_64 | amd64) NODE_ARCH="x64" ;;
55
+ *)
56
+ echo "install: unsupported architecture $(uname -m)" >&2
57
+ exit 1
58
+ ;;
59
+ esac
60
+
61
+ echo "install: node v${NODE_VERSION} (${NODE_ARCH})"
62
+ curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" -o /tmp/node.tar.xz
63
+ $SUDO tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1
64
+ rm -f /tmp/node.tar.xz
65
+
66
+ # The tarball also ships node's C++ headers (~65 MB). node-gyp fetches its own
67
+ # headers into a cache, so these are dead weight in a snapshot.
68
+ $SUDO rm -rf /usr/local/include/node
69
+
70
+ if [ "${SBX_LITE_PNPM:-on}" != "off" ]; then
71
+ echo "install: pnpm"
72
+ $SUDO npm install -g --no-audit --no-fund --loglevel=error pnpm
73
+ fi
74
+ # The npm cache is ~30-60 MB of tarballs that a snapshot never needs again.
75
+ $SUDO npm cache clean --force >/dev/null 2>&1 || true
76
+
77
+ echo "install: done — node $(node -v), pnpm $(pnpm -v 2>/dev/null || echo absent)"
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Load a TypeScript module from a plain Node script.
3
+ *
4
+ * Node >= 23.6 strips types natively and can import `.ts` directly. Older
5
+ * Node (22.x) cannot (`ERR_NO_TYPESCRIPT`) or does not know the extension at
6
+ * all (`ERR_UNKNOWN_FILE_EXTENSION`), so fall back to `jiti` — the same
7
+ * loader pi itself uses for extensions. jiti is resolved from pi's own
8
+ * dependency tree so no extra dependency is needed.
9
+ *
10
+ * Shared by smoke-test.mjs and sandbox/e2e.mjs.
11
+ */
12
+
13
+ import { createRequire } from "node:module";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const repoRoot = path.dirname(fileURLToPath(import.meta.url));
19
+
20
+ let jiti;
21
+
22
+ function makeJiti() {
23
+ if (jiti) return jiti;
24
+ for (const candidate of [
25
+ path.join(repoRoot, "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti"),
26
+ "jiti",
27
+ ]) {
28
+ try {
29
+ const mod = require(candidate);
30
+ const createJiti = mod.createJiti ?? mod.default?.createJiti;
31
+ if (createJiti) {
32
+ jiti = createJiti(repoRoot + path.sep);
33
+ return jiti;
34
+ }
35
+ } catch {
36
+ /* try the next candidate */
37
+ }
38
+ }
39
+ throw new Error("could not load jiti (needed to import .ts on Node < 23.6)");
40
+ }
41
+
42
+ /** Import a `.ts` (or `.mjs`) module, by repo-relative or absolute path. */
43
+ export async function loadTs(relativeOrAbsolute) {
44
+ const absolute = path.isAbsolute(relativeOrAbsolute)
45
+ ? relativeOrAbsolute
46
+ : path.join(repoRoot, relativeOrAbsolute);
47
+ try {
48
+ return await import(absolute);
49
+ } catch (err) {
50
+ if (err?.code !== "ERR_UNKNOWN_FILE_EXTENSION" && err?.code !== "ERR_NO_TYPESCRIPT") throw err;
51
+ return makeJiti()(absolute);
52
+ }
53
+ }