Sandboxed Usage

How to run nexus-agents inside Docker containers, restricted-filesystem sandboxes, and team-distribution flows where the home directory may be read-only.

TL;DR

Inside most container / sandbox environments, nexus-agents auto-detects and announces the switch to portable mode:

[portable-mode] Sandbox detected (container-env). Using /work/.nexus-agents for all nexus-agents data.
                Set NEXUS_PORTABLE_MODE=0 to override; see docs/guides/SANDBOXED-USAGE.md

If you see this on stderr, the auto-detection worked and your nexus-agents data lives in <cwd>/.nexus-agents/. No action needed.

How auto-detection works

Resolution order (first match wins):

  1. NEXUS_DATA_DIR set → respected as-is, no auto-detection. Operator override.
  2. NEXUS_PORTABLE_MODE=0 → never portable, no auto-detection. Operator opt-out.
  3. NEXUS_PORTABLE_MODE=1 → always portable, silent (no announcement — operator already knows).
  4. Heuristic: home directory not writable → portable mode, announces.
  5. Heuristic: container env vars set — portable mode, announces. Detected vars:
    • KUBERNETES_SERVICE_HOST
    • DOCKER_CONTAINER
    • ECS_CONTAINER_METADATA_URI and ECS_CONTAINER_METADATA_URI_V4
    • SANDBOX
    • NEXUS_SANDBOX

When portable mode triggers, nexus-agents:

  • Sets NEXUS_DATA_DIR to <cwd>/.nexus-agents/
  • Announces the switch on stderr (one line, the first time it fires)
  • If <cwd> is a git repository, appends .nexus-agents/ to the project’s .gitignore (idempotent — won’t duplicate)

Forcing the behavior you want

You want Set this
One explicit data directory for everything NEXUS_DATA_DIR=/some/abs/path
All state in ~/.nexus-agents/ (pre-#2872 behavior) NEXUS_REPO_PREFERRED=0
Force portable mode (always) NEXUS_PORTABLE_MODE=1
Disable portable auto-detect NEXUS_PORTABLE_MODE=0
Default — per-repo split (epic #2872) (no env vars)

Default behavior (no env vars): per-repo state lands in <repo>/.nexus-agents/, cross-repo state in ~/.nexus-agents/. If ~ isn’t writable (sandbox), cross-repo state falls back to <repo>/.nexus-agents/ automatically.

Common scenarios

Docker container with project mounted at /work

docker run --rm -v "$(pwd)":/work -w /work -e DOCKER_CONTAINER=1 \
  node:22 bash -c "npx nexus-agents auth status"

Output:

[portable-mode] Sandbox detected (container-env). Using /work/.nexus-agents...
Nexus Agents — CLI authentication status
=========================================
  ⚠  Claude Code    needs login     ...

Restricted-filesystem sandbox (cwd-only writes)

If the sandbox blocks writes to ~/, the home-unwritable heuristic fires automatically. Same [portable-mode] announcement appears.

Team distribution: one workspace, multiple teammates

Use nexus-agents init --portable once at the workspace root:

nexus-agents init --portable

This scaffolds <cwd>/.nexus-agents/ and configures the project so every teammate cloning into the same workspace gets the same data layout. Pair with init --portable --install if your team wants a one-command setup.

The init --portable flow respects whatever NEXUS_DATA_DIR is set to, so it composes cleanly with the auto-detection above.

Troubleshooting

“Failed to write to ./.nexus-agents/…”

The sandbox is more restrictive than auto-detection caught. Set NEXUS_DATA_DIR to an absolute path you know is writable:

export NEXUS_DATA_DIR=/work/data/nexus
nexus-agents <cmd>

Auto-detect didn’t fire but I’m in a sandbox

Add the appropriate container env var to your launcher (SANDBOX=1 works as a generic signal), or set NEXUS_PORTABLE_MODE=1 explicitly.

My CI is hitting auto-detect when I don’t want it to

Set NEXUS_PORTABLE_MODE=0 in CI’s environment. The opt-out wins over every heuristic.

Auto-gitignore isn’t happening

It only fires when <cwd> is a git repository (has a .git/ directory). nexus-agents doesn’t ancestor-walk for .git discovery — see #2301 for the deferred design pass on safe ancestor walking. If you’re in a subdirectory of a git repo, run nexus-agents from the repo root or add .nexus-agents/ to .gitignore manually.

Codex seats fail with bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted

Cause. The host has kernel.apparmor_restrict_unprivileged_userns=1 (the Ubuntu 24.04+ default; check with sysctl kernel.apparmor_restrict_unprivileged_userns). Codex’s Linux sandbox is bwrap, which unshares a network namespace and then cannot bring up loopback because the unprivileged namespace gets no CAP_NET_ADMIN. Every bwrap-backed mode fails the same way — read-only, workspace-write, network off — before codex reads a single file, so the two codex voter seats (devex, scope_steward) either abstained with “repository inspection failed” or voted on the proposal text alone. Deterministic, not intermittent.

What nexus-agents does. On Linux only, both codex spawn paths (codex mcp-server for the voter seats, codex exec for the subprocess adapter) pass -c features.use_legacy_landlock=true, which selects codex’s landlock backend instead of bwrap (#6093). Measured on codex-cli 0.153.4: reads work and writes are still refused with Permission denied. Codex lists the feature as deprecated, so a future codex release may drop it; if the loopback error returns after a codex upgrade, that is the first thing to check.

Read-scope caveat. Legacy landlock is read-only-all-disk. That is the same read scope the read-only bwrap profile already granted, so this changes nothing about what a seat can see — a voter has always been able to read dotfiles under the cwd or home and echo them into its recorded reasoning. Run the voter panel from a checkout that does not sit next to secrets you would not paste into a vote record.

Host-level alternative. If you would rather keep bwrap, either set kernel.apparmor_restrict_unprivileged_userns=0 (sysctl -w, and persist it under /etc/sysctl.d/) or install an AppArmor profile that grants bwrap userns — Ubuntu ships such profiles for its own bwrap consumers under /etc/apparmor.d/. The flag is still passed either way; it is harmless when bwrap would have worked.

What’s NOT touched by portable mode

  • ~/.claude/ (Claude Code CLI’s own data)
  • ~/.gemini/ (Gemini CLI’s own data)
  • ~/.config/opencode/ (OpenCode’s own data)

These are third-party CLI configurations that those CLIs expect at fixed locations. nexus-agents reads them via the auth probe (#2447) but doesn’t redirect them — that’s the third-party CLI’s contract.

Verification

Inside any sandbox, run:

nexus-agents auth status
nexus-agents doctor

Both should complete without errors. The first invocation triggers the [portable-mode] announcement (if auto-detected) and the .nexus-agents/ directory is created lazily by whichever subsequent command first writes data.

OpenCode-in-Docker + OpenAI-compat gateway (epic #2500)

The portable-mode flow above covers the “I’m running nexus-agents directly in a sandbox” case. This section covers the more specific scenario where nexus-agents is loaded as an MCP by OpenCode running inside a Docker sandbox, with a custom OpenAI-compatible gateway proxying upstream provider keys at the host boundary.

Source: epic #2500, shipped across #2501–#2505.

Architecture

┌─ Host ──────────────────────────────────────────────────────┐
│  Workspace key proxy (LiteLLM / OpenRouter / vLLM / …)      │
│   → injects upstream provider keys before forwarding        │
│   → exposes /v1/models + /v1/chat/completions               │
│                                                             │
│  $ docker run --rm -it \                                    │
│       -v /projects:/projects \                              │
│       -e NEXUS_SANDBOX_ROOT=/projects \                     │
│       -e NEXUS_OPENAI_COMPAT_URL=$WORKSPACE_PROXY_URL \      │
│       -e NEXUS_OPENAI_COMPAT_KEY=$WORKSPACE_PROXY_KEY \      │
│       nexus-sandbox:latest opencode .                       │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─ Container ─────────────────────────────────────────────────┐
│  /projects/{repo1, repo2, repo3, .nexus-agents/}            │
│                                                             │
│  OpenCode (entrypoint)                                      │
│    └─ spawns nexus-agents MCP via opencode.json             │
│        ├─ NEXUS_SANDBOX=docker-opencode                     │
│        ├─ NEXUS_DATA_DIR=/projects/.nexus-agents            │
│        ├─ NEXUS_OPENCODE_CONFIG=~/.config/opencode/...      │
│        └─ NEXUS_OPENAI_COMPAT_URL/KEY (passthrough)         │
│                                                             │
│  At startup, nexus-agents:                                  │
│    1. detectSandbox() → active=true, flavor=docker-opencode │
│    2. tryWireGatewayAdapters() → probe <URL>/models         │
│    3. fail-fast if gateway unreachable                      │
│    4. log "gateway wired" { host, modelCount, models }      │
└─────────────────────────────────────────────────────────────┘

Build the image

Dockerfile.sandbox extends docker/sandbox-templates:opencode (the official OpenCode template) and bakes nexus-agents in alongside.

docker build -f Dockerfile.sandbox -t nexus-sandbox:latest .

The image sets ENV NEXUS_SANDBOX=docker-opencode so nexus-agents knows it’s running inside a host-provided sandbox at startup (#2501).

Configure the workspace key proxy on the host

Don’t put upstream provider keys in the image or pass them into the container. Run a workspace key proxy on the host that:

  • Accepts requests from the sandbox at WORKSPACE_PROXY_URL authenticated by WORKSPACE_PROXY_KEY (an opaque per-sandbox token you generate).
  • Injects the real upstream provider key before forwarding.
  • Exposes the standard OpenAI Chat Completions surface (/v1/models, /v1/chat/completions).

Project-specific implementations include LiteLLM, OpenRouter’s BYOK gateway, vLLM with API-key middleware, or a hand-rolled httpx proxy. The contract for the sandbox is one URL, one workspace key, OpenAI-compat models.

Run the sandbox

docker run --rm -it \
  -v /path/to/your/projects:/projects \
  -e NEXUS_SANDBOX_ROOT=/projects \
  -e NEXUS_OPENAI_COMPAT_URL=$WORKSPACE_PROXY_URL \
  -e NEXUS_OPENAI_COMPAT_KEY=$WORKSPACE_PROXY_KEY \
  nexus-sandbox:latest opencode .

NEXUS_SANDBOX_ROOT=/projects tells nexus-agents this is the multi-repo root. State goes at /projects/.nexus-agents/, shared across all repo subfolders. NEXUS_DATA_DIR is unset on the host — the sandbox-mode default places state at ${NEXUS_SANDBOX_ROOT}/.nexus-agents/.

opencode.json layout

Dockerfile.sandbox writes a default opencode.json at /home/agent/.config/opencode/opencode.json:

{
  "$schema": "https://opencode.ai/config.json",
  "providers": {
    "openai-compat": {
      "npm": "@ai-sdk/openai-compatible",
      "options": {
        "baseURL": "{env:NEXUS_OPENAI_COMPAT_URL}",
        "apiKey": "{env:NEXUS_OPENAI_COMPAT_KEY}"
      }
    }
  },
  "mcp": {
    "nexus-agents": {
      "type": "local",
      "command": ["node", "/opt/nexus-agents/dist/cli.js", "--mode=server"],
      "enabled": true,
      "environment": {
        "NEXUS_SANDBOX": "{env:NEXUS_SANDBOX}",
        "NEXUS_DATA_DIR": "{env:NEXUS_DATA_DIR}",
        "NEXUS_OPENAI_COMPAT_URL": "{env:NEXUS_OPENAI_COMPAT_URL}",
        "NEXUS_OPENAI_COMPAT_KEY": "{env:NEXUS_OPENAI_COMPAT_KEY}",
        "NEXUS_OPENCODE_CONFIG": "/home/agent/.config/opencode/opencode.json",
        "NEXUS_GATEWAY_COST": "openai-compat=free"
      }
    }
  }
}

{env:VAR} is OpenCode’s interpolation syntax — substitution happens when OpenCode reads the file, so values flow through to the MCP environment block at spawn time.

NEXUS_OPENCODE_CONFIG is the bridge that lets nexus-agents read the gateway config from opencode.json directly (#2503). Precedence: NEXUS_OPENAI_COMPAT_URL/KEY env vars > opencode.json > unconfigured.

NEXUS_GATEWAY_COST=openai-compat=free declares what the providers.openai-compat gateway costs (#4392); without a declaration the budget gates exclude the gateway and doctor warns. init --opencode writes this line, scoped to openai-compat= rather than a bare free so a second gateway you add later is not silently declared with it. If the proxy meters usage, change the value to openai-compat=priced:<inputPer1M>,<outputPer1M>; a re-run of init --opencode keeps whatever you set.

Fail-fast behaviour

When sandbox mode is active and the gateway is misconfigured, nexus-agents fails fast at startup (#2502):

  • Missing env vars (and no NEXUS_OPENCODE_CONFIG-pointed file with providers.openai-compat): exit, error names the missing env vars + this doc.
  • The $NEXUS_OPENAI_COMPAT_URL/models probe fails: exit, error includes the HTTP failure.
  • Gateway returns zero models: exit.

This is intentional — there’s no human at a CLI prompt inside the container to diagnose later, so a misconfigured gateway should surface at first boot, not on the operator’s first orchestrate call.

Validating it works

From inside the running container:

nexus-agents doctor

The doctor output now includes a “Sandbox awareness” section (#2501) when active. Look for:

  • ✓ Sandbox flavor: docker-opencode
  • NEXUS_SANDBOX_ROOT: /projects (your mounted root)
  • No mismatch warning (heuristic agrees: /.dockerenv present)
  • No dataDirInsideRepo warning (state at the multi-repo root, not inside a single repo)

For a quick orchestrator probe:

nexus-agents orchestrate -t "Say hello"

The first call hits the gateway and returns from one of the configured upstream models.

Adding nexus-agents to an existing opencode.json

If you’re not using Dockerfile.sandbox directly — e.g., you have an existing opencode.json with your own provider config and want to add nexus-agents to it — run:

nexus-agents init --opencode /path/to/opencode.json --dry-run

This shows the proposed merge without writing. Drop --dry-run to commit. The merge (#2504) preserves every existing key. Re-running is idempotent. Operator overrides like enabled: false are preserved across re-runs.

OpenCode-specific troubleshooting

“Sandbox mode active but NEXUS_OPENAI_COMPAT_URL / NEXUS_OPENAI_COMPAT_KEY are not set” — Either pass the env vars when running the container, or set NEXUS_OPENCODE_CONFIG to point at an opencode.json whose providers.openai-compat.options resolves to a real URL + key.

Gateway probe fails — From inside the container, curl -H "Authorization: Bearer $NEXUS_OPENAI_COMPAT_KEY" "$NEXUS_OPENAI_COMPAT_URL/models". NEXUS_OPENAI_COMPAT_URL already ends in /v1, so do not append a second /v1. If that fails, the workspace key proxy isn’t reachable from the sandbox network. Check outbound network access to the proxy host and that the proxy is bound to an interface the container can reach.

“Mock orchestration” warnings appearing post-upgrade — Older Dockerfile.sandbox builds set NEXUS_ALLOW_MOCK_ORCHESTRATION=true as a band-aid for the unwired gateway. With the gateway now wired (#2502), drop that env var — orchestration uses real LLM calls. Mock-orchestration is heuristic-based and silently produces non-LLM results; leaving it on after the gateway is configured will mask real routing decisions.