pi-mono/apps/p2/plugins/envs/sandbox/p2-sandbox.sh
Mario Zechner 2a32a605a9
Some checks are pending
CI / build-check (push) Waiting to run
CI / test (coding-agent) (push) Waiting to run
CI / test (durable) (push) Waiting to run
CI / test (other) (push) Waiting to run
CI / mcp-conformance (push) Waiting to run
feat(p2): Gondolin sandbox environments
A sandbox is a new env type (`type: "gondolin"`): a long-lived Gondolin micro-VM on an SSH environment, with
listed host folders and an HTTP(S) host allowlist. The worker reaches it over SSH to the host, where p2's launcher
(plugins/envs/sandbox: native, or Docker when the host's Node/QEMU are too old) runs pi-env's daemon inside the VM,
so tools, workspace, terminals, uploads and MCP work unchanged. Names outside the allowlist are refused before
the host resolves them.

Conversations working in a sandbox cannot leave it: location tools, subagent spawn/configure, AgentLocations.move,
the terminal tool, and one-shot agents (which now inherit their caller's own location) all keep them there; only the
owner's Relocate moves a session out, after a confirmation. conversationConfinement() exposes it to plugins.

Owner-only management in the Envs sheet: sandboxes listed under their host, host test reports sandbox support,
create/edit/start/stop/restart/test, lock icons in the top bar and sidebar. Tests in test/p2/sandboxes.test.ts
(real VM cases with P2_TEST_GONDOLIN=1) and ui-sandboxes.test.ts; docs in docs/remote-envs.md.
2026-11-05 23:35:09 +00:00

115 lines
3.9 KiB
Bash
Executable file

#!/bin/sh
# p2's sandbox launcher on a host environment. `check` reports whether and how Gondolin can run here (JSON). Every other
# command picks the runtime (natively when Node >= 22, QEMU >= 7.2 and /dev/kvm are usable, else Docker), installs it
# once, then runs p2-sandbox.mjs there with the same arguments. stdout belongs to that program (it carries pi-env's
# protocol), so setup output goes to stderr. Each launcher version has its own directory (named by its content);
# sandboxes keep running across versions.
set -eu
D=$(cd "$(dirname "$0")" && pwd)
qemu_bin() {
case "$(uname -m)" in
x86_64 | amd64) echo qemu-system-x86_64 ;;
aarch64 | arm64) echo qemu-system-aarch64 ;;
*) echo qemu-system-unsupported ;;
esac
}
qemu_version() {
command -v "$(qemu_bin)" >/dev/null 2>&1 || return 0
"$(qemu_bin)" --version 2>/dev/null | head -n 1 | sed -n 's/.*version \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p'
}
qemu_ok() {
v=$(qemu_version)
[ -n "$v" ] || return 1
major=${v%%.*}
minor=${v#*.}
[ "$major" -gt 7 ] || { [ "$major" -eq 7 ] && [ "$minor" -ge 2 ]; }
}
node_version() { command -v node >/dev/null 2>&1 && node -p 'process.versions.node' 2>/dev/null || true; }
node_ok() {
v=$(node_version)
[ -n "$v" ] && [ "${v%%.*}" -ge 22 ]
}
kvm_ok() { [ -r /dev/kvm ] && [ -w /dev/kvm ]; }
docker_ok() { [ -e /dev/kvm ] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; }
runtime() {
if [ "$(uname -s)" != Linux ]; then
echo none
elif kvm_ok && qemu_ok && node_ok; then
echo native
elif docker_ok; then
echo docker
else
echo none
fi
}
if [ "${1:-}" = check ]; then
r=$(runtime)
kvm=false
kvm_ok && kvm=true
docker=false
docker_ok && docker=true
printf '{"runtime":"%s","system":"%s","arch":"%s","kvm":%s,"kvmDevice":%s,"qemu":"%s","node":"%s","docker":%s}\n' \
"$r" "$(uname -s)" "$(uname -m)" "$kvm" "$([ -e /dev/kvm ] && echo true || echo false)" \
"$(qemu_version)" "$(node_version)" "$docker"
exit 0
fi
# Setup runs under a lock: two sessions connecting at once must not install or create the container twice.
lock() {
if command -v flock >/dev/null 2>&1; then
exec 9>"$D/.lock"
flock 9
fi
}
unlock() { exec 9>&-; }
# Gondolin and its dependencies, from package-lock.json into this version's directory, once.
install_gondolin() {
if [ ! -f "$D/.installed" ]; then
lock
if [ ! -f "$D/.installed" ]; then
echo "p2-sandbox: installing Gondolin in $D" >&2
"$@" sh -c 'cd "$1" && npm ci --ignore-scripts --no-audit --no-fund >&2' sh "$D"
touch "$D/.installed"
fi
unlock
fi
}
case "$(runtime)" in
native)
install_gondolin
exec node "$D/p2-sandbox.mjs" "$@"
;;
docker)
# Named by the Dockerfile only (Node and QEMU), not by the launcher version: launcher versions of several p2
# installations share the container and run their own files from their directories in the shared home.
tag=$(sha256sum "$D/Dockerfile" | cut -c1-16)
image="p2-sandbox:$tag"
container="p2-sandbox-$(id -u)-$tag"
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true)" != true ]; then
lock
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true)" != true ]; then
if ! docker image inspect "$image" >/dev/null 2>&1; then
echo "p2-sandbox: building the Docker image $image (first time only)" >&2
docker build -q -t "$image" - <"$D/Dockerfile" >&2
fi
docker rm -f "$container" >/dev/null 2>&1 || true
docker run -d --name "$container" --restart unless-stopped --init \
--device /dev/kvm --group-add "$(stat -c %g /dev/kvm)" \
--user "$(id -u):$(id -g)" -e HOME="$HOME" -v "$HOME:$HOME" \
"$image" sleep infinity >/dev/null
fi
unlock
fi
install_gondolin docker exec -i "$container"
exec docker exec -i -e P2_SANDBOX_RUNTIME=docker "$container" node "$D/p2-sandbox.mjs" "$@"
;;
*)
echo "p2-sandbox: this host cannot run sandboxes (needs Linux with /dev/kvm, and Node >= 22 with QEMU >= 7.2 or Docker)" >&2
exit 3
;;
esac