4 Commits

Author SHA1 Message Date
pi f91dff6090 chore(release): promote CHANGELOG Unreleased -> v1.1.0 (2026-06-10)
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 16s
Publish Docker Image / build-base (push) Successful in 42m12s
Publish Docker Image / smoke-studio (push) Successful in 3m46s
Publish Docker Image / smoke (push) Successful in 5m21s
Publish Docker Image / build-variant-studio (push) Successful in 16m56s
Publish Docker Image / build-variant (push) Successful in 18m0s
Publish Docker Image / promote-base-latest (push) Successful in 8s
Publish Docker Image / update-description (push) Successful in 11s
2026-06-10 23:52:48 +02:00
pi 9ebb0643c7 docs: fix drift — sync compose/volumes, studio coverage, mempalace link
Audit found README/AGENTS carried a stale compose/volume set that
diverged from the shipped docker-compose.yml (DOCKER_HUB + compose +
.env.example were already consistent — README was the outlier):

- README compose block + 'Volumes and persistence' table: correct volume
  names (devbox-shell-history not -bash-history; devbox-uv at
  ~/.local/share/uv not devbox-uv-tools at /opt/uv-tools — the latter
  would SHADOW the baked mempalace install at UV_TOOL_DIR); add
  devbox-ssh-local + devbox-zoxide; mark devbox-palace/-chroma-cache
  optional; WORKSPACE_PATH/SSH_KEY_PATH (not HOST_WORKSPACE).
- README quickstart: 'compose exec -u developer' (no USER in image; bare
  exec lands a root shell).
- README: pi-studio now 'shipped' not 'planned'; build-pipeline + tag
  table cover -studio + smoke-studio/build-variant-studio.
- AGENTS: backward-compat volume names corrected; repo-layout bullets
  cover pi-studio install + studio-expose + STUDIO_EXPOSE bridge.
- DOCKER_HUB: MemPalace source link -> upstream MemPalace/mempalace
  (matches Dockerfile.base + CHANGELOG refs).

Note: the shipped v1.0.0 CHANGELOG migration note still lists the old
(incorrect) volume names; left as immutable released history.
2026-06-10 23:52:17 +02:00
pi 7d8ee4cea1 feat(studio): bundle studio-expose bridge + socat (opt-in STUDIO_EXPOSE)
pi-studio binds the container's 127.0.0.1, which a published Docker port
can't reach. Add a robust, portable bridge rather than a doc-only one-liner:

- Dockerfile.base: add socat (~1 MB, generally useful TCP relay).
- rootfs/usr/local/bin/studio-expose: socat TCP relay listening on the
  container's egress IPv4 (not 0.0.0.0 — that would EADDRINUSE against
  Studio's loopback listener) forwarding to 127.0.0.1:PORT on the SAME
  port, so Studio's printed token URL works verbatim. Robust egress-IP
  detection (hostname -I, loopback-filtered; ip route get fallback),
  --help, port validation, foreground.
- entrypoint-user.sh: opt-in STUDIO_EXPOSE=1 auto-starts the bridge in the
  background (studio variant only). Default OFF — Studio stays loopback-only
  (its secure default) unless explicitly opted in.
- README: 'Using pi-studio' now documents host-networking (A) and the
  studio-expose/STUDIO_EXPOSE bridge (B) with a security note; ssh -L for
  remote, mosh caveat retained.
- smoke-test: assert socat + studio-expose present (base-level).
- CHANGELOG/AGENTS updated.

No tag — stopping for review.
2026-06-10 23:33:44 +02:00
pi a78e59fb5b feat(studio): add :latest-studio variant (PR-3)
Bundle pi-studio (omaclaren/pi-studio) as a new -studio image variant:
browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs,
/studio command + studio_* agent tools.

- Dockerfile.variant: INSTALL_STUDIO + PI_STUDIO_REPO/REF args; vendor
  pi-studio to /opt/pi-studio (no build step — prebuilt client in git;
  npm install --omit=dev for 3 prod deps). STUDIO_PORT=8765 advisory.
- entrypoint-user.sh: register /opt/pi-studio via the existing pi install
  local-path loop (auto-skips in non-studio variant).
- smoke-test.sh: auto-detected studio assertions (clone + prebuilt client
  + pi install registration).
- CI: resolve PI_STUDIO_REF to a SHA; independent smoke-studio +
  build-variant-studio jobs that gate ONLY the -studio tags, so a studio
  failure never blocks the core :latest release.
- README: 'Using pi-studio' section documenting the container access
  reality — pi-studio hard-binds 127.0.0.1 (index.ts .listen(port,
  '127.0.0.1'), no --host flag), so -p publish alone can't reach it.
  Documents host-networking and loopback-bridge paths, the remote ssh -L
  forward, and the mosh caveat (no port forwarding; run parallel ssh -L).
- CHANGELOG/AGENTS/DOCKER_HUB updated. Will tag as v1.1.0 (minor).

No tag created — stopping for review.
2026-06-10 23:15:29 +02:00
10 changed files with 540 additions and 63 deletions
+147
View File
@@ -116,6 +116,7 @@ jobs:
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }} obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }} toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }}
extensions_ref: ${{ steps.resolve.outputs.extensions_ref }} extensions_ref: ${{ steps.resolve.outputs.extensions_ref }}
studio_ref: ${{ steps.resolve.outputs.studio_ref }}
steps: steps:
- name: Resolve pi version + companion refs - name: Resolve pi version + companion refs
id: resolve id: resolve
@@ -150,9 +151,16 @@ jobs:
[ -n "$EXTENSIONS_REF" ] || EXTENSIONS_REF=main [ -n "$EXTENSIONS_REF" ] || EXTENSIONS_REF=main
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT" echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT" echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
# Resolve pi-studio (omaclaren/pi-studio) main HEAD to a SHA for
# the :latest-studio variant — same cache-busting rationale.
STUDIO_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/omaclaren/pi-studio/commits/main" || echo "main")
[ -n "$STUDIO_REF" ] || STUDIO_REF=main
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
echo "Resolved PI_VERSION=${PI_VERSION}" echo "Resolved PI_VERSION=${PI_VERSION}"
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}" echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}" echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
echo "Resolved PI_STUDIO_REF=${STUDIO_REF}"
# ── Phase 2: build & push base (multi-arch), only when needed ────── # ── Phase 2: build & push base (multi-arch), only when needed ──────
build-base: build-base:
@@ -277,6 +285,62 @@ jobs:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
run: bash scripts/smoke-test.sh pi-devbox:smoke run: bash scripts/smoke-test.sh pi-devbox:smoke
# ── Phase 3b: amd64 smoke for the studio variant ────────────────────
# Additive + independent of the core `smoke` job: gates ONLY
# build-variant-studio, never the core build-variant. A studio build or
# smoke failure therefore cannot block the :latest / :vX.Y.Z release.
smoke-studio:
needs: [base-decide, build-base, resolve-versions]
if: |
always() &&
needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build amd64 studio variant for smoke
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: pi-devbox:smoke-studio
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
INSTALL_STUDIO=true
PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }}
- name: Smoke test studio (amd64)
env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
run: bash scripts/smoke-test.sh pi-devbox:smoke-studio
# ── Phase 4: multi-arch publish ───────────────────────────────────── # ── Phase 4: multi-arch publish ─────────────────────────────────────
build-variant: build-variant:
needs: [base-decide, smoke, resolve-versions] needs: [base-decide, smoke, resolve-versions]
@@ -354,6 +418,89 @@ jobs:
echo "==> All 3 build+push attempts failed" echo "==> All 3 build+push attempts failed"
exit 1 exit 1
# ── Phase 4b: multi-arch publish of the studio variant ───────────────
# Additive: publishes :vX.Y.Z-studio (+ :latest-studio on release). Gated
# on its own smoke-studio, NOT on the core build-variant, so it can ship
# or fail independently of the core release.
build-variant-studio:
needs: [base-decide, smoke-studio, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute studio version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-studio"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-studio"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push studio variant (with retry)
shell: bash
env:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }}
run: |
set -euo pipefail
TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
# 3-attempt retry (see build-base step for rationale).
for attempt in 1 2 3; do
echo "==> Build+push attempt ${attempt}/3"
if docker buildx build \
--platform linux/amd64,linux/arm64 \
--file Dockerfile.variant \
--push \
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
--build-arg "PI_VERSION=${PI_VERSION}" \
--build-arg "PI_FORK_REF=${FORK_REF}" \
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
--build-arg "INSTALL_STUDIO=true" \
--build-arg "PI_STUDIO_REF=${STUDIO_REF}" \
"${TAG_FLAGS[@]}" \
.; then
echo "==> Attempt ${attempt} succeeded"
exit 0
fi
if [[ "${attempt}" -lt 3 ]]; then
backoff=$(( attempt * 15 ))
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
sleep "${backoff}"
fi
done
echo "==> All 3 build+push attempts failed"
exit 1
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─ # ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
promote-base-latest: promote-base-latest:
needs: needs:
+25 -13
View File
@@ -12,25 +12,32 @@ re-brand of opencode-devbox's `pi-only` variant.
Node.js, Python toolchain, locales, ssh ControlMaster defaults, and Node.js, Python toolchain, locales, ssh ControlMaster defaults, and
`/etc/tmux.conf` with 0-indexed sessions. `/etc/tmux.conf` with 0-indexed sessions.
- `Dockerfile.variant``FROM base-<hash>`, adds pi + companions - `Dockerfile.variant``FROM base-<hash>`, adds pi + companions
(`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`). (`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`)
and, when `INSTALL_STUDIO=true`, vendors `pi-studio` to `/opt/pi-studio`
(`-studio` variant).
- `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`. - `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`.
- `entrypoint-user.sh` — per-container start: SSH ControlMaster socket - `entrypoint-user.sh` — per-container start: SSH ControlMaster socket
dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions
deploy, mempalace-bridge symlink, fork/recall pi-install, skillset deploy, mempalace-bridge symlink, fork/recall + pi-studio pi-install,
optional `studio-expose` bridge (when `STUDIO_EXPOSE=1`), skillset
deploy. deploy.
- `rootfs/` — files baked into the image (bash aliases, inputrc, - `rootfs/` — files baked into the image (bash aliases, inputrc,
setup-lan-access.sh). setup-lan-access.sh, `studio-expose` helper).
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Hub. - `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Hub.
- `.gitea/workflows/docker-publish.yml` — two-phase CI (base-decide → - `.gitea/workflows/docker-publish.yml` — two-phase CI (base-decide →
build-base → smoke → build-variant → promote-base-latest → build-base → smoke → build-variant → promote-base-latest →
update-description). update-description). The `-studio` variant adds independent
`smoke-studio` + `build-variant-studio` jobs that gate only the
`-studio` tags (never the core `:latest` release).
## Versioning scheme ## Versioning scheme
- Tags follow semver. **v1.0.0** is the first decoupled release; future - Tags follow semver. **v1.0.0** is the first decoupled release; future
minor bumps add variants (`-studio`, `-studio-tex`); patch bumps follow minor bumps add variants (`-studio`, `-studio-tex`); patch bumps follow
pi npm version updates and small fixes. pi npm version updates and small fixes.
- Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`. - Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`
+ (since v1.1.0) `joakimp/pi-devbox:vX.Y.Z-studio` +
`joakimp/pi-devbox:latest-studio`.
Internal tags: `joakimp/pi-devbox:base-<hash>` (content-addressed) + Internal tags: `joakimp/pi-devbox:base-<hash>` (content-addressed) +
`joakimp/pi-devbox:base-latest` (alias of most recent base). `joakimp/pi-devbox:base-latest` (alias of most recent base).
@@ -45,8 +52,8 @@ re-brand of opencode-devbox's `pi-only` variant.
5. Watch CI: smoke job builds amd64 only and asserts size + extensions + 5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
pi version + new-base-tooling presence. Variant build is multi-arch pi version + new-base-tooling presence. Variant build is multi-arch
(amd64 + arm64) only after smoke passes. (amd64 + arm64) only after smoke passes.
6. Verify the Hub tags appear (latest + vX.Y.Z, plus base-latest if the 6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
base was rebuilt this run). base-latest if the base was rebuilt this run).
7. **Revoke any short-lived Gitea PAT** used during the release at 7. **Revoke any short-lived Gitea PAT** used during the release at
`gitea.jordbo.se/user/settings/applications`. `gitea.jordbo.se/user/settings/applications`.
@@ -108,12 +115,16 @@ deprecated artifacts (to be removed in opencode-devbox v2.0.0).
## What we DON'T install (and why) ## What we DON'T install (and why)
- **No texlive** (~600 MB1 GB). Users who need PDF export from pandoc - **No texlive** (~600 MB1 GB). Users who need PDF export from pandoc
can install on demand: `sudo apt-get install texlive-xetex or pi-studio can install on demand: `sudo apt-get install texlive-xetex
texlive-latex-recommended`. The planned `:latest-studio-tex` variant texlive-latex-recommended`. The planned `:latest-studio-tex` variant
will bake this in. will bake this in.
- **No pi-studio** (yet). Coming in v1.1.0 as the `:latest-studio` - **pi-studio** ships in the `:latest-studio` variant (since v1.1.0),
variant. v1.0.0 is intentionally scope-limited to "decouple, don't vendored to `/opt/pi-studio` and registered at container start via
reshape." `pi install /opt/pi-studio` (see Dockerfile.variant `INSTALL_STUDIO`).
The default `:latest` image stays studio-free. Note: pi-studio binds
`127.0.0.1` inside the container, so browser access needs host
networking or the bundled `studio-expose` bridge (socat; auto-starts
when `STUDIO_EXPOSE=1`) — see README "Using pi-studio".
- **No Julia/R/GHCi/Clojure runtimes**. Use `uv run --with X` for - **No Julia/R/GHCi/Clojure runtimes**. Use `uv run --with X` for
Python REPLs; `apt install` other-language runtimes ad-hoc per Python REPLs; `apt install` other-language runtimes ad-hoc per
container if needed. container if needed.
@@ -121,8 +132,9 @@ deprecated artifacts (to be removed in opencode-devbox v2.0.0).
## Backward compatibility ## Backward compatibility
- The host `~/.mempalace` bind-mount path is unchanged. - The host `~/.mempalace` bind-mount path is unchanged.
- Volume names (`devbox-pi-config`, `devbox-bash-history`, - Volume names (`devbox-pi-config`, `devbox-ssh-local`,
`devbox-nvim-data`, `devbox-uv-tools`, `devbox-chroma-cache`) are `devbox-shell-history`, `devbox-zoxide`, `devbox-nvim-data`,
`devbox-uv`; optional `devbox-palace`, `devbox-chroma-cache`) are
unchanged. unchanged.
- `~/.pi/agent/` layout inside the container is unchanged; existing - `~/.pi/agent/` layout inside the container is unchanged; existing
named volumes work without recreation. named volumes work without recreation.
+34 -1
View File
@@ -13,7 +13,40 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
## Unreleased ## Unreleased
_(no changes since v1.0.1)_ ## v1.1.0 — 2026-06-10
### Added — `:latest-studio` variant
- **New `-studio` image variant** bundling
[pi-studio](https://github.com/omaclaren/pi-studio) — a two-pane
browser workspace (prompt/response editor, live KaTeX/Mermaid preview,
tmux-backed literate REPLs for Shell/Python/IPython/Julia/R/GHCi/Clojure)
plus the `/studio` slash command and `studio_repl_send` /
`studio_export_*` agent tools. Published as `:latest-studio` and
`:vX.Y.Z-studio` (multi-arch).
- pi-studio is **vendored to `/opt/pi-studio`** at build time (gated by
`INSTALL_STUDIO=true`, ref pinned via CI-resolved `PI_STUDIO_REF`) and
registered on container start by `entrypoint-user.sh` via
`pi install /opt/pi-studio` — the same pattern as pi-fork /
pi-observational-memory. No build step: pi-studio ships its browser
bundle prebuilt in git. The non-studio `:latest` image is unchanged.
- CI gains independent `smoke-studio` + `build-variant-studio` jobs that
gate **only** the studio tags, so a studio build/smoke failure can
never block the core `:latest` / `:vX.Y.Z` release.
- `STUDIO_PORT=8765` baked as an advisory default.
- **`studio-expose` helper + `socat` (base).** Because pi-studio binds the
container's loopback, a published Docker port can't reach it. The new
`studio-expose` helper (socat, added to the base) bridges the container's
loopback to its egress interface on the same port; set `STUDIO_EXPOSE=1`
in compose to auto-start it on boot (default off — Studio stays
loopback-only otherwise). `socat` is in the base for all variants.
- **README "Using pi-studio" section.** Documents the container access
reality: pi-studio hard-binds `127.0.0.1` inside the container
(`.listen(port,"127.0.0.1")`, no `--host` flag), so a plain `-p`
publish does not reach it. Documents the two working paths — host
networking (recommended on OrbStack) and a loopback bridge for bridge
networking — plus the remote `ssh -L` forward and the **mosh caveat**
(mosh cannot forward ports; run a parallel `ssh -L` alongside it).
## v1.0.1 — 2026-06-10 ## v1.0.1 — 2026-06-10
+5 -1
View File
@@ -10,9 +10,13 @@ A self-contained Docker container for the [pi coding-agent](https://github.com/e
|---|---|---|---| |---|---|---|---|
| `joakimp/pi-devbox:latest` | amd64, arm64 | ~1.1 GB | Self-contained: base + pi `{{PI_VERSION}}` + companions | | `joakimp/pi-devbox:latest` | amd64, arm64 | ~1.1 GB | Self-contained: base + pi `{{PI_VERSION}}` + companions |
| `joakimp/pi-devbox:vX.Y.Z` | amd64, arm64 | same | Pinned semver release | | `joakimp/pi-devbox:vX.Y.Z` | amd64, arm64 | same | Pinned semver release |
| `joakimp/pi-devbox:latest-studio` | amd64, arm64 | ~1.15 GB | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio): browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs |
| `joakimp/pi-devbox:vX.Y.Z-studio` | amd64, arm64 | same | Pinned semver studio release |
| `joakimp/pi-devbox:base-latest` | amd64, arm64 | ~1.0 GB | Base layer alias (internal building block; pull `:latest` instead) | | `joakimp/pi-devbox:base-latest` | amd64, arm64 | ~1.0 GB | Base layer alias (internal building block; pull `:latest` instead) |
| `joakimp/pi-devbox:base-<hash>` | amd64, arm64 | ~1.0 GB | Content-addressed base; immutable. Stable parent for variant rebuilds. | | `joakimp/pi-devbox:base-<hash>` | amd64, arm64 | ~1.0 GB | Content-addressed base; immutable. Stable parent for variant rebuilds. |
> **pi-studio (`-studio` tags):** launch with `/studio --no-browser --port 8765` inside a pi session. The server binds `127.0.0.1` **inside the container**, so reach it via host networking or a loopback bridge (and `ssh -L` for a remote host; mosh needs a parallel `ssh -L`). Full recipe: [README → Using pi-studio](https://gitea.jordbo.se/joakimp/pi-devbox#using-pi-studio--studio-variant).
## Quick start ## Quick start
One-shot, no persistence: One-shot, no persistence:
@@ -147,7 +151,7 @@ Optional volumes for MemPalace (commented out by default — uncomment in `docke
- **pi**: https://github.com/earendil-works/pi - **pi**: https://github.com/earendil-works/pi
- **pi-toolkit**: https://gitea.jordbo.se/joakimp/pi-toolkit - **pi-toolkit**: https://gitea.jordbo.se/joakimp/pi-toolkit
- **pi-extensions**: https://gitea.jordbo.se/joakimp/pi-extensions - **pi-extensions**: https://gitea.jordbo.se/joakimp/pi-extensions
- **MemPalace**: https://github.com/joakimp/mempalace - **MemPalace**: https://github.com/MemPalace/mempalace
## License ## License
+7
View File
@@ -50,6 +50,10 @@ ENV DEBIAN_FRONTEND=noninteractive
# graphviz — `dot` rendering for many diagram tools. ~10 MB. # graphviz — `dot` rendering for many diagram tools. ~10 MB.
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB. # imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
# yq — YAML-aware companion to jq. # yq — YAML-aware companion to jq.
# socat — TCP relay. Powers `studio-expose`, which bridges
# pi-studio's container-loopback server to the container's
# external interface so a published port can reach it.
# ~1 MB; generally useful for any port-forwarding need.
RUN apt-get update && \ RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \ apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
@@ -85,6 +89,7 @@ RUN apt-get update && \
pandoc \ pandoc \
graphviz \ graphviz \
imagemagick \ imagemagick \
socat \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -430,9 +435,11 @@ COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
# ── Entrypoint ──────────────────────────────────────────────────────── # ── Entrypoint ────────────────────────────────────────────────────────
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/ COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \ RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
/usr/local/bin/studio-expose \
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true /usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
# Start as root — entrypoint adjusts UID/GID then drops to developer # Start as root — entrypoint adjusts UID/GID then drops to developer
+48
View File
@@ -88,6 +88,54 @@ RUN set -e && \
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \ echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)" echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)"
# ── Optional: pi-studio (:latest-studio variant) ─────────────────────
# pi-studio (omaclaren/pi-studio) is a pi-package + theme providing a
# two-pane browser workspace: prompt/response editor, KaTeX/Mermaid live
# preview, and tmux-backed literate REPLs. Off by default; the studio
# variant sets INSTALL_STUDIO=true.
#
# Vendored to /opt/pi-studio and registered at container start by
# entrypoint-user.sh via `pi install /opt/pi-studio` — the SAME pattern
# as pi-fork / pi-observational-memory above. We deliberately do NOT run
# `pi install <git-url>` at build time: that writes into ~/.pi/agent,
# which is a named volume, so a build-time install collides with / is
# shadowed by the volume on first run. Vendoring to /opt (an image layer)
# + a runtime local-path install keeps it on the image and idempotent.
#
# No build step is needed: pi-studio ships its browser bundle prebuilt in
# git (client/studio-client.js) and pi loads index.ts directly; its
# package.json scripts are only test/typecheck. So we just fetch + install
# the 3 prod deps (@earendil-works/pi-ai, @sinclair/typebox, ws).
#
# PI_STUDIO_REF is CI-resolved to a commit SHA to defeat the registry-
# buildcache cache-hit footgun (see the PI_VERSION note above).
ARG INSTALL_STUDIO=false
ARG PI_STUDIO_REPO=https://github.com/omaclaren/pi-studio.git
ARG PI_STUDIO_REF=main
RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
set -e; \
rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \
git -C /opt/pi-studio init -q && \
git -C /opt/pi-studio remote add origin "${PI_STUDIO_REPO}" && \
ok=0; for i in 1 2 3 4 5; do \
if git -C /opt/pi-studio fetch --depth 1 origin "${PI_STUDIO_REF}" && \
git -C /opt/pi-studio checkout -q FETCH_HEAD; then ok=1; break; fi; \
echo "git fetch pi-studio@${PI_STUDIO_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
[ "$ok" = "1" ] && \
(cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \
echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \
fi
# STUDIO_PORT: advisory default consumed by docker-compose port publishing
# and the recommended `/studio --no-browser --port "$STUDIO_PORT"` launch.
# Harmless in the non-studio variant. NOTE: pi-studio hard-binds the server
# to 127.0.0.1 inside the container (index.ts: .listen(port,"127.0.0.1")),
# so reaching it from a browser needs a loopback bridge or host networking —
# see the "Using pi-studio" section in README.md.
ENV STUDIO_PORT=8765
# ── Optional: Go toolchain ─────────────────────────────────────────── # ── Optional: Go toolchain ───────────────────────────────────────────
# Off by default; opt in for users who run Go tools inside the devbox. # Off by default; opt in for users who run Go tools inside the devbox.
ARG INSTALL_GO=false ARG INSTALL_GO=false
+135 -38
View File
@@ -98,7 +98,7 @@ git clone https://gitea.jordbo.se/joakimp/pi-devbox
cd pi-devbox cd pi-devbox
cp .env.example .env # edit if needed cp .env.example .env # edit if needed
docker compose up -d docker compose up -d
docker compose exec devbox bash docker compose exec -u developer devbox bash
``` ```
You're now in the container as user `developer` with `pi` on PATH and You're now in the container as user `developer` with `pi` on PATH and
@@ -131,16 +131,100 @@ Currently published:
|---|---|---| |---|---|---|
| `joakimp/pi-devbox:latest` | base + pi + tooling | ~3.2 GB | | `joakimp/pi-devbox:latest` | base + pi + tooling | ~3.2 GB |
| `joakimp/pi-devbox:vX.Y.Z` | pinned-version equivalent | ~3.2 GB | | `joakimp/pi-devbox:vX.Y.Z` | pinned-version equivalent | ~3.2 GB |
| `joakimp/pi-devbox:latest-studio` | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio) (browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs) | ~3.25 GB |
| `joakimp/pi-devbox:vX.Y.Z-studio` | pinned-version studio equivalent | ~3.25 GB |
Planned for upcoming minor releases: Planned for an upcoming minor release:
- `joakimp/pi-devbox:latest-studio`adds [pi-studio](https://github.com/omaclaren/pi-studio) - `joakimp/pi-devbox:latest-studio-tex``-studio` plus `texlive-xetex`
for browser-based prompt editing, KaTeX/Mermaid preview, and
literate REPLs (Shell / Python / IPython / Julia / R / GHCi /
Clojure). Adds ~50 MB.
- `joakimp/pi-devbox:latest-studio-tex` — also adds `texlive-xetex`
for PDF export from Studio. Adds ~600 MB on top of `-studio`. for PDF export from Studio. Adds ~600 MB on top of `-studio`.
## Using pi-studio (`-studio` variant)
The `-studio` images bundle [pi-studio](https://github.com/omaclaren/pi-studio):
a two-pane browser workspace with a prompt/response editor, live
KaTeX/Mermaid preview, and tmux-backed literate REPLs (Shell / Python /
IPython / Julia / R / GHCi / Clojure). It is registered automatically on
container start (no `pi install` needed) and exposes the `/studio` slash
command plus the `studio_repl_send` / `studio_export_*` agent tools.
Inside a pi session in the container:
```
/studio --no-browser --port 8765 # pin a fixed port; STUDIO_PORT=8765 is the baked default
/studio --status # reprint the tokenized URL
```
### Reaching the UI from your browser (the container caveat)
pi-studio **hard-binds its server to `127.0.0.1` inside the container**
(`index.ts`: `.listen(port, "127.0.0.1")`) and serves a tokenized URL.
There is no `--host`/bind flag. This matters for a container: a plain
`docker run -p 8765:8765` publish forwards to the container's *external*
interface, **not** its loopback, so it will not reach Studio. Two paths
work:
**A. Host networking (simplest — OrbStack / single-host, no bridge).**
Run the container with host networking so the container's loopback is the
host's loopback:
```yaml
services:
devbox:
network_mode: host # container 127.0.0.1 == host 127.0.0.1
```
Then `http://127.0.0.1:8765/?token=…` works in a browser on the Docker
host. This is the most secure option (Studio never leaves loopback). Note:
host networking changes `host.docker.internal` semantics, so weigh it
against the LAN-jump SSH feature if you use that.
**B. `studio-expose` bridge (portable — any networking mode).** Publish a
port and run the bundled `studio-expose` helper, which uses `socat` to
bridge the container's loopback to its external interface (binding the
egress IP on the same port, so the token URL Studio printed works
verbatim):
```yaml
services:
devbox:
ports:
- "127.0.0.1:8765:8765" # host-localhost only
environment:
- STUDIO_EXPOSE=1 # auto-start the bridge on container boot
```
With `STUDIO_EXPOSE=1`, the entrypoint starts the bridge for you; just run
`/studio --port 8765` in your pi session. To bridge manually instead
(leave `STUDIO_EXPOSE` unset), run `studio-expose` in a container shell:
```bash
studio-expose # bridges $STUDIO_PORT (default 8765); --help for details
```
> **Security:** the bridge intentionally exposes Studio beyond loopback;
> its tokenized URL is the only auth. Keep the host-side publish on
> `127.0.0.1:` and use `ssh -L` for remote access. Default is **off**.
### Remote host (SSH / mosh)
When the Docker host is remote, keep Studio on localhost and forward the
port from your laptop:
```bash
ssh -L 8765:127.0.0.1:8765 user@docker-host # then open the token URL locally
```
**mosh cannot forward ports** (no `-L`/`-R` equivalent). To use Studio
over a mosh session, run a *separate* `ssh -L 8765:127.0.0.1:8765 host`
tunnel alongside mosh (mosh for the shell, ssh for the port), or reach the
host's published port directly over a trusted network (LAN / Tailscale /
WireGuard).
> PDF export (`/studio-pdf`, `studio_export_pdf`) needs a LaTeX engine,
> which is **not** in `-studio` (only the planned `-studio-tex`). HTML
> export, KaTeX, Mermaid, and all REPL features work without it.
## docker-compose.yml — basic shape ## docker-compose.yml — basic shape
```yaml ```yaml
@@ -152,37 +236,45 @@ services:
container_name: pi-devbox container_name: pi-devbox
stdin_open: true stdin_open: true
tty: true tty: true
env_file: .env env_file:
- .env
environment: environment:
- TZ=${TZ:-Europe/Stockholm}
- TERM=xterm-256color - TERM=xterm-256color
- AWS_PROFILE=${AWS_PROFILE:-} - GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
- AWS_REGION=${AWS_REGION:-eu-west-1} - GITEA_HOST=${GITEA_HOST:-}
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
volumes: volumes:
# Workspace: your host source tree, read-write # Workspace: your host source tree
- ${HOST_WORKSPACE:-./workspace}:/workspace:rw - ${WORKSPACE_PATH:-.}:/workspace
# SSH keys: read-only from host # SSH keys: read-only from host
- ${HOME}/.ssh:/home/developer/.ssh:ro - ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
# AWS config: read-only from host
- ${HOME}/.aws:/home/developer/.aws:ro
# MemPalace: bind-mounted so host pi and container pi share a brain
- ${HOME}/.mempalace:/home/developer/.mempalace:rw
# Per-container persistent state # Per-container persistent state
- devbox-pi-config:/home/developer/.pi - devbox-pi-config:/home/developer/.pi
- devbox-bash-history:/home/developer/.cache/bash - devbox-ssh-local:/home/developer/.ssh-local
- devbox-shell-history:/home/developer/.cache/bash
- devbox-zoxide:/home/developer/.local/share/zoxide
- devbox-nvim-data:/home/developer/.local/share/nvim - devbox-nvim-data:/home/developer/.local/share/nvim
- devbox-uv-tools:/opt/uv-tools - devbox-uv:/home/developer/.local/share/uv
- devbox-chroma-cache:/home/developer/.cache/chroma # Optional (uncomment to enable):
# - ~/.aws:/home/developer/.aws # AWS creds
# - devbox-palace:/home/developer/.mempalace # persist palace
# - devbox-chroma-cache:/home/developer/.cache/chroma # embedding cache
volumes: volumes:
devbox-pi-config: devbox-pi-config:
devbox-bash-history: devbox-ssh-local:
devbox-shell-history:
devbox-zoxide:
devbox-nvim-data: devbox-nvim-data:
devbox-uv-tools: devbox-uv:
devbox-chroma-cache: # devbox-palace:
# devbox-chroma-cache:
``` ```
See `.env.example` in the repo for available environment variables. See `docker-compose.yml` and `.env.example` in the repo for the full
template (build-from-source args, LAN-jump and skillset mounts, MemPalace
persistence). To share one palace between host pi and the container,
bind-mount your host `~/.mempalace` to `/home/developer/.mempalace`.
## uv-driven REPL recipes ## uv-driven REPL recipes
@@ -238,15 +330,16 @@ to refresh.
| Path inside container | Volume | What survives | | Path inside container | Volume | What survives |
|---|---|---| |---|---|---|
| `/workspace` | host bind-mount | host filesystem | | `/workspace` | host bind-mount (`WORKSPACE_PATH`) | host filesystem |
| `~/.ssh` | host bind-mount (read-only) | host filesystem | | `~/.ssh` | host bind-mount (read-only, `SSH_KEY_PATH`) | host filesystem |
| `~/.aws` | host bind-mount (read-only) | host filesystem |
| `~/.mempalace` | host bind-mount | host filesystem |
| `~/.pi` | named volume `devbox-pi-config` | `down -v` wipes | | `~/.pi` | named volume `devbox-pi-config` | `down -v` wipes |
| `~/.cache/bash` | named volume | `down -v` wipes | | `~/.ssh-local` | named volume `devbox-ssh-local` | `down -v` wipes |
| `~/.local/share/nvim` | named volume | `down -v` wipes | | `~/.cache/bash` | named volume `devbox-shell-history` | `down -v` wipes |
| `/opt/uv-tools` | named volume | `down -v` wipes | | `~/.local/share/zoxide` | named volume `devbox-zoxide` | `down -v` wipes |
| `~/.cache/chroma` | named volume | `down -v` wipes | | `~/.local/share/nvim` | named volume `devbox-nvim-data` | `down -v` wipes |
| `~/.local/share/uv` | named volume `devbox-uv` | `down -v` wipes |
| `~/.mempalace` | host bind-mount or `devbox-palace` (optional) | host / volume |
| `~/.cache/chroma` | `devbox-chroma-cache` (optional) | `down -v` wipes |
Anything not on a volume is on the writable layer and is lost on Anything not on a volume is on the writable layer and is lost on
container recreate. container recreate.
@@ -302,9 +395,9 @@ set -g pane-base-index 0
``` ```
This is the default tmux indexing. It's baked here because `pi-studio` This is the default tmux indexing. It's baked here because `pi-studio`
(planned for `:latest-studio`) hard-codes its tmux send target to (shipped in the `:latest-studio` variant) hard-codes its tmux send
`<session>:0.0`. If you override `base-index` to 1 in a personal target to `<session>:0.0`. If you override `base-index` to 1 in a
`~/.tmux.conf`, pi-studio will fail with "can't find window: 0". personal `~/.tmux.conf`, pi-studio will fail with "can't find window: 0".
## AWS Bedrock auth ## AWS Bedrock auth
@@ -327,8 +420,11 @@ pi-devbox is built from this repo's CI in two phases:
where `<hash>` is content-addressed over `Dockerfile.base`, where `<hash>` is content-addressed over `Dockerfile.base`,
`rootfs/`, and `entrypoint*.sh`. Rebuilt only when these change. `rootfs/`, and `entrypoint*.sh`. Rebuilt only when these change.
2. **Variant** (`Dockerfile.variant`) — `FROM ${BASE_IMAGE}` and adds 2. **Variant** (`Dockerfile.variant`) — `FROM ${BASE_IMAGE}` and adds
the pi install. The `:latest` and `vX.Y.Z` tags are produced from the pi install (+ pi-studio when `INSTALL_STUDIO=true`). The `:latest`
this layer; future Studio variants will extend further. / `vX.Y.Z` and `:latest-studio` / `vX.Y.Z-studio` tags are produced
from this layer. The studio variant builds via independent
`smoke-studio` + `build-variant-studio` CI jobs that gate only the
`-studio` tags.
Tag naming: Tag naming:
@@ -337,6 +433,7 @@ Tag naming:
| `base-<hash>` | base image — internal building block | | `base-<hash>` | base image — internal building block |
| `base-latest` | promoted alias of the most recent base | | `base-latest` | promoted alias of the most recent base |
| `latest`, `vX.Y.Z` | variant: base + pi | | `latest`, `vX.Y.Z` | variant: base + pi |
| `latest-studio`, `vX.Y.Z-studio` | variant: base + pi + pi-studio |
CI resolves `PI_VERSION` to a concrete version string before building CI resolves `PI_VERSION` to a concrete version string before building
to defeat a registry-buildcache hit on `npm install -g to defeat a registry-buildcache hit on `npm install -g
+32 -10
View File
@@ -99,16 +99,19 @@ if command -v pi &>/dev/null; then
"$HOME/.pi/agent/extensions/mempalace.ts" "$HOME/.pi/agent/extensions/mempalace.ts"
fi fi
# pi-fork (fork tool) + pi-observational-memory (recall tool). # pi-fork (fork tool) + pi-observational-memory (recall tool) + (in the
# These are pi packages (not symlink-style extensions): they're cloned to # :latest-studio variant only) pi-studio (/studio command + studio_*
# /opt with node_modules baked at BUILD time, then registered here via # tools + theme). These are pi packages (not symlink-style extensions):
# `pi install <local-path>`. A local-path install is instant + in-place # they're cloned to /opt with node_modules baked at BUILD time, then
# (pi loads the extension directly from /opt) + idempotent (no duplicate # registered here via `pi install <local-path>`. A local-path install is
# package entry on re-run), and stores a relative path that resolves into # instant + in-place (pi loads the extension directly from /opt) +
# the image-layer /opt so it survives volume recreate. The fork/recall # idempotent (no duplicate package entry on re-run), and stores a relative
# tools register on the NEXT pi start (extensions bind at startup). Guard # path that resolves into the image-layer /opt so it survives volume
# on settings.json so we only install once per volume. # recreate. The tools/command register on the NEXT pi start (extensions
for _pkg in /opt/pi-fork /opt/pi-observational-memory; do # bind at startup). Guard on settings.json so we only install once per
# volume. /opt/pi-studio is present only in the studio variant; the
# `[ -d ]` test makes this a no-op everywhere else.
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio; do
[ -d "$_pkg" ] || continue [ -d "$_pkg" ] || continue
_name=$(basename "$_pkg") _name=$(basename "$_pkg")
if ! grep -q "$_name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then if ! grep -q "$_name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
@@ -118,6 +121,25 @@ if command -v pi &>/dev/null; then
done done
fi fi
# ── pi-studio: optional loopback bridge (opt-in) ──────────────────────
# pi-studio binds its server to 127.0.0.1 inside the container, which a
# published Docker port cannot reach. When STUDIO_EXPOSE is truthy (set in
# compose), start the `studio-expose` socat bridge in the background so a
# published port + `ssh -L` tunnel can reach Studio once the user runs
# `/studio --port "$STUDIO_PORT"`. Default OFF — Studio stays loopback-only
# (its secure default) unless explicitly opted in. Guarded on the studio
# variant (/opt/pi-studio) so it is a no-op in the plain image.
case "${STUDIO_EXPOSE:-}" in
1|true|TRUE|yes|on)
if [ -d /opt/pi-studio ] && command -v studio-expose &>/dev/null && command -v socat &>/dev/null; then
echo "STUDIO_EXPOSE set — starting studio-expose bridge on port ${STUDIO_PORT:-8765} (background)"
nohup studio-expose "${STUDIO_PORT:-8765}" >/tmp/studio-expose.log 2>&1 &
else
echo "STUDIO_EXPOSE set but studio-expose/socat/pi-studio unavailable — skipping bridge"
fi
;;
esac
# ── Skillset: deploy skills/instructions from mounted skillset repo ── # ── Skillset: deploy skills/instructions from mounted skillset repo ──
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset), # When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
# run the deploy script to create relative symlinks for skills and instructions. # run the deploy script to create relative symlinks for skills and instructions.
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# studio-expose — make a container-loopback pi-studio server reachable
# through a published Docker port.
#
# WHY THIS EXISTS
# pi-studio hard-binds its HTTP/WebSocket server to 127.0.0.1 inside the
# container (index.ts: `.listen(port, "127.0.0.1")`) and there is no
# --host / bind flag. A plain `docker run -p 8765:8765` forwards to the
# container's EXTERNAL interface (eth0), not its loopback, so it cannot
# reach Studio. This helper runs a socat TCP relay that listens on the
# container's egress IP and forwards to 127.0.0.1:<port>, so a published
# port (and an `ssh -L` tunnel from your laptop) can reach Studio.
#
# SECURITY
# This intentionally exposes Studio beyond loopback — anything that can
# reach the container's network interface (and the host port you publish)
# can connect. Studio's tokenized URL is the only auth. Mitigate by
# publishing the host port on localhost only:
# ports: ["127.0.0.1:${STUDIO_PORT}:${STUDIO_PORT}"]
# and use `ssh -L` for remote access. Bridge nothing you don't intend to.
#
# USAGE
# studio-expose [PORT] # bridge PORT (default: $STUDIO_PORT or 8765)
# studio-expose --help
#
# Typically: inside a pi session run `/studio --no-browser --port 8765`,
# then in a container shell run `studio-expose` (or set STUDIO_EXPOSE=1 in
# compose to auto-start it on container boot — see entrypoint-user.sh).
#
# Runs in the foreground; Ctrl-C to stop. The entrypoint auto-start path
# runs it backgrounded.
set -euo pipefail
PORT="${1:-${STUDIO_PORT:-8765}}"
if [ "$PORT" = "--help" ] || [ "$PORT" = "-h" ]; then
sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'
exit 0
fi
case "$PORT" in
''|*[!0-9]*) echo "studio-expose: invalid port '$PORT'" >&2; exit 2 ;;
esac
if ! command -v socat >/dev/null 2>&1; then
echo "studio-expose: socat not found in PATH" >&2
exit 1
fi
# Container's primary egress IPv4. In Docker the container hostname resolves
# to its eth0 address, so `hostname -I` lists it; we take the first
# non-loopback IPv4. We must bind this specific address rather than 0.0.0.0
# — binding 0.0.0.0 would collide with Studio's own 127.0.0.1:PORT listener
# (0.0.0.0 includes loopback) and fail with EADDRINUSE. `ip route get` is a
# fallback only when iproute2 happens to be present (not in the base image).
BIND_IP="$(hostname -I 2>/dev/null | tr ' ' '\n' \
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -vE '^127\.' | head -n1)"
if [ -z "${BIND_IP:-}" ] && command -v ip >/dev/null 2>&1; then
BIND_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')"
fi
[ -n "${BIND_IP:-}" ] || BIND_IP="$(hostname -i 2>/dev/null | awk '{print $1}')"
if [ -z "${BIND_IP:-}" ]; then
echo "studio-expose: could not determine container egress IP" >&2
exit 1
fi
echo "studio-expose: bridging ${BIND_IP}:${PORT} -> 127.0.0.1:${PORT}"
echo "studio-expose: open the tokenized URL pi-studio printed; if the host"
echo "studio-expose: publishes ${PORT}, reach it at http://127.0.0.1:${PORT}/?token=..."
echo "studio-expose: (remote host: ssh -L ${PORT}:127.0.0.1:${PORT} user@host)"
# fork: one child per connection (handles concurrent + long-lived WebSocket
# connections). reuseaddr: survive quick restarts. Studio need not be up yet
# — connections simply fail until `/studio --port ${PORT}` is running.
exec socat "TCP-LISTEN:${PORT},bind=${BIND_IP},fork,reuseaddr" "TCP:127.0.0.1:${PORT}"
+32
View File
@@ -15,6 +15,8 @@
# - mempalace bridge symlink present # - mempalace bridge symlink present
# - settings.json bootstrapped # - settings.json bootstrapped
# - pi-fork + pi-observational-memory registered via `pi install` # - pi-fork + pi-observational-memory registered via `pi install`
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
# client bundle present + registered via `pi install`
# - image size within threshold # - image size within threshold
set -euo pipefail set -euo pipefail
@@ -76,6 +78,8 @@ run "graphviz (dot)" "dot -V"
run "imagemagick" "magick --version" run "imagemagick" "magick --version"
run "yq" "yq --version" run "yq" "yq --version"
run "tldr (tealdeer)" "tldr --version" run "tldr (tealdeer)" "tldr --version"
run "socat" "socat -V"
run "studio-expose helper" "test -x /usr/local/bin/studio-expose"
# ── tmux 0-indexing (required for pi-studio variants) ───────────────── # ── tmux 0-indexing (required for pi-studio variants) ─────────────────
echo "" echo ""
@@ -95,6 +99,20 @@ run "pi-fork clone + node_modules" \
run "pi-observational-memory clone + node_modules" \ run "pi-observational-memory clone + node_modules" \
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules" "test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
# pi-studio is present only in the :latest-studio variant. Auto-detect by
# probing /opt/pi-studio so this one script covers both variants.
if docker run --rm --entrypoint="" "$IMAGE" sh -c 'test -d /opt/pi-studio' >/dev/null 2>&1; then
STUDIO_VARIANT=1
echo " ️ pi-studio detected — running studio assertions"
run "pi-studio clone + node_modules" \
"test -f /opt/pi-studio/package.json && test -d /opt/pi-studio/node_modules"
run "pi-studio prebuilt client bundle" \
"test -f /opt/pi-studio/client/studio-client.js"
else
STUDIO_VARIANT=0
echo " ️ pi-studio not present (non-studio variant) — skipping studio clone checks"
fi
# ── Runtime deployment (needs entrypoint to run) ────────────────────── # ── Runtime deployment (needs entrypoint to run) ──────────────────────
echo "" echo ""
echo "── Runtime deployment ──" echo "── Runtime deployment ──"
@@ -150,6 +168,20 @@ done
exec_test "pi-fork registered (fork tool)" 'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok' exec_test "pi-fork registered (fork tool)" 'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
exec_test "pi-observational-memory registered (recall tool)" 'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok' exec_test "pi-observational-memory registered (recall tool)" 'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok'
# pi-studio registration (studio variant only) — registered by the same
# entrypoint-user.sh local-path install loop as fork/obsmem.
if [ "${STUDIO_VARIANT:-0}" = "1" ]; then
for i in $(seq 1 15); do
if docker exec "$CID" grep -q pi-studio \
/home/developer/.pi/agent/settings.json 2>/dev/null; then
break
fi
sleep 1
done
exec_test "pi-studio registered (/studio command + studio_* tools)" \
'grep -q pi-studio $HOME/.pi/agent/settings.json && echo ok'
fi
# ── /tmp/sshcm directory created by entrypoint ──────────────────────── # ── /tmp/sshcm directory created by entrypoint ────────────────────────
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \ exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok' 'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'