Skip to content

feat(#576): durable per-scope sandbox — P1 interface + Docker backend - #776

Merged
Weegy merged 4 commits into
mainfrom
feat/576-sandbox
Aug 20, 2026
Merged

feat(#576): durable per-scope sandbox — P1 interface + Docker backend#776
Weegy merged 4 commits into
mainfrom
feat/576-sandbox

Conversation

@Weegy

@Weegy Weegy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Issue #576 (qm competitive analysis) — Phase 1 of 3 (interface + Docker
backend; execute tool + policy gate is P2, scope durability + RO-layer is
P3, admin view is P4-if-time). This PR touches only a new workspace
package; it does not touch the orchestrator, src/index.ts, agentBuilder.ts,
or the credential/skill namespaces.

Introduces @omadia/sandbox (middleware/packages/harness-sandbox):

  • Sandbox contract (provision/run/read/write/list/teardown), modelled
    on qm's src/sandbox/sandbox.ts shape per the issue. Optional capabilities
    (process-sessions, backup, blob-staging) are exposed via type
    guards
    (hasProcessSessions, hasBackup, hasBlobStaging) rather than
    interface fields — a backend that doesn't implement one simply fails the
    guard. DockerSandboxBackend v1 implements none of them; the guards return
    false for every sandbox it provisions.
  • AgentComputerProfile — declares persistent/egress/processSessions/
    maxRunSeconds/maxOutputBytes. Every field's doc comment names the exact
    call site required to enforce it (this repo's most-repeated defect is a
    declared field nobody wires — see harness-lib-generalization-plan1 and
    pr736-command-policy postmortems in project memory).
  • DockerSandboxBackend — local Docker, built on the same
    injectable-exec pattern as src/plugins/builder/buildSandbox.ts's
    executeBuild seam (dockerExec.ts's execDocker injection point), so the
    full backend logic runs deterministically with zero real Docker.

Wiring proof, not declaration

  • egress: falsedocker run --network none. Proven at two levels:
    a stub-level argv assertion (always runs, no Docker needed) AND — behind
    the opt-in SANDBOX_DOCKER_TEST=1 gate — a real container attempting an
    outbound wget to example.com and observing it fail. Both were run
    locally against an actual Docker daemon and pass (~6.2s including the
    wget timeout).
  • Traversal hardening (pathGuard.ts's clampSandboxPathPosix), same
    discipline as the feat(#578): credential broker — the egress-stamping layer (phase 2/4) #772 broker and zipExtractor.ts's zip-slip guard:
    absolute paths, NUL bytes, and any ../ resolution outside the sandbox
    root are rejected before a single docker exec is issued — asserted
    directly (the traversal tests check the stub recorded zero calls for a
    rejected path).
  • Scope durability without a registry yet: the container name is a
    deterministic function of the scope key
    (omadia-sbx-<sha256(scopeKey)[0:24]>), so provision() re-attaches to an
    already-running container across backend-instance restarts. Docker itself
    is the durable store for "which container belongs to this scope" — what
    P3's registry adds on top is bookkeeping Docker doesn't give for free
    (reaper timestamps, RO-layer content-hash tracking, multi-backend
    routing), not a prerequisite for this backend to be correct.

Docker-gated vs stub split

Per the plan: CI has no guaranteed privileged Docker, so real-Docker tests
are gated (SANDBOX_DOCKER_TEST=1, opt-in, off by default) and stub tests
cover 100% of the logic branches. 30 stub-tier tests always run in
npm test; 2 real-Docker tests are additional and were run manually against
a live daemon (both green — see commit message for the exact run).

Mutation-check evidence

With a full npm run build -w @omadia/sandbox rebuild between each mutation
and its revert:

  1. Inverted the !profile.egress condition in dockerSandbox.ts
    egress:true does NOT pass --network none test failed as expected
    (asserted the exact wrong argv in the failure output).
  2. Disabled the escape check in pathGuard.ts's clampSandboxPathPosix
    both .. escape tests in pathGuard.test.ts failed as expected.

Both reverted; full suite green again after rebuild.

Blast radius

  • New workspace package only (middleware/packages/harness-sandbox), zero
    new runtime dependencies (shells out to the docker CLI via
    node:child_process, same constraint as dev-runner-shim).
  • middleware/package.json: registered @omadia/sandbox in the build,
    typecheck, lint, lint:fix workspace chains (built/typechecked first —
    no other package depends on it yet).
  • middleware/package-lock.json: workspace registration only (+12 lines).
  • New tests under middleware/test/sandbox/ (this repo's actual
    CI-covered test convention — the root npm test glob is
    test/**/*.test.ts relative to middleware/, so package-local test/
    dirs like dev-runner-shim's are not picked up by CI; confirmed this
    before placing the new tests).

Verification run locally (this branch, after merging origin/main)

  • npm run build (full workspace): green
  • npm run typecheck (full workspace incl. golden/adversarial): green
  • npm run lint: clean
  • npm run test: 7049 tests, 7037 pass, 0 fail, 12 skipped (pre-existing),
    0 cancelled

Base / stacking

Branched from origin/main at merge time (29c2c678, includes #578 P1
credentials). No stacking — this is the first #576 PR. P2 (execute tool +
command-policy gate) will stack on this branch.

Open questions for Marcel

  • P1 deliberately ships zero capability implementations
    (process-sessions/backup/blob-staging) — the type-guard seam exists per
    the issue's qm reference but nothing needs it yet. Confirm that's the
    right amount of yet-unused surface for this phase (YAGNI vs. "the seam
    exists so P2/P3 plug straight in" — I read the issue as wanting the seam
    now, implementations later).
  • Image choice for v1 is alpine:3.20 (small, busybox coreutils, ships
    wget for the egress proof). If agent work is expected to need a fuller
    toolchain (git, node, python) sooner than P3/P4, that's a v1 default worth
    revisiting explicitly rather than inheriting silently.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Weegy added 2 commits August 20, 2026 15:38
Introduces @omadia/sandbox (middleware/packages/harness-sandbox): the narrow
Sandbox contract from issue #576 (qm competitive analysis) — provision/run/
read/write/list/teardown, optional capabilities (process-sessions/backup/
blob-staging) behind type guards rather than interface fields, and an
AgentComputerProfile declaring persistence/egress/process-session posture.

v1 backend is plain local Docker (DockerSandboxBackend), built on the same
injectable-spawn pattern as src/plugins/builder/buildSandbox.ts's
executeBuild seam (see dockerExec.ts's execDocker injection point) so the
full backend logic is testable with zero real Docker.

Wiring, not just declaration:
- profile.egress === false becomes docker run --network none. Proven at two
  levels: a stub-level argv assertion (always runs) and, behind the opt-in
  SANDBOX_DOCKER_TEST=1 gate, a real container attempting an outbound wget
  and observing it fail — not just that the flag was passed.
- read/write/list are traversal-hardened against a fixed sandbox root
  (pathGuard.ts's clampSandboxPathPosix), same discipline as the #772
  broker and zipExtractor.ts's zip-slip guard: absolute paths, NUL bytes,
  and any ../ resolution outside the root are rejected before a single
  docker exec is issued (asserted directly — traversal tests check the
  stub recorded zero calls).
- Container naming is a deterministic function of the scope key
  (sha256(scopeKey)[0:24]), so provision() re-attaches to an
  already-running container across backend-instance restarts without
  needing a DB-backed registry yet — that scope-durability bookkeeping
  (last-used timestamps for a reaper, RO-layer content-hash tracking,
  multi-backend routing) is P3's job, not a blocker for this backend
  working correctly today.

No orchestrator touch (by design — P1 scope per the phase cut). No new
runtime dependencies: the backend shells out to the docker CLI via
node:child_process, mirroring dev-runner-shim's dockerd.ts and
buildSandbox.ts's Node-builtins-only constraint.

Tests: middleware/test/sandbox/{pathGuard,agentComputerProfile,
dockerSandbox}.test.ts. 30 stub-tier tests (always run, no Docker
required) + 2 real-Docker tests gated on SANDBOX_DOCKER_TEST=1 (both
verified green locally against an actual daemon, including the egress
block). Root npm test / typecheck / build / lint all green with this
package included in the workspace chain.

Mutation-checked: inverting the egress condition in dockerSandbox.ts and
disabling the escape check in pathGuard.ts (with a full package rebuild
between runs) both broke the corresponding tests; reverted and confirmed
green again before committing.
Weegy added 2 commits August 20, 2026 16:00
…e-decoupling ratchet

The comment referencing 'dev-runner-shim' by name matched the ratchet's
'dev-runner' literal pattern (scripts/check-core-decoupling.mjs), which
counts references to the Dev Platform being extracted in epic #470 — an
unrelated coincidence (dev-runner-shim is a real, unrelated package; the
ratchet's pattern list is deliberately broad-literal). Reworded to drop
the package name while keeping the same intent (Node-builtins-only
constraint on this spawn seam). Confirmed the ratchet passes locally
after this change: 'Dev Platform references held at 3296' (baseline
unchanged, not lowered — this was never a real Dev Platform reference,
just a name collision).
@Weegy
Weegy merged commit 5a05f6f into main Aug 20, 2026
9 checks passed
@Weegy
Weegy deleted the feat/576-sandbox branch August 20, 2026 14:11
Weegy added a commit that referenced this pull request Aug 20, 2026
Add/add conflicts in the three harness-sandbox files P3 evolved: main
carries P1's squash (#776), this branch carries P1 plus P3's deliberate
changes. Verified via git log that only the #776 squash ever touched these
paths on main, so the branch side is the correct resolution. The lockfile
conflict is resolved by taking main's and reconciling via npm install.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant