diff --git a/.gitignore b/.gitignore index 37d36fa..018c739 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ yarn-error.log* # local env files .env*.local +.env # vercel .vercel diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4d7514b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,521 @@ +# CLAUDE.md — repo guide for agents + +This is a working fork of `mmckeen-nv/openshell_controller`. It carries +**~94 commits ahead of upstream** (dual-auth with operator + OAuth/IDP, +file-backed sandbox-access store, Hermes Remote Desktop, MCP broker, +Quick Deploy fixes, terminal fullscreen, copy-link, etc.). Treat +upstream as a moving target we periodically pull from. For a +risk-prioritised view of what's fork-local, see +`docs/upstream-divergence-audit.md` (§4 below). + +--- + +## 1. Branches + +| Branch | Purpose | +|---|---| +| `gatewaydashboard` | **Production branch.** Origin's default; deployed to the VPS. Always green; only fast-forward or `--no-ff` merges land here. | +| `main` | Mirror of upstream/main on origin. Don't commit here; only used to compare diffs. | +| `sync/upstream-YYYY-MM-DD` | Short-lived branches for testing each upstream pull before merging. Delete after the merge lands on `gatewaydashboard`. | +| `scratch/*` | Throwaway. | + +Remotes: +- `origin` → `github.com/ivobrett/openshell_controller` (our fork) +- `upstream` → `github.com/mmckeen-nv/openshell_controller` + +`git fetch upstream` regularly. The fork can diverge fast. + +--- + +## 2. Deploy / rollback + +**VPS IP changes** when the box is reprovisioned (test-agency +deployments are ephemeral). **Always confirm the current IP with the +user before SSHing** — don't reuse a stale one from earlier turns. When +the IP (or host key) rotates: run `ssh-keygen -R ` then reconnect +with `-o StrictHostKeyChecking=accept-new`. If `Permission denied +(publickey)` follows, the new VM doesn't have the operator's public +key — coordinate with the user. + +Service runs under systemd as `openshell-controller` (unit owned by +`scripts/setup/openshell-controller.service`). + +### Deploy procedure (apply local commits to an already-installed VPS) + +Always work on a branch on your laptop first. Once green: + +```bash +git push origin +ssh 'set -e + cd /opt/openshell-controller + echo "rollback SHA: $(git rev-parse HEAD)" + git fetch origin + git checkout + git pull --ff-only + PATH=/root/.nvm/versions/node/v22.22.3/bin:$PATH \ + node_modules/.bin/next build + systemctl restart openshell-controller + sleep 6 + curl -sS -o /dev/null -w "/login -> %{http_code}\n" \ + http://127.0.0.1:3000/login' +``` + +For a fresh VPS (no existing install), see +`docs/runbooks/fresh-vps-setup.md`. + +### Rollback + +```bash +ssh 'set -e + cd /opt/openshell-controller + git checkout + PATH=/root/.nvm/versions/node/v22.22.3/bin:$PATH \ + node_modules/.bin/next build + systemctl restart openshell-controller' +``` + +The big refactor merge into `gatewaydashboard` was done `--no-ff` +(commit `328370a`), so `git revert -m 1 328370a` rolls the entire +refactor back as one revertable commit. Keep using `--no-ff` for +future upstream merges so the same property holds. + +**Do not** use rsync. The user explicitly wants every deployed state +to correspond to a pushed commit. See `memory/feedback_deployment.md`. + +--- + +## 3. Gateway recovery + +**Symptom:** `openshell sandbox list` (or any `openshell sandbox` +command) returns: + +``` +Error: × transport error + ╰─▶ Connection refused (os error 111) +``` + +**Cause:** The OpenShell Docker-driver gateway process died. The +gateway shuts down all sandbox containers when it exits, so every +sandbox shows Exited in `docker ps`. + +**Fix — run on the VPS:** + +```bash +PATH=/root/.nvm/versions/node/v22.22.3/bin:/root/.local/bin:$PATH \ +HOME=/root \ +OPENSHELL_GATEWAY=nemoclaw \ +nemoclaw recover +``` + +`` = any name from `nemoclaw list` (reads the local +registry, works even when the gateway is down). Once you see +`✓ Docker-driver gateway is healthy`, `openshell sandbox list` works +again and sandboxes return to Ready. `HOME` must be set explicitly — +NemoClaw needs it for `~/.local/state/nemoclaw/`. It is always set in +the systemd unit; add it for bare SSH sessions. + +After the host gateway is back, individual sandbox inner gateways +(Hermes / OpenClaw) may also need recovery. Repeat the `nemoclaw + recover` command per affected sandbox. + +--- + +## 4. Runbooks index — when to consult each doc + +This repo ships several long-form docs alongside CLAUDE.md. Reach for +the right one based on what you're doing: + +| Doc | When to read it | +|---|---| +| **`docs/upstream-divergence-audit.md`** | (a) After any `sync/upstream-*` merge (see §5 step 9) — drop shims the upstream now provides. (b) Before bumping any NEMOCLAW/OPENSHELL/HERMES version. (c) Before touching a file marked 🔴 HIGH in the audit (auth/, sandboxPrivilegedFiles, sandboxOpenClawMcpConfig, mcpBroker*, hermesRemote, scripts/hermes-remote/, dashboard token chain in server.mjs, restore handling). (d) For any security review. (e) When deciding whether a "cleanup" is safe — it may be load-bearing. | +| **`docs/runbooks/nemoclaw-version-bumps.md`** | Bumping `NEMOCLAW_INSTALL_REF`, `NEMOCLAW_INSTALL_TAG`, `OPENSHELL_VERSION`, or `OPENCLAW_VERSION` in `install_versioned_nemoclaw_openshell.sh`. Covers the Debian-pin failure mode + pre-flight smoke test. | +| **`docs/runbooks/byovps-architecture.md`** | A script works on cloud VPS but breaks on BYOVPS (or vice versa). Covers Traefik network mode, hermes process naming, openshell-gateway ensure-mtls flips, needrestart, ollama bootstrap source-of-truth. | +| **`docs/runbooks/fresh-vps-setup.md`** | Brand-new VPS (BYOVPS or cloud) bring-up. Not needed for incremental deploys — those use §2. | +| **`HERMES_REMOTE_DESKTOP.md`** | Anything about the Hermes Desktop public-URL flow: architecture, expose.sh / launch.sh, session-token gate, Traefik rule, troubleshooting cheatsheet (§5). | +| **`SANDBOX_ACCESS_CONTROL.md`** | Per-sandbox OAuth-user access controls + the file-backed `data/sandbox-access.json` store. | + +After every `sync/upstream-*` merge, also refresh +`docs/upstream-divergence-audit.md` (file lists go stale immediately). +Target cadence: every 2 weeks during active development, or +immediately after an upstream merge. + +--- + +## 5. Pulling from upstream — the safe procedure + +This is the procedure I'd recommend whenever `git fetch upstream` +shows new commits on `upstream/main`. **Always use a sync branch — +never merge into `gatewaydashboard` directly.** + +```bash +git fetch upstream +git log --oneline upstream/main..HEAD | wc -l # ours-ahead +git log --oneline HEAD..upstream/main # theirs-ahead (review) +git diff --stat upstream/main...HEAD | tail # files we've touched +git diff --stat HEAD..upstream/main # files they've touched + +# Spin a dated sync branch off gatewaydashboard. +git checkout gatewaydashboard +git pull --ff-only +DATE=$(date +%Y-%m-%d) +git checkout -b "sync/upstream-$DATE" + +# Try the merge (no-ff so it stays as a single revertable commit). +git merge --no-commit --no-ff upstream/main +``` + +If `git merge` reports `Automatic merge failed; fix conflicts`: + +1. List conflicting files: `git status --short | grep ^UU` (or + `grep -lnE '^<<<<<<<' -r .` from the repo root). +2. For each conflicting file, decide: + - **Take upstream verbatim** when it's a doc / installer / test the + upstream author owns (version pinning, README, install scripts). + Use `git checkout --theirs ` then `git add `. + - **Take ours verbatim** when the file is one of our additions and + upstream's parallel change is irrelevant. Rare. + - **Manually merge hunk-by-hunk** when both sides have real code + changes. Combine both sides while preserving any new upstream + imports, activity-log / telemetry calls, and all of our + `app/lib/auth/*`, `app/lib/sandboxCreate/*`, and middleware logic. +3. After resolution, `npm run build && npm test`. +4. Commit the merge with a thorough message listing the resolutions. +5. Push the sync branch: `git push -u origin sync/upstream-$DATE`. +6. Deploy the sync branch to the VPS using the Deploy procedure above + (note the rollback SHA before switching). +7. Run the smoke tests (§7). All must pass. +8. Fast-forward `gatewaydashboard` to the sync branch and push: + ```bash + git checkout gatewaydashboard + git merge --ff-only "sync/upstream-$DATE" + git push origin gatewaydashboard + git branch -d "sync/upstream-$DATE" + git push origin --delete "sync/upstream-$DATE" + ``` +9. **Walk `docs/upstream-divergence-audit.md`** (see §4). For every + item tagged "HIGHLY upstream-fixable", check whether this merge + brings in the upstream capability that obsoletes our workaround — + and if so, delete it in a follow-up commit. Then refresh the + audit's file lists and per-area sections. + +### Common conflict patterns and the right resolution + +| Pattern | Resolution | +|---|---| +| Upstream changed `README.md` / version pins / installer scripts | `git checkout --theirs` | +| Upstream added new test files | Take theirs (additive) | +| Upstream added a new import to `app/api/sandbox/create/route.ts` | Keep both — re-order alphabetically | +| Upstream added activity/telemetry calls inside our refactored sections | Keep upstream's call but place it inside our refactored control flow (e.g. inside our readiness re-poll check, not before it) | +| Upstream changed `middleware.ts` to add a new public path | Add it to our `PUBLIC_PATHS` array; keep our dual-auth dispatch | +| Upstream changed cookie names | Don't follow them — our cookie is `oauth_session`. Keep the legacy `CF_Authorization` fallback. | +| Upstream changed `app/api/sandbox/create/route.ts` or `delete/route.ts` near our hermes-remote hooks | Take upstream's changes, then re-apply our hooks: in create, the `hermesRemote` block + import + response field; in delete, the `unexposeHermesRemote` teardown block + import + response field. All hermes-remote logic lives in `app/lib/hermesRemote.ts` + `scripts/hermes-remote/` (ours only, never conflict). | +| Upstream changed `ensureOpenClawGatewayToken` in `app/api/sandbox/create/route.ts` | Fork-only function. Keep ours verbatim — the shell script polls the gateway WS handshake to verify the JSON token is live, satisfying the §10 dashboard-token invariant. Test: `tests/openclaw-create-gateway-token-verify-check.mjs`. | +| Upstream changed `bootstrapScriptResponse` in `app/api/openshell/dashboard/proxy/shared.ts` near the `sessionStorage` block | Keep upstream's structure; re-apply our two fork additions BOTH inside the same `try { ... } catch {}`: (1) the `sessionKeysToWipe` scan that removes existing `tokenPrefix + ` keys whose scope contains the current `proxyPrefix`, placed BETWEEN `window.sessionStorage.removeItem(tokenKey)` and `if (token) { ... }`; (2) the `pageScope` setItem inside the `if (token)` block that also writes the token under the https:// page-origin scope. See §10 — without these, the SPA replays stale tokens. Test: `tests/openclaw-dashboard-session-token-cleanup-check.mjs`. | +| Upstream changed `app/components/SandboxList.tsx` | Keep upstream; re-apply our 4 additions: `HermesRemotePanel` import, `'hermesRemote'` in `DrawerKey` + state init, and the "Remote Desktop Access" `` before File Transfer. | + +--- + +## 6. Architecture pointers (where the load-bearing code lives) + +Read these in this order if you're a new agent picking up the +codebase. **For a security-prioritised view of what's fork-local vs +upstream, also skim `docs/upstream-divergence-audit.md` — its summary +table tells you which areas are 🔴 HIGH risk and warrant extra care +before editing.** + +1. **`middleware.ts`** — entry point. Uses `resolveAuthContext()` to + build an `AuthContext` discriminated union, then dispatches per + kind. Runs on `runtime: "nodejs"` so it can read `process.env` and + the file-backed access store fresh per request. **Don't switch to + Edge runtime** — it'll re-introduce the config-reload bug we + already removed. +2. **`app/lib/auth/`** — the consolidated auth library. + - `policy.mjs` (+ `policy.d.ts`) — runtime-agnostic pure logic + (cookie/JWT splitting, base64url, sandbox-access map parsing). + - `edge.ts` — Web Crypto HMAC adapter for the Next.js bundle. + - `node.mjs` — `node:crypto` adapter for `server.mjs`. + - `context.ts` — `AuthContext` union + `resolveAuthContext()` + + `isOperator()` + `oauthEmail()`. + - `sandboxAccessStore.ts` — file-backed access map + (`data/sandbox-access.json`), atomic writes, env-var CSV fallback. +3. **`app/lib/controlAuth.ts`** — thin compatibility shim. Existing + imports keep working; new code should import directly from + `@/app/lib/auth/...`. +4. **`server.mjs`** — custom Next.js server, owns WS upgrade auth. + Imports policy + crypto from `app/lib/auth/node.mjs`. The custom + server is required because Next.js's built-in upgrade handler + conflicts with our dashboard WS proxy. +5. **`app/api/sandbox/create/route.ts`** — sandbox creation. Branches: + `nemoclaw-blueprint`, `nemoclaw-hermes`, `custom-sandbox`, + `redeploy-image` (covers Quick Deploy for OpenClaw/Hermes/Custom). + Helpers in `app/lib/sandboxCreate/{policy,agentFilter}.ts`. Agent + detection uses `app/lib/sandboxContainerImage.ts`. + +### Things to remember about the auth design + +- `AuthContext.kind` is one of `"operator" | "oauth" | "anonymous" | "disabled"`. +- The OAuth cookie is `oauth_session`; the legacy alias + `CF_Authorization` is **read** but no longer **written**. Both are + cleared on logout. +- Env vars are read in priority order: + `OAUTH_JWT_SECRET > MCPAUTH_JWT_SECRET > CF_AUTH_JWT_SECRET`. Same + pattern for `_LOGIN_URL`, `_CLIENT_ID`, `_CLIENT_SECRET`, + `_CALLBACK_URL`. +- `x-forwarded-user` is set by middleware **only** for verified OAuth + users. Routes can trust it. `server.mjs`'s WS upstream proxy + **strips** any client-supplied `x-forwarded-user` (see + `copyHeaders()`). Never weaken that. +- `MCPAUTH_WRITE_ALLOWED_PATHS` (in middleware.ts) is the allowlist + for OAuth users to POST. New entries require the route handler + itself to gate by sandbox access (see how `terminal/live` does it). +- **Don't** add a fallback secret to `getOAuthSecret`. The + fail-closed-on-missing-secret behaviour is intentional. + +--- + +## 7. Smoke tests + local regression suite + +### After a deploy (run on the VPS) + +```bash +# Operator login + cookie + GET / +COOKIE=$(curl -sS -i -X POST http://127.0.0.1:3000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"password":"...operator password..."}' \ + | grep -i "set-cookie" | head -1 \ + | sed -E 's/.*openshell_control_session=([^;]+).*/\1/') +curl -sS -o /dev/null -w "GET / -> %{http_code}\n" \ + -b "openshell_control_session=$COOKIE" http://127.0.0.1:3000/ + +# Auth identity +curl -sS -b "openshell_control_session=$COOKIE" \ + http://127.0.0.1:3000/api/auth/me + +# Password rotation should NOT require a restart +curl -sS -X POST http://127.0.0.1:3000/api/auth/setup \ + -H "Content-Type: application/json" \ + -b "openshell_control_session=$COOKIE" \ + -d '{"currentPassword":"old","password":"newpwd1234"}' +# willRestart should be false; /login should still 200 immediately. +``` + +### Local regression suite (run BEFORE every push) + +```bash +npm test +``` + +Runs every `tests/*.mjs` and reports `PASS:` / `FAIL:` per file, +exiting non-zero on any failure. The expected baseline is **all tests +PASS except `control-auth-cookie-check.mjs`** (pre-existing TypeScript +module resolution issue, unrelated tech debt). Any *other* failure is +a real regression — fix the root cause rather than weakening the +assertion. Each assertion has a documented historical failure mode +behind it. + +Particularly important after touching `server.mjs` or +`app/api/openshell/dashboard/proxy/shared.ts`: + +```bash +node tests/dashboard-token-cookie-wins-check.mjs # source-text guards +node tests/dashboard-token-runtime-check.mjs # behavioural guards +node tests/control-auth-oauth-check.mjs # auth invariants +``` + +If `dashboard-token-runtime-check.mjs` fails with assertion +`actual: 'STALE_FROM_LOCALSTORAGE' / expected: 'FRESH_FROM_COOKIE'`, +the cookie-wins invariant from §10 has been broken — do NOT push. + +--- + +## 8. Memory & past decisions + +Persistent memory lives at: +``` +~/.claude/projects/-Users-mattercoder-Projects-nvidia-openshell-controller/memory/ +``` + +Key files to read before changing security-adjacent code: +- `feedback_deployment.md` — *Don't rsync; use git commit → push → pull.* +- `project_password_reset_bug.md` — Edge-runtime middleware + snapshotted `process.env`, causing freshly-issued login cookies to + be rejected. Fixed by moving middleware to Node runtime. +- `project_sandbox_access_control.md` — Per-sandbox access via the + `oauth_session` cookie + `SANDBOX_ACCESS_USERS` env var (now + superseded by `data/sandbox-access.json`). +- `project_sandbox_ssh.md` — Historical: previously sandbox shells + went via `docker exec` through `openshell-cluster-nemoclaw`. As of + 2026-06-22 we migrated all privileged in-sandbox writes to + `openshell sandbox exec` (see audit §6). + +Update those files when your work would change their accuracy. + +--- + +## 9. Don'ts + +These have all been tried and rejected — don't reintroduce them. The +parallel list at the end of `docs/upstream-divergence-audit.md` is +kept in sync — if you change either, update both. + +- **Don't** call `process.exit(0)` after writing config. The + Node-runtime middleware refresh covers this. +- **Don't** `rsync` source files to the VPS. Use git. +- **Don't** rename `MCPAUTH_*` env vars in the operator's `.env.local`. + The fallback reads them; just add `OAUTH_*` for new deployments. +- **Don't** remove the legacy `CF_Authorization` cookie reader from + `context.ts` / `server.mjs` until the user explicitly OKs it. + Existing browsers may still hold sessions under that name. +- **Don't** switch middleware back to Edge runtime. The whole + no-restart-on-config-change machinery depends on the Node runtime + having a live `process.env` view. +- **Don't** generate or guess URLs for the user. +- **Don't** push to `gatewaydashboard` without running `npm run build` + AND deploying + smoke-testing on the VPS first. Force-push to + `gatewaydashboard` is forbidden. +- **Don't** push without running `npm test` first. The suite is fast + (~2s) and locks in dashboard token (§10), auth, middleware, sandbox + lifecycle, and MCP config invariants. Baseline is "all PASS except + the one known tech-debt failure". +- **Don't** delete or weaken `tests/dashboard-token-cookie-wins-check.mjs` + or `tests/dashboard-token-runtime-check.mjs` without understanding + §10. They are the only mechanical guards against the brittle + 2026-06-13 dashboard regression coming back. + +--- + +## 10. OpenClaw dashboard token + tunnel architecture (the brittle one) + +This section exists because we spent a full day rediscovering this in +June 2026. The OpenClaw "Open Dashboard" path is by far the most +fragile piece of the controller: a token has to be in sync across +four carriers, and there's a lazy SSH tunnel in the middle. When it +breaks the user sees `Auth did not match — gateway token mismatch` +and no obvious server-side error. + +### The four token carriers + +The gateway compares against `gateway.auth.token` in +`/sandbox/.openclaw/openclaw.json` (inside the sandbox container) for +every connection. The same value has to ride along on **every** one +of these carriers, or the connection is rejected: + +| Carrier | Who sets it | Who reads it | +|---|---|---| +| `openclaw_dashboard_token` HttpOnly cookie | `/api/openshell/dashboard/open` sets it from a live probe of `openclaw dashboard --no-open` | `server.mjs` for WS upgrades, `shared.ts` for HTTP proxy | +| URL `?token=…` query | The OpenClaw Control UI SPA, from `settings.gatewayUrl` in localStorage | `server.mjs` `withDashboardTokenQuery` | +| `Authorization: Bearer …` header | Browser, replayed from a cached value | `server.mjs` `copyDashboardWebSocketHeaders` | +| URL hash `#token=…` on the launchUrl | `/api/openshell/dashboard/open` from the probe | The injected bootstrap script (`shared.ts` `bootstrapScriptResponse`), which writes it to `localStorage[openclaw.control.settings.v1*]` | + +**The invariant (post 2026-06-13 fixes):** the HttpOnly cookie is the +*only* trusted source. Both `server.mjs` `withDashboardTokenQuery` +and `copyDashboardWebSocketHeaders` **unconditionally overwrite** any +client-supplied `?token=` / `Authorization` with the cookie value. Do +not re-introduce `if (!headers.authorization)` or +`if (!searchParams.has('token'))` guards on those carriers — that's +the regression that left "delete and recreate the same sandbox name" +broken for any browser with cached state. + +### The lazy SSH tunnel on 127.0.0.1:20049 + +The controller does NOT connect directly to the in-sandbox gateway at +`ws://127.0.0.1:18789`. It connects to `127.0.0.1:20049`, a **lazy +SSH forward** spawned by `ensureOpenClawDashboardListener()` in +`app/lib/openshellHost.ts`: + +- Created on demand by `/dashboard/open`'s `probeOpenClawDashboard()`. +- Maps `127.0.0.1:20049` (host) → `127.0.0.1:18789` (sandbox). +- Dies when idle / sandbox restart / controller restart. +- Has to be re-spun by another `/dashboard/open` call before any WS + or HTTP traffic can flow. + +**Gotcha for server-side testing:** a raw `curl` WS handshake against +`/api/openshell/instances//dashboard/proxy` will return +`HTTP/1.1 101 Switching Protocols` (the controller accepts the +upgrade) and then immediately `connect ECONNREFUSED 127.0.0.1:20049` +because no `/dashboard/open` was called to spin the forward. Always +call `/dashboard/open` first when reproducing failures from a shell; +the browser path does this automatically. + +### Browser-side cache layers + +The SPA writes the gateway URL+token combination to several +localStorage keys scoped by URL origin +(`openclaw.control.settings.v1[:scope]`, `openclaw.control.token.v1:` +in sessionStorage). When a user deletes a sandbox and recreates it +with the same name, the controller URL is identical, so the SPA +reads the SAME entries and tries to connect with the OLD token. The +server-side cookie-wins fix masks this end-to-end, so users don't +need to clear localStorage. **But if you ever debug this in a +browser, hitting "hard reload" does NOT clear localStorage** — use +DevTools → Application → Local Storage → clear, or an Incognito +window. + +### When to suspect this section + +| Symptom | This section? | First check | +|---|---|---| +| "Auth did not match" in OpenClaw UI | YES | Token chain (see diagnostic below) | +| `code=1008 reason=token_mismatch` in controller journal | YES | Same | +| `connect ECONNREFUSED 127.0.0.1:20049` in journal/curl | YES (tunnel layer) | Did `/dashboard/open` run recently? | +| Dashboard works once then fails after sandbox restart | YES | Tunnel died, restart re-randomises the token | +| Dashboard works on cloud VPS but not BYOVPS | NO | See `docs/runbooks/byovps-architecture.md` | +| 401 on `/__openclaw/control-ui-config.json` | YES | The HTTP 401 auto-refresh (`shared.ts`) should handle it; if not, regression there | + +### Diagnostic recipe (when "Auth did not match" appears) + +1. **Confirm the token chain matches server-side.** Read + `gateway.auth.token` from `/sandbox/.openclaw/openclaw.json` via + `docker exec -u sandbox ...`, hit + `/api/openshell/dashboard/open?sandboxId=` (with operator + cookie), parse the `Set-Cookie: openclaw_dashboard_token=…` and + the `bootstrapUrl` body field. All three must be byte-identical. + If not, the probe failed — check `/tmp/gateway.log` inside the + sandbox + the controller's `probeOpenClawDashboard()` result. +2. **Confirm the server-side WS path works** with `curl` after + calling `/dashboard/open` (to spin the lazy tunnel): + ```bash + curl -sS -i -N \ + -H 'Upgrade: websocket' -H 'Connection: Upgrade' \ + -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + -H 'Sec-WebSocket-Version: 13' \ + -b "openshell_control_session=$SESSION; openclaw_dashboard_token=$TOK" \ + http://127.0.0.1:3000/api/openshell/instances//dashboard/proxy + ``` + Success = `HTTP/1.1 101 Switching Protocols` followed by a + `connect.challenge` event from the gateway. If curl succeeds but + browser fails, it's a client-cache problem. +3. **If server-side curl ALSO fails** with `code=1008 reason=token_mismatch`, + re-introduce the diagnostic logging from commit `ed22c9e` + (token-fingerprint fields in `dashboard-ws-client-connected`). + +### Commits and tests that protect this section + +The four load-bearing commits — **do not revert**: + +| SHA | What it does | +|---|---| +| `c35fea5` | `shared.ts` — on HTTP 401/403 from upstream for GET/HEAD, re-probe and retry with fresh token, refresh the cookie | +| `48bbfa5` | `server.mjs` `withDashboardTokenQuery` — cookie token always overwrites URL `?token=` | +| `a2e8ddb` | `server.mjs` `copyDashboardWebSocketHeaders` — cookie token always overwrites `Authorization: Bearer` | +| `b42b323` | reverts a temporary diagnostic — keep, leaves the three real fixes in place | + +Rollback baseline (yesterday-working before any of these): +`8b9eb852097448bbfc6c4449ce9dddcda08ca37d`. + +Two regression tests lock these invariants in. **Run them before +pushing ANY change to `server.mjs` or +`app/api/openshell/dashboard/proxy/shared.ts`** (`npm test` runs +both): + +| Test | What it catches | +|---|---| +| `tests/dashboard-token-cookie-wins-check.mjs` | Direct reverts — someone re-adds `!url.searchParams.has('token')` or `!headers.authorization` guards | +| `tests/dashboard-token-runtime-check.mjs` | Behavioural bugs — new code path bypasses the guard, subtle logic error, dependency drift in `copyHeaders` / `filterCookieHeader` / `readCookieValue` | + +The runtime test extracts function source from `server.mjs` and +executes it in a `node:vm` sandbox. If you refactor function +signatures or move them, the extract regex will fail to locate them — +update the regex, do NOT skip the test. If refactoring makes vm-eval +painful, extract the helpers into `app/lib/dashboardProxy.mjs` and +switch the test to import them directly. diff --git a/HERMES_REMOTE_DESKTOP.md b/HERMES_REMOTE_DESKTOP.md new file mode 100644 index 0000000..928444f --- /dev/null +++ b/HERMES_REMOTE_DESKTOP.md @@ -0,0 +1,271 @@ +# Hermes Remote Desktop — How It Works and How to Set It Up + +Status: **implemented and verified** (2026-06-10) +Owner: ivobrett +Validated against: Hermes v0.16.0 · NemoClaw v0.0.58 · OpenShell v0.0.44 + +End-users running the **Hermes Desktop app** point its "Remote gateway" +setting at a public HTTPS URL and drive a Hermes agent running inside a +NemoClaw sandbox on a shared, multi-tenant VPS. One VPS hosts many sandboxes; +each is reachable at its own URL with its own credential, with no per-sandbox +VPS, VPN, or client-side SSH tunnel. + +This document describes the system **as built**, how a deployment gets it, +how to operate and debug it, and what we learned getting it working. The +original design draft lives in git history (`git log -- HERMES_REMOTE_DESKTOP_PLAN.md`); +§7 summarises where reality diverged from it. + +--- + +## 1. Architecture (as built) + +``` +Hermes Desktop (client laptop, same hermes-agent minor version as backend) + │ HTTPS Remote URL = https://openshell-controller./hermes/ + │ credential = per-sandbox session token (X-Hermes-Session-Token / ?token=) + ▼ +Traefik on VPS — file-provider rule per sandbox + │ TLS termination · stripPrefix · X-Forwarded-Prefix injection + │ desktop mode: matches ONLY /hermes//api (see §4 Security) + ▼ +Traefik's compose-bridge gateway IP (typically 172.18.0.1), port 21000+hash + │ `openshell forward` (gRPC channel through the OpenShell gateway), + │ supervised by systemd `hermes-remote-forward@.service` + ▼ +sandbox container → gateway-process netns (only place inference.local resolves) + │ + ▼ +hermes dashboard (uvicorn, 0.0.0.0:, pinned session token) + │ HTTPS_PROXY → 10.200.0.1:3128 (policy-enforcing L7 proxy) + ▼ +upstream LLM provider +``` + +Key mechanics: + +- **Port scheme** — `port = 21000 + hash(sandboxName) % 2000`, the same + `hashSandboxId` as OpenClaw's dashboard proxy in `server.mjs` (OpenClaw owns + 19000–20999). The hashed value is **both** the in-sandbox listen port and + the host bind port, because `openshell forward` maps host:PORT → + sandbox:PORT with no remapping. Collisions are checked against `ss -lnt` + and existing access records, rehashing with a salt. +- **Auth** — Hermes' built-in session-token gate. We pin the token per + sandbox via `HERMES_DASHBOARD_SESSION_TOKEN` (supported in hermes ≥0.16) so + it survives dashboard restarts and the desktop's saved credential keeps + working. The desktop sends it as `X-Hermes-Session-Token` on REST and + `?token=` on WebSockets — both validated with constant-time compares by + Hermes itself; the proxy is pure transport. +- **Supervision** — the forward runs under a systemd template unit + (`Restart=always`); a 2-minute `hermes-remote-watchdog.timer` re-runs the + idempotent launcher for every exposed sandbox, because a sandbox restart + changes the gateway PID/netns and silently kills the dashboard. +- **State** — one JSON per sandbox at `/etc/openshell/hermes-access/.json` + (mode, port, token, URL, hermes version). This is the single source of + truth read by the launcher, the watchdog, and the controller API/UI. + +## 2. Components + +Everything lives in **this repo** so deployments pick it up by cloning a +branch — manidae-cloud only sets one env var (see §3). + +| Path | Role | +|---|---| +| `scripts/hermes-remote/lib.sh` | Shared helpers: port hash, container/gateway-PID/Traefik-bridge/rules-dir/public-host discovery. No hardcoded IPs or paths — every assumption is discovered or loud-fails. | +| `scripts/hermes-remote/expose.sh [--mode desktop\|web]` | Idempotent end-to-end setup: port + token, dashboard launch, systemd forward unit, UFW, Traefik rule, watchdog timer, access record. Self-installs its systemd units. Verifies the public URL (status 200, token 200, bogus-token 401, no token leak) before declaring success. | +| `scripts/hermes-remote/unexpose.sh ` | Symmetric best-effort teardown of all of the above. | +| `scripts/hermes-remote/launch.sh ` | Idempotent dashboard (re)launcher. Re-discovers container + gateway PID each run; short-circuits when healthy; provisions `API_SERVER_KEY` (+ config-hash re-pin); auto-upgrades hermes <0.16 via `upgrade-hermes.sh`. | +| `scripts/hermes-remote/upgrade-hermes.sh ` | In-place hermes-agent upgrade inside the sandbox (pip through the L7 proxy from the gateway netns) + gateway restart. | +| `scripts/hermes-remote/watchdog.sh` | Iterates access records; re-runs `launch.sh` and nudges wedged forward units. Run by the timer. | +| `app/lib/hermesRemote.ts` | Controller-side wrapper: `exposeHermesRemote` / `unexposeHermesRemote` / `readHermesRemoteAccess` / `hermesRemoteMode()`. | +| `app/api/sandbox/[sandboxId]/hermes-remote/route.ts` | `GET` returns the access record (URL + token); `POST` (re)exposes on demand. OAuth users must hold per-sandbox access; operators are fully trusted. | +| `app/api/sandbox/create/route.ts` | After a successful Hermes sandbox create (and unless `HERMES_REMOTE_MODE=off`), calls `exposeHermesRemote`; result returned as `hermesRemote` in the response. Non-fatal — a proxy hiccup never fails creation. | +| `app/api/sandbox/delete/route.ts` | Calls `unexposeHermesRemote` before deletion when an access record exists. | +| `app/components/HermesRemotePanel.tsx` | "Remote Desktop Access" drawer: URL/token copy (token masked + reveal), public reachability probe, desktop setup steps, enable/retry button. Rendered in `SandboxList.tsx` for Hermes sandboxes. | + +systemd units (written by `expose.sh` on first use, nothing to install by hand): + +- `/etc/systemd/system/hermes-remote-forward@.service` — `openshell forward + start ${BIND}:${PORT} %i` in the foreground, `Restart=always`, env from + `/etc/openshell/hermes-remote/.env`. +- `/etc/systemd/system/hermes-remote-watchdog.{service,timer}` — runs + `watchdog.sh` every 2 minutes, 90 s after boot. + +## 3. Setting it up + +### Fresh agentgateway deployment (manidae-cloud) + +Nothing manual. `startup_agentgateway.sh.j2` writes `HERMES_REMOTE_MODE` +(default `desktop`) into the controller's `.env.local`; the controller repo +branch (settings `OPENSHELL_CONTROLLER_REPO` / `OPENSHELL_CONTROLLER_BRANCH`) +carries everything else. Creating a Hermes sandbox through the controller +automatically: + +1. builds/onboards the sandbox (`nemoclaw onboard --agent hermes`), +2. upgrades in-sandbox hermes to ≥0.16 if the base image is older, +3. exposes the dashboard and writes the access record, +4. surfaces URL + token in the sandbox's **Remote Desktop Access** drawer. + +Mode selection per deployment (`hermes_remote_mode` template var): + +| Mode | Behaviour | Use when | +|---|---|---| +| `desktop` (default) | Public path serves **only `/api/*`**. Token is the credential, distributed solely via the controller UI (behind operator login / Pangolin SSO; OAuth users additionally need per-sandbox access grants). | Enterprises using the Hermes Desktop app. | +| `web` | Additionally serves the dashboard SPA at the path. **The SPA HTML embeds the session token**, so the path must sit behind a Pangolin-gated resource or a trusted network. | Enterprises preferring a browser web UI with SSO-style login. | +| `off` | No automatic exposure. | Air-gapped / policy-restricted deployments. | + +### Existing deployment / manual operation + +```bash +# Enable for one sandbox (idempotent; safe to re-run any time) +/opt/openshell-controller/scripts/hermes-remote/expose.sh + +# Disable + tear everything down +/opt/openshell-controller/scripts/hermes-remote/unexpose.sh + +# Read the credential (also shown in the controller UI) +cat /etc/openshell/hermes-access/.json +``` + +Or through the controller API: `POST /api/sandbox//hermes-remote` +(expose/repair), `GET` (read URL + token). + +### Connecting the desktop app + +1. Hermes Desktop → Settings → Gateway → **Remote gateway** +2. Remote URL: `https://openshell-controller./hermes/` +3. Wait ~1 s for the probe — a **Session token** field appears (the probe sees + no `auth_required` field and classifies the gateway as token-auth) +4. Paste the token, **Save and reconnect** + +The token is stable across dashboard/sandbox restarts (pinned via env), so +the desktop reconnects from cold start without re-entry. + +**Version rule (hard requirement):** the desktop and the in-sandbox +hermes-agent must run the same minor version. Newer desktops call endpoints +older backends don't have (e.g. `/api/profiles/sessions`, added ~0.15) and +the request falls through to the SPA route → "Expected JSON … but got HTML". +Older gateways also reject newer embedded-chat TUIs at the WS handshake. +`launch.sh` auto-upgrades the sandbox to the latest PyPI hermes-agent; +"Backend out of date" toasts for a 1-commit delta are cosmetic — do **not** +click "Update Hermes" in the desktop (it would pip-upgrade and restart the +remote dashboard mid-session). + +## 4. Security model + +- **The `/hermes/` path bypasses Pangolin by design.** The desktop's + `/api/status` probe and token header flow cannot follow SSO redirects. + This is safe in `desktop` mode because: + - only `/api/*` is proxied — the SPA shell, whose HTML embeds + `window.__HERMES_SESSION_TOKEN__`, is never served on the public path + (`expose.sh` hard-fails if any response there contains the token); + - every `/api/*` route except Hermes' small public list (`/api/status`, + config schema, etc.) requires the token, compared constant-time; + - the token is only obtainable through the controller (operator session or + Pangolin/IDP-verified OAuth user with an explicit per-sandbox grant). +- **Token scope** — one token per sandbox, random 33 bytes urlsafe. Rotation: + delete the `token` field from the access JSON, re-run `expose.sh`, hand out + the new value (a `rotate` API is future work, §8). +- **Tenant isolation** — per-sandbox URL, port, token, forward unit, and + Traefik rule; sandboxes cannot see each other's netns. Compromise of one + token exposes one sandbox's dashboard only. +- **`--insecure` on the dashboard is required and OK** — it permits the + non-loopback bind *and* disables `_ws_client_is_allowed`, which would + otherwise reject Traefik-proxied WS upgrades (X-Forwarded-For rewrites the + client address). The session-token gate stays fully active. +- **Nous OAuth (future)** — hermes ≥0.16 ships `hermes dashboard register` + (writes an OAuth client for the Nous Portal). When we want SSO-grade login + on the desktop path, that flips `/api/status` to `auth_required: true` and + the desktop renders a "Sign in" button instead of the token box. See §8. + +## 5. Operating and debugging + +State to look at first: + +```bash +ls /etc/openshell/hermes-access/ # which sandboxes are exposed +systemctl status hermes-remote-forward@ # forward supervision +systemctl list-timers | grep hermes-remote # watchdog armed? +journalctl -u hermes-remote-forward@ -n 20 # forward errors +docker exec tail -20 /tmp/hermes-dashboard.log +``` + +Fail-mode cheatsheet (every row was hit for real during bring-up): + +| Symptom | Likely cause | Fix | +|---|---|---| +| 502 from Traefik | Forward not bound, or bound on the wrong bridge | `systemctl restart hermes-remote-forward@`. The bind IP must be **Traefik's compose-bridge gateway** (`docker inspect … .Gateway`, typically `172.18.0.1`) — NOT docker0, and `host.docker.internal` does not resolve in this stack. Check UFW allows `172.0.0.0/8 → `. | +| Forward unit flapping with "sandbox is not ready" | OpenShell gateway restarted / sandbox supervisor disconnected | `openshell sandbox list`. If sandboxes sit in Provisioning/Error after a gateway restart, see the **ensure-mtls gotcha** below. | +| Public `/api/status` 200 but desktop gets 401 | Token mismatch (e.g. dashboard restarted unpinned) | `launch.sh ` relaunches with the pinned token from the access record; re-copy the token from the UI. | +| "Expected JSON … but got HTML" in desktop | Version skew — backend older than desktop | `upgrade-hermes.sh ` (launch.sh does this automatically). | +| Chat tab stuck CONNECTING / "gateway websocket connection failed" | Dashboard and `hermes gateway run` on different versions (upgrade without gateway restart) | `upgrade-hermes.sh` restarts the gateway; or kill `hermes gateway run` and run `nemohermes recover`. | +| 0.16 gateway exits: "API_SERVER_KEY is required" | New 0.16 requirement, absent from 0.14-era `.env` | `launch.sh` provisions it and **re-pins the config-integrity hash** (`sha256sum config.yaml .env > /etc/nemoclaw/hermes.config-hash`, root 444) — without the re-pin the container refuses to boot. | +| `nemohermes recover` refuses: "NODE_OPTIONS missing safety-net preload (#2478)" | Hermes-flavour `/tmp/nemoclaw-proxy-env.sh` lacks the preloads the guard expects | `mv` the file aside → recover (warn-and-proceed) → restore. `upgrade-hermes.sh` does this. recover's success probe also races gateway boot — trust `pgrep -f 'hermes gateway run'`. | +| pip inside sandbox: proxy 403 / "tunnel error" | L7 proxy enforces per-binary + process-tree policy | Run pip **inside the gateway netns** (`nsenter -t -n`) using the venv python (allowlisted; `uv` is not), with `HTTPS_PROXY=http://10.200.0.1:3128` and the OpenShell CA bundle. The venv ships without pip — `ensurepip` first. | +| Dashboard dies after sandbox restart | Gateway PID/netns changed; old netns join is stale | Expected — the watchdog relaunches within 2 min. Manual: `launch.sh `. | +| Desktop "Backend stopped / Hermes background process exited" | The app's **local** backend, unrelated to remote | Benign when switching to remote. If the app is stuck connecting locally, kill orphaned local `hermes_cli.main dashboard` processes squatting ports 9120+. | +| Desktop stays in local mode despite saved URL | Token box only renders after the URL probe; saving early silently keeps `mode: local` | Wait for the probe, paste token, then save. Config lives at `~/Library/Application Support/Hermes/connection.json` and accepts a hand-written `{"mode":"remote","remote":{"url":…,"authMode":"token","token":{"encoding":"plain","value":…}}}`. | + +**ensure-mtls gotcha (one-time migration hazard):** agentgateway deployments +run `openshell-gateway-ensure-mtls.sh` (hooked to the controller service) to +enforce an mTLS OpenShell gateway. `nemoclaw onboard` rewrites `gateway.env` +to plaintext; the next controller restart re-arms mTLS — and any sandboxes +created during the plaintext window have plaintext supervisors that can never +reconnect (stuck Provisioning/Error). The only fix is recreating those +sandboxes. Fresh deployments are mTLS from provision and immune. Avoid +running `nemoclaw onboard` by hand on agentgateway boxes; create sandboxes +through the controller. + +## 6. Verification (what "working" means) + +`expose.sh` self-verifies on every run; the full matrix we validated live: + +1. Sandbox create via controller → access record + reachable URL, no manual steps +2. Public `/api/status` → 200; authed `/api/config` → 200; bogus token → 401 +3. Real WS upgrade (`/api/ws?token=…`) → 101 through the full chain +4. No response on the public path contains `__HERMES_SESSION_TOKEN__` +5. Desktop app connects, lists sessions, chat round-trips via `inference.local` +6. Desktop cold restart reconnects without re-entering the token +7. Sandbox delete via controller → rule, unit, UFW, access record all gone +8. Forward unit and watchdog active (`systemctl`) — survive sandbox restarts + +## 7. What changed from the original design (and why) + +| Original plan | As built | Why | +|---|---|---| +| SSH tunnels (`ssh -L` + ProxyCommand) in systemd units | `openshell forward` in systemd units | Simpler, no SSH key/known-hosts handling; the gRPC channel is the supported path. Constraint inherited: host port == sandbox port, so the hashed port moved inside the sandbox too (fine — netns-isolated). | +| Bind tunnels on docker0 `172.17.0.1` | Bind on Traefik's compose-bridge gateway (discovered, typically `172.18.0.1`) | Traefik isn't on docker0 in the Komodo stack and `host.docker.internal` doesn't resolve there. | +| Hermes basicAuth (`HERMES_DASHBOARD_BASIC_AUTH_*`) for the POC tier | Pinned session token (`HERMES_DASHBOARD_SESSION_TOKEN`) | The basicAuth env vars described by the docs don't exist in shipped 0.14–0.16; the session-token gate does, and ≥0.16 lets us pin it. The "auth_required/auth_providers introspection" flow exists but only engages for OAuth. | +| Proxy the whole path; rely on Hermes auth for everything | desktop mode proxies `/api/*` only | The SPA HTML hands the session token to any fetcher — serving it publicly would void the credential. API-only proxying is what makes Pangolin-bypass safe. | +| Per-sandbox dashboards all on 9119 inside the sandbox | Hashed port inside the sandbox | `openshell forward` can't remap ports (see row 1). | +| install.sh provisions dirs/units | `expose.sh` self-installs everything | Zero deployment-side setup; the only manidae-cloud change is one env var. | +| (unforeseen) | Auto-upgrade of in-sandbox hermes + `API_SERVER_KEY` + hash re-pin + gateway restart | Desktop/backend version-match is a hard requirement; NemoClaw's base image lags PyPI. | + +## 8. Future work + +- **Nous Portal OAuth** (`hermes dashboard register`) as an opt-in auth tier + for enterprises wanting SSO-grade login on the desktop path; flips the + desktop to a "Sign in" flow automatically. Open questions from the original + plan (redirect-URI flexibility, headless registration) still apply. +- **Token rotation API** — `POST /api/sandbox//hermes-remote/rotate`. +- **manidae-cloud surfacing** — proxy the access record up to the + HostMyAgents deployment-detail page for end-customers who never see the + controller UI. +- **Pangolin-gated `web` mode automation** — currently `web` mode writes the + full-path rule and leaves Pangolin resource creation to the operator. +- **Nightly audit** — cron that reconciles UFW rules / forward units / + Traefik rules against `/etc/openshell/hermes-access/` and reaps strays. + +## 9. Merge-resilience notes + +This feature was deliberately kept modular so upstream pulls stay cheap. +All logic lives in **new files**; upstream-shared files carry only minimal +additive hooks: + +| Shared file | Footprint | +|---|---| +| `app/api/sandbox/create/route.ts` | 1 import + 1 self-contained `hermesRemote` block (after `hermesDashboardBuild`) + 1 response field | +| `app/api/sandbox/delete/route.ts` | 1 import + 1 teardown block (before `deleteSandbox`) + 1 response field | +| `app/components/SandboxList.tsx` | 1 import + 1 `DrawerKey` variant + 1 state-init entry + 1 `` block | + +On upstream-merge conflicts in those files: keep upstream's changes and +re-apply our hook block alongside (see CLAUDE.md §3 conflict table). diff --git a/README.md b/README.md index d0d1502..f23b6e6 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,10 @@ It is currently built for active development and lab use. It includes a simple p ## Compatibility Targets -This dashboard is validated against the current NVIDIA NemoClaw repo and the OpenShell version range declared in NemoClaw's `nemoclaw-blueprint/blueprint.yaml`, not the older April 2026 point releases. Current NemoClaw `main` pins OpenShell exactly to `0.0.71`, so the bundled refresh helper defaults to: +This dashboard is validated against the current NVIDIA NemoClaw repo and the OpenShell version range declared in NemoClaw's `nemoclaw-blueprint/blueprint.yaml`, not the older April 2026 point releases. NemoClaw `v0.0.71` pins OpenShell exactly to `0.0.71` (native gateway mTLS), so the bundled refresh helper defaults to: - OpenShell installer release: `v0.0.71` (`OPENSHELL_VERSION=v0.0.71`) -- NemoClaw source ref: `main` (`NEMOCLAW_INSTALL_REF=main`) +- NemoClaw source ref: `v0.0.71` (`NEMOCLAW_INSTALL_REF=v0.0.71`) — keeps the Hermes v0.17.0 base (v0.0.69 was the first to bundle it) - OpenClaw base-image build target: `2026.5.27` (`OPENCLAW_VERSION=2026.5.27`) unless overridden Runtime/toolchain versions used during development: @@ -98,7 +98,7 @@ Install or refresh the locked OpenShell/NemoClaw pair first: ./install_versioned_nemoclaw_openshell.sh ``` -That helper defaults to `OPENSHELL_VERSION=v0.0.71`, `NEMOCLAW_INSTALL_REF=main`, and `OPENCLAW_VERSION=2026.5.27`. +That helper defaults to `OPENSHELL_VERSION=v0.0.71`, `NEMOCLAW_INSTALL_REF=v0.0.71`, and `OPENCLAW_VERSION=2026.5.27`. Then install the dashboard from the repository root: @@ -426,6 +426,40 @@ Before exposing this outside a trusted lab network, replace auth with a real ide ## Troubleshooting +### Gateway down — `openshell sandbox list` returns "Connection refused" + +The OpenShell Docker-driver gateway is a background process managed by NemoClaw. If it crashes or is killed, every `openshell sandbox` command fails with: + +``` +Error: × transport error + ╰─▶ Connection refused (os error 111) +``` + +All sandbox containers are also stopped when the gateway shuts down. + +**Recovery — one command:** + +```bash +PATH=/root/.nvm/versions/node/v22.22.3/bin:/root/.local/bin:$PATH \ +HOME=/root \ +OPENSHELL_GATEWAY=nemoclaw \ +nemoclaw recover +``` + +Replace `` with any name from `nemoclaw list` (e.g. `my-first-hermes`). The `recover` sub-command restarts the host gateway as a side-effect. Once it prints `✓ Docker-driver gateway is healthy`, `openshell sandbox list` works again and the sandboxes are back to Ready. + +`HOME` must be set — NemoClaw needs it to locate `~/.local/state/nemoclaw/`. It is always set correctly inside the controller's systemd unit, but bare SSH sessions may lack it. + +After the gateway is back, individual sandbox Hermes/OpenClaw gateways may need their own recovery if the inner gateway process also died: + +```bash +# repeat for each sandbox that shows degraded health +PATH=/root/.nvm/versions/node/v22.22.3/bin:/root/.local/bin:$PATH \ +HOME=/root \ +OPENSHELL_GATEWAY=nemoclaw \ +nemoclaw recover +``` + Check OpenShell: ```bash diff --git a/SANDBOX_ACCESS_CONTROL.md b/SANDBOX_ACCESS_CONTROL.md new file mode 100644 index 0000000..caa7834 --- /dev/null +++ b/SANDBOX_ACCESS_CONTROL.md @@ -0,0 +1,164 @@ +# Per-Sandbox Access Control via OAuth/IDP + +Allows enterprise users to authenticate via the internal IDP (OAuth/IDP) and access +only the sandboxes assigned to them, without needing the operator password. + +## Architecture + +``` +User browser + │ + ▼ +openshell-controller.ag-*.nemoclaw.dpdns.org (Traefik, no badger@http) + │ + ▼ +Next.js middleware.ts + ├── Operator session cookie → full access (all sandboxes, can create/delete) + └── `oauth_session` JWT cookie → read-only, own sandboxes only +``` + +The key insight: OAuth/IDP sets a `oauth_session` JWT cookie scoped to +`.ag-*.nemoclaw.dpdns.org`. Once a user logs into the IDP at +`idp.ag-*.nemoclaw.dpdns.org`, that cookie is automatically sent to the +openshell controller on the same domain. The controller validates the JWT +locally (no network call to OAuth/IDP) and gates per-sandbox access. + +## What Was Changed + +### 1. Traefik on VPS (`/etc/komodo/stacks/support-799109_setup-stack/config/traefik/rules/resource-overrides.yml`) + +Removed `badger@http` from the openshell controller router so Pangolin no longer +gates the controller. The controller handles all auth itself. + +**Before:** +```yaml +8-openshell-controller-router-auth: + middlewares: + - badger@http + rule: "Host(`openshell-controller.ag-*.nemoclaw.dpdns.org`)" + service: "8-openshell-controller-service@http" +``` + +**After:** +```yaml +8-openshell-controller-router-auth: + rule: "Host(`openshell-controller.ag-*.nemoclaw.dpdns.org`)" + service: "8-openshell-controller-service@http" +``` + +### 2. `.env.local` — New env vars + +```env +# OAuth IDP Configuration for Enterprise Sandbox Access +OAUTH_JWT_SECRET=my-secret-key # must match the IDP's JWT signing secret +SANDBOX_ACCESS_USERS=sandbox-name:user@example.com,other-sandbox:other@example.com +OAUTH_LOGIN_URL=https://idp.ag-*.nemoclaw.dpdns.org/internal/login +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= +OAUTH_CALLBACK_URL=https://openshell-controller.ag-*.nemoclaw.dpdns.org/api/auth/callback +``` + +> The historical `MCPAUTH_*` and `CF_AUTH_JWT_SECRET` env var names are still +> read as fallbacks; existing deployments don't need to rename anything to +> upgrade. Note that `SANDBOX_ACCESS_USERS` is now optional — the access +> list is preferentially read from `data/sandbox-access.json`, edited via +> the Security page. + +`SANDBOX_ACCESS_USERS` is a comma-separated list of `sandboxName:email` pairs. +Use the sandbox **name** (not the UUID) — e.g. `my-first-claw:alice@company.com`. +The name is what appears in the dashboard URL and the instance ID format `sandbox-{port}-{name}`. +A sandbox can have at most one assigned user. Operator (password login) bypasses +this check and sees all sandboxes. + +### 3. `middleware.ts` — oauth_session validation + +Added to the existing Next.js middleware: + +- Reads `oauth_session` cookie on every request (falls back to the legacy + `CF_Authorization` cookie name for sessions issued by an older controller) +- Validates the JWT signature (HS256, `OAUTH_JWT_SECRET`) +- Extracts `email` claim +- If valid email found: + - GET `/api/sandbox/create` → allowed (template list is read-only) + - POST `/api/sandbox/create`, POST `/api/sandbox/delete` → 403 (operator only) + - Any `/api/sandbox/[sandboxId]/...` or `/api/telemetry/sandbox/[sandboxId]/...` → allowed only if `isUserAuthorizedForSandbox(email, sandboxId)` returns true + - Sets `x-forwarded-user` header for downstream handlers + +### 4. `app/lib/auth/` — Shared auth library + +**`verifyOAuthJWT(token, secret)`** (in `edge.ts` / `node.mjs`) — validates HMAC +HS256 JWT, returns `{ email, sub, exp, … }` or null. + +**`isUserAuthorizedForSandbox(email, sandboxId)`** (in `controlAuth.ts`) — +consults the file-backed `SandboxAccessStore` and returns true if the email is +assigned to that sandbox. Sandbox matching is by sandbox name (not ID — the +sandbox ID from the URL is used as the sandbox name in this context). + +The store is `data/sandbox-access.json`; the legacy `SANDBOX_ACCESS_USERS` CSV +env var is read as a fallback if the file is absent. + +## User Flows + +### Enterprise user (Alice) +1. Alice navigates to `https://idp.ag-*.nemoclaw.dpdns.org` +2. Logs in with email/password via the internal IDP +3. OAuth/IDP sets `oauth_session` JWT cookie (24h TTL, domain `.ag-*.nemoclaw.dpdns.org`) +4. Alice navigates to `https://openshell-controller.ag-*.nemoclaw.dpdns.org` +5. Controller middleware validates cookie → extracts `alice@company.com` +6. Only sandboxes assigned to `alice@company.com` in `SANDBOX_ACCESS_USERS` are visible/accessible +7. Create/delete operations return 403 + +### Operator +1. Navigates to `https://openshell-controller.ag-*.nemoclaw.dpdns.org` +2. Logs in with `OPENSHELL_CONTROL_PASSWORD` at `/login` +3. Gets `openshell_control_session` cookie +4. Full access — all sandboxes, create/delete enabled +5. Unaffected by `SANDBOX_ACCESS_USERS` + +## Assigning a Sandbox to a User + +Use the Security page (operator login required) at +`https://openshell-controller.ag-*.nemoclaw.dpdns.org/setup-account` to add +or remove rows. Changes are persisted to `data/sandbox-access.json` and +take effect immediately — no controller restart needed. + +The sandbox name is the short identifier (e.g. `my-first-claw`), not the UUID. +Find it with `openshell sandbox list` or from the sandbox URL in the dashboard. + +For headless / first-run setup the legacy `SANDBOX_ACCESS_USERS` CSV env var +in `.env.local` is still read as a fallback when the JSON file is absent: + +```env +SANDBOX_ACCESS_USERS=alice-sandbox:alice@company.com,bob-sandbox:bob@company.com +``` + +To create a user in the IDP, use the IDP's own admin surface (e.g. the +Pangolin admin panel at `https://pangolin.ag-*.nemoclaw.dpdns.org`). + +## JWT Secret + +The OAuth `oauth_session` cookie is signed with HMAC HS256 using a shared +secret that must match the IDP's signing secret. The controller reads it +from `OAUTH_JWT_SECRET`, falling back to `MCPAUTH_JWT_SECRET` / +`CF_AUTH_JWT_SECRET` for backwards compatibility. + +If the IDP defaults to a hardcoded development secret (e.g. `"my-secret-key"`), +override it for production and keep `OAUTH_JWT_SECRET` in sync. + +## Future Enhancements + +- **Dynamic sandbox assignment**: Add an "Assign to user" field in the sandbox + creation wizard (WizardPanel.tsx) that writes to a JSON config file instead of + requiring manual `.env.local` edits. + +- **User creation in controller**: Add an operator-only UI to create/invite OAuth/IDP + users without needing to use the Pangolin admin panel. + +- **Stronger JWT secret**: Move OAuth/IDP's `jwtSecret` to an env var and generate + a cryptographically random value on first run. + +- **Per-sandbox URL**: If direct per-sandbox links are needed (e.g., to email + Alice a link directly to her sandbox), add a `/sandbox/[sandboxId]` route that + skips the full dashboard and proxies directly to the OpenClaw dashboard for that + sandbox. This requires the browser-redirect flow in OAuth/IDP (a new + `/auth/browser` endpoint that redirects to login instead of returning 401). diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts new file mode 100644 index 0000000..b7390e7 --- /dev/null +++ b/app/api/auth/callback/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server" +import { mintOAuthSessionJWT, sessionCookieOptionsForRequest } from "@/app/lib/controlAuth" +import { OAUTH_COOKIE_NAME } from "@/app/lib/auth/policy.mjs" + +function base64UrlDecode(value: string) { + const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=") + return Buffer.from(padded, "base64").toString("utf-8") +} + +// Generic OAuth2 / OIDC callback handler. Reads IDP coordinates from the +// OAUTH_* env vars, falling back to the legacy MCPAUTH_* names so existing +// deployments don't need to rename anything. +function getIdpEnv(name: string) { + const upper = name.toUpperCase() + return ( + process.env[`OAUTH_${upper}`] + || process.env[`MCPAUTH_${upper}`] + || "" + ) +} + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl + const code = searchParams.get("code") + const state = searchParams.get("state") || "/" + + if (!code) { + return NextResponse.json({ ok: false, error: "Missing authorization code" }, { status: 400 }) + } + + const loginUrl = getIdpEnv("LOGIN_URL") + const tokenUrl = `${loginUrl ? loginUrl.replace("/authorize", "") : "http://localhost:11000"}/token` + const clientId = getIdpEnv("CLIENT_ID") + const redirectUri = getIdpEnv("CALLBACK_URL") + + try { + // Exchange authorization code for token + const formData = new URLSearchParams() + formData.append("grant_type", "authorization_code") + formData.append("code", code) + formData.append("redirect_uri", redirectUri) + formData.append("client_id", clientId) + + const response = await fetch(tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formData.toString(), + }) + + const data = await response.json() + if (!response.ok) { + return NextResponse.json({ ok: false, error: data.error_description || "Token exchange failed" }, { status: response.status }) + } + + // Standard OIDC: extract email from the id_token JWT payload + const idToken = data.id_token as string | undefined + const accessToken = data.access_token as string | undefined + + let userEmail: string | null = null + let scopes: string[] = [] + + if (idToken) { + try { + const parts = idToken.split(".") + if (parts.length >= 2) { + const payload = JSON.parse(base64UrlDecode(parts[1])) + userEmail = payload.email || payload.sub || null + } + } catch { + // fall through to access token parsing + } + } + + // Fallback: parse scopes from access token payload + if (accessToken) { + try { + const parts = accessToken.split(".") + if (parts.length >= 2) { + const payload = JSON.parse(base64UrlDecode(parts[1])) + if (!userEmail && payload.email) userEmail = payload.email + if (Array.isArray(payload.scopes)) scopes = payload.scopes + } + } catch { + // ignore + } + } + + if (!userEmail) { + return NextResponse.json({ ok: false, error: "User identity could not be retrieved from provider" }, { status: 500 }) + } + + // Mint the OAuth session JWT cookie using our shared secret. + const oauthToken = await mintOAuthSessionJWT(userEmail, scopes) + + // Reconstruct target URL using the incoming Host header to ensure we + // redirect to the correct domain/port (e.g. localhost:3000 instead of + // 0.0.0.0:3000). + const host = request.headers.get("host") || "localhost:3000" + const protocol = request.headers.get("x-forwarded-proto") || request.nextUrl.protocol || "http" + const cleanProtocol = protocol.endsWith(":") ? protocol.slice(0, -1) : protocol + const baseUrl = `${cleanProtocol}://${host}` + const targetUrl = new URL(state.startsWith("/") ? state : "/", baseUrl) + const redirectResponse = NextResponse.redirect(targetUrl) + redirectResponse.cookies.set(OAUTH_COOKIE_NAME, oauthToken, sessionCookieOptionsForRequest(request)) + + return redirectResponse + } catch (error) { + console.error("OAuth callback error:", error) + return NextResponse.json( + { ok: false, error: error instanceof Error ? error.message : "Internal server error during callback processing" }, + { status: 500 }, + ) + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 6241885..05072bf 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -37,3 +37,36 @@ export async function POST(request: NextRequest) { response.cookies.set(settings.cookieName, await createSessionCookieValue(), sessionCookieOptionsForRequest(request)) return response } + +// Read IDP coordinates from OAUTH_* env vars, falling back to the legacy +// MCPAUTH_* names so existing .env.local files keep working. +function idpEnv(name: string) { + const upper = name.toUpperCase() + return process.env[`OAUTH_${upper}`] || process.env[`MCPAUTH_${upper}`] || "" +} + +export async function GET() { + const loginBase = idpEnv("LOGIN_URL") + const clientId = idpEnv("CLIENT_ID") + const redirectUri = idpEnv("CALLBACK_URL") + + let oauthLoginUrl: string | null = null + if (loginBase && clientId && redirectUri) { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid email", + }) + oauthLoginUrl = `${loginBase}?${params.toString()}` + } + + // Returns the URL under both names for one release: the new `oauthLoginUrl` + // and the historical `mcpAuthLoginUrl`, so a stale cached login page from + // a previous deploy keeps working. + return NextResponse.json({ + oauthLoginUrl, + mcpAuthLoginUrl: oauthLoginUrl, + }) +} + diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index e376454..71186ca 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,12 +1,18 @@ import { NextRequest, NextResponse } from "next/server" import { getAuthSettings, sessionCookieOptionsForRequest } from "@/app/lib/controlAuth" +import { OAUTH_COOKIE_NAME, LEGACY_OAUTH_COOKIE_NAME } from "@/app/lib/auth/policy.mjs" export async function POST(request: NextRequest) { const settings = getAuthSettings() const response = NextResponse.json({ ok: true }) - response.cookies.set(settings.cookieName, "", { - ...sessionCookieOptionsForRequest(request), - maxAge: 0, - }) + const clear = { ...sessionCookieOptionsForRequest(request), maxAge: 0 } + + // Operator session. + response.cookies.set(settings.cookieName, "", clear) + // OAuth (IDP) session — clear both the new and legacy cookie names so a + // browser carrying a session minted by an older controller version is + // properly logged out. + response.cookies.set(OAUTH_COOKIE_NAME, "", clear) + response.cookies.set(LEGACY_OAUTH_COOKIE_NAME, "", clear) return response } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..f0902e7 --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,8 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthSettings, verifySessionCookieValue } from "@/app/lib/controlAuth" + +export async function GET(request: NextRequest) { + const settings = getAuthSettings() + const operator = await verifySessionCookieValue(request.cookies.get(settings.cookieName)?.value) + return NextResponse.json({ operator, configured: settings.configured }) +} diff --git a/app/api/auth/recover/route.ts b/app/api/auth/recover/route.ts index 21d64a5..514d714 100644 --- a/app/api/auth/recover/route.ts +++ b/app/api/auth/recover/route.ts @@ -1,5 +1,12 @@ import { NextRequest, NextResponse } from "next/server" -import { createSessionCookieValue, getAuthSettings, sessionCookieOptionsForRequest, verifyRecoveryToken } from "@/app/lib/controlAuth" +import { + createSessionCookieValue, + getAuthSettings, + sessionCookieOptionsForRequest, + verifyRecoveryToken, + verifySessionCookieValue, +} from "@/app/lib/controlAuth" +import { oauthEmail } from "@/app/lib/auth/context" import { updateLocalAuthCredentials } from "@/app/lib/controlAuthConfig" import { checkRateLimit, clearRateLimit, rateLimitKey, recordRateLimitFailure } from "@/app/lib/rateLimit" @@ -20,6 +27,14 @@ export async function POST(request: NextRequest) { ) } + const settings = getAuthSettings() + const signedIn = await verifySessionCookieValue(request.cookies.get(settings.cookieName)?.value) + // Block OAuth (IDP) users from elevating to operator via the recovery path. + const idpEmail = await oauthEmail(request) + if (idpEmail && !signedIn) { + return NextResponse.json({ ok: false, error: "Operator session required to reset the password." }, { status: 403 }) + } + if (password.length < 8) { return NextResponse.json({ ok: false, error: "Password must be at least 8 characters." }, { status: 400 }) } @@ -31,11 +46,12 @@ export async function POST(request: NextRequest) { clearRateLimit(limitKey) const result = await updateLocalAuthCredentials(password) - const settings = getAuthSettings() + // No restart required — see /api/auth/setup for the rationale. const response = NextResponse.json({ ok: true, recoveryToken: result.recoveryToken, note: "Password reset. Save the new recovery token from .env.local.", + willRestart: false, }) response.cookies.set(settings.cookieName, await createSessionCookieValue(), sessionCookieOptionsForRequest(request)) return response diff --git a/app/api/auth/setup/route.ts b/app/api/auth/setup/route.ts index 08fdc1a..cdc82eb 100644 --- a/app/api/auth/setup/route.ts +++ b/app/api/auth/setup/route.ts @@ -7,6 +7,7 @@ import { verifyRecoveryToken, verifySessionCookieValue, } from "@/app/lib/controlAuth" +import { oauthEmail } from "@/app/lib/auth/context" import { updateLocalAuthCredentials } from "@/app/lib/controlAuthConfig" import { checkRateLimit, clearRateLimit, rateLimitKey, recordRateLimitFailure } from "@/app/lib/rateLimit" @@ -38,6 +39,13 @@ export async function POST(request: NextRequest) { } const signedIn = await verifySessionCookieValue(request.cookies.get(settings.cookieName)?.value) + // Block OAuth (IDP) users from elevating to operator via the password + // change path. Only the operator session may rotate the operator password. + const idpEmail = await oauthEmail(request) + if (idpEmail && !signedIn) { + return NextResponse.json({ ok: false, error: "Operator session required to change the password." }, { status: 403 }) + } + const currentPasswordOk = currentPassword ? await verifyPassword(currentPassword) : false const recoveryOk = recoveryToken ? await verifyRecoveryToken(recoveryToken) : false const firstRun = !settings.configured @@ -49,10 +57,15 @@ export async function POST(request: NextRequest) { clearRateLimit(limitKey) const result = await updateLocalAuthCredentials(password) + // Node-runtime middleware reads process.env (and the file-backed access + // store) fresh per request, so password rotation no longer requires a + // process restart. The response includes willRestart for backwards + // compatibility with clients that branched on it. const response = NextResponse.json({ ok: true, recoveryToken: result.recoveryToken, note: "Password updated. Save the new recovery token from .env.local.", + willRestart: false, }) response.cookies.set(settings.cookieName, await createSessionCookieValue(), sessionCookieOptionsForRequest(request)) return response diff --git a/app/api/openshell/dashboard/proxy/shared.ts b/app/api/openshell/dashboard/proxy/shared.ts index c1af3be..7f0b5fc 100644 --- a/app/api/openshell/dashboard/proxy/shared.ts +++ b/app/api/openshell/dashboard/proxy/shared.ts @@ -1,6 +1,10 @@ import { NextResponse } from 'next/server' -import { getDefaultOpenClawDashboardInstanceId } from '@/app/lib/openshellHost' -import { OPENCLAW_DASHBOARD_TOKEN_COOKIE } from '@/app/lib/openclawDashboardToken' +import { getDefaultOpenClawDashboardInstanceId, probeOpenClawDashboard } from '@/app/lib/openshellHost' +import { + OPENCLAW_DASHBOARD_TOKEN_COOKIE, + extractOpenClawDashboardToken, + setOpenClawDashboardTokenCookie, +} from '@/app/lib/openclawDashboardToken' import { resolveRuntimeAuthority } from '@/app/lib/runtimeAuthority' const LEGACY_PROXY_PREFIX = '/api/openshell/dashboard/proxy' @@ -95,9 +99,16 @@ function buildTargetUrl(requestUrl: URL) { return { target, proxyPrefix, controlUiOrigin, authorityMode, bridgeActive } } -function copyRequestHeaders(request: Request, target: URL, controlUiOrigin: string) { +function copyRequestHeaders( + request: Request, + target: URL, + controlUiOrigin: string, + tokenOverride?: string | null, +) { const headers = new Headers() - const dashboardToken = readCookieValue(request.headers.get('cookie'), OPENCLAW_DASHBOARD_TOKEN_COOKIE) + const dashboardToken = tokenOverride !== undefined + ? tokenOverride + : readCookieValue(request.headers.get('cookie'), OPENCLAW_DASHBOARD_TOKEN_COOKIE) request.headers.forEach((value, key) => { const lowerKey = key.toLowerCase() @@ -246,17 +257,80 @@ function bootstrapScriptResponse(proxyPrefix: string) { }; const settings = readSettings(); - settings.gatewayUrl = gatewayUrl; + // Purge stale scoped settings for other gateway URLs so OpenClaw does not + // detect a URL change and show the "Change Gateway URL" confirmation modal. + try { + const keysToRemove = []; + for (let i = 0; i < window.localStorage.length; i++) { + const k = window.localStorage.key(i); + if (k && k.startsWith(settingsPrefix) && !settingsKeys.includes(k) && k !== settingsPrefix + 'default' && k !== settingsKey) { + keysToRemove.push(k); + } + } + for (const k of keysToRemove) window.localStorage.removeItem(k); + } catch {} + + const effectiveGatewayUrl = token + ? gatewayUrl + (gatewayUrl.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token) + : gatewayUrl; + settings.gatewayUrl = effectiveGatewayUrl; const serializedSettings = JSON.stringify(settings); window.localStorage.setItem(settingsKey, serializedSettings); for (const key of settingsKeys) window.localStorage.setItem(key, serializedSettings); window.sessionStorage.removeItem(tokenKey); + // ── FORK BEGIN: BYOVPS sessionStorage token-scope cleanup (#2478) ── + // See CLAUDE.md §3 conflict table + §11 token architecture. The SPA + // writes sessionStorage[tokenPrefix + ] for both the wss:// + // gatewayUrl scope AND an https:// page-origin scope derived from + // window.location. After multiple failed dashboard opens, these scoped + // keys accumulate stale tokens that the SPA replays in the application- + // level WS connect frame — server.mjs cookie-wins only covers the WS + // handshake, not the in-frame token. Wipe ANY existing + // tokenPrefix: key whose scope path matches this proxyPrefix. + // On upstream merge: if this block conflicts, see the conflict-pattern + // entry for "bootstrapScriptResponse in shared.ts" in CLAUDE.md §3. + try { + const sessionKeysToWipe = []; + for (let i = 0; i < window.sessionStorage.length; i++) { + const k = window.sessionStorage.key(i); + if (k && k.startsWith(tokenPrefix) && k.includes(proxyPrefix)) { + sessionKeysToWipe.push(k); + } + } + for (const k of sessionKeysToWipe) window.sessionStorage.removeItem(k); + } catch {} + // ── FORK END ── if (token) { for (const scope of gatewayScopes) window.sessionStorage.setItem(tokenPrefix + scope, token); + // ── FORK BEGIN: page-origin scope write (#2478) ── + // Also write under the https:// (page-origin) scope so the SPA's own + // sessionStorage lookup keyed off window.location finds the fresh token. + try { + const pageScope = window.location.protocol + '//' + window.location.host + proxyPrefix; + window.sessionStorage.setItem(tokenPrefix + pageScope, token); + } catch {} + // ── FORK END ── } } catch { // Best-effort compatibility bridge for OpenClaw's persisted UI settings. } + + // Auto-confirm the "Change Gateway URL" modal — this modal appears when OpenClaw detects + // a URL change between sandbox sessions, but we always trust the URL set by this proxy. + try { + const interval = setInterval(() => { + if (!document.body.textContent?.includes('Change Gateway URL')) return; + const buttons = document.querySelectorAll('button'); + for (const btn of buttons) { + if (btn.textContent?.trim() === 'Confirm') { + btn.click(); + clearInterval(interval); + break; + } + } + }, 100); + setTimeout(() => clearInterval(interval), 10000); + } catch {} })(); ` @@ -312,10 +386,35 @@ export async function proxyOpenClawDashboard(request: Request) { upstreamInit.duplex = 'half' } - const upstream = await fetch(target.toString(), { + let upstream = await fetch(target.toString(), { ...upstreamInit, }) + // Self-heal stale dashboard token: when a sandbox is deleted and recreated with + // the same name, the openclaw_dashboard_token cookie still holds the previous + // sandbox's bearer. Upstream then rejects every request with 401/403. Re-probe + // the live sandbox for the current token, retry once, and refresh the cookie + // so subsequent calls (including the WS upgrade) use the new value. + let refreshedToken: string | null = null + if ( + (upstream.status === 401 || upstream.status === 403) && + !shouldSendBody + ) { + const probe = await probeOpenClawDashboard(resolution.instanceId) + const candidate = extractOpenClawDashboardToken(probe.bootstrapUrl) + if (candidate) { + const retryHeaders = copyRequestHeaders(request, target, controlUiOrigin, candidate) + const retry = await fetch(target.toString(), { + ...upstreamInit, + headers: retryHeaders, + }) + if (retry.ok) { + upstream = retry + refreshedToken = candidate + } + } + } + const responseHeaders = copyResponseHeaders(upstream) responseHeaders.set('x-openclaw-authority-mode', authorityMode) responseHeaders.set('x-openclaw-bridge-active', bridgeActive ? 'true' : 'false') @@ -329,16 +428,20 @@ export async function proxyOpenClawDashboard(request: Request) { if (contentType.includes('text/html')) { const body = rewriteHtml(await upstream.text(), proxyPrefix) responseHeaders.set('content-type', contentType) - return new NextResponse(body, { + const htmlResponse = new NextResponse(body, { status: upstream.status, headers: responseHeaders, }) + if (refreshedToken) setOpenClawDashboardTokenCookie(htmlResponse, request, proxyPrefix, refreshedToken) + return htmlResponse } - return new NextResponse(upstream.body, { + const response = new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders, }) + if (refreshedToken) setOpenClawDashboardTokenCookie(response, request, proxyPrefix, refreshedToken) + return response } export function proxyErrorResponse(error: unknown) { diff --git a/app/api/openshell/terminal/live/route.ts b/app/api/openshell/terminal/live/route.ts index f1e21dc..bda6168 100644 --- a/app/api/openshell/terminal/live/route.ts +++ b/app/api/openshell/terminal/live/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { isUserAuthorizedForSandbox } from '@/app/lib/controlAuth' import { inspectSandbox } from '@/app/lib/openshellHost' import { resolveRuntimeAuthority } from '@/app/lib/runtimeAuthority' @@ -105,6 +106,19 @@ export async function POST(request: Request) { ? body.dashboardSessionId.trim() : 'dashboard-host' + // OAuth (IDP) users are gated by SANDBOX_ACCESS_USERS. The middleware + // sets x-forwarded-user only for verified IDP callers (it strips any + // client-supplied value in other paths) so trusting it here is safe. + const idpUser = request.headers.get('x-forwarded-user')?.trim().toLowerCase() + if (idpUser) { + if (!sandboxId || !isUserAuthorizedForSandbox(idpUser, sandboxId)) { + return NextResponse.json( + { ok: false, error: `Forbidden: no access to sandbox ${sandboxId || '(none)'}` }, + { status: 403 }, + ) + } + } + const requestedSandboxId = sandboxId || 'host' let inspection = null diff --git a/app/api/sandbox/[sandboxId]/hermes-remote/route.ts b/app/api/sandbox/[sandboxId]/hermes-remote/route.ts new file mode 100644 index 0000000..cf70fe0 --- /dev/null +++ b/app/api/sandbox/[sandboxId]/hermes-remote/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server" +import { isUserAuthorizedForSandbox } from "@/app/lib/controlAuth" +import { exposeHermesRemote, hermesRemoteMode, readHermesRemoteAccess } from "@/app/lib/hermesRemote" +import { resolveSandboxRef } from "@/app/lib/openshellHost" + +// The access record contains the session token (the desktop app's +// credential), so OAuth users must hold explicit access to this sandbox. +// Operators pass middleware without x-forwarded-user and are fully trusted. +function forbiddenForIdpUser(request: Request, sandboxName: string) { + const idpUser = request.headers.get("x-forwarded-user")?.trim().toLowerCase() + if (idpUser && !isUserAuthorizedForSandbox(idpUser, sandboxName)) { + return NextResponse.json( + { ok: false, error: `Forbidden: no access to sandbox ${sandboxName}` }, + { status: 403 }, + ) + } + return null +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ sandboxId: string }> }, +) { + const { sandboxId } = await params + let sandboxName = sandboxId + try { + sandboxName = (await resolveSandboxRef(sandboxId)).name + } catch { + // Fall back to treating the ref as a name; access file lookup decides. + } + + const forbidden = forbiddenForIdpUser(request, sandboxName) + if (forbidden) return forbidden + + const access = readHermesRemoteAccess(sandboxName) + if (!access) { + return NextResponse.json({ ok: false, configured: false, mode: hermesRemoteMode() }, { status: 404 }) + } + return NextResponse.json({ ok: true, configured: true, access }) +} + +// POST = (re)expose on demand: lets operators enable remote desktop for +// pre-existing Hermes sandboxes or self-heal after manual teardown. +export async function POST( + request: Request, + { params }: { params: Promise<{ sandboxId: string }> }, +) { + const { sandboxId } = await params + let sandboxName = sandboxId + try { + sandboxName = (await resolveSandboxRef(sandboxId)).name + } catch { + return NextResponse.json({ ok: false, error: `Sandbox not found: ${sandboxId}` }, { status: 404 }) + } + + const forbidden = forbiddenForIdpUser(request, sandboxName) + if (forbidden) return forbidden + + const result = await exposeHermesRemote(sandboxName) + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: 502 }) + } + return NextResponse.json({ ok: true, configured: true, access: result.access }) +} diff --git a/app/api/sandbox/[sandboxId]/hermes/dashboard/proxy/[[...path]]/route.ts b/app/api/sandbox/[sandboxId]/hermes/dashboard/proxy/[[...path]]/route.ts new file mode 100644 index 0000000..94ea2ac --- /dev/null +++ b/app/api/sandbox/[sandboxId]/hermes/dashboard/proxy/[[...path]]/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from 'next/server' +import { readHermesRemoteAccess } from '@/app/lib/hermesRemote' + +// Proxy the in-sandbox Hermes dashboard (NemoClaw v0.17+) through the controller, +// behind the controller's own auth. Unlike the public `web`-mode Traefik route +// (which serves the session-token-bearing SPA HTML past Pangolin), this keeps the +// dashboard gated by controller auth and injects the session token server-side. +// +// Hermes handles path-prefixing natively via X-Forwarded-Prefix (no HTML rewrite), +// and v0.17 authorises WebSockets with single-use ws-tickets, so this route is pure +// HTTP transport; the WS upgrade is proxied in server.mjs. The dashboard is reachable +// from the controller container at http://: (the desktop-mode forward). + +const HOP_BY_HOP = new Set([ + 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'te', 'trailer', 'transfer-encoding', 'upgrade', 'content-length', 'accept-encoding', 'host', +]) + +function proxyPrefix(sandboxId: string) { + return `/api/sandbox/${encodeURIComponent(sandboxId)}/hermes/dashboard/proxy` +} + +function upstreamBase(access: { bridgeIp?: string; port: number }) { + const host = access.bridgeIp && access.bridgeIp.trim() ? access.bridgeIp.trim() : '127.0.0.1' + return `http://${host}:${access.port}` +} + +async function proxy(request: Request, sandboxId: string) { + const access = readHermesRemoteAccess(sandboxId) as (ReturnType & { bridgeIp?: string }) | null + if (!access) { + return NextResponse.json( + { ok: false, error: `Hermes sandbox '${sandboxId}' is not exposed yet — enable remote access first.` }, + { status: 404 }, + ) + } + + const reqUrl = new URL(request.url) + const prefix = proxyPrefix(sandboxId) + const upstreamPath = reqUrl.pathname.startsWith(prefix) ? reqUrl.pathname.slice(prefix.length) || '/' : '/' + const target = new URL(upstreamPath + reqUrl.search, upstreamBase(access)) + + const headers = new Headers() + request.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) headers.set(key, value) + }) + headers.set('host', target.host) + // Hermes renders the SPA under this prefix natively — no HTML rewriting needed. + headers.set('x-forwarded-prefix', prefix) + // Inject the session token server-side so it gates every /api/* call (the browser + // never needs to hold it). The single-use ws-ticket flow (/api/auth/ws-ticket) + // rides the same injection, so the WS handshake in server.mjs is pure transport. + headers.set('x-hermes-session-token', access.token) + + const method = request.method.toUpperCase() + const hasBody = !['GET', 'HEAD'].includes(method) + const init: RequestInit & { duplex?: 'half' } = { + method, + headers, + body: hasBody ? request.body : undefined, + redirect: 'manual', + cache: 'no-store', + } + if (hasBody) init.duplex = 'half' + + let upstream: Response + try { + upstream = await fetch(target.toString(), init) + } catch (error) { + return NextResponse.json( + { ok: false, error: error instanceof Error ? error.message : 'Hermes dashboard unreachable' }, + { status: 502 }, + ) + } + + const responseHeaders = new Headers() + upstream.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) responseHeaders.set(key, value) + }) + responseHeaders.set('cache-control', 'no-store') + const location = upstream.headers.get('location') + if (location && location.startsWith('/')) responseHeaders.set('location', `${prefix}${location}`) + + return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders }) +} + +type Ctx = { params: Promise<{ sandboxId: string; path?: string[] }> } +async function handler(request: Request, { params }: Ctx) { + const { sandboxId } = await params + return proxy(request, sandboxId) +} + +export const GET = handler +export const POST = handler +export const PUT = handler +export const PATCH = handler +export const DELETE = handler +export const HEAD = handler +export const OPTIONS = handler diff --git a/app/api/sandbox/[sandboxId]/openclaw-remote/route.ts b/app/api/sandbox/[sandboxId]/openclaw-remote/route.ts new file mode 100644 index 0000000..5c1c412 --- /dev/null +++ b/app/api/sandbox/[sandboxId]/openclaw-remote/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from "next/server" +import { isUserAuthorizedForSandbox } from "@/app/lib/controlAuth" +import { exposeOpenClawRemote, readOpenClawRemoteAccess, unexposeOpenClawRemote } from "@/app/lib/openclawRemote" +import { resolveSandboxRef } from "@/app/lib/openshellHost" + +// The access record contains the gateway token (the mobile app's credential), +// so OAuth/IDP users must hold explicit access to this sandbox. Operators pass +// middleware without x-forwarded-user and are fully trusted. +function forbiddenForIdpUser(request: Request, sandboxName: string) { + const idpUser = request.headers.get("x-forwarded-user")?.trim().toLowerCase() + if (idpUser && !isUserAuthorizedForSandbox(idpUser, sandboxName)) { + return NextResponse.json( + { ok: false, error: `Forbidden: no access to sandbox ${sandboxName}` }, + { status: 403 }, + ) + } + return null +} + +async function resolveName(sandboxId: string): Promise { + try { + return (await resolveSandboxRef(sandboxId)).name + } catch { + return sandboxId + } +} + +// Reachability is checked server-side: a browser fetch to the per-sandbox +// subdomain would be CORS-blocked (false "Unreachable"). The controller is a +// host process, so it probes the forward backend (bridgeIp:hostPort) directly — +// no DNS, no proxmox hairpin, no CORS. The gateway serves HTTP on the same port, +// so a plain GET answers when the forward + Traefik route are healthy. Falls +// back to the public https URL for records written before bridgeIp existed. +async function probeReachable(access: { bridgeIp?: string; hostPort: number; url: string }): Promise { + const target = access.bridgeIp + ? `http://${access.bridgeIp}:${access.hostPort}/` + : access.url.replace(/^wss:/i, "https:") + try { + const res = await fetch(target, { signal: AbortSignal.timeout(8000), redirect: "manual" }) + return res.status > 0 + } catch { + return false + } +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ sandboxId: string }> }, +) { + const { sandboxId } = await params + const sandboxName = await resolveName(sandboxId) + + const forbidden = forbiddenForIdpUser(request, sandboxName) + if (forbidden) return forbidden + + const access = readOpenClawRemoteAccess(sandboxName) + if (!access) { + return NextResponse.json({ ok: false, configured: false }, { status: 404 }) + } + const reachable = await probeReachable(access) + return NextResponse.json({ ok: true, configured: true, access, reachable }) +} + +// POST = (re)expose on demand: lets operators enable the mobile-app remote +// gateway for an OpenClaw sandbox, or self-heal after manual teardown. +export async function POST( + request: Request, + { params }: { params: Promise<{ sandboxId: string }> }, +) { + const { sandboxId } = await params + let sandboxName = sandboxId + try { + sandboxName = (await resolveSandboxRef(sandboxId)).name + } catch { + return NextResponse.json({ ok: false, error: `Sandbox not found: ${sandboxId}` }, { status: 404 }) + } + + const forbidden = forbiddenForIdpUser(request, sandboxName) + if (forbidden) return forbidden + + const result = await exposeOpenClawRemote(sandboxName) + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: 502 }) + } + return NextResponse.json({ ok: true, configured: true, access: result.access }) +} + +// DELETE = tear down the exposure (forward + UFW + Traefik rule + access record). +export async function DELETE( + request: Request, + { params }: { params: Promise<{ sandboxId: string }> }, +) { + const { sandboxId } = await params + const sandboxName = await resolveName(sandboxId) + + const forbidden = forbiddenForIdpUser(request, sandboxName) + if (forbidden) return forbidden + + const result = await unexposeOpenClawRemote(sandboxName) + if (!result.ok) { + return NextResponse.json({ ok: false, error: result.error }, { status: 502 }) + } + return NextResponse.json({ ok: true, configured: false }) +} diff --git a/app/api/sandbox/create/route.ts b/app/api/sandbox/create/route.ts index 0a1d340..aaee425 100644 --- a/app/api/sandbox/create/route.ts +++ b/app/api/sandbox/create/route.ts @@ -3,9 +3,17 @@ import { execFile, spawn } from "node:child_process" import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" import path from "node:path" import { promisify } from "node:util" -import { inspectSandbox, resolveSandboxRef } from "@/app/lib/openshellHost" +import { inspectSandbox, prebuildHermesDashboardWebUi, resolveSandboxRef } from "@/app/lib/openshellHost" +import { exposeHermesRemote, hermesRemoteMode } from "@/app/lib/hermesRemote" import { recordActivity } from "@/app/lib/activityLog" import { repairOpenClawExecApprovalsFile } from "@/app/lib/sandboxPrivilegedFiles" +import { exportSandboxPolicyToFile as exportPolicy } from "@/app/lib/sandboxCreate/policy" +import { + bucketCandidatesByAgent, + type QuickDeployAgent, + type RegistryShape, +} from "@/app/lib/sandboxCreate/agentFilter" +import { readSandboxContainerImageMap, type SandboxImageMap } from "@/app/lib/sandboxContainerImage" import { commandExists, HOST_PATH, @@ -25,6 +33,16 @@ const OPENSHELL_CLUSTER_CONTAINER = process.env.OPENSHELL_CLUSTER_CONTAINER || " const OPENSHELL_NAMESPACE = process.env.OPENSHELL_SANDBOX_NAMESPACE || "openshell" const NEMOCLAW_REGISTRY_FILE = path.join(process.env.HOME || "/tmp", ".nemoclaw", "sandboxes.json") +// Prebuilt baseline sandboxes. Produced once per VPS by manidae-cloud's install +// scripts (one `nemoclaw onboard` per agent) and left running. The existing +// `redeploy-image` blueprint flow finds them via `openshell sandbox list`, so +// the first Quick Deploy on a fresh box can clone from a baseline instead of +// waiting 12-15 min for the in-image docker build. +const BASELINE_SANDBOX_NAMES = { + openclaw: "openclaw-baseline", + hermes: "hermes-baseline", +} as const + function validateSandboxName(name: string) { if (!name || typeof name !== "string") throw new Error("sandbox name is required") if (name.length > 63) throw new Error("sandbox name too long (max 63 chars)") @@ -56,6 +74,23 @@ function elapsedMs(start: number) { return Date.now() - start } +// Last N chars of stderr, single-lined for journalctl-friendly logging. +// The actionable error is almost always at the tail of the stream (e.g. +// "pull access denied" on a Docker FROM failure). 2KB is enough for the +// last few hundred lines without blowing log volume. +function stderrTail(stderr: string | undefined | null, limit = 2048) { + if (!stderr) return "" + const trimmed = String(stderr).trim() + if (!trimmed) return "" + const tail = trimmed.length > limit ? trimmed.slice(-limit) : trimmed + return tail.replace(/\s+/g, " ").trim() +} + +function logStderr(tag: string, file: string, stderr: string | undefined | null) { + const excerpt = stderrTail(stderr) + if (excerpt) console.log(`[sandbox/create] ${tag} file=${file} stderrTail=${JSON.stringify(excerpt)}`) +} + function appendNote(...parts: Array) { return parts.filter(Boolean).join(" ") } @@ -214,13 +249,19 @@ function openShellGpuArgs(mode: CreateGpuMode) { return mode === "required" ? ["--gpu"] : [] } -function buildNemoClawCreateCommand(gpuMode: CreateGpuMode, agent: NemoClawAgent): NemoClawCreateCommand { +function buildNemoClawCreateCommand(gpuMode: CreateGpuMode, agent: NemoClawAgent, sandboxName?: string): NemoClawCreateCommand { if (NEMOCLAW_BIN && commandExists(NEMOCLAW_BIN)) { + // Forward the operator-supplied sandbox name to `nemoclaw onboard --name`. + // Without this, nemoclaw silently picks its default (`my-assistant`), + // creates a sandbox under THAT name, and the controller's subsequent + // `openshell sandbox get ` polls fail forever. + const nameArgs = sandboxName ? ["--name", sandboxName] : [] const args = [ "onboard", "--non-interactive", "--recreate-sandbox", "--yes-i-accept-third-party-software", + ...nameArgs, ...nemoClawAgentArgs(agent), ...nemoClawGpuArgs(gpuMode), ] @@ -319,6 +360,31 @@ async function listOpenShellSandboxNames() { } } +async function getBaselineSandboxesStatus() { + const liveNames = new Set(await listOpenShellSandboxNames()) + // Only openclaw/hermes have baseline images in this fork; upstream widened + // NemoClawAgent to include langchain-deepagents-code, which has no baseline, + // so narrow the iterated set to the agents present in BASELINE_SANDBOX_NAMES. + const agents: Array<"openclaw" | "hermes"> = ["openclaw", "hermes"] + return Object.fromEntries(agents.map((agent) => { + const name = BASELINE_SANDBOX_NAMES[agent] + return [agent, { name, available: liveNames.has(name) }] + })) as Record +} + +async function resolveSourceDockerImage(sandboxName: string): Promise { + try { + const { stdout } = await execFileAsync(DOCKER_BIN, [ + "ps", + "--filter", `name=openshell-${sandboxName}`, + "--format", "{{.Image}}", + ], { env: hostCommandEnv(), timeout: 10000, maxBuffer: 1024 * 1024 }) + return String(stdout).trim().split(/\r?\n/)[0] || null + } catch { + return null + } +} + async function resolveSourcePodImageFromRef(sourceSandboxRef: string) { const requested = sourceSandboxRef.trim() const source = await resolveSandboxRef(requested) @@ -326,6 +392,7 @@ async function resolveSourcePodImageFromRef(sourceSandboxRef: string) { const sourceImage = await readPodImage(sourceName, '{.spec.containers[?(@.name=="agent")].image}') .catch(() => null) || await readPodImage(sourceName, "{.spec.containers[0].image}").catch(() => null) + || await resolveSourceDockerImage(sourceName) if (!sourceImage) { throw new Error(`Could not resolve the running image for source sandbox '${sourceName}'.`) @@ -339,22 +406,36 @@ async function resolveSourcePodImageFromRef(sourceSandboxRef: string) { } } -async function resolveSourcePodImage(sourceSandboxRef: string | null | undefined, targetSandboxName: string) { +function agentDisplayName(agent: QuickDeployAgent) { + if (agent === "hermes") return "Hermes" + if (agent === "openclaw") return "OpenClaw" + return "Custom" +} + +async function resolveSourcePodImage( + sourceSandboxRef: string | null | undefined, + targetSandboxName: string, + requestedAgent: QuickDeployAgent | null, + imageMap: SandboxImageMap, +) { if (sourceSandboxRef && typeof sourceSandboxRef === "string" && sourceSandboxRef.trim()) { return await resolveSourcePodImageFromRef(sourceSandboxRef) } const registry = readNemoClawRegistry() const registeredEntries = Object.entries(registry.sandboxes ?? {}) - const newestRegisteredNames = registeredEntries .sort(([, a], [, b]) => String(b?.createdAt ?? "").localeCompare(String(a?.createdAt ?? ""))) - .map(([key, value]) => value?.name || key) + + const agentFilter: QuickDeployAgent | null = + requestedAgent === "hermes" || requestedAgent === "openclaw" || requestedAgent === "custom" ? requestedAgent : null const liveNames = await listOpenShellSandboxNames() - const candidates = Array.from(new Set([ + const seeds = [ registry.defaultSandbox || undefined, - ...newestRegisteredNames, + ...registeredEntries.map(([key, value]) => value?.name || key), ...liveNames, - ].filter((candidate): candidate is string => Boolean(candidate && candidate !== targetSandboxName)))) + ].filter((candidate): candidate is string => Boolean(candidate && candidate !== targetSandboxName)) + + const { candidates } = bucketCandidatesByAgent(seeds, registry as RegistryShape, agentFilter, imageMap) for (const candidate of candidates) { try { @@ -364,7 +445,19 @@ async function resolveSourcePodImage(sourceSandboxRef: string | null | undefined } } - throw new Error("No running NemoClaw sandbox image was found for quick deploy. Create one Fresh NemoClaw Image first, or pass sourceSandboxName explicitly.") + throw new Error( + agentFilter + ? `No running ${agentDisplayName(agentFilter)} sandbox image was found for quick deploy. Create a Fresh ${agentDisplayName(agentFilter)} sandbox first, or pass sourceSandboxName explicitly.` + : "No running NemoClaw sandbox image was found for quick deploy. Create one Fresh NemoClaw Image first, or pass sourceSandboxName explicitly.", + ) +} + +async function exportSandboxPolicyToFile(sourceSandboxName: string): Promise { + return exportPolicy( + sourceSandboxName, + OPENSHELL_BIN, + hostCommandEnv({ OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY || "nemoclaw" }), + ) } function registerNemoClawImageRedeploy(sourceName: string, sandboxName: string) { @@ -411,6 +504,28 @@ function registerNemoClawImageRedeploy(sourceName: string, sandboxName: string) } } +function patchNemoClawRegistryAgent(sandboxName: string, agent: string) { + try { + const current = existsSync(NEMOCLAW_REGISTRY_FILE) + ? JSON.parse(readFileSync(NEMOCLAW_REGISTRY_FILE, "utf8")) + : {} + const sandboxes = current && typeof current.sandboxes === "object" && current.sandboxes !== null + ? current.sandboxes + : {} + const entry = sandboxes[sandboxName] + if (!entry || entry.agent === agent) return { ok: true as const, patched: false } + sandboxes[sandboxName] = { ...entry, agent } + const next = { ...current, sandboxes } + mkdirSync(path.dirname(NEMOCLAW_REGISTRY_FILE), { recursive: true }) + const tempPath = `${NEMOCLAW_REGISTRY_FILE}.tmp.${process.pid}.${Date.now()}` + writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 }) + renameSync(tempPath, NEMOCLAW_REGISTRY_FILE) + return { ok: true as const, patched: true } + } catch (error) { + return { ok: false as const, error: error instanceof Error ? error.message : "Failed to patch NemoClaw registry agent" } + } +} + async function verifySandboxCreation(sandboxName: string): Promise { const startedAt = Date.now() console.log(`[sandbox/create] verify:start sandbox=${sandboxName}`) @@ -466,6 +581,7 @@ async function runCommand(file: string, args: string[], env: NodeJS.ProcessEnv, console.log( `[sandbox/create] command:error file=${file} elapsedMs=${elapsedMs(startedAt)} message=${message} stdoutBytes=${String(error?.stdout || "").length} stderrBytes=${String(error?.stderr || "").length}`, ) + logStderr("command:error-stderr", file, error?.stderr) return { ok: false as const, stdout: String(error?.stdout || "").trim(), @@ -523,6 +639,7 @@ async function runCreateCommandBounded(file: string, args: string[], env: NodeJS child.on("error", (error) => { const message = error instanceof Error ? error.message : String(error ?? "Command failed") console.log(`[sandbox/create] bounded-command:error file=${file} elapsedMs=${elapsedMs(startedAt)} message=${message}`) + logStderr("bounded-command:error-stderr", file, stderr) finish({ completed: false, timedOut: false, @@ -536,6 +653,7 @@ async function runCreateCommandBounded(file: string, args: string[], env: NodeJS child.on("close", (code, signal) => { console.log(`[sandbox/create] bounded-command:close file=${file} elapsedMs=${elapsedMs(startedAt)} code=${code} signal=${signal} stdoutBytes=${stdout.length} stderrBytes=${stderr.length}`) + if (code !== 0) logStderr("bounded-command:close-stderr", file, stderr) finish({ completed: true, timedOut: false, @@ -569,7 +687,7 @@ async function runCreateCommandBounded(file: string, args: string[], env: NodeJS }) } -async function runCreateCommandUntilReady(file: string, args: string[], env: NodeJS.ProcessEnv, sandboxName: string, timeoutMs: number, intervalMs: number) { +async function runCreateCommandUntilReady(file: string, args: string[], env: NodeJS.ProcessEnv, sandboxName: string, timeoutMs: number, intervalMs: number, cwd?: string) { const startedAt = Date.now() console.log(`[sandbox/create] ready-command:start file=${file} args=${JSON.stringify(args)} timeoutMs=${timeoutMs}`) @@ -586,6 +704,7 @@ async function runCreateCommandUntilReady(file: string, args: string[], env: Nod }>((resolve) => { const child = spawn(file, args, { env, + cwd, stdio: ["ignore", "pipe", "pipe"], }) @@ -623,6 +742,7 @@ async function runCreateCommandUntilReady(file: string, args: string[], env: Nod child.on("error", (error) => { const message = error instanceof Error ? error.message : String(error ?? "Command failed") console.log(`[sandbox/create] ready-command:error file=${file} elapsedMs=${elapsedMs(startedAt)} message=${message}`) + logStderr("ready-command:error-stderr", file, stderr) finish({ completed: false, timedOut: false, @@ -637,6 +757,7 @@ async function runCreateCommandUntilReady(file: string, args: string[], env: Nod child.on("close", (code, signal) => { console.log(`[sandbox/create] ready-command:close file=${file} elapsedMs=${elapsedMs(startedAt)} code=${code} signal=${signal} stdoutBytes=${stdout.length} stderrBytes=${stderr.length}`) + if (code !== 0) logStderr("ready-command:close-stderr", file, stderr) finish({ completed: true, timedOut: false, @@ -732,6 +853,78 @@ async function approveOpenClawDeviceRequests(sandboxName: string) { } } +// ── FORK-ONLY (BYOVPS): see CLAUDE.md §3 conflict table + §11 token architecture. +// This function does not exist in upstream — it was added by the fork to seed +// the OpenClaw gateway token at sandbox creation. Verification logic added on +// top of that to close the doctor-rotation race documented in §11. +async function ensureOpenClawGatewayToken(sandboxName: string) { + // 1. Run doctor (may rotate the JSON token + trigger gateway restart) + // 2. Read the token from JSON + // 3. Verify the token is accepted by the live gateway via a WS handshake + // against the gateway's in-sandbox port (18789). Retry up to 10× over + // ~15 s to cover the gateway-restart window. If the gateway never + // accepts the JSON token, the dashboard would fail with "Auth did not + // match" — surface that here so the caller can react (eg log + alert) + // instead of letting the user discover it in the browser. + // The verification is harmless when the gateway already accepts the token + // (single WS handshake, ~50 ms). The retry only fires on the slow path. + const script = [ + "openclaw doctor --generate-gateway-token >/dev/null 2>&1 || true", + 'token=$(node -e \'const fs=require("fs"); try { const c=JSON.parse(fs.readFileSync("/sandbox/.openclaw/openclaw.json","utf8")); process.stdout.write(String(c?.gateway?.auth?.token||c?.gateway?.token||"")); } catch(e) {}\')', + // Poll the gateway WS handshake up to 10× to confirm the JSON token is live. + // The gateway's in-sandbox port is OPENCLAW_GATEWAY_PORT (default 18789). + 'gw_port="${OPENCLAW_GATEWAY_PORT:-18789}"', + 'gw_accepted=0', + 'for _i in $(seq 1 10); do', + ' code=$(curl -sf -m 2 -o /dev/null -w "%{http_code}" \\', + ' -H "Connection: Upgrade" -H "Upgrade: websocket" \\', + ' -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \\', + ' -H "Authorization: Bearer ${token}" \\', + ' "http://127.0.0.1:${gw_port}/?token=${token}" 2>/dev/null)', + ' if [ "$code" = "101" ]; then gw_accepted=1; break; fi', + ' sleep 1.5', + 'done', + 'printf "%s\\n%s" "$token" "$gw_accepted"', + ].join("; ") + + const result = await runCreateCommandBounded(OPENSHELL_BIN, [ + "sandbox", + "exec", + "-n", + sandboxName, + "--", + "sh", + "-lc", + script, + ], hostCommandEnv({ + OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY || "nemoclaw", + }), 60000) + + // The script prints "\n" — the second line is "1" when + // the gateway has accepted the token (= dashboard will not show "Auth did + // not match" on first open). + const lines = (result.stdout || "").split(/\r?\n/) + const token = (lines[0] || "").trim() + const gatewayAccepted = (lines[1] || "").trim() === "1" + const tokenPresent = Boolean(token) && !/\s/.test(token) + + return { + attempted: true, + tokenPresent, + gatewayAccepted, + completed: result.completed, + timedOut: result.timedOut, + exitCode: result.exitCode, + signal: result.signal, + error: result.error, + note: !tokenPresent + ? "Tried to generate OpenClaw gateway auth token but the sandbox config still has no token; dashboard proxy will fail until this is fixed." + : gatewayAccepted + ? "Ensured OpenClaw gateway auth token in /sandbox/.openclaw/openclaw.json and confirmed the live gateway accepts it." + : "OpenClaw gateway auth token written to /sandbox/.openclaw/openclaw.json but the live gateway did not accept it within 15 s. Dashboard may show 'Auth did not match' on first open until the gateway restarts.", + } +} + async function waitForSandboxReady(sandboxName: string, timeoutMs: number, intervalMs: number) { const startedAt = Date.now() let lastVerification: SandboxVerification | null = null @@ -761,8 +954,10 @@ async function waitForSandboxReady(sandboxName: string, timeoutMs: number, inter } export async function GET() { + const baselineStatus = await getBaselineSandboxesStatus() return NextResponse.json({ ok: true, + baselineSandboxes: baselineStatus, blueprints: [ { id: "nemoclaw-blueprint", @@ -771,6 +966,7 @@ export async function GET() { type: "blueprint", source: "~/NemoClaw/nemoclaw-blueprint/blueprint.yaml", supportsTailscale: true, + baseline: baselineStatus.openclaw, }, { id: "nemoclaw-hermes", @@ -779,6 +975,7 @@ export async function GET() { type: "blueprint", source: "~/NemoClaw/agents/hermes/Dockerfile", supportsTailscale: false, + baseline: baselineStatus.hermes, }, { id: "nemoclaw-deepagents-code", @@ -836,7 +1033,7 @@ export async function POST(request: Request) { if (isNemoClawOnboardBlueprint(blueprint)) { const agent = nemoClawAgentForBlueprint(blueprint) const isOpenClawAgent = agent === "openclaw" - const createCommand = buildNemoClawCreateCommand(gpuMode, agent) + const createCommand = buildNemoClawCreateCommand(gpuMode, agent, sandboxName) const env: NodeJS.ProcessEnv = hostCommandEnv({ NEMOCLAW_SANDBOX_NAME: sandboxName, NEMOCLAW_AGENT: agent, @@ -846,6 +1043,16 @@ export async function POST(request: Request) { OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY || "nemoclaw", }) + // When Ollama is configured, route NemoClaw to the local Ollama provider + // so it never falls back to NVIDIA NIM even if NEMOCLAW_PROVIDER was + // omitted from .env.local. + if (process.env.OLLAMA_BASE_URL && !env.NEMOCLAW_PROVIDER) { + env.NEMOCLAW_PROVIDER = "ollama" + if (process.env.OLLAMA_MODEL && !env.NEMOCLAW_MODEL) { + env.NEMOCLAW_MODEL = process.env.OLLAMA_MODEL + } + } + if (!enableTailscale) { env.NVIDIA_INFERENCE_API_KEY = env.NVIDIA_INFERENCE_API_KEY || env.NVIDIA_API_KEY || "optional-local-mode" env.NVIDIA_API_KEY = env.NVIDIA_API_KEY || env.NVIDIA_INFERENCE_API_KEY @@ -853,14 +1060,48 @@ export async function POST(request: Request) { applyCreateInferenceEnv(env, createInference, body) - const result = await runCommand( - createCommand.file, - createCommand.mode === "legacy-setup" ? [...createCommand.args, sandboxName] : createCommand.args, - env, - NEMOCLAW_CWD, - ) + // OpenClaw: SIGTERM as soon as the sandbox is Ready — this skips nemoclaw's step 8/8 + // policy application which reliably times out and exits 1 even on success. + // Hermes: must run to completion. The agent_setup step (after sandbox-ready) is where + // nemoclaw configures the Hermes API server inside the sandbox; killing early leaves + // Hermes half-installed and unregistered. The fallback below still tolerates a + // non-zero exit if the sandbox itself comes up Ready. + const createCommandArgs = createCommand.mode === "legacy-setup" + ? [...createCommand.args, sandboxName] + : createCommand.args + // First-time NemoClaw image builds are slow — the in-image docker build is + // 80+ steps including a full NPM install and OpenClaw npm install/build. + // On under-provisioned VPSs (4 vCPU / 8 GiB, no swap) this routinely + // takes 12-15 minutes. The previous 10 / 15 min ceilings caused our + // bounded command to SIGTERM the onboard mid-build, leaving the sandbox + // in a half-baked state. Bump both paths to 20 minutes. Subsequent + // builds reuse the cached layers and finish in ~30 seconds — the cap + // only matters for the very first sandbox on a fresh box. + const FIRST_BUILD_TIMEOUT_MS = 20 * 60 * 1000 + const result = agent === "hermes" + ? await runCreateCommandBounded(createCommand.file, createCommandArgs, env, FIRST_BUILD_TIMEOUT_MS) + : await runCreateCommandUntilReady( + createCommand.file, + createCommandArgs, + env, + sandboxName, + FIRST_BUILD_TIMEOUT_MS, + 5000, + NEMOCLAW_CWD, + ) + + // If the command exited non-zero before the sandbox was detected as Ready, do one + // final readiness poll before giving up — the sandbox may have just beaten the interval. + const readiness = await waitForSandboxReady(sandboxName, 90000, 2000) + const verification = readiness.verification ?? { + verified: false, + summary: "Sandbox readiness polling produced no verification result.", + error: "Sandbox readiness polling produced no verification result.", + } + const created = readiness.verified + if (created) patchNemoClawRegistryAgent(sandboxName, agent) - if (!result.ok) { + if (!created && result.error && !result.timedOut) { await recordCreateActivity({ type: "sandbox.create.error", status: "error", @@ -882,21 +1123,40 @@ export async function POST(request: Request) { }, { status: 500 }) } - const readiness = await waitForSandboxReady(sandboxName, 90000, 2000) - const verification = readiness.verification ?? { - verified: false, - summary: "Sandbox readiness polling produced no verification result.", - error: "Sandbox readiness polling produced no verification result.", - } - const created = readiness.verified const execApprovalsRepair = created && isOpenClawAgent ? await repairOpenClawExecApprovalsFile(sandboxName).catch((error) => ({ sandboxName, path: "/sandbox/.openclaw/exec-approvals.json", error: error instanceof Error ? error.message : "Failed to repair OpenClaw exec approvals file", })) : null const deviceApproval = created && isOpenClawAgent ? await approveOpenClawDeviceRequests(sandboxName) : null + const gatewayToken = created && isOpenClawAgent ? await ensureOpenClawGatewayToken(sandboxName).catch((error) => ({ + attempted: true, + tokenPresent: false, + gatewayAccepted: false, + completed: false, + timedOut: false, + exitCode: null as number | null, + signal: null as string | null, + error: error instanceof Error ? error.message : "Failed to ensure OpenClaw gateway auth token.", + note: "Failed to ensure OpenClaw gateway auth token; dashboard proxy will fail until this is fixed.", + })) : null + // Pre-build the Hermes dashboard web UI dependencies on sandbox creation. + const hermesDashboardBuild = created && agent === "hermes" + ? await prebuildHermesDashboardWebUi(sandboxName).catch((error) => ({ + built: false, + skipped: false, + error: error instanceof Error ? error.message : "Hermes dashboard web UI pre-build failed", + })) + : null + // Expose the dashboard for the Hermes Desktop app (HERMES_REMOTE_MODE + // gates this; non-fatal so a proxy/Traefik hiccup never fails creation — + // the UI offers a retry via POST /api/sandbox//hermes-remote). + const hermesRemote = created && agent === "hermes" && hermesRemoteMode() !== "off" + ? await exposeHermesRemote(sandboxName) + : null + const forcedReady = "forcedReady" in result ? result.forcedReady : false console.log( - `[sandbox/create] request:complete sandbox=${sandboxName} created=${created} agent=${agent} readinessAttempts=${readiness.attempts} deviceApproval=${deviceApproval?.approved ?? false} elapsedMs=${elapsedMs(requestStartedAt)}`, + `[sandbox/create] request:complete sandbox=${sandboxName} created=${created} agent=${agent} forcedReady=${forcedReady} readinessAttempts=${readiness.attempts} deviceApproval=${deviceApproval?.approved ?? false} elapsedMs=${elapsedMs(requestStartedAt)}`, ) await recordCreateActivity({ type: created ? "sandbox.create.success" : "sandbox.create.warning", @@ -921,7 +1181,13 @@ export async function POST(request: Request) { enableTailscale, gpuMode, createInference, - createCommand, + createCommand: { + ...createCommand, + forcedReady, + timedOut: result.timedOut, + exitCode: result.exitCode, + signal: result.signal, + }, hostPath: HOST_PATH, readiness: { attempts: readiness.attempts, @@ -929,15 +1195,20 @@ export async function POST(request: Request) { }, execApprovalsRepair, deviceApproval, + gatewayToken, + hermesDashboardBuild, + hermesRemote, stdout: result.stdout, stderr: result.stderr, note: created ? appendNote( agent === "hermes" ? "NemoClaw Hermes workflow completed. Hermes exposes an API endpoint from the sandbox rather than an OpenClaw browser dashboard." - : enableTailscale - ? "NemoClaw blueprint workflow completed with Tailscale-enabled prerequisites. Existing healthy OpenShell gateways are reused before any new gateway start is attempted." - : "NemoClaw blueprint workflow completed in local/default mode. Existing healthy OpenShell gateways are reused before any new gateway start is attempted.", + : forcedReady + ? "NemoClaw blueprint workflow: sandbox reached Ready state and the onboard command was stopped early." + : enableTailscale + ? "NemoClaw blueprint workflow completed with Tailscale-enabled prerequisites. Existing healthy OpenShell gateways are reused before any new gateway start is attempted." + : "NemoClaw blueprint workflow completed in local/default mode. Existing healthy OpenShell gateways are reused before any new gateway start is attempted.", gpuMode === "none" ? "GPU passthrough was disabled for this create run." : gpuMode === "required" @@ -992,9 +1263,10 @@ export async function POST(request: Request) { error: error instanceof Error ? error.message : "Failed to repair OpenClaw exec approvals file", })) : null const deviceApproval = created ? await approveOpenClawDeviceRequests(sandboxName) : null + const gatewayToken = created ? await ensureOpenClawGatewayToken(sandboxName).catch(() => null) : null const policyPrepared = Boolean(policy) console.log( - `[sandbox/create] request:complete sandbox=${sandboxName} created=${created} createTimedOut=${createAttempt.timedOut} readinessAttempts=${readiness.attempts} deviceApproval=${deviceApproval?.approved ?? false} elapsedMs=${elapsedMs(requestStartedAt)}`, + `[sandbox/create] request:complete sandbox=${sandboxName} created=${created} createTimedOut=${createAttempt.timedOut} readinessAttempts=${readiness.attempts} deviceApproval=${deviceApproval?.approved ?? false} gatewayTokenPresent=${gatewayToken?.tokenPresent ?? false} elapsedMs=${elapsedMs(requestStartedAt)}`, ) await recordCreateActivity({ type: created ? "sandbox.create.success" : "sandbox.create.warning", @@ -1030,6 +1302,7 @@ export async function POST(request: Request) { }, execApprovalsRepair, deviceApproval, + gatewayToken, policyPrepared, note: created ? appendNote( @@ -1051,27 +1324,57 @@ export async function POST(request: Request) { if (blueprint === "redeploy-image") { const sourceSandboxName = typeof body?.sourceSandboxName === "string" ? body.sourceSandboxName.trim() : "" - const source = await resolveSourcePodImage(sourceSandboxName, sandboxName) + const requestedAgentRaw = typeof body?.agent === "string" ? body.agent.trim().toLowerCase() : "" + const requestedAgent: QuickDeployAgent | null = + requestedAgentRaw === "hermes" ? "hermes" : + requestedAgentRaw === "openclaw" ? "openclaw" : + requestedAgentRaw === "custom" ? "custom" : null + const imageMap = await readSandboxContainerImageMap() + const source = await resolveSourcePodImage(sourceSandboxName, sandboxName, requestedAgent, imageMap) const sourceImage = source.image - const basePolicyPath = resolveNemoClawBasePolicyPath() + const isCustomAgent = requestedAgent === "custom" + // For OpenClaw/Hermes clones, inherit the source's effective policy. + // Hermes sandboxes need /opt/hermes in the read_only set (which the + // static openclaw-sandbox.yaml template lacks); without this, Landlock + // denies execute on the hermes binary in the redeployed sandbox. Fall + // back to the static policy if export fails. For Custom clones we + // intentionally skip --policy (and -- nemoclaw-start) — bare openshell + // sandboxes don't carry a NemoClaw policy and shouldn't start under + // NemoClaw's runtime hook. + const inheritedPolicyPath = isCustomAgent ? null : await exportSandboxPolicyToFile(source.name) + const basePolicyPath = isCustomAgent ? null : (inheritedPolicyPath ?? resolveNemoClawBasePolicyPath()) + const policySource: "source-sandbox" | "base-template" | "none" = isCustomAgent + ? "none" + : inheritedPolicyPath ? "source-sandbox" : "base-template" const env: NodeJS.ProcessEnv = hostCommandEnv({ OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY || "nemoclaw", }) - const createAttempt = await runCreateCommandUntilReady(OPENSHELL_BIN, [ - "sandbox", - "create", - "--name", - sandboxName, - ...openShellGpuArgs(gpuMode), - "--from", - sourceImage, - "--policy", - basePolicyPath, - "--auto-providers", - "--", - "nemoclaw-start", - ], env, sandboxName, 120000, 2000) + const createArgs = isCustomAgent + ? [ + "sandbox", + "create", + "--name", + sandboxName, + ...openShellGpuArgs(gpuMode), + "--from", + sourceImage, + ] + : [ + "sandbox", + "create", + "--name", + sandboxName, + ...openShellGpuArgs(gpuMode), + "--from", + sourceImage, + "--policy", + basePolicyPath as string, + "--auto-providers", + "--", + "nemoclaw-start", + ] + const createAttempt = await runCreateCommandUntilReady(OPENSHELL_BIN, createArgs, env, sandboxName, 120000, 2000) const readiness = createAttempt.readyVerification?.verified ? { @@ -1087,15 +1390,28 @@ export async function POST(request: Request) { error: "Sandbox readiness polling produced no verification result.", } const created = readiness.verified - const registry = created ? registerNemoClawImageRedeploy(source.name, sandboxName) : null - const execApprovalsRepair = created ? await repairOpenClawExecApprovalsFile(sandboxName).catch((error) => ({ + const registry = (created && !isCustomAgent) ? registerNemoClawImageRedeploy(source.name, sandboxName) : null + const sourceAgent: QuickDeployAgent = (() => { + const map = readNemoClawRegistry().sandboxes ?? {} + const entry = (map[source.name] ?? map[source.id ?? ""]) as { agent?: string } | undefined + const value = typeof entry?.agent === "string" ? entry.agent.trim() : "" + if (value === "hermes" || value === "openclaw") return value + // No registry entry → check image to disambiguate Custom from "ambiguous NemoClaw". + const image = imageMap.get(source.name) + if (image && !/openshell\/sandbox-from/i.test(image)) return "custom" + return "openclaw" + })() + const effectiveAgent: QuickDeployAgent = requestedAgent ?? sourceAgent + const isOpenClawAgent = effectiveAgent === "openclaw" + const execApprovalsRepair = created && isOpenClawAgent ? await repairOpenClawExecApprovalsFile(sandboxName).catch((error) => ({ sandboxName, path: "/sandbox/.openclaw/exec-approvals.json", error: error instanceof Error ? error.message : "Failed to repair OpenClaw exec approvals file", })) : null - const deviceApproval = created ? await approveOpenClawDeviceRequests(sandboxName) : null + const deviceApproval = created && isOpenClawAgent ? await approveOpenClawDeviceRequests(sandboxName) : null + const gatewayToken = created && isOpenClawAgent ? await ensureOpenClawGatewayToken(sandboxName).catch(() => null) : null console.log( - `[sandbox/create] request:complete sandbox=${sandboxName} created=${created} mode=redeploy-image createTimedOut=${createAttempt.timedOut} readinessAttempts=${readiness.attempts} deviceApproval=${deviceApproval?.approved ?? false} elapsedMs=${elapsedMs(requestStartedAt)}`, + `[sandbox/create] request:complete sandbox=${sandboxName} created=${created} mode=redeploy-image agent=${effectiveAgent} policySource=${policySource} createTimedOut=${createAttempt.timedOut} readinessAttempts=${readiness.attempts} deviceApproval=${deviceApproval?.approved ?? false} gatewayTokenPresent=${gatewayToken?.tokenPresent ?? false} elapsedMs=${elapsedMs(requestStartedAt)}`, ) const createFailed = createAttempt.completed && createAttempt.exitCode !== 0 && !created @@ -1119,6 +1435,8 @@ export async function POST(request: Request) { sourceSandboxId: source.id, sourceImage, basePolicyPath, + policySource, + agent: effectiveAgent, created, verified: verification.verified, verification, @@ -1141,6 +1459,7 @@ export async function POST(request: Request) { registry, execApprovalsRepair, deviceApproval, + gatewayToken, note: created ? appendNote( `Sandbox created by redeploying the running image from '${source.name}' instead of rebuilding it.`, diff --git a/app/api/sandbox/delete/route.ts b/app/api/sandbox/delete/route.ts index 95b9f62..28ce743 100644 --- a/app/api/sandbox/delete/route.ts +++ b/app/api/sandbox/delete/route.ts @@ -3,6 +3,7 @@ import { execFile } from "node:child_process" import { promisify } from "node:util" import { resolveSandboxRef } from "@/app/lib/openshellHost" import { OPENSHELL_BIN, hostCommandEnv } from "@/app/lib/hostCommands" +import { readHermesRemoteAccess, unexposeHermesRemote } from "@/app/lib/hermesRemote" const execFileAsync = promisify(execFile) @@ -224,6 +225,19 @@ export async function POST(request: Request) { try { const body = await request.json() const target = await resolveDeleteTarget(parseDeleteTarget(body), parseDeleteAgent(body)) + + // Tear down the Hermes remote-desktop exposure (Traefik rule, forward + // unit, UFW, access record) before the sandbox goes away. Best effort: + // a teardown failure must not block the delete itself. + let hermesRemoteTeardown: { ok: boolean; error?: string } | null = null + if (readHermesRemoteAccess(target.sandboxName)) { + hermesRemoteTeardown = await unexposeHermesRemote(target.sandboxName) + if (!hermesRemoteTeardown.ok) { + console.log(`[sandbox/delete] hermes-remote teardown failed sandbox=${target.sandboxName} error=${hermesRemoteTeardown.error}`) + } + } + + // Upstream #26: wipe persistent sandbox state before delete. const stateWipe = await wipePersistentSandboxState(target.sandboxName, target.agent) const result = await deleteSandbox(target.sandboxName) const deleteOutput = [result.error, result.stdout, result.stderr].filter(Boolean).join("\n") @@ -261,6 +275,7 @@ export async function POST(request: Request) { stateWipe, openShell: result, deletion, + hermesRemoteTeardown, note: deleted ? "Sandbox delete completed." : "Sandbox delete command completed, but inventory still reports the sandbox.", }, { status: deleted ? 200 : 202 }) } catch (error) { diff --git a/app/api/security/sandbox-access/route.ts b/app/api/security/sandbox-access/route.ts new file mode 100644 index 0000000..4ef7137 --- /dev/null +++ b/app/api/security/sandbox-access/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server" +import { isOperator } from "@/app/lib/auth/context" +import { + listSandboxAccessEntries, + replaceSandboxAccessEntries, + type SandboxAccessEntry, +} from "@/app/lib/auth/sandboxAccessStore" + +const EMAIL_RE = /^[^\s,:@]+@[^\s,:@]+\.[^\s,:@]+$/ +const SANDBOX_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/ + +export async function GET(request: NextRequest) { + if (!(await isOperator(request))) { + return NextResponse.json({ ok: false, error: "Operator session required." }, { status: 401 }) + } + return NextResponse.json({ ok: true, entries: listSandboxAccessEntries() }) +} + +export async function POST(request: NextRequest) { + if (!(await isOperator(request))) { + return NextResponse.json({ ok: false, error: "Operator session required." }, { status: 401 }) + } + + const body = await request.json().catch(() => ({})) + const rawEntries = Array.isArray(body?.entries) ? body.entries : null + if (!rawEntries) { + return NextResponse.json({ ok: false, error: "Body must include an `entries` array." }, { status: 400 }) + } + + const seen = new Set() + const normalized: SandboxAccessEntry[] = [] + for (const raw of rawEntries) { + const sandboxName = typeof raw?.sandboxName === "string" ? raw.sandboxName.trim() : "" + const email = typeof raw?.email === "string" ? raw.email.trim().toLowerCase() : "" + if (!sandboxName || !email) { + return NextResponse.json({ ok: false, error: "Each entry needs both sandboxName and email." }, { status: 400 }) + } + if (!SANDBOX_NAME_RE.test(sandboxName)) { + return NextResponse.json({ ok: false, error: `Invalid sandbox name: ${sandboxName}` }, { status: 400 }) + } + if (!EMAIL_RE.test(email)) { + return NextResponse.json({ ok: false, error: `Invalid email: ${email}` }, { status: 400 }) + } + const key = `${sandboxName}:${email}` + if (seen.has(key)) continue + seen.add(key) + normalized.push({ sandboxName, email }) + } + + // File-backed, atomic write. The middleware (Node runtime) reads this file + // fresh on every request, so the change takes effect immediately — no + // process restart required. + const result = replaceSandboxAccessEntries(normalized) + return NextResponse.json({ ok: true, entries: normalized, storePath: result.path, willRestart: false }) +} diff --git a/app/api/telemetry/real/route.ts b/app/api/telemetry/real/route.ts index 0491e1b..59096b2 100644 --- a/app/api/telemetry/real/route.ts +++ b/app/api/telemetry/real/route.ts @@ -6,6 +6,8 @@ import path from "node:path" import { promisify } from "node:util" import { NEMOCLAW_BIN, NODE_BIN, OPENSHELL_BIN, hostCommandEnv } from "@/app/lib/hostCommands" import { resolveRuntimeAuthority } from "@/app/lib/runtimeAuthority" +import { isUserAuthorizedForSandbox } from "@/app/lib/controlAuth" +import { isNemoClawImage, readSandboxContainerImageMap, type SandboxImageMap } from "@/app/lib/sandboxContainerImage" const execFileAsync = promisify(execFile) const NEMOCLAW_REGISTRY_FILE = path.join(process.env.HOME || "/tmp", ".nemoclaw", "sandboxes.json") @@ -163,12 +165,26 @@ function readNemoClawRegistry(): NemoClawRegistryData { } } -function resolveSandboxAgent(name: string, id: string | null, registry: NemoClawRegistryData) { +function resolveSandboxAgent( + name: string, + id: string | null, + registry: NemoClawRegistryData, + imageMap: SandboxImageMap, +) { const entries = registry.sandboxes ?? {} const directEntry = entries[name] || (id ? entries[id] : undefined) const namedEntry = Object.values(entries).find((entry) => entry?.name === name || Boolean(id && entry?.name === id)) - const agent = directEntry?.agent || namedEntry?.agent - return typeof agent === "string" && agent.trim() ? agent.trim() : "openclaw" + const registryAgent = directEntry?.agent || namedEntry?.agent + if (typeof registryAgent === "string" && registryAgent.trim()) return registryAgent.trim() + + // No registry entry → use the container image. NemoClaw-built sandboxes + // get "openclaw" as the default; bare openshell sandboxes are "custom". + const image = imageMap.get(name) + if (isNemoClawImage(image)) return "openclaw" + if (image) return "custom" + + // No image data at all (transient docker hiccup) — preserve prior behaviour. + return "openclaw" } async function execNemoclaw(args: string[]) { @@ -215,7 +231,7 @@ function readHostIdentity() { return { hostname: hostname(), address: "127.0.0.1", interface: "lo0" } } -async function readSandbox(name: string, defaultSandboxNames: Set, registry: NemoClawRegistryData): Promise<{ summary: SandboxSummary; pod: SandboxItem }> { +async function readSandbox(name: string, defaultSandboxNames: Set, registry: NemoClawRegistryData, imageMap: SandboxImageMap): Promise<{ summary: SandboxSummary; pod: SandboxItem }> { try { const [{ stdout: detailsStdout }, { stdout: sshStdout }] = await Promise.all([ execOpenShell(["sandbox", "get", name]), @@ -229,7 +245,7 @@ async function readSandbox(name: string, defaultSandboxNames: Set, regis const sshConfig = sshStdout.trim() const sshHostAlias = parseSshHostAlias(sshConfig, sandboxName) const isDefault = defaultSandboxNames.has(sandboxName) - const agent = resolveSandboxAgent(sandboxName, sandboxId, registry) + const agent = resolveSandboxAgent(sandboxName, sandboxId, registry, imageMap) return { summary: { @@ -274,7 +290,7 @@ async function readSandbox(name: string, defaultSandboxNames: Set, regis } catch (error) { const sandboxName = name const isDefault = defaultSandboxNames.has(sandboxName) - const agent = resolveSandboxAgent(sandboxName, sandboxName, registry) + const agent = resolveSandboxAgent(sandboxName, sandboxName, registry, imageMap) return { summary: { id: sandboxName, @@ -318,10 +334,16 @@ async function readSandbox(name: string, defaultSandboxNames: Set, regis } } -export async function GET() { +export async function GET(request: Request) { try { + const userEmail = request.headers.get("x-forwarded-user") const { stdout: sandboxListStdout } = await execOpenShell(["sandbox", "list"]) - const names = parseOpenShellSandboxNames(sandboxListStdout) + let names = parseOpenShellSandboxNames(sandboxListStdout) + + if (userEmail) { + names = names.filter((name) => isUserAuthorizedForSandbox(userEmail, name)) + } + const [nemoclawListResult, nemoclawStatusResult] = names.length > 0 ? await Promise.all([ execNemoclaw(["list"]).catch(() => null), @@ -330,7 +352,8 @@ export async function GET() { : [null, null] const defaultSandboxNames = parseDefaultSandboxNames(nemoclawListResult?.stdout ?? "") const registry = readNemoClawRegistry() - const results = await Promise.all(names.map((name) => readSandbox(name, defaultSandboxNames, registry))) + const imageMap: SandboxImageMap = names.length > 0 ? await readSandboxContainerImageMap() : new Map() + const results = await Promise.all(names.map((name) => readSandbox(name, defaultSandboxNames, registry, imageMap))) const sandboxes = results.map((result) => result.summary) const items = results.map((result) => result.pod) const nemoclaw = buildNemoClawSummary(nemoclawStatusResult?.stdout ?? null, defaultSandboxNames) @@ -348,6 +371,7 @@ export async function GET() { pods: { items }, nemoclaw, host: readHostIdentity(), + userEmail: userEmail || null, source: "openshell-cli", authoritySource: "runtimeAuthority", truthState: hasMappedFallbackWithoutInventory ? "unverified" : "verified", diff --git a/app/components/ActivityPanel.tsx b/app/components/ActivityPanel.tsx index 9b7a836..78d962a 100644 --- a/app/components/ActivityPanel.tsx +++ b/app/components/ActivityPanel.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useMemo, useState } from "react" type ActivityEntry = { id: string @@ -11,6 +11,9 @@ type ActivityEntry = { status?: "success" | "error" | "info" | "warning" } +const PAGE_SIZE = 10 +const FETCH_LIMIT = 100 + function toneClass(status?: ActivityEntry["status"]) { if (status === "success") return "bg-[var(--status-running-bg)] text-[var(--status-running)]" if (status === "error") return "bg-red-500/15 text-red-300" @@ -21,13 +24,17 @@ function toneClass(status?: ActivityEntry["status"]) { export default function ActivityPanel() { const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(false) + const [page, setPage] = useState(0) async function loadActivity() { try { setLoading(true) - const response = await fetch("/api/activity?limit=40", { cache: "no-store" }) + const response = await fetch(`/api/activity?limit=${FETCH_LIMIT}`, { cache: "no-store" }) const data = await response.json() - if (Array.isArray(data?.entries)) setEntries(data.entries) + if (Array.isArray(data?.entries)) { + setEntries(data.entries) + setPage(0) + } } finally { setLoading(false) } @@ -37,6 +44,15 @@ export default function ActivityPanel() { loadActivity() }, []) + const pageCount = Math.max(1, Math.ceil(entries.length / PAGE_SIZE)) + const safePage = Math.min(page, pageCount - 1) + const pageEntries = useMemo( + () => entries.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE), + [entries, safePage], + ) + const rangeStart = entries.length === 0 ? 0 : safePage * PAGE_SIZE + 1 + const rangeEnd = Math.min(entries.length, (safePage + 1) * PAGE_SIZE) + return (
@@ -55,7 +71,7 @@ export default function ActivityPanel() {
- {entries.map((entry) => ( + {pageEntries.map((entry) => (
@@ -76,6 +92,35 @@ export default function ActivityPanel() {
)}
+ + {entries.length > PAGE_SIZE && ( +
+ + Showing {rangeStart}–{rangeEnd} of {entries.length} + +
+ + + Page {safePage + 1} / {pageCount} + + +
+
+ )}
) } diff --git a/app/components/ConfigurationPanel.tsx b/app/components/ConfigurationPanel.tsx index 2d1f957..b661d66 100644 --- a/app/components/ConfigurationPanel.tsx +++ b/app/components/ConfigurationPanel.tsx @@ -16,6 +16,8 @@ interface NetworkBinary { path: string } interface NetworkPolicyBlock { key: string; name: string; endpoints: NetworkEndpoint[]; binaries: NetworkBinary[] } type OpenShellPolicy = OpenShellPolicyShape +type BaselineStatus = { name: string; available: boolean } + type BlueprintOption = { id: string label: string @@ -23,6 +25,7 @@ type BlueprintOption = { type: "blueprint" | "custom" | "image" source: string supportsTailscale?: boolean + baseline?: BaselineStatus } interface ConfigurationPanelProps { @@ -90,7 +93,9 @@ export default function ConfigurationPanel({ sandboxId, mode = 'existing', onCre const [sandboxName, setSandboxName] = useState('') const [enableTailscale, setEnableTailscale] = useState(false) const [createGpuMode, setCreateGpuMode] = useState("none") - const [createInferenceMode, setCreateInferenceMode] = useState("vllm") + const [quickDeployAgent, setQuickDeployAgent] = useState<'openclaw' | 'hermes' | 'custom'>('openclaw') + const [useBaseline, setUseBaseline] = useState(true) + const [createInferenceMode, setCreateInferenceMode] = useState("auto") const [createInferenceModel, setCreateInferenceModel] = useState("") const [createInferenceEndpointUrl, setCreateInferenceEndpointUrl] = useState("") const [createNvidiaApiKey, setCreateNvidiaApiKey] = useState("") @@ -130,6 +135,9 @@ export default function ConfigurationPanel({ sandboxId, mode = 'existing', onCre const activePreset = selectedPreset ? getSecurityPreset(selectedPreset) : null const activeBlueprint = blueprints.find((bp) => bp.id === selectedBlueprint) const isNemoClawOnboardBlueprint = selectedBlueprint === 'nemoclaw-blueprint' || selectedBlueprint === 'nemoclaw-hermes' || selectedBlueprint === 'nemoclaw-deepagents-code' + const freshBlueprintAgent: 'openclaw' | 'hermes' = selectedBlueprint === 'nemoclaw-hermes' ? 'hermes' : 'openclaw' + const freshBaseline = isNemoClawOnboardBlueprint ? activeBlueprint?.baseline : undefined + const canUseBaseline = Boolean(freshBaseline?.available) function applyPreset(presetId: SecurityPresetId) { const preset = getSecurityPreset(presetId); if (!preset) return @@ -148,7 +156,18 @@ export default function ConfigurationPanel({ sandboxId, mode = 'existing', onCre endpointUrl: createInferenceEndpointUrl.trim(), apiKey: (createInferenceMode === 'nvidia' || createInferenceMode === 'compatible') ? createNvidiaApiKey.trim() : '', } - const res = await fetch('/api/sandbox/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprint: selectedBlueprint, sandboxName: sandboxName.trim(), enableTailscale, gpuMode: createGpuMode, createInference, policy: assembledPolicy, preset: selectedPreset || null }) }) + // When the user picks a Fresh blueprint and a baseline sandbox is + // available (and they've left "use baseline" checked), rewrite the + // wire payload to the existing redeploy-image blueprint pinned to the + // baseline sandbox. The server handles --from cloning end-to-end. + const baselineActive = isNemoClawOnboardBlueprint && canUseBaseline && useBaseline + const effectiveBlueprint = baselineActive ? 'redeploy-image' : selectedBlueprint + const effectiveAgent = + effectiveBlueprint === 'redeploy-image' + ? (baselineActive ? freshBlueprintAgent : quickDeployAgent) + : undefined + const sourceSandboxName = baselineActive ? freshBaseline?.name : undefined + const res = await fetch('/api/sandbox/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ blueprint: effectiveBlueprint, sandboxName: sandboxName.trim(), enableTailscale, gpuMode: createGpuMode, createInference, policy: assembledPolicy, preset: selectedPreset || null, agent: effectiveAgent, sourceSandboxName }) }) const data = await res.json() if (!res.ok) throw new Error([data.error, data.verification?.summary, data.verification?.error, data.stdout, data.stderr].filter(Boolean).join('\n\n')) const createdSandboxId = data.verification?.details?.id || data.verification?.details?.name || data.sandboxName @@ -243,9 +262,53 @@ export default function ConfigurationPanel({ sandboxId, mode = 'existing', onCre + {isNemoClawOnboardBlueprint && freshBaseline && ( +
+
+ setUseBaseline(e.target.checked)} + disabled={!canUseBaseline} + className="mt-0.5 h-4 w-4 accent-[var(--nvidia-green)]" + /> + + + Use prebuilt baseline {canUseBaseline ? '(fast — ~30s)' : '(not available on this host)'} + + + {freshBaseline.name} + + + {canUseBaseline + ? `Skips the 12-15 min Docker rebuild by cloning the prebuilt ${freshBlueprintAgent}-baseline sandbox. Uncheck to rebuild the image layers from source.` + : `No prebuilt ${freshBlueprintAgent}-baseline sandbox is running on this host. The manidae-cloud install scripts pre-create one — without it, a full rebuild will run.`} + + +
+
+ )} {selectedBlueprint === 'redeploy-image' && ( -
-

Quick Deploy uses the default running NemoClaw image and skips the Docker rebuild. Use Fresh NemoClaw Image when you need to rebuild the image layers.

+
+

Quick Deploy clones the running image of an existing sandbox and skips the Docker rebuild. Use Fresh NemoClaw Image when you need to rebuild the image layers.

+
+
Agent
+

Pick which kind of running sandbox to clone from. The newest matching one is used as the source.

+
+
+ + + +
)} {activeBlueprint?.supportsTailscale && ( diff --git a/app/components/HermesRemotePanel.tsx b/app/components/HermesRemotePanel.tsx new file mode 100644 index 0000000..7877d84 --- /dev/null +++ b/app/components/HermesRemotePanel.tsx @@ -0,0 +1,174 @@ +"use client" +import { useCallback, useEffect, useState } from 'react' + +type HermesRemoteAccess = { + sandbox: string + mode: string + port: number + token: string + url: string + hermesVersion: string + updatedAt: string +} + +type FetchState = + | { status: 'loading' } + | { status: 'unconfigured'; mode: string } + | { status: 'ready'; access: HermesRemoteAccess; healthy: boolean | null } + | { status: 'error'; message: string } + +export default function HermesRemotePanel({ sandboxName }: { sandboxName: string }) { + const [state, setState] = useState({ status: 'loading' }) + const [tokenRevealed, setTokenRevealed] = useState(false) + const [copied, setCopied] = useState(null) + const [enabling, setEnabling] = useState(false) + + const load = useCallback(async () => { + try { + const res = await fetch(`/api/sandbox/${encodeURIComponent(sandboxName)}/hermes-remote`) + if (res.status === 404) { + const data = await res.json().catch(() => ({})) + setState({ status: 'unconfigured', mode: data?.mode || 'desktop' }) + return + } + const data = await res.json() + if (!res.ok || !data?.access) { + setState({ status: 'error', message: data?.error || `Request failed (${res.status})` }) + return + } + setState({ status: 'ready', access: data.access, healthy: null }) + // Health: the public /api/status must answer for the desktop app to work. + try { + const probe = await fetch(`${data.access.url}/api/status`, { signal: AbortSignal.timeout(8000) }) + setState({ status: 'ready', access: data.access, healthy: probe.ok }) + } catch { + setState({ status: 'ready', access: data.access, healthy: false }) + } + } catch (error) { + setState({ status: 'error', message: error instanceof Error ? error.message : 'Failed to load remote access' }) + } + }, [sandboxName]) + + useEffect(() => { + setTokenRevealed(false) + void load() + }, [load]) + + const enable = async () => { + setEnabling(true) + try { + const res = await fetch(`/api/sandbox/${encodeURIComponent(sandboxName)}/hermes-remote`, { method: 'POST' }) + const data = await res.json() + if (!res.ok) { + setState({ status: 'error', message: data?.error || `Enable failed (${res.status})` }) + return + } + await load() + } catch (error) { + setState({ status: 'error', message: error instanceof Error ? error.message : 'Enable failed' }) + } finally { + setEnabling(false) + } + } + + const copy = async (label: string, value: string) => { + try { + await navigator.clipboard.writeText(value) + setCopied(label) + window.setTimeout(() => setCopied((current) => (current === label ? null : current)), 1800) + } catch { + // Clipboard unavailable; the value is visible for manual copy. + } + } + + if (state.status === 'loading') { + return

Loading remote desktop access…

+ } + + if (state.status === 'unconfigured') { + if (state.mode === 'off') { + return ( +

+ Remote desktop exposure is disabled on this deployment (HERMES_REMOTE_MODE=off). +

+ ) + } + return ( +
+

+ This Hermes sandbox is not yet exposed for the Hermes Desktop app. +

+ +
+ ) + } + + if (state.status === 'error') { + return ( +
+

{state.message}

+ +
+ ) + } + + const { access, healthy } = state + const maskedToken = `${access.token.slice(0, 4)}…${access.token.slice(-4)}` + + return ( +
+
+
+ Remote URL + {access.url} + +
+
+ Token + {tokenRevealed ? access.token : maskedToken} + + +
+
+ Status + {healthy === null ? ( + checking… + ) : healthy ? ( + ● Reachable + ) : ( + ● Unreachable — try “Retry exposure” below + )} + Backend {access.hermesVersion} · {access.mode} mode +
+
+ +
+

Hermes Desktop setup

+
    +
  1. Settings → Gateway → choose Remote gateway
  2. +
  3. Paste the Remote URL, wait for the probe, then paste the Session token
  4. +
  5. Save and reconnect
  6. +
+

+ The desktop app must run the same Hermes version as the backend ({access.hermesVersion}). +

+
+ + {healthy === false && ( + + )} +
+ ) +} diff --git a/app/components/OpenClawRemotePanel.tsx b/app/components/OpenClawRemotePanel.tsx new file mode 100644 index 0000000..86234a3 --- /dev/null +++ b/app/components/OpenClawRemotePanel.tsx @@ -0,0 +1,172 @@ +"use client" +import { useCallback, useEffect, useState } from 'react' + +type OpenClawRemoteAccess = { + sandbox: string + gatewayPort: number + hostPort: number + token: string + host: string + port: number + url: string + updatedAt: string +} + +type FetchState = + | { status: 'loading' } + | { status: 'unconfigured' } + | { status: 'ready'; access: OpenClawRemoteAccess; healthy: boolean | null } + | { status: 'error'; message: string } + +export default function OpenClawRemotePanel({ sandboxName }: { sandboxName: string }) { + const [state, setState] = useState({ status: 'loading' }) + const [tokenRevealed, setTokenRevealed] = useState(false) + const [copied, setCopied] = useState(null) + const [enabling, setEnabling] = useState(false) + + const load = useCallback(async () => { + try { + const res = await fetch(`/api/sandbox/${encodeURIComponent(sandboxName)}/openclaw-remote`) + if (res.status === 404) { + setState({ status: 'unconfigured' }) + return + } + const data = await res.json() + if (!res.ok || !data?.access) { + setState({ status: 'error', message: data?.error || `Request failed (${res.status})` }) + return + } + // Reachability is determined server-side (the route probes the gateway — + // a browser fetch to the per-sandbox subdomain would be CORS-blocked). + setState({ + status: 'ready', + access: data.access, + healthy: typeof data.reachable === 'boolean' ? data.reachable : null, + }) + } catch (error) { + setState({ status: 'error', message: error instanceof Error ? error.message : 'Failed to load remote access' }) + } + }, [sandboxName]) + + useEffect(() => { + setTokenRevealed(false) + void load() + }, [load]) + + const enable = async () => { + setEnabling(true) + try { + const res = await fetch(`/api/sandbox/${encodeURIComponent(sandboxName)}/openclaw-remote`, { method: 'POST' }) + const data = await res.json() + if (!res.ok) { + setState({ status: 'error', message: data?.error || `Enable failed (${res.status})` }) + return + } + await load() + } catch (error) { + setState({ status: 'error', message: error instanceof Error ? error.message : 'Enable failed' }) + } finally { + setEnabling(false) + } + } + + const copy = async (label: string, value: string) => { + try { + await navigator.clipboard.writeText(value) + setCopied(label) + window.setTimeout(() => setCopied((current) => (current === label ? null : current)), 1800) + } catch { + // Clipboard unavailable; the value is visible for manual copy. + } + } + + if (state.status === 'loading') { + return

Loading mobile-app gateway access…

+ } + + if (state.status === 'unconfigured') { + return ( +
+

+ This OpenClaw sandbox is not yet exposed for the OpenClaw mobile apps. +

+ +
+ ) + } + + if (state.status === 'error') { + return ( +
+

{state.message}

+ +
+ ) + } + + const { access, healthy } = state + const maskedToken = `${access.token.slice(0, 4)}…${access.token.slice(-4)}` + + const Row = ({ label, value, copyKey }: { label: string; value: string; copyKey: string }) => ( +
+ {label} + {value} + +
+ ) + + return ( +
+
+ + + +
+ Token + {tokenRevealed ? access.token : maskedToken} + + +
+
+ Status + {healthy === null ? ( + checking… + ) : healthy ? ( + ● Reachable + ) : ( + ● Unreachable — try “Retry exposure” below + )} +
+
+ +
+

OpenClaw mobile app setup

+
    +
  1. Open the app → Connect tab → Manual / Advanced
  2. +
  3. Host {access.host}, Port {access.port}, TLS / wss:// on
  4. +
  5. Paste the Token as the gateway auth token, then connect
  6. +
  7. Approve the pairing request on the gateway host: openclaw devices approve --latest
  8. +
+

+ The token is the gateway shared secret — the app sends it in the connection (connect) frame; it is not part of the URL. +

+
+ + {healthy === false && ( + + )} +
+ ) +} diff --git a/app/components/SandboxList.tsx b/app/components/SandboxList.tsx index ee2a529..473c32d 100644 --- a/app/components/SandboxList.tsx +++ b/app/components/SandboxList.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import ConfigurationPanel from './ConfigurationPanel' +import HermesRemotePanel from './HermesRemotePanel' +import OpenClawRemotePanel from './OpenClawRemotePanel' import SandboxArchivePanel from './SandboxArchivePanel' import SandboxFilesPanel from './SandboxFilesPanel' import SandboxInferencePanel from './SandboxInferencePanel' @@ -29,7 +31,7 @@ interface SandboxListProps { dashboardSessionId: string } -type DrawerKey = 'operations' | 'files' | 'inference' | 'policy' | 'archive' | 'mcp' +type DrawerKey = 'operations' | 'files' | 'inference' | 'policy' | 'archive' | 'mcp' | 'hermesRemote' | 'openclawRemote' type McpServerAccess = { id: string name: string @@ -79,24 +81,57 @@ function openDashboardUrl(url: string, openInNewTab: boolean) { function displaySandboxAgent(agent?: string) { if (agent === 'hermes') return 'Hermes' + if (agent === 'custom') return 'Custom' return 'OpenClaw' } +function CopyLinkButton({ label, copied, onClick }: { label: string; copied: boolean; onClick: () => void }) { + return ( + + ) +} + function SandboxTypeLogo({ agent }: { agent?: string }) { const isHermes = agent === 'hermes' - const label = isHermes ? 'Hermes sandbox' : 'OpenClaw sandbox' + const isCustom = agent === 'custom' + const label = isHermes ? 'Hermes sandbox' : isCustom ? 'Custom sandbox' : 'OpenClaw sandbox' const logoSrc = isHermes ? HERMES_SANDBOX_LOGO : OPENCLAW_SANDBOX_LOGO + const borderBg = isHermes + ? 'border-sky-300/60 bg-sky-500/15' + : isCustom + ? 'border-[var(--border-subtle)] bg-[var(--background-tertiary)]' + : 'border-rose-300/60 bg-rose-500/15' return ( -