Add sandbox scaffolding and airgapped relay surfaces#17
Merged
Conversation
Two opt-in surfaces lifted from the 2026-05-10 electricrag prompt- brittleness session that motivated this port: - aexp.sandbox: scaffolding for exploratory notebook work under notebooks/_sandbox/<YYYY-MM-DD>_<slug>/, deliberately outside the H/E/F enforcement chain. Adds /aexp-new-sandbox slash command, `aexp new-sandbox` CLI verb, and `setup_sandbox_notebook(name)` first-cell helper that closes the F4 kernel-cwd-vs-repo-root trap on remote Jupyter setups. - aexp.airgapped: file-queue bridge between a no-internet compute node and an internet-having login node sharing $HOME. Daemon on login node services a closed whitelist (git_pull/push/fetch/ status/rebase auto-approved; wandb_sync consent-gated) via atomic-rename JSON requests. RelayClient exposes git verbs as semantic methods, designing out F7 (raw git_push rejected without args) and F8 (git_push arg interpreted as remote not branch). Neither surface is imported at package init — opt-in via explicit import / slash command. 51 new tests; targeted suite green (test_sandbox.py + test_airgapped.py: 51 passed in 6.66s). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two port-time gaps surfaced by the first end-to-end client→daemon roundtrip on the laptop, both stemming from the original electricrag implementation being a single-file module rather than a package: - `python -m aexp.airgapped daemon` failed with `No module named aexp.airgapped.__main__`. Lifting the original single-file `electricrag.dev.relay` into a package directory broke the implicit module-as-script entry point. Fix: add a minimal `aexp/airgapped/__main__.py` that delegates to `_relay.main`. - `--log <path>` failed with `FileNotFoundError` on a fresh machine because the log handler opens its file before the daemon's startup() runs `ensure_queue(...)` to create the parent dir. Fix: `_cli_daemon` now `mkdir(parents=True, exist_ok=True)` on the log file's parent before constructing the FileHandler. Regression test pins the `python -m aexp.airgapped` entry point; full client→daemon→client smoke run successfully against a smoke git repo under $HOME (returncode=0, duration ~110ms, audit-trail visible in ~/.relay/daemon.log). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI on PR #17 surfaced 41 ruff violations across the two new surfaces. All resolved: - F401 (7): unused imports across __init__.py, client.py, cli.py, sandbox.py, test_airgapped.py — auto-fixed. - I001 (6): unsorted import blocks (the in-function importlib + module reload pattern in two tests) — auto-fixed. - B904 (2): `raise ... from err` in two except clauses in _relay.py (cwd-not-under-home + heartbeat-not-found) — manually fixed. - E501 (5): line-length wraps in _relay.py — manually fixed (docstring, install-helpers shell script bodies, _cli_status count-glob lines). Targeted 52-test suite still green (7.46s). No behavior changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two opt-in surfaces for cases the H/E/F discipline doesn't fit cleanly: pre-tracked exploratory notebook work, and HPC compute environments without internet.
Neither is imported at
aexppackage init — users opt in via explicitfrom aexp.sandbox import .../from aexp.airgapped import ...(or via the new slash command). The existing install / hooks / validator behaviour is unchanged.aexp.sandbox— scaffolding for free-form exploratory workA sandbox is an exploratory notebook subdir under
notebooks/_sandbox/<YYYY-MM-DD>_<slug>/that hasn't yet earned a trackedH### → E### → F###chain. Deliberately outside thekb_write_guardenforcement; reversible (git checkout <slug-dir>undoes everything); promotes back into the tracked chain via the existing/aexp-new-thread → /aexp-new-hypothesis → /aexp-promote-nbflow./aexp-new-sandboxslash command +aexp new-sandbox --slug ... [--title ...] [--parent-dir ...]CLI verb (slash count 21 → 22; CLI verb count 21 → 22).aexp.sandbox.scaffold(slug, ...) -> SandboxScaffoldResultis the underlying Python API. Idempotent at the directory-name level (rerun on same slug + same date raises rather than clobbering).aexp.sandbox.setup_sandbox_notebook(name) -> dictis a first-cell helper that closes the kernel-cwd-vs-repo-root trap on remote Jupyter — naivePath("notebooks/...").resolve()doubles the path when the kernel's cwd is the notebook's directory; this walks up to the repo root fromPath.cwd()and resolves robustly.## Intentdiscipline that shipped for tracked experiments in 0.2.0.README.md+.gitignore(excludes*.npy,*.parquet,*.h5,outputs/large/, etc.) are created on the first invocation in a repo and preserved on subsequent runs — hand-edited roots are never overwritten.aexp.airgapped— file-queue bridge for no-internet computeDesigned for HPC sites where the agent's runtime is on a network-isolated compute node, but a sibling node sharing
$HOMEhas outbound internet — and institutional policy forbids SSH from the agent to the cluster. The compute-side client writes a JSON request to~/.relay/inbox/via atomic rename; a daemon undertmuxon the login node polls, runs whitelisted commands, writes the response to~/.relay/outbox/. Client polls back and returns aRelayResult.RelayClientexposes git verbs as semantic methods (.pull(),.push(branch=...),.fetch(),.status(),.rebase()) so callers don't hand-construct args. Closed whitelist:git_pull / push / fetch / status / rebaseauto-approved;wandb_syncconsent-gated (user touches~/.relay/approved/<uuid>via the shippedrelay-approveshell helper). No escape hatch for arbitrary commands.validate_request()is a pure validator: op-in-whitelist, args list-of-str with per-op regexfullmatch, max 32 args at 256 chars each, cwd required + must resolve under$HOME. OptionalAEXP_RELAY_CWD_NAMESenv var further restricts cwd to a named allowlist.python -m aexp.airgappedexposesdaemon/status/install-helperssubcommands with a shared--queue PATH(default~/.relay).RelayDownErrorif missing or >30s stale), 250ms client poll / 500ms daemon poll (cross-nodeinotifyis unreliable on networked filesystems), 7-day GC ofoutbox/log/approved/rejected/, 24h pending-TTL for un-decided consent, stale-processing recovery on daemon restart.Designed-out frictions
The high-level wrappers exist specifically to close arg-passing gotchas that bite raw users:
Path("notebooks/...").resolve()from a notebook one sandbox dir deep doubles the path (kernel cwd is the notebook's dir, not the repo root).setup_sandbox_notebookwalks up.request("git_push")raises because the whitelist regex is set, so per-call args are required.RelayClient.push()defaults to["origin", "HEAD"].request("git_push", args=["main"])runsgit push mainwheremainis interpreted as a remote, not a branch.RelayClient.push(branch=...)builds the argv in the right order.Test plan
tests/test_sandbox.py(21) +tests/test_airgapped.py(31). Full repo suite remains green on the targeted run.preserved_user_modified(customized templates +kb/mission/CHALLENGE.md), 20skipped_identical, 44 tooling refreshes. New.claude/commands/aexp-new-sandbox.mdlands; nothing underkb/research/{hypotheses,findings,threads}/touched./aexp-new-sandboxsmoke — scaffolds a throwaway sandbox dir; pre-existing sandbox subdirs and hand-edited sandbox-rootREADME.mduntouched (preserve-existing-root logic verified).setup_sandbox_notebooksmoke — resolves a pre-existing sandbox (one that wasn't created byscaffold()), returns{'repo_root': ..., 'sandbox_dir': ...}correctly.ALLOWEDops + the sixRelayErrorsubclasses +RelayClientimport cleanly; defaults populate correctly (queue=~/.relay,cwd=Path.cwd(),default_timeout=60.0).python -m aexp.airgapped daemon, verified heartbeat appeared in~/.relay/heartbeat, ranRelayClient(cwd=...).status()from a separate process, got backRelayResult(returncode=0, duration_s=0.11, ...)with the expectedgit status --porcelain=v2output.aexp.airgapped statusCLI also exercised. Audit trail indaemon.logcomplete (start → validation rejection → exec → done rc=0).62ebb50):__main__.py—python -m aexp.airgappedfailed withNo module named __main__because lifting the original single-file reference implementation into a package directory broke the implicit entry point. Addedaexp/airgapped/__main__.pydelegating to_relay.main. Regression test pins it.--log ~/.relay/daemon.logfailed withFileNotFoundErroron a fresh machine._cli_daemonnowmkdir(parents=True, exist_ok=True)on the log file's parent.os.setsid, etc.), which are exercised by the upstream 56-test daemon-lifecycle suite the port preserves.Docs
docs/sandbox.md— full layout, slash-command + CLI + Python API, first-cell convention, promotion path.docs/airgapped.md— problem framing, protocol diagram, whitelist table, client API,RelayResult+ error semantics, daemon bootstrap recipe, end-to-end workflow example, optional cwd-allowlist hardening.docs/cli.md— newaexp new-sandboxverb added; install-slash-commands enumerated list updated.docs/quickstart.md— pre-section-2 callout pointing exploratory users at/aexp-new-sandboxinstead of forcing them into the H/E/F flow.README.md— new "Exploratory surfaces" feature table; doc index + project layout updated.CHANGELOG.md— Unreleased section.Out of scope (follow-ups planned)
.aexp/config.yaml—sandbox.parent_diris currently hardcoded tonotebooks/_sandbox; non-conforming repos must pass--parent-dirper call.relay.cwd_allowlistis currently env-var only. A small user-editable config file (opt-in; absence = current defaults; precedence: explicit kwarg > env var > config > default) is designed but deliberately separate to keep this PR scoped to "lift two surfaces."python_execlarity —aexp installsilently bakessys.executableinto.aexp/installed.json, with no guardrail against installing from a "dedicated aexp env" rather than the consumer's actual project env.docs/quickstart.mdcurrently models the wrong pattern. Designed follow-up: README/quickstart edit + a yellow warning ifconda_env_name in ("", "base")and the path doesn't look venv-shaped + optional--python-exe/--conda-envinstall overrides.install.py's heads-up text — "21 slash commands" should now read "22." Cosmetic.aexp.airgapped. Consumers should rewrite imports toaexp.airgapped; their local copies can then be removed or shimmed. Its own change.