| Server | SSH | Domain |
|---|---|---|
| Thomas / agent-core | ssh -i ~/.ssh/paloma root@65.109.98.246 |
https://agent-backend.thomas.md |
| Thomas / old-agent compute | ssh -i ~/.ssh/paloma root@95.216.112.253 |
Sandboxed node only |
| Ben | ssh -i ~/.ssh/paloma root@88.99.4.254 |
https://fricobackend.relens.ai |
agent-core (65.109.98.246, Tailscale 100.107.113.19) is the only Thomas
control plane. It runs the two sandboxed.sh instances plus the Hermes assistant
stack. Hermes's server inventory calls it agent-core; the historical
sepolia.rpc.starknet.id name is only a compatibility DNS alias.
old-agent (95.216.112.253) is intentionally still powered on, but only as a
leaf compute host. Its Sandboxed node ID is old-agent and exposes port
3088 only to 65.109.98.246. Its former sandboxed.sh, Hermes, fleet-daemon, and
nginx control-plane services remain stopped and disabled. Do not use it as a
scheduler, relay, or rollback control plane.
Network routing is direct:
agent-corereaches DGX Spark over Tailscale at100.77.4.93:3088. There is no production SSH tunnel throughold-agent.- Ashur, Babylon, Nippur, and
old-agentexpose their Sandboxed node API only to the public address65.109.98.246. - The Paloma SSH key on
agent-coreis authorized for operational reads on Ashur, Babylon, Nippur, DGX, andold-agent;disk-sentinelexercises these direct paths. - DNS already points the production endpoints at
agent-core. The current Cloudflare automation token still returnsInvalid API Token; rotate it before the next DNS mutation. Do not reintroduce anold-agentrelay as a workaround.
The production cron runs:
/srv/sandboxed-storage/staging/agent-core/hermes/.hermes/scripts/disk-sentinel.sh
Keep its mirrored operator copy in
/srv/sandboxed-storage/staging/agent-core/hermes/scripts/ identical. It checks
agent-core locally, then Ashur, Babylon, Nippur, DGX Spark, and old-agent
over direct SSH. It must not contain the retired CI runner or route DGX through
old-agent.
Remote SSH is deliberately non-interactive and fail-closed:
- Ashur, Babylon, Nippur, and
old-agentuse/var/lib/hermes-assistant/.ssh/paloma. - DGX uses
/root/.ssh/agent-core-dgx-tunneldirectly; despite the historical filename, this is now a direct Tailscale SSH identity. - Every host key must already be pinned in
/root/.ssh/known_hosts; do not solve a sentinel incident withStrictHostKeyChecking=no.
When several hosts suddenly report DISK UNKNOWN, first execute the script
manually on agent-core. Check the explicit identity path and pinned host key
for each failing entry before diagnosing a simultaneous fleet outage:
ssh -i ~/.ssh/paloma root@65.109.98.246 \
'/srv/sandboxed-storage/staging/agent-core/hermes/.hermes/scripts/disk-sentinel.sh'No output means every monitored filesystem is reachable and below its alert
threshold. The current cron job ID is 6bfc537e6dc9; a manual cron execution
must also finish successfully before closing the incident.
DGX is the one sentinel target that depends on the control plane's Tailscale
service. If only dgx-spark is unknown, verify the route before changing SSH
keys or adding a relay:
systemctl is-enabled tailscaled
systemctl is-active tailscaled
tailscale ping -c 2 100.77.4.93tailscaled must be both enabled and active on agent-core. Recover the
expected persisted service with systemctl enable --now tailscaled, then
require all three checks to pass: Tailscale ping, strict-host-key SSH to DGX,
and a silent disk-sentinel.sh run. Never restore the retired old-agent
tunnel to mask a stopped local Tailscale service.
| Service | Port | Domain | Binary |
|---|---|---|---|
sandboxed-sh-prod |
3000 | agent-backend.thomas.md | /usr/local/bin/sandboxed-sh-prod |
sandboxed-sh-dev |
3002 | agent-backend-dev.thomas.md | /usr/local/bin/sandboxed-sh-dev |
hermes-assistant |
8642 (loopback) | agent-backend.thomas.md /hermes-remote |
/usr/local/bin/hermes (gateway) |
hermes-dashboard |
9130 (loopback) | agent-backend.thomas.md /hermes-desktop |
/usr/local/bin/hermes (dashboard) |
Hermes endpoints (nginx config in
/etc/nginx/sites-enabled/agent-backend.thomas.md — NOT tracked in git):
/hermes-remote→ the gateway's OpenAI-compatible API server (loopback 8642), re-exposed by the sandboxed.sh backend itself (hermes_remote_proxyinsrc/api/system.rs)./v1/*+ SSE only, no WebSocket. Used for split mode (a local Hermes setsGATEWAY_PROXY_URL/GATEWAY_PROXY_KEYand delegates agent work). Key =API_SERVER_KEYin/etc/sandboxed-sh/hermes-assistant.env, readable viaGET /api/system/hermes-assistant/remote./hermes-desktop→ the full Hermes dashboard backend (loopback 9130) for the desktop app's native Remote-gateway mode (/api/status+wss /api/ws). Unit:/etc/systemd/system/hermes-dashboard.service(hermes dashboard --no-open --host 127.0.0.1 --port 9130 --skip-build, sameHERMES_HOME/var/lib/hermes-assistantas the gateway so sessions/memory are shared, auth viaHERMES_DASHBOARD_SESSION_TOKEN= same key, production web dist at/var/lib/hermes-assistant/web_dist). The nginx location pinsHost 127.0.0.1:9130(host-header check) and must NOT addX-Forwarded-For(the WS gate requires a loopback peer).
Hermes gotchas:
- After a prod deploy,
systemctl restart hermes-assistantso the gateway respawns the freshly installed/usr/local/bin/assistant-mcp. - Companion binaries are environment-scoped: production owns the historical
unsuffixed paths (
assistant-mcp,orchestrator-mcp,palomactl), whilesandboxed-sh-devinstalls*-dev. Never copy a dev MCP over an unsuffixed production path; doing so changes Hermes production tooling without a prod deploy. - When testing the WS handshake with curl, force
--http1.1— ALPN negotiates h2, which has noUpgradeheader, and the resulting 401 is a red herring. - When rotating the
hsk_key, updateAPI_SERVER_KEY,HERMES_DASHBOARD_SESSION_TOKEN, and the desktop'sconnection.jsontogether.
# Production inspection. Deploy/restart through the guarded endpoint below.
systemctl status sandboxed-sh-prod
journalctl -u sandboxed-sh-prod -f
# Development
systemctl status sandboxed-sh-dev
journalctl -u sandboxed-sh-dev -f
systemctl restart sandboxed-sh-devPaths (Production):
- Binary:
/usr/local/bin/sandboxed-sh-prod - Config:
/etc/open_agent/open_agent.env - Service:
/etc/systemd/system/sandboxed-sh-prod.service - Data root:
/srv/sandboxed-storage/staging/agent-core
Paths (Development):
- Binary:
/usr/local/bin/sandboxed-sh-dev - Config:
/etc/open_agent/open_agent_dev.env - Service:
/etc/systemd/system/sandboxed-sh-dev.service - Data root: inspect
WORKING_DIR/storage settings in the dev environment file; never infer it from the production path.
When a mission reports SIGTERM or SIGKILL, first determine whether the
backend was deliberately stopped by systemd:
journalctl -u sandboxed-sh-prod \
--since "2026-04-18 06:00:00 UTC" \
--until "2026-04-18 06:10:00 UTC" \
--no-pager | grep -Ei "Shutdown signal|Stopping|Stopped|Started|SIGTERM|SIGKILL|client request"If systemd says on client request, correlate that timestamp with SSH sessions:
journalctl \
--since "2026-04-18 06:00:00 UTC" \
--until "2026-04-18 06:10:00 UTC" \
--no-pager | grep -Ei "Accepted publickey|session-|sshd|sandboxed-sh-prod"The app can log that it received SIGTERM/SIGINT, process metadata, and
interrupted mission IDs, but Linux does not pass the exact systemctl caller to
the process. For command-level attribution, enable host auditing for systemctl
before reproducing:
apt-get install -y auditd
auditctl -a always,exit -F arch=b64 -S execve -F path=/usr/bin/systemctl -k systemctl_exec
auditctl -a always,exit -F arch=b64 -S execve -F path=/bin/systemctl -k systemctl_exec
ausearch -k systemctl_exec --start "2026-04-18 06:00:00" --end "2026-04-18 06:10:00"Ben's server runs a single instance:
systemctl status sandboxed-sh
journalctl -u sandboxed-sh -f
systemctl restart sandboxed-shRun the dashboard locally and point it to any backend:
cd dashboard
bun install
bun run dev # Runs on http://localhost:3001Configure the backend URL in the dashboard settings or environment to connect to Thomas's or Ben's server.
Always use debug builds (cargo build) instead of release (cargo build --release).
Debug builds compile in ~1 minute vs ~5+ minutes for release. The performance
difference is negligible for this I/O-bound service.
Never copy a locally-built binary to the server if you're on macOS. The binary format differs between platforms:
- macOS produces Mach-O executables (won't run on Linux)
- Linux requires ELF executables
If you copy a macOS binary, the service will fail with exit code 203/EXEC.
Sync source code and build directly on the server:
# Sync source (backend-only; avoids copying dashboard/ which deploys via Vercel)
rsync -avz --exclude 'target' --exclude '.git' --exclude 'dashboard' \
-e "ssh -i ~/.ssh/paloma" \
/Users/thomas/work/open_agent/ root@65.109.98.246:/opt/sandboxed-sh-dev/
# If you need the dashboard on the server for debugging, remove the dashboard exclude:
# rsync -avz --exclude 'target' --exclude '.git' --exclude 'dashboard/node_modules' --exclude 'dashboard/.next' \
# -e "ssh -i ~/.ssh/paloma" \
# /Users/thomas/work/open_agent/ root@65.109.98.246:/opt/sandboxed-sh-dev/
# Build on server (debug mode)
ssh -i ~/.ssh/paloma root@65.109.98.246 "cd /opt/sandboxed-sh-dev && source ~/.cargo/env && cargo build"
# Copy binaries (main + MCP tools) and restart
# Note: stop service first to avoid "Text file busy" when replacing MCP binaries.
ssh -i ~/.ssh/paloma root@65.109.98.246 "systemctl stop sandboxed-sh-dev && \
cp /opt/sandboxed-sh-dev/target/debug/sandboxed-sh /usr/local/bin/sandboxed-sh-dev && \
cp /opt/sandboxed-sh-dev/target/debug/workspace-mcp /usr/local/bin/ && \
cp /opt/sandboxed-sh-dev/target/debug/desktop-mcp /usr/local/bin/ && \
cp /opt/sandboxed-sh-dev/target/debug/orchestrator-mcp /usr/local/bin/ && \
systemctl start sandboxed-sh-dev"
# Verify health
curl https://agent-backend-dev.thomas.md/api/health# Build (debug mode - always use this)
cargo build
# Deploy to Thomas / agent-core (dev first, then promote to prod)
scp -i ~/.ssh/paloma target/debug/sandboxed_sh root@65.109.98.246:/usr/local/bin/sandboxed-sh-dev
ssh -i ~/.ssh/paloma root@65.109.98.246 "systemctl restart sandboxed-sh-dev"
# After testing, deploy production through POST /api/system/deploy with
# target_environment=prod and expected_service=sandboxed-sh-prod.service.
# Never copy over the live production binary or run an unguarded raw restart.
# Deploy to Ben
scp -i ~/.ssh/paloma target/debug/sandboxed_sh root@88.99.4.254:/usr/local/bin/sandboxed-sh
ssh -i ~/.ssh/paloma root@88.99.4.254 "systemctl restart sandboxed-sh"Faster compilation tips:
- Use
cargo checkfor syntax/type validation without producing a binary - Build on the server (has 8 cores) rather than transferring the binary
- Install
sccachefor caching compiled dependencies across builds
Missions should never call systemctl restart sandboxed-sh-prod directly.
Each restart SIGTERMs every codex/claude session under the service, which
auto-resumes from the backend but loses the in-flight turn's progress.
Worse, an agent in a retry loop can chainsaw the host (the original
incident: 5 deploys + 2 rollbacks in 90 minutes).
The orchestrator MCP exposes a deploy_sandboxed_sh tool that hits an
internal /api/system/deploy endpoint with safety rails.
Targeting rule: deploy_sandboxed_sh deploys the backend API it is sent
to. If target_environment is omitted, the tool uses the API that launched the
mission. A prod mission therefore deploys prod by default; a dev mission deploys
dev by default. To deploy a different Sandboxed.sh environment, set
target_environment explicitly:
| Target | Tool argument | API URL | Service restarted |
|---|---|---|---|
| Current mission API | omit target_environment |
mission API_URL |
current API's service |
| Production | "prod" |
http://127.0.0.1:3000 |
sandboxed-sh-prod.service |
| Development | "dev" |
http://127.0.0.1:3002 |
sandboxed-sh-dev.service |
The backend also receives an expected_service guard from the MCP and refuses
with HTTP 409 if the request reaches the wrong service. This prevents a prod
mission that intends to test dev from accidentally deploying prod.
Safety rails:
- Self-protection — refuses by default if the calling mission lives on
the service being restarted. Override with
force=trueonly if you accept that your own turn will be SIGTERM'd. - Debounce — refuses if another deploy fired in the last 5 minutes.
Override with
force=true. - Atomic install via versioned symlink swap + a detached
setsid/nohuprestart so the response flushes before the service dies.
From inside an agent that has the orchestrator MCP, the call shape is:
Successful response:
{
"deployed": true,
"summary": "Deployed commit abc123def456; service sandboxed-sh-prod will restart in ~2s",
"logs": ["Building sandboxed-sh-prod ...", "Build complete", ...]
}A refusal surfaces a message that explains the next step:
- HTTP 409 target mismatch: the request reached the wrong service/API URL.
- HTTP 409 self-target: pass
force=trueonly if killing the caller is acceptable. - HTTP 429 debounce: wait or pass
force=truefor an urgent deploy.
For a dev-only restart without rebuilding, use the explicit service command:
ssh -i ~/.ssh/paloma root@65.109.98.246 "systemctl restart sandboxed-sh-dev"Do not call deploy_sandboxed_sh from a prod mission unless you intend to
deploy a Sandboxed.sh backend. For dev testing from prod, prefer
target_environment: "dev" so the request goes to the dev API and carries the
sandboxed-sh-dev.service guard.
Mission containers ship with an SSH private key (SSH_PRIVATE_KEY_B64 in
their env) whose public counterpart is in /root/.ssh/authorized_keys on
the host. Agents have been using this to ssh root@<host> "systemctl restart ...", which bypasses the MCP's safety rails. Restrict the key to
the one command we actually want it to run.
ssh -i ~/.ssh/paloma root@65.109.98.246 \
'cat /root/.ssh/authorized_keys'The agent key is the one whose fingerprint matches the SSH_PRIVATE_KEY_B64
baked into mission containers (it's a fixed key, not per-mission). Look
for the entry that has no command="..." restriction and isn't your own
paloma admin key.
Edit /root/.ssh/authorized_keys and prepend the agent key entry with the
following options (one line, no leading whitespace):
command="/usr/local/bin/sandboxed-agent-ssh",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-pty <ssh-ed25519 ...>
The command= clause makes SSH only run that program, regardless of
what the client requested. The remaining flags drop attack surface.
/usr/local/bin/sandboxed-agent-ssh should be a small shell script that
accepts a tightly-scoped set of "subcommands" (e.g. log tailing, mission
status queries) and rejects everything else. Deploy is intentionally
NOT in this list — agents must go through the MCP for that.
A minimal version:
#!/usr/bin/env bash
set -euo pipefail
case "${SSH_ORIGINAL_COMMAND:-}" in
"status:prod")
systemctl is-active sandboxed-sh-prod
;;
"status:dev")
systemctl is-active sandboxed-sh-dev
;;
"logs:prod:"*)
n="${SSH_ORIGINAL_COMMAND#logs:prod:}"
journalctl -u sandboxed-sh-prod --no-pager -n "${n:-100}"
;;
*)
echo "agent-ssh: command refused: ${SSH_ORIGINAL_COMMAND:-<none>}" >&2
exit 126
;;
esacMake it executable: chmod +x /usr/local/bin/sandboxed-agent-ssh.
From a mission container shell:
ssh -o StrictHostKeyChecking=no root@<host> "systemctl restart sandboxed-sh-prod"
# expect: "agent-ssh: command refused: systemctl restart sandboxed-sh-prod"
# expect: exit 126
ssh -o StrictHostKeyChecking=no root@<host> "status:prod"
# expect: "active"After this lands, all deploys go through the MCP and the chainsaw incident can't recur.
Located at ~/.sandboxed-sh/missions/missions.db on the server.
# Query via API
curl "https://agent-backend.thomas.md/api/control/missions/<id>/events" \
-H "Authorization: Bearer <token>"
# Direct access
sqlite3 ~/.sandboxed-sh/missions/missions.db "SELECT id, status, created_at FROM missions ORDER BY created_at DESC LIMIT 10;"Use the manual smoke test script to validate streaming behavior for Claude Code, OpenCode, and Codex against the development backend. This is intentionally not part of CI.
- Copy the example env file and fill in values:
cp scripts/mission_stream_smoke.env.example .env.local
- Export the variables (or source
.env.local):
set -a
source .env.local
set +a
- Run the smoke test (all backends):
python3 scripts/mission_stream_smoke.py
Optional flags:
--backend claudecode(repeatable; limit to one backend)--timeout 180(seconds per backend)--allow-no-thinking(skip the thinking requirement)--verbose(print streamed events)
Expected behavior per backend:
- First message triggers tool calls + results
- Second message is queued (
queued: true)
To verify Codex model_effort end-to-end on dev:
# Create mission with Codex effort override
curl -sS -X POST "https://agent-backend-dev.thomas.md/api/control/missions" \
-H "Content-Type: application/json" \
-d '{
"title": "Codex effort smoke",
"backend": "codex",
"model_override": "gpt-5-codex",
"model_effort": "high"
}'Then load the mission in the dashboard and send a message. The mission metadata
should show model_effort: high.
- Stream includes
thinking,text_delta,tool_call,tool_result, andassistant_message
Service won't start (exit code 203/EXEC): Usually means wrong binary architecture (e.g., macOS ARM binary on Linux). Build on server instead.
MCPs show "Failed to spawn process" error: The MCP binaries (workspace-mcp, desktop-mcp, orchestrator-mcp) need to be installed to /usr/local/bin/. After building, copy them:
ssh -i ~/.ssh/paloma root@65.109.98.246 "cp /opt/sandboxed-sh-dev/target/debug/workspace-mcp /usr/local/bin/ && \
cp /opt/sandboxed-sh-dev/target/debug/desktop-mcp /usr/local/bin/ && \
cp /opt/sandboxed-sh-dev/target/debug/orchestrator-mcp /usr/local/bin/"Then restart the backend. Workspace-scoped MCPs must be in PATH for both host workspace missions and the Extensions page to work.
Config profile not being applied: Check that:
- The configs exist in the correct library path (
.sandboxed-sh/library/for production, not.openagent/library/which was the old path) - The library has the expected
configs/<profile>/.opencode/files - Pull latest library:
cd ~/.sandboxed-sh/library && git pull
Missions not using correct settings:
Missions stuck: Look for running CLI processes:
ps aux | grep -E "claude|opencode"
machinectl list # For container workspacesProxy issues:
- Thomas / agent-core uses nginx:
/etc/nginx/sites-available/ - Ben uses Caddy:
/etc/caddy/Caddyfile
When a mission fails or behaves unexpectedly, export a reproducible debug bundle first.
scripts/mission_debug_bundle.sh \
--base-url "https://agent-backend-dev.thomas.md" \
--token "<control-api-token>" \
--mission-id "<mission-uuid>"Optional tuning:
--max-events 5000for longer missions--page-size 500for fewer API round-trips--out-dir /tmp/mission-bundlesto write elsewhere
Output archive:
output/debug-bundles/mission-debug-<mission-id>-<timestamp>.tar.gz
bundle_meta.json: confirms export coverage and request failures.mission_summary.json: mission status, backend, andterminal_reason.events_summary.json: event type counts, first/last timestamps, error excerpts.raw/mission.json: full mission object including history metadata.raw/progress.json: latest runtime progress snapshot.raw/opencode_diagnostics.json: OpenCode runtime diagnostic mode/status.raw/events/page-*.json: paginated event history used for replay/triage.
Validate these metrics first before deeper log spelunking:
terminal_reason(mission_summary.json)- Event distribution (
events_summary.json.by_type) - Last protocol activity (
events_summary.json.last_timestamp) - Presence of
errorevents (events_summary.json.terminal_error_events) - Active run state (
raw/progress.json)
# Mission snapshot
curl -sS "https://agent-backend-dev.thomas.md/api/control/missions/<mission-id>" \
-H "Authorization: Bearer <token>" | jq
# Recent mission events
curl -sS "https://agent-backend-dev.thomas.md/api/control/missions/<mission-id>/events?limit=100&offset=0" \
-H "Authorization: Bearer <token>" | jq
# Runtime progress + diagnostics
curl -sS "https://agent-backend-dev.thomas.md/api/control/progress" \
-H "Authorization: Bearer <token>" | jq
curl -sS "https://agent-backend-dev.thomas.md/api/control/diagnostics/opencode" \
-H "Authorization: Bearer <token>" | jqSymptom: the whole server feels overloaded, dashboards lag, and harnesses can barely stream — usually while a mission runs a heavy fan-out (Lean/proof or contract builds). Diagnose what kind of pressure it is before touching anything:
uptime; nproc # load average vs core count
free -h # is memory/swap the problem?
cat /proc/pressure/cpu # `some avg10` high + load > nproc => CPU-bound
cat /proc/pressure/memory /proc/pressure/io
ps -eo pid,pcpu,ni,comm --sort=-pcpu | head -20If it's CPU, confirm the cgroup tiering is actually in effect:
# All three should NOT be equal — missions.slice must be < system.slice.
for s in missions.slice system.slice user.slice; do
echo -n "$s cpu.weight="; cat /sys/fs/cgroup/$s/cpu.weight 2>/dev/null
done
systemctl show sandboxed-sh-prod -p CPUWeight -p Slice # API tier weight
systemctl show missions.slice -p CPUWeight -p CPUQuotaPerSecUSec
# Is the cpu controller delegated into the slice? (must contain `cpu`)
cat /sys/fs/cgroup/missions.slice/cgroup.subtree_control
# Which cgroup is a harness actually in? (claude/codex/opencode)
cat /proc/$(pgrep -n claude)/cgroupGotcha seen on prod 2026-06-15: every slice sat at the default cpu.weight=100
with CPUQuota=infinity, the cpu controller was not delegated into
missions.slice (subtree_control = memory pids), and the harness CLIs live
inside missions.slice next to the build hogs. Boosting only the API service
(CPUWeight=10000) didn't help because the bottleneck was the harness, not the
API. A single mission running ~21 parallel harness.cli run-task jobs
oversubscribed all 8 cores.
mkdir -p /etc/systemd/system/missions.slice.d
cat > /etc/systemd/system/missions.slice.d/cpu.conf <<'EOF'
[Slice]
# Missions yield CPU to the API tier (system.slice = 100) under contention,
# and can never take more than ~6.5 of 8 cores in aggregate.
CPUWeight=30
CPUQuota=650%
EOF
systemctl daemon-reload
# Apply to the already-running slice immediately (runtime, also covered by the
# drop-in on next boot):
systemctl set-property missions.slice CPUWeight=30 CPUQuota=650%Tune CPUQuota to (cores - ~1.5) * 100%. A runtime-only set-property
without the drop-in is lost on restart — always write the drop-in too.
Set MISSION_CPU_WEIGHT (low, e.g. 40) and MISSION_CPU_QUOTA (e.g. 400%) in
the prod env file so each new mission scope is capped; existing missions can be
retuned live from the dashboard Resources panel (or
POST /api/workspaces/:id/resources with cpu_weight/cpu_quota). Setting any
CPU property is also what makes systemd delegate the cpu controller into
missions.slice — without it, a per-scope CPUQuota is silently unenforced.
See the "Resource isolation" section in CLAUDE.md for the architecture.
{ "tool": "deploy_sandboxed_sh", "arguments": { "target_environment": "dev", // optional; "dev" or "prod"; omit for current API "git_ref": "origin/master", // optional; omit to use current checkout "skip_build": false, // optional; true if you built elsewhere "force": false // override safety rails } }