A real WordPress environment for designers, developers, and QA at WPDeveloper — drivable by Claude Code (or any MCP client: Cursor, Cline, Continue, Zed).
Recovery is profile-driven through sb recovery. Capture, restore apply, retention deletion,
and schedule activation are protected; see docs/recovery.md.
Sandbox keeps public CLI and MCP behavior stable while feature ownership is modularized:
- project descriptors select
kindbefore runtime-specific defaults; omittedkindremainswordpress; - registry identity and atomic persistence live behind the project-registry repository;
- runtime capabilities reject unsupported work before process, network, proxy, or registry side effects;
- CLI commands and MCP tool groups are owned by explicit deterministic manifests;
- shared process, HTTP, port, path, and proxy services own mechanisms, while adapters own runtime policy;
- Hermes state, routing, jobs, gateway, and backup planning are bounded modules.
sandbox_core.py, sandbox.registry.COMMANDS, sandbox.hermes.facade, and the MCP
app.py helper namespace are compatibility/rollback paths, not extension points.
New code must use the bounded service or registration contract. Their consumer sets
are frozen by architecture tests; removal requires parity evidence and separate
human approval.
Compose remains the only automatic/default WordPress runtime. A gitignored machine override
may explicitly select a supported native adapter; detection never opts a project in. Managed
Ubuntu execution is advertised only after effective namespace, mount, network, credential,
resource, and hostile-path proofs pass. Herd, Valet, and declared POSIX profiles are labeled
trusted_shared_host and are intended only for trusted project code.
Compose trust boundary: Use Compose only with trusted project, plugin, and agent-generated code. Docker containers and workspaces share the host kernel and Docker daemon; this is not a hostile-code or multi-tenant security boundary. No per-instance deny-by-default egress policy exists.
Inspect support without mutation:
./sb native support --json
./sb native preflight --project-dir . --json
./sb native install-plan --project-dir . --web-server nginx --jsonNative package installation is interactive-only. Instance plugins, CLI, tests, Composer, and
jobs never fall back to host execution when managed-native isolation is selected. See
docs/native-runtime-isolation.md for guarantees,
limitations, egress grants, evidence, and recovery.
CLI-first, per-project, and MCP-optional. Each plugin repo carries its own
sandbox.config.json. You cd into a plugin, and a single MCP server boots a
WordPress instance for that directory on demand and runs the plugin's real
phpunit tests — no central catalog, nothing to pre-register.
Note: This is a major rewrite to the per-project model hosted at
alimuzzaman/sandbox. Install:
Prerequisites: A running Docker-compatible engine (Docker Desktop or OrbStack on macOS) · Python 3.9+ · Claude Code (or any MCP client). On a fresh machine, run the OS bootstrap script first:
# macOS
bash scripts/install-macos.sh # Homebrew → python3 → Docker Desktop/OrbStack → Reader.md
# Ubuntu / Debian
bash scripts/install-ubuntu.sh # apt (python3+venv) → Docker CE
# Arch Linux (and derivatives: Manjaro, EndeavourOS)
bash scripts/install-arch.sh # pacman → python → docker + docker-composeOther Linux distros (Fedora/RHEL, openSUSE, etc.) work too — ./sb setup
detects dnf/zypper and offers the right install commands automatically;
there's just no dedicated one-shot bootstrap script for them yet. Windows
isn't supported natively (the CLI is a POSIX shell + Python tool, and relies
on Docker Unix sockets and process groups/signals) — run it inside WSL2
instead, where it behaves exactly like the Ubuntu path above.
Clone and set up:
git clone -b main https://github.com/alimuzzaman/sandbox.git
cd sandbox
./sb global # puts `sb` on your PATH (do this first)
./sb --version # report the checked-in CLI version without setup/mutation
./sb setup # prepares the CLI and local runtime
./sb guide # show the runtime-aware CLI catalog
./sb domains setup # optional: clean no-port URLs → https://<name>.<tld>setup offers to install missing prerequisites (default always No)
and never needs sudo for the base install.
sb setup is registry-wide and cannot be targeted with --instance or the
routing --label. To reconcile an existing named instance, use
sb apply --instance NAME; to prepare one project, use the project-scoped
sb ensure --project-dir DIR.
sb ensure is project-scoped and refuses --instance NAME, which the shared
parser cannot otherwise use safely. Select the project with --project-dir
and, when it owns more than one instance, --label LABEL (add --create when
minting that label). Reconcile an existing named instance with
sb apply --instance NAME.
sb init follows the same project-scoped boundary and refuses --instance NAME; pass --project-dir DIR so an initializer cannot mutate the tooling
checkout by mistake. For an additional labeled instance, run
sb ensure --project-dir DIR --label LABEL --create explicitly.
Init treats that exact directory (or the exact current directory when omitted) as its
maximum root, does not inherit ancestor project markers, and refuses the user home itself.
On macOS, the bootstrap also installs Reader.md
by default when Homebrew is available. It provides the reader command for
opening local Sandbox documentation and read-only remote documentation folders.
Set SANDBOX_SKIP_READER_MD=1 before running the bootstrap to opt out; a
Reader.md failure only warns and never prevents Sandbox setup.
Reader.md is maintained in its own Homebrew tap. The bootstrap scopes
Homebrew's required trust grant to its reader-md cask before installation;
review that upstream tap if your environment disallows third-party casks.
Reader.md is an optional local, visual reading surface. An agent on the macOS workstation may open a known local Markdown file or folder when that helps the operator review documentation:
reader /absolute/path/to/spec.md
reader /absolute/path/to/folderIt is not an MCP server and its window is not evidence an agent can inspect.
Use fs_read, repository reads, or ssh for machine-readable evidence and
tests. Do not use reader remote or reader rm from an agent: the former
adds an SSH-backed application connection and the latter removes saved Reader
configuration. Those remain explicit operator commands. reader ls is safe
for an operator to inspect configured Reader roots.
New projects whose hostname is omitted use the standards-reserved .test
suffix. Existing persisted names—including .tst—are preserved. Use
./sb domains status --project-dir . --json to see the requested name, source,
active resolver, actual and expected address, ownership, health, and fallback.
Sandbox never creates a local override for a public FQDN or a new .local name.
A project can pin its own TLD with "tld": "<your-tld>" in its
sandbox.config.json (overrides the prompt for that project):
domains setup is optional — without it, instances still work at
http://localhost:<port>.
Running ./sb global first means the MCP registration uses sb (PATH-based,
like @wordpress/env) rather than a hardcoded absolute path — so the
registration survives the repo being moved or re-cloned.
setup registers one MCP server named sandbox at user scope so
every claude session on the machine has it — from any directory:
claude # in any project, in any dirThat single server routes by the project_dir every tool receives — there are
no per-instance servers to manage.
A plugin repo carries a sandbox.config.json describing its stack:
{
"plugins": ["."], // this repo; sibling slugs/paths/zip-URLs for addons
"mappings": { "wp-content/plugins/elementor-pro": "/abs/path" },
"phpVersion": null, // null → wordpress:latest; e.g. "8.1"
"wpVersion": null, // EXACT pin ("6.4" = 6.4.0, not 6.4.x).
// Leave null unless one build is required.
"server": "apache", // apache | nginx | litespeed
"config": { "WP_DEBUG": true }, // → wp-config constants
"tests": { "suite": "auto" } // auto-detect WP_UnitTestCase vs Brain/Monkey
}(An existing .wp-env.json is read as a fallback and converted on
sandbox init. Full schema: docs/sandbox-config-reference.md.)
Generic PHP, JavaScript/Node, Docker, Laravel/Sail, Astro, and similar projects
can use the same framework-neutral Compose runtime by declaring kind: compose
and their public service in sandbox.config.json. See the
generic Compose configuration reference.
Then, from the plugin directory:
cd ~/dev/embedpress
sandbox init # scaffold sandbox.config.json (or convert .wp-env.json),
# boot a per-directory instance, provision the test harness
sandbox test # auto-select unit or integration mode and run PHPUnit
sandbox test unit # pure PHPUnit; skips WP suite, polyfills, and test DB
sandbox test integration # externally-provisioned WP suite + isolated test DB
sandbox ensure # just boot/refresh this project's instance (create-if-missing)init is the one command from a bare checkout to a running, testable stack.
Each project gets one instance by default, keyed by its directory and
tracked in an on-disk registry. Sibling plugins listed in one config share
that instance. A project can also own additional labelled instances side by
side (e.g. to test a second PHP/WP version, or a zip install alongside dev) —
pass --label <name> / label= (default default); see
docs/multi-instance-spec.md.
With Claude, you don't even run those — the MCP tools take project_dir
(the agent passes your plugin dir), and ensure_instance boots on demand. Just
work in the plugin and ask Claude to test/fix/build.
Sandbox provides the WP test suite, phpunit, the Yoast polyfills, composer, and
an isolated wp_tests database externally for integration tests — mounted
only at test time — so a plugin's composer.json stays clean. sandbox test
resolves tests.suite (auto, unit, or integration); auto selects unit only
for unambiguous Brain/Monkey-only evidence and conservatively selects integration
otherwise. Unit mode uses project Composer dependencies and PHPUnit without the WP
suite, polyfills, test DB, or WP_TESTS_* environment. The run_tests MCP tool
accepts the same optional mode and returns the resolved mode with its summary.
Version pins resolve server-aware: phpVersion: "8.1" boots wordpress:php8.1
on apache, the -fpm flavor on nginx, and an OpenLiteSpeed lsphp81 image on
litespeed; the wp-cli container (where tests run) follows the PHP pin too.
Claude in your IDE is already smart. It can read your code, propose diffs, talk
through architecture. What it cannot do alone is run your WordPress, see
what your block actually renders, query your DB, check debug.log, or know your
plugin's specific conventions. It's a brilliant pair-programmer working
blindfolded against an unfamiliar codebase. The sandbox removes the blindfold
and hands it the keys.
- Your source code on disk (Read / Write / Edit).
- The internet (web search, fetch).
- Its training knowledge of WordPress / PHP / JS.
- Nothing about your WordPress, your plugin's conventions, or whether the edit it just made actually works.
- A live WordPress with your plugin symlinked in. Edits land in seconds, no rebuild. The agent acts on the stack instead of guessing at it.
- Real tests on demand —
run_testsruns the plugin's phpunit suite against an externally-provisioned WP test harness, so "it works" is backed by a green run, not aphp -l. - Your plugin's institutional knowledge auto-loaded. The project's
CLAUDE.md(textdomain rules,save()BC traps, build conventions, task-tracker board, sister-repo location) reaches the model viafocus_get. - A compact operating prompt in every Claude session via the MCP
instructionsfield — reflexes ("first tool call reproduces, not Read"), anti-patterns ("declaring fixed from code reading"), the project handshake (always passproject_dir; callensure_instancefirst). Deeper guidance loads on demand viaload_context/load_skill(name). - Skills + workflows for the patterns that repeat:
fixfor bugs (one-pass loop with paired before/after evidence),build-featurefor new features (three-phase, size-scaled gates),wp-pilotfor browser-driven admin testing,fluentboardsfor task management.
Fix a bug in your plugin.
| Step | Plain Claude | Claude + sandbox |
|---|---|---|
| Understand | Asks you the version, the active plugins, the theme. | The project's CLAUDE.md is already in context; can fetch the task-tracker card via REST in one call. |
| Reproduce | "Let me look at the file" → guesses the cause; can't verify. | First tool call provisions whatever the bug needs and triggers it on the live WP; captures the real error as EVIDENCE.before. |
| Find every site | Reads the file the report names; misses the Pro-side mirror. | Greps every call site across the plugin AND its -pro sibling in one pass. |
| Fix | Edit, ask you to test, edit again. 3–5 rounds. | Batch-edits every affected file in one pass. |
| Verify | "Looks right," or php -l. |
Re-triggers the failing call → confirms the output flipped → EVIDENCE.after. Or sandbox test → green. |
| Ship | Stops at the working tree. | Commits and pushes verified completed work on the active branch automatically. |
Build a new feature. load_workflow('build-feature') → Phase 1 ESTABLISH
(verb-led title, size class, live-verifiable success criteria, out-of-scope,
edge cases) → Phase 2 PLAN (reuse audit naming every existing helper/table/route
it'll ride on; cross-surface grep) → Phase 3 BUILD (vertical slices, each
verified by an sb CLI/MCP call; non-negotiables — auth, sanitize-in/escape-out, slug
prefixing — enforced per Edit). Final STATUS: SHIPPED block pairs every
success criterion with live evidence + rollout notes.
For material or ambiguous work, Spec Kit can begin one stage earlier:
speckit-refine creates and repeatedly tightens a single prd.md, preferring
Terra Medium for drafting and requiring an independent Sol High validation before
readiness. It cannot create specifications, plans, tasks, or code. A validated PRD
marked READY FOR SPECKIT is consumed in place by Sol Medium speckit-specify,
preserving the numbered feature directory. The normal clarify, plan, tasks, and
analyze stages remain required before implementation; implementation prefers Terra
High, or Sol Medium for architecture-sensitive or cross-cutting work.
The resulting handoff is deliberately phase-specific: Terra Medium drafts product intent, Sol High validates and strengthens the ready PRD, Sol Medium creates the formal specification, and Terra High implements the approved task plan. A named model preference is a task-launch default, not an implicit root-model switch; a fallback must be disclosed and cannot be represented as a completed Sol validation.
speckit-refine → Sol High validation → speckit-specify → speckit-clarify
→ speckit-plan → speckit-tasks → speckit-analyze → speckit-implement
Verify a UI flow. visit is URL-scoped (WordPress or generic Compose), opens
a real admin or frontend URL, and returns a screenshot, DOM, and console errors
without you switching tabs.
- Live evidence is the only evidence. Every "fixed" / "shipped" /
"verified" is backed by an
sbCLI/MCP call (or a test run) against the running WordPress — not a claim from reading code. - Verified changes ship as a normal Git update. Sandbox commits and pushes the active branch after required checks. Force-pushes, tags, releases, deployments, and PR actions remain explicit.
Use the registered-source secret broker to list key names or structured key
paths across dotenv, JSON, INI, properties, TOML, YAML, XML, PEM, opaque-token,
and binary-container sources. Before parsing, secrets source-info can report
whether the registered file exists, whether it is empty, its type, a size
bucket, and whether the broker can safely open it—without reading its contents
or returning its path. It can validate or apply a fixed mask to an
eligible scalar, run a bounded trusted child without displaying the credential,
and update one dotenv assignment through protected input. Plaintext reveal is a
human-only local TTY exception and is never available through MCP. See
Safe secret inspection or load the
secret-inspection skill for the least-disclosure workflow and incident steps.
Inspect local or named-remote storage without booting an instance:
./sb resources status --json
./sb resources status --remote scaleway-sandbox --thorough --budget 60 --json
./sb resources status --remote scaleway-sandbox --deep --budget 600 --json
# whole-host attribution in one command (rebuilds the cached directory index)
./sb resources status --remote scaleway-sandbox --refresh --json
# always-available: capacity plus the cached index, no disk walk
./sb resources status --remote scaleway-sandbox --fast
./sb resources monitor --remote scaleway-sandbox --scheduled --json
./sb resources schedule --remote scaleway-sandbox --json # render only
./sb resources plan --scope cache --thorough --budget 60 --json
./sb resources plan --scope stale --thorough --budget 90 --jsonresources monitor performs a bounded cache-only pressure pass (900 seconds
by default) and records the result. --scheduled is a trigger label only;
--dry-run guarantees that automatic cleanup and reaping do not delete, while
still allowing the local monitor record and review-plan metadata to be written.
The monitor policy is resolved before any host-facing service is built, and
warning/normal runs exit 0 while critical, unknown, refused, or failed runs
exit 1. Automatic reclamation and real reaping are off by default.
resources schedule renders a disabled systemd user service/timer on Linux or
a review-only launchd user plist on macOS. Launchd activation is refused because it cannot
enforce the configured timeout. Systemd installs nothing unless the operator passes
--activate --confirm; --deactivate --confirm removes only the installed receipt-bound
schedule even after policy drift. The unit always runs the fixed cache-only monitor command, and the
rendered plan includes its paths and reverse command. No schedule is activated
by default.
Planning is read-only. Cleanup requires a current target-bound plan plus
--confirm, revalidates each exact candidate, and never uses a broad Docker
prune. Cache and stale persistent-resource cleanup are deliberately separate.
Deep status uses safe mount topology and opaque capacity-scope identities to
measure selected root, Sandbox, Docker, and typed managed filesystems once.
It uses installed gdu with allocated-block du fallback, deleted-open
allocated-block evidence, and Docker unique/shared/activity/reclaimable
diagnostics without double counting them. It is bounded (budget plus five
seconds), preserves valid partial/cancelled evidence, installs nothing, and
adds no cleanup path.
Deployment storage has its own tiered path. status classifies every entry of
deploy-src as PROTECTED / LIVE / STOPPED / REGONLY / BASE / ORPHAN with sizes,
mtimes, per-class totals, and index-versus-disk drift; plan previews a tier
with a reason per candidate and a skipped list; cleanup executes it, writing a
deletion manifest before each removal so "what happened to X" stays answerable:
./sb resources status --remote scaleway-sandbox --deep --budget 180
./sb resources plan --remote scaleway-sandbox --tier safe
./sb resources cleanup --remote scaleway-sandbox --tier safe --confirm
./sb workspace release <name> --remote scaleway-sandbox # done with it
./sb workspace ttl <name> --ttl 14d --remote scaleway-sandbox
./sb workspace reap --remote scaleway-sandbox --dry-runOnly workspace-scoped node_modules-style volumes are ever eligible — every
other volume is protected at every tier, including ones the engine reports as
unused — hosted sites are untouchable, a partial delete is reported as a
failure rather than success, and the default retention window is 7 days.
See Resource Monitoring and Safe Cleanup.
Feature 046 provides remote-only, authenticated host memory and swap operations:
./sb resources swap-status --remote scaleway-sandbox --json
./sb resources swap-plan --remote scaleway-sandbox --size-gib 4 --json
./sb resources swap-apply --remote scaleway-sandbox --plan-id PLAN_ID --confirm --json
./sb resources swap-disable --remote scaleway-sandbox --confirm --json
./sb resources swap-history --remote scaleway-sandbox --limit 144 --json- Validation & Refusal: Planning validates size strictly within
1..8GiB (invalid_size) and enforces disk headroom (headroom_insufficient). - Protected Apply: Requires
--confirm(confirmation_required) and binds to target identity, runtime revision, and observation digest. - Replay & Rollback: Same-identity replays resolve via durable ledger without duplicate mutations;
mutation failures trigger owned reverse rollback (
rollback_completeorrollback_incomplete). - Safe Disable: Removes owned units/files in reverse order while strictly preserving aggregate
telemetry history (
/var/log/sandbox/host-memory.jsonl). - Control Plane: Uses fixed wire actions (
host_memory_*) through the authenticated control service; never falls back to SSH.
When sandbox.config.json configures a provisioned runtime.default: "remote",
Sandbox recommends remote execution. Local execution remains available only by
an explicit --local override. Remote job submission deploys the exact working
tree first, including uncommitted and untracked files; the remote supervisor
persists process output and callers resume it by cursor rather than streaming
child pipes over SSH.
job-output transfers only bounded pages from those retained logs. Select a
stream, tail, cursor, or bounded long-poll interval (0-20 whole seconds; zero
disables a one-shot wait) to suit the agent's output verbosity; --follow
converts a validated zero into its one-second polling wait. The complete sealed
log remains available for later retrieval.
Generic Compose exec failures retain stdout and stderr independently, each
bounded to the 1 MiB process-runner limit; when a stream overflows, the runner
keeps both edges around an explicit truncation marker. Failures include the child exit_code. Human sb exec
writes those streams to their matching local streams; --json returns one
envelope containing both fields and exits with the child code. A nested remote
controller uses the human path so the outer durable supervisor persists the
same separate evidence.
./sb exec --remote scaleway-sandbox --workspace node-unit --timeout 3600 --detach \
--request-id node-unit-tests-1 -- npm test
./sb job-status <job-id> --json
./sb job-output <job-id> --follow
./sb job-output <job-id> --stream stderr --tail-bytes 8192 --wait-seconds 2
./sb workspace create --local --workspace node-unit
./sb workspace list --remote scaleway-sandbox --project-identity <id> --json
./sb workspace migrate --remote scaleway-sandbox --project-identity <id> --json
# Apply only the exact unexpired metadata-only plan after reviewing all records:
./sb workspace migrate --remote scaleway-sandbox --plan-id <plan-id> --confirm --json
./sb remote docker-pool scaleway-sandbox --json # read-only plan
./sb remote docker-pool scaleway-sandbox --confirm --json # backup, validate, restart, verify
./sb remote docker-pool scaleway-sandbox --recover-interrupted --expected-running 72 --json # evidence-bound recovery plan
# Plans include measured total/allocated/usable subnet fields; partial IPAM is null, never guessed.
./sb remote domains scaleway-sandbox --json # secret-free instance/host route inventory
./sb test matrix --local --workspace node-20 --workspace node-22 --timeout 3600 -- npm test
./sb test matrix --remote scaleway-sandbox --plan verify --timeout 1800 --json
./sb ci run .github/workflows/tests.yml --remote scaleway-sandbox --timeout 3600 --json
./sb job-artifact-get <child-job-id> <artifact-id> --remote scaleway-sandbox \
--output-file tmp/report.tarWorkspace control is backed by an owner-only durable index under
$SANDBOX_HOME/runtime/workspaces/index.sqlite3. Remote list/status use project or
workspace identity rather than a deployed checkout path. Legacy workspace.json
files remain byte-preserved; ambiguous, malformed, or unattributed records are reported
as workspace_index_incomplete instead of an empty inventory. Migration is metadata-only
and never resets/destroys a workspace or removes a Docker network.
workspace list is a read-only report and stays successful when the index is degraded:
the payload carries index.complete=false with index.code="workspace_index_incomplete"
(mirrored as a top-level code/warning, and as a WARNING: line in text output), plus
an on_disk block enumerating every directory under the deployment root
($SANDBOX_HOME/deploy-src) with path, indexed, workspace_id, modified_at,
age_seconds, and size_bytes. Sizes are null with a size_reason unless
--measure-sizes is passed, and even then the walk is bounded by entry and time budgets
(size_budget_exhausted / size_deadline_exceeded rather than a hanging du). This
keeps unindexed deployment storage visible for reclaim decisions. Degradation is not
weakened anywhere else: workspace status, create, reset, destroy, and migration apply
still refuse a degraded or non-ready record.
Remote CI is a durable parent/child submission. Sandbox preflights the workflow
and blocks named incompatibilities until explicitly accepted, deploys the exact
working tree once, then creates one isolated retained-log child per selected job
and matrix cell. Inspect parent_job_id and each child with job-status and
job-output; the submitting SSH/MCP connection never owns the workflow pipes.
The co-located act adapter runs on the remote host, which must advertise
job.exec and have any workflow-specific credentials configured there. The
remote provisioner installs act; GitHub's actions/upload-artifact is
converted to Sandbox's retained job-artifact collection because a self-hosted
act runner has no GitHub Actions runtime token. Remote CI preflight accepts only literal
project-relative upload paths with if-no-files-found: error; globs, expressions, and
unsupported upload options produce named blocking differences before execution. Literal artifact directories are
stored as deterministic bounded tar archives. CLI --output-file retrieval reads
all bounded pages into a temporary file, validates declared size and SHA-256, then
atomically publishes it; MCP artifact reads remain one bounded page per call.
Parent status preserves aggregate, frozen original children, and result_json while
adding a normalized terminal result capped at 256 KiB. Persisted child references carry
outcome, output completeness, artifact/difference counts, and cleanup state; full current
detail remains in children, and linked retries appear separately in retry_attempts.
Aggregate-parent retry returns aggregate_retry_unsupported; child retry reuses the
durable bounded submission snapshot without mutating prior terminal attempts.
Generic Compose instances have enforced default limits of 2 CPUs, 4 GiB RAM,
and 512 PIDs. The remote durable scheduler admits at most two jobs and checks
free memory/disk before starting another. Retrieve the authenticated, log-free
control-plane host snapshot with
./sb remote service diagnostics <remote> --json.
Add --processes for an opt-in, service-backed read-only snapshot grouped by the sanitized
comm name. It reports bounded process and optional Docker rows without command
lines, arguments, environment, paths, or sudo. CPU is the ps lifetime average;
RSS can double-count shared pages, host and container rows overlap, and the
point-in-time views can drift immediately. comm grouping is heuristic and its
CPU sum can exceed 100% on multicore hosts. This requires diagnostics schema 2;
update an older installed remote through the supported Sandbox lifecycle first.
Open the local dashboard with ./sb web, then choose a configured remote from the
Remotes rail. Its inventory page shows hosted-instance counts, running/stopped
state, per-instance container memory/CPU attribution, process/apps, containers, jobs,
RAM/load/disk, and storage evidence. The quick view is cache-only and may be partial;
Rebuild attribution performs a bounded deep refresh through the authenticated
service. Unknown and overlapping values are shown as unknown/non-additive rather than
treated as safe cleanup candidates.
The dashboard uses a loopback HTTP BFF with one in-flight refresh per resource and completion-based polling (30 seconds after the previous refresh finishes). Remote summaries load independently from the slower local-instance status probes, so a configured remote appears in the host rail promptly; its full inventory remains lazy and bounded. It does not open a WebSocket: the expensive operation is the host inventory itself, so a push channel would not make that scan cheaper. A future event stream can be added behind the same single-flight cache if remote services begin emitting incremental changes.
For the exceptional case where an operator must run a command directly on a host, use the explicit CLI escape hatch. It is never used internally and is not exposed as an MCP tool:
./sb remote ssh <remote> --confirm --reason "diagnose service" --command 'systemctl --user status sandbox-remote-mcp'Normal diagnostics, resource probes, dashboard inventory, and future service-backed operations never fall back to it.
Projects whose service startup bootstraps dependencies can declare a bounded
compose.startupTimeoutSeconds; persistent workspaces can additionally opt
into compose.recreateOnEnsure to rerun that bootstrap after each deployed
source revision while retaining named volumes.
If the health deadline expires, the durable result includes a bounded tail of
Use the CLI-first sb server config command family to apply, inspect, list, and
revert web server configuration fragments without manual container or SSH edits:
./sb server config apply --name page-cache --file ./page-cache.conf
./sb server config list --json
./sb server config show page-cache
./sb server config show page-cache --content # deliberate exact stdout; incompatible with --json
./sb server config show page-cache --output ./exported.conf
./sb server config revert page-cache- Scope & boundary: Strictly instance-scoped. Modifies only the instance-specific container configuration mount; host Caddy, host DNS, and global server settings are never modified.
- Safety & validation: Input is parsed and validated in an isolated exact-image container
(
--network none, read-only root) before activation. Post-validation reload failures automatically restore the prior generation. - Content protection: Default inspection channels (
list,show,--json, logs, error messages) emit bounded metadata only. Exact bytes are emitted only upon explicit--contentor--output. - Compatibility: Supports
nginxandlitespeed. Unsupported web servers (apache,herd) are refused fail-closed withserver_unsupported. Server configuration is CLI-first and intentionally not exposed as an unconstrained MCP tool to avoid unauthorized web-tier mutations.
Use the same runtime operations without an MCP client:
./sb guide --project-dir . # runtime-aware command catalog
./sb skill show sandbox-cli # CLI-first operating skill
./sb ensure # start/reconcile local instance
./sb ensure --json --reveal-login # ...and emit a usable admin autologin URL
./sb exec -- sh -lc 'npm test' # generic Compose projects only
./sb exec --project-dir <dir> -- sh -lc 'npm test' # select project from any cwd
./sb deploy --remote <name> --ensure --expose
./sb wp --remote <name> --project-dir . --timeout 60 -- plugin list--json output is redacted: every credential-shaped field, including the
sandbox_autologin token inside login_url, comes back as [REDACTED]. Test
harnesses that need to open an admin session without a password pass
--reveal-login, which restores login_url alone (other credentials stay
redacted). A local instance qualifies when its host is loopback-bound; a remote
ensure record qualifies on the flag, which is forwarded to the VPS so its own
redaction runs after. A revealed URL for a publicly exposed instance is an
admin credential — keep it in a gitignored descriptor, out of logs and commits.
If login_url carries a sandbox_autologin parameter, the same JSON document
also contains the derived login_url_redacted boolean. It remains true for
placeholders, already-redacted input, unusable/non-loopback local URLs, and
failed reveals; only a successful explicit --reveal-login produces false.
It derives from a boolean-only classification of raw input before redaction;
any producer-supplied status is discarded. That classification never emits the
raw URL or token; a validated URL is emitted only by explicit --reveal-login.
Remote reachability checks are read-only and strict: one non-multiplexed
ssh ... true probe, bounded to a 15-second connect timeout and 20-second
overall timeout, with no stateful transport fallback. ./sb remote list --json
also reports a safe reachability state and measured latency so a timeout is not
collapsed into a generic unreachable result.
./sb mcp --project-dir . remains available for an MCP-capable client. It is
runtime-scoped: generic Compose projects do not load WordPress tools, and
WordPress projects do not load generic container-exec tools.
After setup, the single sandbox server exposes these against the live stack.
Every tool takes project_dir (the agent passes your plugin's root, or cwd)
and resolves the target instance from the registry — booting one if needed.
| Tool | Purpose |
|---|---|
ensure_instance |
Boot (create-if-missing) the instance for a project dir; a ready Docker instance attests source mounts and verifies WordPress install state before returning. Mount drift/refusal returns instance_mount_drift or instance_mount_state_unavailable; an indeterminate install probe returns instance_install_state_unavailable with mutated:false and no writes. |
destroy_instance |
Permanently delete an instance (containers, DB volume, wp dir, registry) |
recreate_instance |
Destroy then immediately recreate — clean WP install from current config |
run_tests |
Run the plugin's phpunit tests on the external WP harness → pass/fail + failures |
run_plugin_check |
Run WordPress.org's Plugin Check, gated by a committed baseline → pass/fail + new findings (see docs/plugin-check.md) |
remote_deploy |
One-way, on-demand push of local project state to a registered remote VPS (see docs/remote-hosting.md) |
wp_cli |
Run any wp command |
wp_exec |
Arbitrary shell in any container (composer, npm, php, …) |
wp_rest |
Call the WordPress REST API (pre-wired app password) |
http_fetch |
Lightweight anonymous HTTP probe — status, headers, body, redirects |
visit |
Headless Chromium; auto-logs in on /wp-admin/. Returns status + DOM + iframes + console + network + optional screenshot |
db_query |
Run SQL — writes require mutate: true |
snapshot / wp_reset |
Capture a named snapshot (db_only: true skips uploads) / reset to protected @install (confirm: true) |
tail_log |
Tail wp-content/debug.log |
fs_read / fs_write / fs_list |
Read/write files under the instance's WP dir |
mail_list / mail_get |
Read Mailpit (test SMTP inbox) |
focus_get |
The project's focused plugin and available skills; pass include_claude_md=true when the project guide is needed |
activate_plugin / deactivate_plugin |
Toggle plugins |
import_content |
Import a WXR XML from runtime/seeds/ |
load_context |
Pull the full sandbox CLAUDE.md on demand |
load_skill |
Pull a skill (fix, bug-repro, snapshot, wp-debug, wp-pilot, fluentboards) |
load_workflow |
Pull a workflow (build-feature) |
feedback_submit / feedback_list |
Send or inspect bounded, secret-redacted agent feedback stored as untrusted machine-local data (see docs/feedback.md) |
Plus Claude's normal Read/Write/Edit reach the plugin source on disk —
bind-mounted into the container, so edits are live with no rebuild.
You can also invoke skills as slash commands, e.g.
/mcp__sandbox__activate (load the full operating guide) or
/mcp__sandbox__fix <task> (one-pass bug-fix loop).
Instances are created per-project by init/ensure; the browser dashboard also offers
a local Create an instance form backed by the same ensure operation. Remote
creation remains unavailable until the remote lifecycle service exposes it. You can
view and drive instances with:
./sb instances # list every per-project instance + status + URL
./sb instance list # discoverable singular alias for the same inventory
./sb dashboard # full-screen TUI: start/stop/restart/open/focus/delete
./sb web # the same dashboard in the browser (127.0.0.1:8765)
./sb instance suspend <name> # graceful stop; idle_stop is the resolved default
./sb instance resume <name> # start a provisioned instance and wait for readiness
./sb instance delete <name> # tear one down (containers, volume, files, registry)
# Remote inventory and exact-name teardown (never glob remote runtime paths):
./sb instances --remote <name> --json
./sb instance delete <exact-instance-name> --remote <name> --yesThe macOS desktop shell reuses this loopback dashboard and adds a narrowly scoped native project-folder picker. It never exposes Docker, SSH, credentials, or generic filesystem/process access to the renderer; see docs/desktop-app.md.
Request wake defaults on for newly resolved Docker instances. Set
instanceLifecycle.mode: "always_on" to opt out and pin an instance on.
Clean-URL setup installs and enables the per-user activation service before
eligible Caddy routes authorize readiness through stock forward_auth. The
same service scans idle routes and gracefully suspends them only after live
runtime, established HTTP/WebSocket connection, durable-job, and WP-CLI lease
checks pass. Missing evidence pins the route. Invalid catalogs or an unhealthy
authority retain direct/previous Caddy routes. Existing registry rows adopt the
default only on normal ensure/apply. Remote wake and function/FaaS adapters
remain future work. The supervised authority refreshes its catalog from the
current registry/config on authenticated activation requests and scheduler
cycles, so it does not keep serving a stale route snapshot after an instance
is created, changed, or removed. Liveness stays independent of registry I/O;
a failed refresh revokes the previous route allowlist until the source is
readable again. Caddy clears the original query from its cloned activation
request; after activation, the instance proxy receives the original path and
query unchanged.
./sb activation status
./sb activation status --json
./sb activation scan --dry-run
./sb activation enable
./sb activation disableactivation enable treats a healthy loopback authority as success even when a
macOS service-manager transition reports a transient non-zero result; the JSON
response may include a warning while the supervisor is already serving.
Each instance can run a different web server, and you can switch in place without re-importing content:
./sb server <name> nginx # apache → nginx (adds the nginx sidecar)
./sb server <name> litespeed # → OpenLiteSpeed
./sb server <name> apache # → back to apacheThe default provider is Sandbox's own Caddy proxy plus Sandbox-owned DNS, on every platform and for every runtime. One optional setup upgrades every instance to a trusted, no-port URL:
./sb domains setup # default provider: Caddy proxy + *.tst resolution
./sb domains use # show the active provider
./sb domains use herd-valet # opt in to a host incumbent instead (switchable anytime)Host-incumbent adoption is opt-in and has its own read-only planning surface:
./sb domains plan --project-dir .
./sb domains apply --project-dir . # first mutation asks only in a terminal
./sb domains cleanup --project-dir . # compare-before-remove; safe to retryAdapter proof tiers gate adoption only, never the default path. The instance stays
usable at http://localhost:<port> when the selected provider is unavailable. See
the clean-URL default and
domain resolution.
sandbox init # in a plugin dir: config + instance + test harness
sandbox ensure # boot/refresh this project's instance
sandbox apply # reconcile THIS project in place (cwd or --instance)
sandbox test [-- <args>] # run the plugin's phpunit tests (pass extra phpunit args after --)
./sb focus <plugin> # mark which plugin is focused (for Claude)
./sb open [admin|site|mail] # open in browser (default: admin)
./sb visit <url> [...] # load any URL in headless Chromium, report DOM/console/iframes
./sb snapshot <name> [--db-only] # save DB + uploads, or fast DB-only state
./sb restore <name> # restore a saved snapshot
./sb reset --yes # restore the protected post-install DB baseline
./sb update # git pull the project repo this instance tracks
./sb xdebug on|off # toggle step-debug (port 9003, host trigger)
./sb zip [--dev|--clean] # build the distributable plugin zip (see docs/plugin-zip.md)
./sb doctor [--instance NAME|--label LABEL] [--json] # audit one local instance + controller health
./sb status # which containers + project + focus are active
./sb status --instance <name> --json # inspect a known local instance from any cwd
./sb status --project-dir <dir> --json # inspect a registered local project from any cwd
./sb status --remote <name> --instance <remote-instance> --json # inspect a known remote instance directly
./sb down # stop containers (state preserved)
./sb clean # stop + wipe DB volume (start fresh)Run ./sb with no args for the full list. doctor runs on the local controller and
intentionally has no --project-dir, --local, or --remote; run it from the project
directory, or resolve the registered instance with ./sb instances --project-dir DIR --json
and pass --instance NAME. Most instance-scoped commands accept
--instance <name>; project-routed ensure/test/init use
--project-dir <dir> (and --label where supported). Use
sb apply --instance NAME to reconcile an existing named instance.
sandbox test / ./sb test dispatches plugin test modes: auto resolves to
unit or integration; integration provisions and runs the external
WordPress/PHPUnit harness, while unit runs plugin unit PHPUnit with the runner tools. Declared Compose modes and
matrix are separate execution paths; none run Sandbox's own Python tests. To test
this checkout, use ./sb selftest for the full Python gate or the focused stdlib
unittest commands in
tests/README.md, for example:
./sb selftest
.cli-venv/bin/python -m unittest tests.test_cli.TestResolutionGate -v
.cli-venv/bin/python -m unittest discover -s tests -p 'test_hosting_image_activation*.py' -v
.cli-venv/bin/python -m unittest tests.test_architecture_boundaries tests.test_owned_storage_architecture -vTwo layers:
- Per-project
sandbox.config.json(in the plugin repo, canonical) + gitignoredsandbox.config.override.json. This is what makes a plugin a sandbox project. Seedocs/sandbox-config-reference.md. A descriptor in another checkout directory can be selected with--project-dir DIR --config-file path/to/sandbox.config.jsonfor ensure/apply/test; the path must stay insideDIR. - Machine/global
sandbox.yml— ports base, admin creds, image defaults. Per-machine overrides go in the gitignoredsandbox.local.yml:
defaults:
plugins_home: "$HOME/dev" # where cloned plugins live
pro_plugins_home: "$HOME/Sites/plugins-pro" # shared Pro store, offered on demand
github_org: "wpdeveloper"pro_plugins_home (default ~/Sites/plugins-pro) is the one directory holding Pro
plugin copies. ./sb deploy and ./sb remote plugins <name> mirror it to a remote
host so every instance there lists the same slugs on Plugins → Sandbox On-Demand
— see docs/remote-hosting.md.
Immutable multi-image activation prepares private candidate configuration without overwriting retained deployment inputs. See the v2 activation contract for source binding, replay limits and required initializer execution proof.
There is no central project catalog — each plugin self-describes.
Host ingress adoption is the opt-in alternative to the default Docker/Caddy provider
(./sb domains use <provider>); see the clean-URL default.
./sb domains ingress support --json lists host products and their current proof tier;
detect, status, and plan are read-only. A product being detected does not mean Sandbox
may alter it: only an adapter with a documented control surface and accepted live proof can
become adoptable. A machine-local ingress override, when configured, beats a committed
project pin; an unavailable explicit pin returns the per-port URL rather than selecting a
different host service.
Route adoption requires a verified DNS handoff, interactive consent on first use, and an
owned route record. cleanup and reconcile remove only unchanged owned routes; drift or
an unavailable incumbent retains non-secret recovery state. In CI/MCP, pending consent or
credentials returns immediately and never prompts. See host ingress adoption
and the configuration reference.
The current mutation surface is deliberately narrow: Linux system Caddy, exact HTTP
hostnames, an already-enabled /etc/caddy/conf.d/*.caddy import, and an explicitly
installed owner-scoped helper. HTTPS, wildcard routing, and every other incumbent remain
unadvertised. The helper installation and pending live-evidence gate are documented in the
host-ingress guide.
Disposable remote development workspaces can opt into relationship-owned source
sync with explicit sync start|once|status|stop|resolve commands. Sync is off by
default, requires opaque project/workspace ownership, refuses credential-like
input before transfer, and keeps accepted/pending generations visible. Durable
job execution against synchronized generations remains fail-closed until an
enforced source authority is composed. See remote hosting.
Remote job workspaces share only immutable Git object files by hard link; their worktree,
refs, index, logs, and configuration stay private, with a complete-copy fallback for older or
unsupported layouts. Generic Compose projects can explicitly set compose.nodeStore: true
to receive one family-scoped sandbox-nodestore-<family> volume at /sandbox-node, with the
store and dependency-tree paths inside that same mount. Projects without the opt-in keep the
legacy overlay byte-for-byte, and BuildKit caches are separate and unchanged. The package
store is family-shared; each canonical runtime receives its own dependency-tree child so
sibling workspaces with different dependency versions do not share mutable node_modules.
Shared stores are never removed automatically. Use sb resources plan --node-store-family <canonical-family> first, review its exact volume, size, and running
mounts, then apply that named plan only with --confirm. Wildcards, inferred families, and
broad volume pruning are unsupported. See remote hosting for the
compatibility, migration, and rollback checklist.
Three attach points, all automatic:
- Sandbox
CLAUDE.md— the operating guide, loaded on demand viaload_context(the compact summary ships every session via the MCPinstructionsfield). - Project
CLAUDE.md— a plugin repo's ownCLAUDE.md(+ any.claude/skills/<area>/SKILL.md) is surfaced byfocus_getfor that project. - Personal skills —
~/.claude/skills/*/SKILL.mdare loaded by Claude Code itself, alongside the sandbox.
Skills (loaded via load_skill('<name>')): fix, bug-repro, snapshot,
wp-debug, wp-pilot, fluentboards. Workflows (load_workflow('<name>')):
build-feature. Each lives in its own folder with an uppercase entry file
(skills/<name>/SKILL.md, workflows/<name>/WORKFLOW.md).
sandbox/
├── sb # the CLI (Python — invoke as ./sb or `sandbox`)
├── sandbox_core.py # shared core: per-project config + registry
├── sandbox.yml # machine/global defaults
├── sandbox.local.yml # per-machine overrides (gitignored)
├── bin/sandbox.js # npm entry shim (execs the bundled sb)
├── package.json # npm package (@alimuzzaman/sandbox)
├── packaging/ # Homebrew formula + packaging notes
├── docker-compose.yml # managed by the CLI
├── runtime/
│ ├── wp-<instance>/ # each instance's WordPress install (bind-mounted)
│ ├── registry.json # project-root → instance mapping
│ ├── test-suite/ # cached wordpress-develop phpunit suite
│ ├── test-tools/ # phpunit + composer phars + polyfills + wp-tests-config
│ └── seeds/ # demo content / WXR imports
├── plugins/ # default home for cloned plugin repos (gitignored)
├── mcp/wp-server/ # the Python MCP server + its venv
├── skills/<name>/SKILL.md # role packs
└── workflows/<name>/WORKFLOW.md
The only state outside this folder: Docker's named volumes (cleared by
./sb clean / ./sb instance delete).
./sb doctor # checks containers, WP, REST auth, MCP venv, symlinks, project, focus- REST auth fails — re-run
./sb ensure(regenerates the app password). - MCP server not connected —
claude mcp listshould showsandboxas✓ Connected. If missing, re-run./sb setup. For the project-local fallback,cat .mcp.json(it points at./sb mcp). - A plugin "isn't found" — make sure you've run
sandbox init(orensure) in its directory so it has asandbox.config.json+ a registered instance. - Container won't start —
./sb ensureresumes a stopped/half-booted instance in place; if Docker itself restarted (e.g. an auto-update), relaunch Docker and re-runensure. - Fresh start —
./sb instance delete <name>thensandbox initagain.
For everything else, ask Claude — it has tail_log, wp_exec, and db_query
and can usually diagnose itself.
- Shipped — Docker WP stack; the single
sandboxMCP server routing byproject_dir; per-projectsandbox.config.*+ on-disk registry; externally-provisioned phpunit harness (sandbox test/run_tests);sandbox init; server-aware version pins; headless Chromium with auto-login (visit); size-scaledbuild-featureworkflow; one-passfixskill; FluentBoards integration; Plugin Check; first-pass remote VPS hosting; managed Compose-host validation and confirmation-gated permanent Cloudflare DNS/TLS deployment; personal~/.zshrc.secretssupport; npm + Homebrew + curl distribution. - Next — protected recovery and Hermes/Lenzora acceptance remain operator-gated.
Use the consolidated release-readiness checklist
before a release, then see
docs/future-roadmap.mdfor deferred product work.
Re-run ./sb setup after a global config change — it's idempotent.
Feature 046 host memory and swap monitoring, planning, apply, disable, history, and
safe rollback are implemented and verified against local and synthetic suites.
It uses authenticated control actions (host_memory_*) and never falls back to SSH.
See resource monitoring.
External acceptance gates (live Linux host mutations, reboot persistence, and production release)
remain explicit operator-gated steps; local and synthetic-provider passes do not claim live proof.
Remote Hermes control is documented in docs/hermes-agent.md.
Its optional public dashboard route uses Cloudflare Access and Tunnel while keeping
Hermes loopback-only; see the public-route section in that guide before any live apply.
Fresh sb hermes setup also prepares the Spark/Luna/Terra/Sol routed-worker profile;
provider authentication and gateway activation remain explicit operator steps.
Hermes scheduled state is reproducible from the committed cron catalog: use
sb hermes cron reconcile --remote NAME to preview, then repeat with
--confirm --force-replace. sb hermes health reports false-green provider
errors, catalog drift, competing gateway owners, and dirty managed worktrees.