Skip to content

Feature/multiuser remote access#29

Open
ivobrett wants to merge 110 commits into
mmckeen-nv:mainfrom
ivobrett:feature/multiuser-remote-access
Open

Feature/multiuser remote access#29
ivobrett wants to merge 110 commits into
mmckeen-nv:mainfrom
ivobrett:feature/multiuser-remote-access

Conversation

@ivobrett

@ivobrett ivobrett commented Jul 1, 2026

Copy link
Copy Markdown

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

  • Optional OAuth login (via an MCPAUTH-compatible IdP) layered on top of the existing
    simple password gate — app/api/auth/{callback,me,login,logout,setup,recover},
    app/lib/auth/{context,edge,node,policy}.
  • Per-sandbox access control keyed off an authenticated identity, propagated to sandbox
    operations as an X-Forwarded-User header so each user only sees/acts on the sandboxes
    they're entitled to (app/lib/auth/sandboxAccessStore.ts, SANDBOX_ACCESS_CONTROL.md).
  • Covered by tests/control-auth-oauth-check.mjs.

2. External exposure of sandbox services via a host reverse proxy

  • Scripted generation/teardown of per-sandbox reverse-proxy routes (Traefik file-provider
    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.
  • Lifecycle-safe: exposure is torn down on sandbox delete; watchdog/recovery guards keep
    routes healthy across sandbox restarts (watchdog.sh, ensure-recovery-guards.sh).

3. In-browser WebUI access to sandbox dashboards

  • Controller-proxied dashboard access with WS/events/PTY tunnelling, so a sandbox's
    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/OpenClawRemotePanel UI.
  • Same-origin/session-token handling covered by
    tests/openclaw-dashboard-same-origin-ws-check.mjs,
    tests/openclaw-dashboard-session-token-cleanup-check.mjs.

4. Remote gateway access for the native apps

  • Expose a sandbox's OpenClaw gateway (for the OpenClaw mobile app) and Hermes
    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.
  • Token-verify path covered by tests/openclaw-create-gateway-token-verify-check.mjs.

Supporting changes

  • Hardening preloads for exposed sandboxes (ciao-network-guard.js, sandbox-safety-net.js).
  • Production install path + systemd unit (scripts/setup/install-production.sh,
    openshell-controller.service).
  • Operator runbooks (docs/runbooks/*) and an upstream-divergence audit
    (docs/upstream-divergence-audit.md).
  • Pinned OpenShell/NemoClaw installer helper kept in sync with the blueprint.

Compatibility & testing

  • Additive/opt-in: unset the new env flags and behaviour equals current main.
  • npm run build passes; the added tests/*.mjs checks run under the existing node test flow.

Known limitations / follow-ups

  • Remote-gateway device pairing bootstrap for the OpenClaw app still needs a clean
    first-device approval path (a pre-approved operator has to exist to approve the first
    device; dangerouslyDisableDeviceAuth only covers the browser control-UI, not the device
    channel). 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.

ivobrett and others added 30 commits May 14, 2026 21:02
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>
ivobrett and others added 30 commits June 21, 2026 16:18
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant