Feature/multiuser remote access#29
Open
ivobrett wants to merge 110 commits into
Open
Conversation
This change makes the dashboard WebSocket connection work correctly when the controller is deployed behind a reverse proxy (e.g. Traefik/Pangolin) that terminates TLS and proxies all traffic through a single host/port. Previously the WS gateway URL was built with a hardcoded `:3001` fallback port, which is unreachable through a reverse proxy. Changed both the server-side URL builder (`dashboard/open/route.ts`) and the in-browser bootstrap script (`dashboard/proxy/shared.ts`) to default to an empty port so WebSocket connections go through the same proxied host as HTTP. Set `OPENCLAW_DASHBOARD_WS_PROXY_PORT` to override when a dedicated WS sidecar port is directly accessible. Added `socket.allowHalfOpen = true` before resuming the client socket so the tunnel handles half-closed TCP connections correctly — required when the upstream WebSocket peer closes its write side while the client still has data in flight. Also improved error logging on client socket errors to include the error code, message, and byte counts. `nemoclaw onboard` reliably exits with code 1 at step 8/8 (policy application timeout) even when the sandbox is successfully running. Replaced `runCommand` with `runCreateCommandUntilReady`, which polls `openshell sandbox get` every 5 s and sends SIGTERM to the onboard process as soon as the sandbox reaches Ready state, rather than waiting for the command to exit. The `cwd: NEMOCLAW_CWD` option is preserved so the subprocess runs from the NemoClaw source root. Added a Docker-based fallback (`docker ps --filter name=openshell-<sandbox>`) to `resolveSourcePodImageFromRef` so quick-deploy works on Docker-only hosts that don't have a Kubernetes control plane. Added automatic Ollama provider routing: when `OLLAMA_BASE_URL` is set and `NEMOCLAW_PROVIDER` is not, the controller injects `NEMOCLAW_PROVIDER=ollama` (and `NEMOCLAW_MODEL` from `OLLAMA_MODEL`) so Ollama inference is used without requiring manual `.env.local` configuration. When `nemoclaw` is installed via `npm link` inside NVM, the binary is a symlink several hops removed from the actual source root. Added `realpathSync`-based resolution so `NEMOCLAW_CWD_CANDIDATES` correctly derives `/opt/nemoclaw` (or equivalent) from the real binary path rather than the NVM version directory. OpenClaw shows a "Change Gateway URL" confirmation dialog when it detects the persisted gateway URL differs from the current one, which happens when switching between sandbox sessions. The bootstrap script now purges stale scoped localStorage keys before writing the new URL, preventing the detection. A setInterval fallback auto-clicks the Confirm button in case stale state originates from outside the bootstrap lifecycle. Also fixed a bug where a `token` passed via URL hash was written to sessionStorage scoped keys but not embedded in `settings.gatewayUrl`, causing reconnects after page reload to proceed without authentication. Added `tests/openclaw-dashboard-same-origin-ws-check.mjs` which asserts the `?? ''` default and same-origin host computation are present in both the server-side route and the browser bootstrap script. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
server.mjs: Next.js's NextCustomServer lazily attaches its own 'upgrade' listener to the http server on the first request (setupWebSocketHandler). EventEmitter dispatches the 'upgrade' event to every listener, so the controller's dashboard upgrade handler ran first and sent 101 Switching Protocols, then Next.js's listener ran and destroyed the socket — killing the dashboard WebSocket ~3 ms after upgrade. Setting the internal flag app.didWebSocketSetup = true before any requests prevents Next.js from attaching that listener. sandbox/create/route.ts: the previous commit's runCreateCommandUntilReady sends SIGTERM as soon as the sandbox is Ready, which is fine for OpenClaw (skips a step-8 policy timeout) but cuts Hermes off mid-onboarding — agent_setup runs after sandbox-ready and is where Hermes's API server is configured. Killing early left Hermes half-installed, the sandbox missing from /root/.nemoclaw/sandboxes.json, and the UI showing the wrong button (Start OpenClaw Gateway Dashboard instead of Connect to Hermes). Use runCreateCommandBounded for Hermes so the full onboard runs; the existing readiness fallback still tolerates non-zero exit codes when the sandbox itself comes up Ready.
Adds a subsection under Authentication describing how to move the shared- password gate to the internal mcpauth IdP via Traefik's forwardAuth middleware. Covers the minimal-change approach (middleware on both HTTP and WS routers, OPENSHELL_CONTROL_AUTH_DISABLED=true), the identity-aware variant (read Remote-Email instead of decoding the password JWT), and the open questions to resolve before implementing — chiefly whether mcpauth returns 401 vs 302 for unauthenticated WebSocket upgrades. No behaviour change.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…riting The openshell gateway (localhost:8080) is not persistently running, so openshell ssh-proxy fails from the Mac controller. Switch to a VPS SSH + docker exec approach that bypasses the gateway entirely. Tunnel flow: SSH to VPS → docker exec build web_dist if missing → start hermes dashboard → socat bridge (0.0.0.0:19119 → 127.0.0.1:9119) in container → SSH -L tunnel Mac:port → VPS → container:19119. Requires SANDBOX_VPS_HOST and SANDBOX_VPS_SSH_KEY env vars (.env.local). Also fix the proxy HTML rewriting for Vite-built dashboards: rewrite absolute-root src/href attributes to go through the proxy base, and inject a fetch/XHR interceptor so JS API calls (/api/config etc.) are also routed through the proxy rather than hitting the controller origin. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the "Launch Hermes Dashboard" button, VPS docker-exec/socat tunnel infrastructure, and proxy API routes. The web UI prebuild on sandbox creation is retained so hermes web dependencies are ready if the feature is revisited. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Integrates MCPAuth OAuth2 IDP into openshell-controller gateway dashboard, including dynamic callback protocols, Edge Crypto API verification, operators vs enterprise role-based gating, and customized redirect flows.
============================================================
REVERT / BACK OUT PROCEDURES
============================================================
If these auth changes break the VPS deployment or prevent operators from logging in, follow these instructions to back out:
1. openshell-controller:
- If merged as a merge commit:
$ git checkout gatewaydashboard
$ git revert -m 1 <merge_commit_sha>
- If fast-forwarded or we want to hard-reset to clean origin:
$ git checkout gatewaydashboard
$ git reset --hard origin/gatewaydashboard
2. manidae (unstaged compose edits):
- Discard the Docker Compose bootstrap environment edits:
$ cd /Users/mattercoder/Projects/manidae
$ git restore components/mcpauth/compose-internal.yaml components/mcpauth/compose.yaml
3. manidae-cloud (unstaged deployment template edits):
- Discard the terraform/startup template edits:
$ cd /Users/mattercoder/Projects/manidae-cloud
$ git restore backend/app/core/deployment/terraform_templates/includes/config_setup_stack.toml.j2 backend/app/core/deployment/terraform_templates/includes/startup_agentgateway.sh.j2
============================================================
============================================================
REVERT / BACK OUT PROCEDURES
============================================================
If these auth changes break the VPS deployment or prevent operators from logging in, follow these instructions to back out:
1. openshell-controller:
- Revert this specific merge commit:
$ git checkout gatewaydashboard
$ git revert -m 1 HEAD
- Or to completely align back with remote origin/gatewaydashboard:
$ git checkout gatewaydashboard
$ git reset --hard origin/gatewaydashboard
2. manidae (unstaged compose edits):
- Discard the Docker Compose bootstrap environment edits:
$ cd /Users/mattercoder/Projects/manidae
$ git restore components/mcpauth/compose-internal.yaml components/mcpauth/compose.yaml
3. manidae-cloud (unstaged deployment template edits):
- Discard the terraform/startup template edits:
$ cd /Users/mattercoder/Projects/manidae-cloud
$ git restore backend/app/core/deployment/terraform_templates/includes/config_setup_stack.toml.j2 backend/app/core/deployment/terraform_templates/includes/startup_agentgateway.sh.j2
============================================================
getSandboxIdFromRequest was extracting UUID from /api/sandbox/ and
/api/telemetry/sandbox/ paths but SANDBOX_ACCESS_USERS stores names.
Only the instance proxy path (sandbox-{port}-{name}) was accessible to
MCPAuth users anyway — UUID-based API paths are operator-level GETs,
already read-only due to the write block.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SANDBOX_ACCESS_USERS uses the sandbox name (e.g. my-first-claw), not the UUID.
The middleware gates dashboard proxy paths by name extracted from the
instance ID format sandbox-{port}-{name}.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
server.mjs's upgrade handler only checked openshell_control_session (operator cookie), so MCPAuth enterprise users got 401 on the WebSocket handshake → 1006 disconnect in the OpenClaw UI. Added isMCPAuthDashboardUpgradeAuthorized: verifies CF_Authorization JWT (HMAC HS256, MCPAUTH_JWT_SECRET) and checks per-sandbox access via SANDBOX_ACCESS_USERS before allowing the upgrade. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously only blocked create/delete sandbox endpoints for MCPAuth users, leaving all other POST/PUT/DELETE endpoints (settings, inference, etc.) wide open. MCPAuth users are intended to be read-only — block all state-changing methods (POST/PUT/DELETE/PATCH) globally, not just a narrow path list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Edge runtime middleware snapshots process.env at sandbox init, so when updateLocalAuthCredentials() rotated OPENSHELL_CONTROL_AUTH_SECRET in .env.local, the middleware kept verifying with the stale secret while the login route signed cookies with the new one — fresh logins were rejected with a redirect back to /login. Schedule process.exit() after writes to .env.local; systemd restarts the unit and the Edge sandbox picks up the new values. Frontend shows an "Applying changes — controller restarting…" message and reloads. Repurposes the existing /setup-account page as the operator-only Security page. Adds a Sandbox Access section that GETs/POSTs against the new /api/security/sandbox-access endpoint to edit SANDBOX_ACCESS_USERS as structured rows; both methods require the operator session cookie so MCPAuth users cannot read or modify the list.
Add /api/auth/me returning { operator, configured } so the Security page
can decide which sections to render. Show the password form only when an
operator session is present, or on first-run when no password is configured
yet. Hide the sandbox-access section too unless operator. Non-operators see
"Operator session required" instead of forms they can't submit.
Backend defense in depth: /api/auth/setup and /api/auth/recover now reject
requests carrying a valid CF_Authorization (MCPAuth) cookie without an
operator session. This closes the rate-limited brute-force surface where
a company-SSO user could attempt to guess the operator password via
currentPassword or the recovery token.
The Security page reads /api/auth/me to decide whether to render the
password and sandbox-access sections. Without this, an unauthenticated
visitor on a freshly-deployed (unconfigured) controller would see the
"Operator session required" branch instead of the first-run setup form.
The endpoint only returns { operator, configured } — both booleans, no
sensitive data — so it is safe to expose to anonymous callers.
Modern OpenClaw requires token auth even on loopback ("Token auth is now
the recommended default" per `openclaw doctor`). New sandboxes were
landing with `gateway.auth.token: ""` in /sandbox/.openclaw/openclaw.json,
so opening the dashboard returned "Auth required" from the in-sandbox
gateway — even when the controller proxy was wired up correctly.
Add ensureOpenClawGatewayToken() that runs `openclaw doctor --generate-
gateway-token` inside the sandbox after creation, then reads back the
token to verify it was populated. Wire it into all three blueprint
branches (nemoclaw, custom-sandbox, redeploy-image) and surface a
gatewayToken summary in the create response so the UI can show the state.
Existing sandboxes need a one-off `openclaw doctor --generate-gateway-token`
run inside them; future ones get it for free.
The global MCPAuth write-block in middleware.ts was rejecting the POST /api/openshell/terminal/live session-allocation request with 403, which broke the Hermes (and OpenClaw) terminal flow for company-SSO users who had been explicitly granted access to a sandbox via SANDBOX_ACCESS_USERS. Add an allowlist (MCPAUTH_WRITE_ALLOWED_PATHS) for endpoints that take the sandboxId in the request body and therefore can't be gated from the URL alone. The middleware lets these POSTs through and sets x-forwarded-user; the route handler enforces isUserAuthorizedForSandbox against the body's sandboxId. The operator path now strips any client-supplied x-forwarded-user so downstream routes only see it when the MCPAuth path populated it. Extend the same access map to the terminal WebSocket upgrade in server.mjs. The existing isMCPAuthDashboardUpgradeAuthorized helper was already generic enough (it reads sandboxId from URL/query) — rename to isMCPAuthSandboxUpgradeAuthorized and use it for both the terminal proxy and the dashboard proxy upgrade paths.
… icons Operator terminal: add a fullscreen icon in the LIVE TERMINAL header. Toggling expands the terminal panel to fixed inset-0 z-50 and re-fits the xterm. Esc exits fullscreen. Useful for working with Hermes which can produce wide output. Dashboard deep-link auth: the middleware was returning 401 JSON for any unauthenticated /api/* request, which is correct for XHR but wrong for the browser navigations the dashboard proxy gets. Carve /api/openshell/dashboard/ proxy and /api/openshell/instances/.../dashboard/proxy out of the JSON branch so they redirect to /login?next=<original-url> just like the operator-terminal does. The login page now also reattaches window.location.hash to the post-login redirect so launch URLs that carry a #token=… fragment survive the round trip. Shareable links: add a small /launch/dashboard?sandboxId=<name> page that hits /api/openshell/dashboard/open (auth-gated by middleware) and replaces its location with the resulting launchUrl, mirroring what the in-app "Start OpenClaw Gateway Dashboard" button does. Next to that button, and the Hermes "Connect to Hermes" button, add a small clipboard icon that copies the respective deep-link so an operator can hand a sandbox to a company-SSO user.
…loys Inference at Create defaulted to vLLM (experimental) which does not work on standard VPS deployments; switch the default to Auto so the most common deployment path Just Works. Quick Deploy (redeploy-image blueprint) had no way to pick OpenClaw vs Hermes as the source — it just took the newest sandbox from the registry. With both agent types present an admin couldn't predictably clone the one they wanted. Add an Agent radio in the Quick Deploy UI, pass it through to the backend, and filter the source-image candidate list by agent so the chosen kind wins regardless of recency. The post-create OpenClaw setup steps (exec-approvals repair, device-pairing approval, gateway-token generation) now only run when the effective agent is OpenClaw. Quick-deployed Hermes sandboxes were created but `hermes` returned "permission denied" inside them because the redeploy path hard-coded `openclaw-sandbox.yaml` as the --policy argument. That template's filesystem_policy.read_only does not include /opt/hermes, so Landlock blocked execute on /opt/hermes/.venv/bin/hermes (where the binary actually lives). Fix by inheriting the source sandbox's effective policy at redeploy time — shelling out to `openshell policy get <source> --full`, writing the YAML to a temp file, and passing that as --policy. The static template remains the fallback if the export fails.
…ss map
PHASE 1: security hotfixes
- server.mjs copyHeaders() now strips x-forwarded-user so a client can't
spoof it onto upstream WebSockets that might trust it.
- getCFAuthSecret() fails closed when MCPAUTH_JWT_SECRET is unset rather
than falling back to "my-secret-key".
PHASE 2: auth library consolidation
- New app/lib/auth/policy.mjs holds all the runtime-agnostic pieces shared
between Edge bundles and the raw Node server (session cookie split,
JWT split, sandbox-access CSV parsing, sandbox-id URL extraction,
base64url/constant-time helpers).
- New app/lib/auth/edge.ts and app/lib/auth/node.mjs wrap the policy
helpers with the runtime-appropriate HMAC (Web Crypto vs node:crypto).
- New app/lib/auth/context.ts exposes a discriminated AuthContext union
({ kind: "operator" | "mcpauth" | "anonymous" | "disabled" }) plus
resolveAuthContext(request) and convenience isOperator / mcpAuthEmail
helpers. Middleware and route handlers now derive identity from one
helper instead of each rebuilding the same cookie reads ad hoc.
- middleware.ts dispatches off the AuthContext union instead of nested
conditionals; sandbox-id extraction and cookie parsing are now shared
helpers from policy.mjs. server.mjs imports the same helpers, so the
triple-implementation of JWT verify + access-map parsing is gone.
- app/lib/controlAuth.ts is a thin compatibility layer over the new
modules so existing callers keep working.
PHASE 3: file-backed sandbox-access store + no more restart-on-write
- New app/lib/auth/sandboxAccessStore.ts persists the access map to
data/sandbox-access.json (path overridable via SANDBOX_ACCESS_FILE).
Writes are atomic (temp file + rename). Reads fall back to the legacy
SANDBOX_ACCESS_USERS CSV env var when the file is absent.
- middleware.ts now runs on `runtime: "nodejs"` — process.env (and the
access file) refresh on every request, so updating the access map or
rotating the password no longer requires a process restart.
- /api/security/sandbox-access POST writes the JSON file and returns
willRestart:false. /api/auth/setup and /api/auth/recover stop calling
scheduleControllerRestart(). controlAuthConfig.ts loses the
updateSandboxAccessUsers + scheduleControllerRestart helpers entirely.
- Security page UI drops its "controller restarting..." branch.
PHASE 4: sandbox-create modularization (pure refactor)
- New app/lib/sandboxCreate/policy.ts holds exportSandboxPolicyToFile
(including the CLI-header stripping logic) as a parameterised helper
that doesn't depend on the route handler's local closures.
- New app/lib/sandboxCreate/agentFilter.ts holds bucketCandidatesByAgent
as a pure function — easier to unit test and reason about; the route
becomes a 5-line caller. Source-candidate bucketing for Quick Deploy
is unchanged in behaviour.
No behaviour changes that should be user-visible beyond "operator
mutations no longer cause a brief 502 while systemd restarts".
The earlier branch named the IDP integration after the specific Pangolin
sidecar product (mcpauth) and reused Cloudflare Access's CF_Authorization
cookie name. Neither name describes what the code actually is — a generic
OAuth2 / OIDC integration with a HS256 session cookie minted by our own
callback handler.
Renames, with backwards-compat fallbacks so live deployments keep working:
- AuthContext kind "mcpauth" → "oauth"
- Cookie name CF_Authorization → oauth_session
(the legacy name is still READ during a transition so existing sessions
don't get logged out; new sessions are always written under the new name.
Logout clears both.)
- Code identifiers verifyCFJWT, verifyCFAuthorizationJWT, getCFAuthSecret,
mintCFAuthorizationJWT, isMCPAuthSandboxUpgradeAuthorized,
cfUserEmail, mcpAuthLoginUrl, MCPAUTH_WRITE_ALLOWED_PATHS,
etc.
→ verifyOAuthJWT, getOAuthSecret, mintOAuthSessionJWT,
isOAuthSandboxUpgradeAuthorized, oauthEmail,
oauthLoginUrl, OAUTH_WRITE_ALLOWED_PATHS, etc.
(controlAuth.ts keeps the old names as @deprecated re-exports.)
- Env vars OAUTH_JWT_SECRET (preferred) is now read first; the
historical MCPAUTH_JWT_SECRET and CF_AUTH_JWT_SECRET
names are kept as fallback. Same pattern for
OAUTH_LOGIN_URL / OAUTH_CLIENT_ID / OAUTH_CALLBACK_URL.
- UI strings "Authorize MCPAuth (company) users…" →
"Authorize company (OAuth/IDP) users… Changes take
effect immediately."
- /api/auth/login Returns both `oauthLoginUrl` (new) and `mcpAuthLoginUrl`
(alias) for one release so stale cached login pages
still find the IDP URL.
- Docs SANDBOX_ACCESS_CONTROL.md and README.md updated to
describe a generic OAuth/IDP integration rather than
naming a specific product. Historical "delegate auth
via Traefik forwardAuth" section marked superseded.
- Tests tests/control-auth-mcpauth-check.mjs renamed to
control-auth-oauth-check.mjs and rewritten to exercise
the new app/lib/auth/ modules directly (no TS-in-VM
transpile dance). Adds priority-order assertions for
OAUTH_JWT_SECRET > MCPAUTH_JWT_SECRET > CF_AUTH_JWT_SECRET.
No behaviour change for callers using the legacy names or env vars.
Brings in two commits from gatewaydashboard-refactored verified against the VPS deployment (22/22 smoke tests passing — HTTP + WS terminal + WS dashboard proxy paths, both new and legacy cookie names). - 737956a refactor: consolidate auth, move middleware to Node, file-backed access map - a88aa69 rename: mcpauth/CF_Authorization → OAuth/oauth_session Key changes preserved as a single merge commit so a clean revert is one `git revert -m 1` away: * New app/lib/auth/ library (policy.mjs + edge.ts + node.mjs + context.ts + sandboxAccessStore.ts) replaces the previously duplicated auth code in middleware.ts, server.mjs, and app/lib/controlAuth.ts. * AuthContext discriminated union ({ kind: "operator" | "oauth" | … }) resolved by resolveAuthContext(request); middleware and route handlers dispatch off it instead of rebuilding cookie reads ad hoc. * Middleware now runs on `runtime: "nodejs"`, so process.env and the file- backed sandbox-access store refresh on every request. Operator password rotation and sandbox-access changes no longer require a process restart; scheduleControllerRestart() and the process.exit(0) calls are gone. * SANDBOX_ACCESS_USERS env var is now optional — data/sandbox-access.json is the source of truth, edited via the Security page. The CSV env var is still read as a fallback for headless / first-run setup. * Sandbox-create helpers split out: app/lib/sandboxCreate/policy.ts and agentFilter.ts are pure functions extracted from the route handler. * mcpauth → OAuth rename: AuthContext kind, function names, env var priority (OAUTH_JWT_SECRET > MCPAUTH_JWT_SECRET > CF_AUTH_JWT_SECRET), cookie name (oauth_session, with CF_Authorization still READ as fallback for in-flight sessions; logout clears both). * Security hotfixes folded in: x-forwarded-user stripped from outgoing WS upstream headers; CF/OAuth JWT verify fails closed on missing secret (no "my-secret-key" default). * Compatibility shims in app/lib/controlAuth.ts mean existing imports keep compiling unchanged.
…ON reference) The v0.0.37 NemoClaw release generates a sandbox Dockerfile that pins specific Ubuntu package versions (procps=2:4.0.4-9, e2fsprogs=1.47.2-3+b11, tmux=3.5a-3). Ubuntu's noble repo rotates older package versions out; tmux=3.5a-3 was dropped, so docker build now fails with: E: Version '3.5a-3' for 'tmux' was not found Step 11/80 stopped after 3.9s. NemoClaw v0.0.56 generates a Dockerfile that doesn't pin tmux to a stale version and the 80-step build completes cleanly. Verified end-to-end on a live AgentGateway VPS 2026-06-02: new OpenClaw sandbox reaches Ready state in ~7 minutes. Also bumped the OPENCLAW_VERSION reference in README from 2026.4.27 to 2026.5.20 to match what install_versioned_nemoclaw_openshell.sh has defaulted to since prior commits (the README was already out of sync). Test updated to match the new pin assertion. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ction NemoClaw v0.0.56 (and prior) ship a Dockerfile / Dockerfile.base that pin Debian package versions (procps=2:4.0.4-9, e2fsprogs=1.47.2-3+b11, tmux=3.5a-3) even though the runtime base image is Ubuntu 24.04 noble, where those exact versions don't exist (noble has tmux=3.4-1ubuntu0.1 etc.). The pinned apt-get install therefore returns exit 100, blowing up every `nemoclaw onboard` on a newly-deployed VPS and the openshell- controller polls forever for a sandbox that never registers. Patch both Dockerfiles in the extracted source tree before running install.sh / docker build so apt picks whatever's available in the distro on the actual base image. Verified on Hetzner CX21 deploy: tmux 3.5a was already baked into the GHCR sandbox-base, so unpinning short-circuits the conditional install entirely. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
install.sh is documented as a dev installer ("This installer does not
install or manage a systemd service"). On a production BYOVPS the
remaining host-side wiring used to be undocumented and manual, which led
to four separate fresh-deploy failures on 2026-06-21:
1. Missing HOME/XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESS in the
systemd unit caused `openshell ssh-proxy` (invoked by the
controller's ssh tunnel spawn) to fail silently — every dashboard
probe returned reachable: false, listenerPresent: false.
2. Missing /root/.local/state/nemoclaw/openshell-docker-gateway/
caused openshell-gateway to crash-loop with "unable to open
database file" on first start.
3. Missing UFW rule on port 8080 caused sandbox containers to fail
policy fetch over gRPC and enter Error state at boot.
4. Missing /etc/needrestart/conf.d/openshell-controller.conf caused
the controller to be hammered into Failed state by libssl
unattended-upgrades cycling (see CLAUDE.md §10 incident
2026-06-11).
This change introduces scripts/setup/:
- openshell-controller.service: canonical systemd unit template,
with all three load-bearing env vars, LimitNOFILE=65536, and a
long Environment= comment explaining WHY each var is required.
- install-production.sh: idempotent post-install script that
installs the unit, daemon-reloads, writes the needrestart guard,
creates the gateway DB parent dir, adds the UFW allow, enables
linger for root, and starts the service. Safe to re-run after
every deploy.
CLAUDE.md §2 now includes a "Fresh-VPS first-time setup" section that
walks through the canonical sequence: purge → install_versioned → npm
install + next build → install-production.sh → nemoclaw onboard. The
companion table explains why each concern lives in install-production.sh
and not install.sh.
tests/production-setup-check.mjs locks in the eight load-bearing
invariants (env vars, LimitNOFILE, daemon-reload, DB parent dir, UFW,
needrestart drop-in, linger, idempotency) so a refactor of the unit
template can't silently drop one of them.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s 15 stream-disturbed bug Next.js 15's fromNodeNextRequest disturbs the IncomingMessage ReadableStream for large multipart uploads before the route handler runs, causing a 500 "Response body object should not be disturbed or locked" that our try/catch can't catch. Fix: intercept POST /api/sandbox/*/restore in server.mjs before handle(), collect the body with Node.js streams (unaffected by the bug), parse the multipart manually, write the archive bytes to a temp file, and pass a 64-char random token + field metadata as request headers. The route handler checks for the token header and reads from the temp file instead of calling request.formData(). Falls back to the original formData() path if no token header is present, preserving forward compatibility. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-in Request.formData() The custom multipart parser in preBufferRestoreBody failed to correctly extract the archive field (likely a Content-Disposition regex edge case). Replace it with a call to new Request(...).formData() using the Node.js 22 global Fetch API — same multipart parsing semantics as the browser with no custom code to maintain. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Next.js routing
The previous approach (pre-buffering body then passing to Next.js) still
triggered the "body disturbed or locked" error: consuming the IncomingMessage
via req.on('data') puts it in ended-flowing state, which is exactly what
fromNodeNextRequest throws on — whether the body arrived too large or was
pre-read by us. There is no way to hand a consumed stream back to Next.js.
Fix: intercept POST /api/sandbox/*/restore in the http.createServer callback
before handle() is called, and respond entirely in server.mjs. The handler:
- checks operator auth (isAuthenticatedUpgrade) matching middleware policy
- collects the raw body with Node.js streams (unaffected by the bug)
- parses multipart using the Node.js 22 built-in Request.formData()
- resolves sandbox ID → name via `openshell sandbox get`
- runs the same restore shell script as the TypeScript route via spawn()
- returns the same JSON shape the UI expects
Route handler reverted to its original clean form; it remains in place but
is only reached on non-prod setups that don't use server.mjs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
openshell sandbox get only accepts names, not UUIDs. Mirror the TypeScript resolveSandboxRef logic: first try a direct get (works when the ref is a name), then fall back to listing all sandboxes and matching by Id field. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…limit openshell sandbox exec pipes stdin through gRPC, which enforces a 1 MiB per-message limit. The archive is 2.7 MiB, so exec fails with OutOfRange. Fix: write the archive to a VPS host temp file, docker cp it into the container (bypasses gRPC entirely), chmod 644 so the sandbox user can read it, then restore with docker exec -u sandbox. Host and container temp files are cleaned up in a finally block. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Archives created on macOS contain SCHILY.fflags/LIBARCHIVE.xattr PAX header keywords and ._* AppleDouble resource-fork sidecar files. GNU tar on Linux rejects them with permission errors and exits non-zero. - Add --warning=no-unknown-keyword to all tar invocations to silence PAX keyword warnings without failing - Add --exclude='._*' to skip AppleDouble sidecars entirely - Treat tar exit code 1 (warnings/partial) as success; only fail on 2+ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The &&-chained script broke before rc=\$? when tar exited 1 mid-chain, leaving \$rc empty and causing [ -le: unexpected operator in dash/sh. Switch tar listing steps to || handlers (tolerating exit 1), and join all script lines with newlines so ec=\$? and the final test always run regardless of earlier tar exit codes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The privileged sandbox writers (MCP broker manifest, OpenClaw mcp.servers config, inference profile apply) hard-coded `docker exec openshell-cluster-nemoclaw kubectl exec -n openshell <sandbox> -- ...`, which only works on the legacy kind-in-a-container NemoClaw deployment. On Docker-driver VPSes (sandboxes run as direct openshell-<name>-<uuid> containers) every call dies with `No such container: openshell-cluster-nemoclaw`, so "Issue Broker Config" fails and the Inter-Sandbox Chat MCP server is never wired into the sandbox's openclaw.json. Switch all three files to spawn `openshell sandbox exec -n <name> -- sh -lc ...` using OPENSHELL_BIN + hostCommandEnv, matching the pattern sandboxFiles.ts already uses (which works on both drivers and is why file upload/download already worked here). Removes DOCKER_BIN / OPENSHELL_CLUSTER_CONTAINER / OPENSHELL_NAMESPACE / HOST_PATH usage from these files. Source guard in mcp-configuration-page-check flipped from "must contain kubectl" to "must use openshell sandbox exec". Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Follow-up to 2d04774 (openshell sandbox exec migration). On the Docker driver, openshell sandbox exec runs as the sandbox user, not root, so: 1. chown root:root on /sandbox/.openclaw/openclaw.json (and .config-hash) fails with EPERM and aborts Issue Broker Config on OpenClaw sandboxes. These files are already 444 sandbox:sandbox on the Docker driver, so the chown is best-effort — make both calls `... 2>/dev/null || true`. (The kubectl-cluster path still works as before — kubectl exec runs as root in the pod, so the chown succeeds there.) 2. Hermes sandboxes don't have /sandbox/.openclaw at all, so syncSandboxOpenClawMcpConfig (cat openclaw.json) and repairOpenClawExecApprovalsFile (writes into .openclaw) crash. Detect the missing config and return a `{skipped: true, reason: ...}` result instead of throwing. The MCP broker is still wired in via the sandbox manifest (/sandbox/openshell_control_mcp.md) which Hermes does support — only the OpenClaw-specific config layer is skipped. End-to-end test on VPS 192.168.3.201: Issue Broker Config my-first-openclaw → 200 (was 500 chown EPERM) Issue Broker Config my-first-hermes → 200 (was 500 ENOENT) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Follow-up to 1d95fae. Two latent bugs in the openclaw.json writer that combined to make Issue Broker Config silently no-op on the Docker driver: 1. Operator precedence on a single sh -lc line: A && B && C 2>/dev/null || true && D && E parses as `((A && B && C) || true) && D && E` — the `|| true` swallows ANY failure from the cat>chmod>chown chain, then D (the sha256sum) runs against the OLD openclaw.json and overwrites .config-hash so OpenClaw is none the wiser. So a write failure surfaces as HTTP 200 + a stale token in the file. 2. The existing openclaw.json is 444 sandbox:sandbox on the Docker driver (written by NemoClaw on bootstrap or by a previous failed broker call that completed the chmod 444 step). `cat > 444file` then fails with EACCES even as the file's owner. Need to chmod the file writable before truncating. Fix: switch to newline-joined script with `set -e` (so each failure surfaces independently and propagates), prepend `chmod 644 ... || true` before `cat >` to unlock an existing 444 file, and drop the chown root:root calls entirely — they only succeed on the legacy kubectl-cluster driver and the resulting file is sandbox:sandbox 444 on Docker driver either way. Verified end-to-end on VPS 192.168.3.201 — after a fresh Issue Broker Config call, the osmcp_ bearer token in /sandbox/.openclaw/openclaw.json matches the one embedded in the API response markdown (was diverging silently before). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Third follow-up to the Issue Broker Config migration. Two issues with the previous attempt: 1. openshell sandbox exec rejects argv entries that contain newlines (gRPC InvalidArgument: "command argument N contains newline or carriage return"). The set-e + newline-joined script was thus never executed. Switch to `;` separators on one line — semicolons are statement terminators with no precedence interaction with `|| true`, so the swallow-the-whole-chain bug from the previous round can't reappear. 2. The chmod 644 → cat → chmod 444 sequence briefly weakens the sandbox's tamper-resistance on openclaw.json, even if only the same sandbox user could observe the window. Switch to write-then- atomic-rename: `mktemp openclaw.json.XXXXXX; cat > "$tmp"; chmod 444 "$tmp"; mv -f "$tmp" openclaw.json`. The destination is never transiently writable, and rename only needs parent-dir write access (already granted by the 700 sandbox:sandbox .openclaw directory). Same pattern for .config-hash. Test assertion in sandbox-lifecycle-check.mjs updated to match the new atomic-rename pattern. Verified on VPS: before mtime: 2026-06-22 16:47:25 (stale, write was silently no-op) after mtime: 2026-06-22 17:01:xx (fresh) bearer token in openclaw.json == bearer token in API response Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- create/route.ts: after a verified sandbox creation, patch the
NemoClaw registry entry to carry the agent type ("openclaw" /
"hermes"). Atomic write via temp + rename. Best-effort: failures
are returned but don't abort the create response.
- README.md: add Troubleshooting section for the
"openshell sandbox list -> Connection refused" gateway-crash mode,
with the nemoclaw <name> recover one-liner and HOME=/root caveat.
- CLAUDE.md: same recovery procedure documented for agent context.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The activity log shows every entry in a flat list and gets unwieldy fast (the controller can keep up to 200 entries server-side and a fresh-deploy day fills that quickly with create/ready pairs). Client-side pagination: fetch up to 100 entries (was 40) on load, show 10 at a time with Newer/Older buttons and a "Page X / Y" indicator. Refreshing resets to page 1. No API changes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New docs/upstream-divergence-audit.md catalogs every meaningful divergence from upstream (mmckeen-nv/openshell_controller), ranked by security risk tier and tagged with upstream-fixability. Designed to be walked after each upstream merge and version bump — items tagged HIGHLY upstream-fixable are the first candidates for deletion when NemoClaw/OpenShell updates land. CLAUDE.md additions: - New §4 explaining when to consult the audit (5 triggers + refresh cadence) - §5 step 9: walk the audit after every upstream merge - §6 intro: pointer for new agents - §9 intro: keep Don't-do-this list in sync between CLAUDE.md and the audit - §10: read the audit before bumping NEMOCLAW/OPENSHELL/HERMES versions Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three coupled improvements driven by user-visible bugs on the dashboard: 1. Bare openshell sandboxes (created via `openshell sandbox create` without NemoClaw onboarding) were labelled "OpenClaw" and surfaced a "Start OpenClaw Gateway Dashboard" button that would never work, because resolveSandboxAgent() in app/api/telemetry/real/route.ts defaulted to "openclaw" whenever the NemoClaw registry had no entry for the sandbox. Add a single docker-ps lookup per inventory call to read each sandbox's container image; if it's not an openshell/sandbox-from:* (NemoClaw-built) image, treat the sandbox as agent="custom". The registry remains authoritative when present. 2. SandboxList now renders Custom sandboxes with their own label + a generic terminal icon, and groups Hermes + Custom under a single "Connect to Terminal" button (selectedSandboxUsesTerminal). The "Start OpenClaw Gateway Dashboard" button is reserved for actual OpenClaw sandboxes. Hermes-specific Remote Desktop drawer stays gated on agent === 'hermes' only. 3. The pre-existing "Connect to Hermes" flow was typing a multi-line sh wrapper (`printf '\n%s\n' ...; if command -v nemohermes; ...`) as keyboard input into a host bash shell, so the operator saw the wrapper script echoed before bash executed it. Move the attach to the server side: terminal-server.mjs buildAttachCommand now defaults to `ssh openshell-<name>` when sandboxId is provided and no OPENSHELL_TERMINAL_ATTACH_TEMPLATE override is set. The operator terminal lands directly inside the sandbox shell — no wrapper text, no auto-hermes invocation. User can type `hermes` manually if/when they want the Hermes CLI. Drop the buildHermesTerminalConnectCommand / HERMES_AGENT_COMMAND / isHermesAttachReadyOutput plumbing from operator-terminal/page.tsx, and the launch=hermes param from buildOperatorTerminalRoute. Auth: unchanged. /api/openshell/terminal/live POST still enforces isUserAuthorizedForSandbox(idpUser, sandboxId) and the WS upgrade in server.mjs still validates the session cookie before the upstream ws upgrade proceeds. The change only swaps what command runs in the pty after the authorised session is created. Test guards flipped: - post-authority-terminal-contract-check.mjs: must NOT contain buildHermesTerminalConnectCommand / HERMES_AGENT_COMMAND etc. - sandbox-lifecycle-check.mjs: button text is "Connect to Terminal", selectedSandboxUsesTerminal covers Hermes + Custom. - dashboard-session-check.mjs: route builder must NOT thread a `launch` param. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Follow-up to 4774f69. The raw \`ssh openshell-<name>\` attach assumed the openshell-* SSH alias was already wired into ~/.ssh/config, which isn't the case on fresh controllers (\`openshell sandbox ssh-config\` only prints the stanza, never installs it). Use the openshell CLI's own \`sandbox connect <name>\` subcommand instead — it installs/updates the SSH config wiring before invoking ssh, so the attach works out of the box. No wrapper text visible to the operator either way. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Quick Deploy "Agent" picker exposed only OpenClaw and Hermes. Now that the inventory recognises bare-openshell Custom sandboxes (commit 4774f69), Quick Deploy should let users clone from them too. Changes: 1. app/lib/sandboxContainerImage.ts (new) — extract the docker-ps {sandbox-name → image} helper out of app/api/telemetry/real/route.ts so the create route can use the same lookup for source-candidate filtering. Adds isNemoClawImage() for the openshell/sandbox-from:* match used in both files. 2. app/lib/sandboxCreate/agentFilter.ts: - Add QuickDeployAgent = NemoClawAgent | "custom" (keep NemoClawAgent narrow so the nemoclaw-onboard paths stay typed). - Add classifySandbox() that combines registry agent + image map. - bucketCandidatesByAgent() now takes an optional imageMap. Custom filter matches ONLY positively-identified Custom sandboxes — its unknown bucket stays empty so we never accidentally clone an ambiguous NemoClaw image as Custom. OpenClaw/Hermes back-compat preserved (matched + unknown). 3. app/api/sandbox/create/route.ts (redeploy-image branch): - Parse body.agent === "custom". - Pull imageMap up front, pass into resolveSourcePodImage. - For Custom: emit `openshell sandbox create --name <n> ...gpu --from <image>` — drop --policy, drop --auto-providers, drop `-- nemoclaw-start`. Bare openshell sandboxes don't carry a NemoClaw policy and shouldn't start under NemoClaw's runtime hook. - Skip OpenClaw-only post-create steps (exec-approvals repair, device approval, gateway-token verify) when agent is Custom. - Skip registerNemoClawImageRedeploy() for Custom — registry entries are NemoClaw-only. - Source-agent fallback uses imageMap to disambiguate Custom from NemoClaw when the source's registry entry is missing. 4. app/components/ConfigurationPanel.tsx — third "Custom" radio in the Quick Deploy agent picker, grid widened to 3 columns on md+. Tests: - tests/agent-filter-quick-deploy-check.mjs (new) — focused unit on the new classifySandbox + bucketCandidatesByAgent custom semantics. - tests/sandbox-lifecycle-check.mjs — guards for the Custom radio option, the requestedAgentRaw === "custom" parser, the isCustomAgent branching, and the new imageMap arg to resolveSourcePodImage. Auth: unchanged. Same POST /api/sandbox/create, same operator gate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CLAUDE.md was 1067 lines / ~33 KB. The long-tail operational sections
(NemoClaw version bumps, BYOVPS vs cloud architecture, fresh-VPS
setup) were repeatedly accessed by name, not by cold-read, so they
were paying full prompt cost on every agent session for material only
needed when a specific failure mode hit.
Moves three runbooks out to dedicated docs and replaces them with a
short index section (new §4 "Runbooks index"):
- docs/runbooks/nemoclaw-version-bumps.md (was §10, ~156 lines)
- docs/runbooks/byovps-architecture.md (was §12, ~119 lines)
- docs/runbooks/fresh-vps-setup.md (was §2 subsection, ~52 lines)
Also fixed stale items the audit flagged:
- "~25 commits ahead" → "~94 commits ahead"
- Hard-coded VPS IP (91.99.224.38) removed — the surrounding "always
confirm IP with user" note is now the only guidance
- §7 baseline ("20 files / 19 PASS") → generic "all PASS except the
pre-existing tech-debt failure" (test count grew to 25+)
- §8 sandbox SSH memory pointer flipped from "via docker exec through
openshell-cluster-nemoclaw" (the legacy assumption we just removed
this session) to a historical note pointing at the audit
- §6 blueprint list now mentions the unified `redeploy-image` branch
(covers OpenClaw/Hermes/Custom)
§13 (dashboard token, now §10) was condensed in place: removed the
redundant "diagnostic recipe / when to suspect / regression tests"
subsections that overlapped. The invariants, the four protected
commits, and the regression-test lock-in are all preserved verbatim.
§14 (one-liners) cut entirely — they were generic git commands not
worth carrying.
HERMES_REMOTE_DESKTOP.md already has a fuller operating-and-debugging
section (its §5) than CLAUDE.md §11 did, so §11's troubleshooting
items are now just a §4 index pointer at HERMES_REMOTE_DESKTOP.md.
Net structural change: 14 sections → 10. No semantic loss — every
removed paragraph either moved to a runbook or was already covered by
a regression test that locks the invariant in mechanically.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bump NEMOCLAW_INSTALL_REF default from `main` to `v0.0.66` in install_versioned_nemoclaw_openshell.sh, matching the release validated with OpenShell v0.0.44 and OpenClaw 2026.5.27. Update README compatibility section and the portable-install assertion accordingly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
v0.0.69 is the first NemoClaw release whose base image bundles Hermes v0.17.0 (v0.14.0 through v0.0.68). v0.17 uses query-param ws_tickets for the proxied dashboard WS auth (fixes web chat behind a reverse proxy) and makes scripts/hermes-remote/upgrade-hermes.sh a no-op — launch.sh only runs it for hermes <0.16, which is never true on a v0.0.69 base. OpenShell stays v0.0.44 (NemoClaw still declares openshell_version 0.0.44); OpenClaw stays 2026.5.27. Updates the default NEMOCLAW_INSTALL_REF, README version refs, and the portable-install-check assertion. The launcher already provisions API_SERVER_KEY, pins HERMES_DASHBOARD_SESSION_TOKEN, and matches hermes.real, so it's expected to work on v0.17 — verify the dashboard WS on a live deploy. upgrade-hermes.sh can be deleted as a follow-up (now obsolete; see docs/upstream-divergence-audit.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erminal for all sandbox types - Add "Open Hermes Dashboard" with a copy-shareable-link button for Hermes sandboxes, mirroring the OpenClaw dashboard button. - Show "Connect to Terminal" for every sandbox type (OpenClaw included), and layer the OpenClaw/Hermes dashboard buttons on top where applicable. - Proxy the Hermes web dashboard through the controller (HTTP route + server.mjs WS upgrade tunnel), injecting the session token server-side so the token-bearing HTML stays behind controller auth. - Gate the Hermes dashboard by email/IdP exactly like the terminal: extractSandboxIdFromUrl now recognises /api/sandbox/<name>/... paths, so isUserAuthorizedForSandbox runs for both the HTTP middleware and the WS upgrade. Anonymous browser navigations to the proxy redirect to login (isDashboardProxyNavigation) instead of 401 JSON. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mcJTJ1n7meWo37cuTUJNx
v0.0.70 keeps the v0.0.69 base: Hermes v2026.6.19 (v0.17.0) and min/max_openshell_version 0.0.44 (verified against the tag). OpenShell stays v0.0.44, OpenClaw build target stays 2026.5.27. Updated the installer default, README, and the portable-install-check assertion. Companion to the manidae-cloud pin bump. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mcJTJ1n7meWo37cuTUJNx
…mobile apps Committed-but-not-wired script suite (parallel to scripts/hermes-remote/) that exposes an OpenClaw sandbox's gateway for the new OpenClaw Android/iOS apps via wss://<host>:443 + the gateway token. PARKED 2026-06-30 pending the app's remote-gateway connect form stabilising (currently wants host+port, no path). Proven working manually on a BYOVPS AgentGateway (WSS handshake -> 101). The mechanism: - reads gateway.port + auth.token from openclaw.json - unique HASHED host port (23000+, separate range from Hermes 21000-22999) - systemd-supervised `openshell forward service --target-port <gw> --local <bridge>:<hostport>` — forward SERVICE (not start) is what makes it multi-sandbox safe: maps a unique host port -> each sandbox's 127.0.0.1:<gw> - ufw allow from the docker bridge (the missing-UFW gotcha = silent timeout) - Host-based Traefik rule Host(openclaw-<sb>.<domain>) on :443 - access record + WSS 101 verification; StartLimit so a dead sandbox can't hammer the single OpenShell gateway Not invoked by install yet. README documents the productionising TODOs (wildcard DNS-01 cert instead of per-subdomain HTTP-01, watchdog, controller UI surface). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mcJTJ1n7meWo37cuTUJNx
…url:port:token
Mirrors the Hermes "Remote Desktop Access" surface for OpenClaw sandboxes so
operators can expose the gateway for the OpenClaw Android/iOS apps and see/copy
the connection details. Three pieces, paralleling hermes-remote:
- app/lib/openclawRemote.ts: read /etc/openshell/openclaw-access/<sb>.json +
expose/unexpose via scripts/openclaw-remote/{expose,unexpose}.sh
- app/api/sandbox/[sandboxId]/openclaw-remote/route.ts: GET (read access),
POST (enable/expose), DELETE (teardown); IDP users gated per-sandbox
- app/components/OpenClawRemotePanel.tsx: shows Host / Port / URL / Token with
copy + reveal, reachability probe, enable/retry, and mobile-app setup steps
(incl. `openclaw devices approve --latest` for pairing)
- SandboxList: "Mobile App Gateway Access" drawer for OpenClaw sandboxes
The underlying expose.sh is the parked POC suite (3f3d8c3) — host-based
subdomain on :443 + token. Validated live with a Pixel 9a (transport + token +
pairing all confirmed); device-approval bootstrap awaits the newer OpenClaw.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mcJTJ1n7meWo37cuTUJNx
…heredoc Two bugs surfaced running expose.sh live on a BYOVPS: - The `if [ ! -f unit ]` guard skipped overwriting a pre-existing systemd unit, so a stale unit (forward start) shadowed the intended `forward service` command -> forwarded host:port -> sandbox:port (same port) instead of -> sandbox:gatewayPort, giving empty replies / 502. Always (re)write the template unit + daemon-reload so command changes take effect. - An unescaped `connect` in backticks inside the unquoted Traefik-rule heredoc ran as command substitution (`line: connect: command not found`). Dropped the backticks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mcJTJ1n7meWo37cuTUJNx
…lse-negative) The panel showed "Unreachable" even when the gateway was up: it probed the per-sandbox subdomain with a client-side fetch from the controller's own origin, which the browser blocks via CORS. Hermes avoids this because its URL is same-origin (path-based); OpenClaw uses a dedicated subdomain. Move the probe into the GET route (controller process, no CORS) and return `reachable`; the panel renders that instead of fetching cross-origin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mcJTJ1n7meWo37cuTUJNx
The public-URL probe is unreliable on BYOVPS: the box can't hairpin-NAT the per-sandbox subdomain to itself (times out) even though external clients reach it fine. Record the docker bridge IP in the access record and have the GET route probe http://bridgeIp:hostPort directly — the controller is a host process, so this needs no DNS, hairpin, or CORS. Falls back to the public https URL for older records. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016mcJTJ1n7meWo37cuTUJNx
OpenShell 0.0.71 provisions gateway mTLS natively (self-signed certs, Ed25519 JWT, allow_unauthenticated_users=false), so no host-side DISABLE_TLS/ensure-mtls patching is required. NemoClaw v0.0.71 declares min/max_openshell_version 0.0.71 and keeps the Hermes v0.17.0 base (v2026.6.19). OpenClaw base-image target unchanged (2026.5.27). Updates the standalone install_versioned_nemoclaw_openshell.sh defaults, README compatibility targets, and the portable-install / sandbox-lifecycle test assertions. Validated on a BYOVPS testbed: fresh OpenShell 0.0.71 + NemoClaw v0.0.71 onboard (openclaw + hermes) + controller management all work over native mTLS with zero custom gateway-auth patches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WiNLfgfHJGJA8SMaTbksfK
Brings in the 4 upstream commits since 511d313 while keeping the fork's customizations and our explicit v0.0.71 version pins (upstream tracks NEMOCLAW_INSTALL_REF=main; we deliberately pin the tag): - 722a7b0 sync NemoClaw agent options (adds langchain-deepagents-code agent) - 1b57527 fix: wipe NemoClaw sandbox state before delete (mmckeen-nv#26) - 9a57ee6 sync NemoClaw provider onboarding (build/custom providers, nvapi-* gate) - 981ed2d sync OpenShell NemoClaw compatibility Conflict resolutions: - delete/route.ts: keep our Hermes remote-desktop teardown AND take upstream's wipePersistentSandboxState(target.agent) via the new 2-arg resolveDeleteTarget. - create/route.ts: merged provider branches now cover nim-local (ours) + build/custom (upstream); narrowed getBaselineSandboxesStatus() agent list to openclaw|hermes (upstream widened NemoClawAgent with deepagents-code, which has no baseline in this fork) to fix the TS build. - ConfigurationPanel.tsx: keep our baseline logic, add upstream's deepagents-code onboard blueprint. - README + tests: keep our v0.0.71 pin assertions. Verified: `npm run build` OK, portable-install-check PASS. The one red sandbox-lifecycle-check assertion (SandboxList selectedSandboxUsesTerminal) is pre-existing fork drift — fails identically on pre-merge gatewaydashboard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WiNLfgfHJGJA8SMaTbksfK
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
This proposes a feature branch that extends OpenShell Control from a single-operator,
localhost-oriented dashboard into a controller that can run a multi-user, externally
reachable fleet of sandboxes — while keeping the existing single-password/local flow
intact as the default.
It's a large change (~90 files), so I'd rather not drop it as one PR. I'm opening this to
propose the direction and agree how you'd like to receive it — I'm very happy to split
it into stackable PRs along the four areas below. Everything here is additive and
opt-in; with the new env flags unset, behaviour matches current
main.What it adds
1. Multi-user sandboxes with OAuth + header-based identity
simple password gate —
app/api/auth/{callback,me,login,logout,setup,recover},app/lib/auth/{context,edge,node,policy}.operations as an
X-Forwarded-Userheader so each user only sees/acts on the sandboxesthey're entitled to (
app/lib/auth/sandboxAccessStore.ts,SANDBOX_ACCESS_CONTROL.md).tests/control-auth-oauth-check.mjs.2. External exposure of sandbox services via a host reverse proxy
rules + per-subdomain TLS) so sandbox services are reachable from outside the host, not
just loopback —
scripts/hermes-remote/{expose,unexpose,lib}.sh,scripts/openclaw-remote/{expose,unexpose}.sh,app/lib/{hermesRemote,openclawRemote}.ts.routes healthy across sandbox restarts (
watchdog.sh,ensure-recovery-guards.sh).3. In-browser WebUI access to sandbox dashboards
OpenClaw/Hermes dashboard renders in the browser through the controller, gated by a
per-sandbox session token —
app/api/sandbox/[sandboxId]/hermes/dashboard/proxy/[[...path]],the dashboard WS proxy, and
HermesRemotePanel/OpenClawRemotePanelUI.tests/openclaw-dashboard-same-origin-ws-check.mjs,tests/openclaw-dashboard-session-token-cleanup-check.mjs.4. Remote gateway access for the native apps
gateway (for the Hermes desktop app) on a dedicated subdomain over WSS/443, authenticated
by the gateway shared-secret token sent in the WS connect frame (so the route is safe to be
public) —
scripts/openclaw-remote/,scripts/hermes-remote/,app/api/sandbox/[sandboxId]/{openclaw-remote,hermes-remote}/route.ts.tests/openclaw-create-gateway-token-verify-check.mjs.Supporting changes
ciao-network-guard.js,sandbox-safety-net.js).scripts/setup/install-production.sh,openshell-controller.service).docs/runbooks/*) and an upstream-divergence audit(
docs/upstream-divergence-audit.md).Compatibility & testing
main.npm run buildpasses; the addedtests/*.mjschecks run under the existing node test flow.Known limitations / follow-ups
first-device approval path (a pre-approved operator has to exist to approve the first
device;
dangerouslyDisableDeviceAuthonly covers the browser control-UI, not the devicechannel). Happy to discuss the right upstream mechanism here.
How would you like this?
I can split into stackable PRs — suggested order: (1) auth/multi-user, (2) reverse-proxy
exposure lib+scripts, (3) dashboard WebUI proxy, (4) remote gateway for native apps — or
land it as a single feature branch if you'd prefer to review it whole. Your call.