From b4247d685bac4e696180b06e4a0d69223c04c61c Mon Sep 17 00:00:00 2001 From: ivobrett Date: Sun, 10 May 2026 08:40:45 +0100 Subject: [PATCH 001/102] Support running OpenShell Controller behind a reverse proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change makes the dashboard WebSocket connection work correctly when the controller is deployed behind a reverse proxy (e.g. Traefik/Pangolin) that terminates TLS and proxies all traffic through a single host/port. Previously the WS gateway URL was built with a hardcoded `:3001` fallback port, which is unreachable through a reverse proxy. Changed both the server-side URL builder (`dashboard/open/route.ts`) and the in-browser bootstrap script (`dashboard/proxy/shared.ts`) to default to an empty port so WebSocket connections go through the same proxied host as HTTP. Set `OPENCLAW_DASHBOARD_WS_PROXY_PORT` to override when a dedicated WS sidecar port is directly accessible. Added `socket.allowHalfOpen = true` before resuming the client socket so the tunnel handles half-closed TCP connections correctly — required when the upstream WebSocket peer closes its write side while the client still has data in flight. Also improved error logging on client socket errors to include the error code, message, and byte counts. `nemoclaw onboard` reliably exits with code 1 at step 8/8 (policy application timeout) even when the sandbox is successfully running. Replaced `runCommand` with `runCreateCommandUntilReady`, which polls `openshell sandbox get` every 5 s and sends SIGTERM to the onboard process as soon as the sandbox reaches Ready state, rather than waiting for the command to exit. The `cwd: NEMOCLAW_CWD` option is preserved so the subprocess runs from the NemoClaw source root. Added a Docker-based fallback (`docker ps --filter name=openshell-`) to `resolveSourcePodImageFromRef` so quick-deploy works on Docker-only hosts that don't have a Kubernetes control plane. Added automatic Ollama provider routing: when `OLLAMA_BASE_URL` is set and `NEMOCLAW_PROVIDER` is not, the controller injects `NEMOCLAW_PROVIDER=ollama` (and `NEMOCLAW_MODEL` from `OLLAMA_MODEL`) so Ollama inference is used without requiring manual `.env.local` configuration. When `nemoclaw` is installed via `npm link` inside NVM, the binary is a symlink several hops removed from the actual source root. Added `realpathSync`-based resolution so `NEMOCLAW_CWD_CANDIDATES` correctly derives `/opt/nemoclaw` (or equivalent) from the real binary path rather than the NVM version directory. OpenClaw shows a "Change Gateway URL" confirmation dialog when it detects the persisted gateway URL differs from the current one, which happens when switching between sandbox sessions. The bootstrap script now purges stale scoped localStorage keys before writing the new URL, preventing the detection. A setInterval fallback auto-clicks the Confirm button in case stale state originates from outside the bootstrap lifecycle. Also fixed a bug where a `token` passed via URL hash was written to sessionStorage scoped keys but not embedded in `settings.gatewayUrl`, causing reconnects after page reload to proceed without authentication. Added `tests/openclaw-dashboard-same-origin-ws-check.mjs` which asserts the `?? ''` default and same-origin host computation are present in both the server-side route and the browser bootstrap script. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + app/api/openshell/dashboard/proxy/shared.ts | 35 +- app/api/sandbox/create/route.ts | 71 +++- app/lib/hostCommands.ts | 9 +- package-lock.json | 323 +++++++++++++++++- server.mjs | 14 +- ...penclaw-dashboard-same-origin-ws-check.mjs | 73 ++++ 7 files changed, 506 insertions(+), 20 deletions(-) create mode 100644 tests/openclaw-dashboard-same-origin-ws-check.mjs 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/app/api/openshell/dashboard/proxy/shared.ts b/app/api/openshell/dashboard/proxy/shared.ts index c1af3be..f809f8e 100644 --- a/app/api/openshell/dashboard/proxy/shared.ts +++ b/app/api/openshell/dashboard/proxy/shared.ts @@ -246,7 +246,23 @@ 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); @@ -257,6 +273,23 @@ function bootstrapScriptResponse(proxyPrefix: string) { } 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 {} })(); ` diff --git a/app/api/sandbox/create/route.ts b/app/api/sandbox/create/route.ts index be0c95f..75de29d 100644 --- a/app/api/sandbox/create/route.ts +++ b/app/api/sandbox/create/route.ts @@ -260,6 +260,19 @@ async function listOpenShellSandboxNames() { } } +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) @@ -267,6 +280,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}'.`) @@ -510,7 +524,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}`) @@ -526,6 +540,7 @@ async function runCreateCommandUntilReady(file: string, args: string[], env: Nod }>((resolve) => { const child = spawn(file, args, { env, + cwd, stdio: ["ignore", "pipe", "pipe"], }) @@ -768,20 +783,45 @@ 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_API_KEY = env.NVIDIA_API_KEY || "optional-local-mode" } applyCreateInferenceEnv(env, createInference, body) - const result = await runCommand( + // Use runCreateCommandUntilReady so the controller resolves as soon as the sandbox + // reaches Ready — before nemoclaw's step 8/8 policy application, which exits 1 on timeout. + const result = await runCreateCommandUntilReady( createCommand.file, createCommand.mode === "legacy-setup" ? [...createCommand.args, sandboxName] : createCommand.args, env, + sandboxName, + 600000, + 5000, NEMOCLAW_CWD, ) - if (!result.ok) { + // 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 && result.error && !result.timedOut) { return NextResponse.json({ ok: false, error: result.error, @@ -796,13 +836,6 @@ 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", @@ -810,7 +843,7 @@ export async function POST(request: Request) { })) : null const deviceApproval = created && isOpenClawAgent ? await approveOpenClawDeviceRequests(sandboxName) : null 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=${result.forcedReady} readinessAttempts=${readiness.attempts} deviceApproval=${deviceApproval?.approved ?? false} elapsedMs=${elapsedMs(requestStartedAt)}`, ) return NextResponse.json({ @@ -825,7 +858,13 @@ export async function POST(request: Request) { enableTailscale, gpuMode, createInference, - createCommand, + createCommand: { + ...createCommand, + forcedReady: result.forcedReady, + timedOut: result.timedOut, + exitCode: result.exitCode, + signal: result.signal, + }, hostPath: HOST_PATH, readiness: { attempts: readiness.attempts, @@ -839,9 +878,11 @@ export async function POST(request: Request) { ? 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.", + : result.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" diff --git a/app/lib/hostCommands.ts b/app/lib/hostCommands.ts index 9e826ea..9618170 100644 --- a/app/lib/hostCommands.ts +++ b/app/lib/hostCommands.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs" +import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs" import path from "node:path" const HOME = process.env.HOME || "" @@ -222,10 +222,17 @@ export const NEMOCLAW_SETUP_CANDIDATES = unique([ ]) export const NEMOCLAW_SETUP = firstExisting(NEMOCLAW_SETUP_CANDIDATES, "") +function resolveSymlink(p: string) { + try { return realpathSync(p) } catch { return p } +} + export const NEMOCLAW_CWD_CANDIDATES = unique([ process.env.NEMOCLAW_CWD, HOME ? path.join(HOME, ".nemoclaw/source") : undefined, NEMOCLAW_SETUP ? path.dirname(path.dirname(NEMOCLAW_SETUP)) : undefined, + // Resolve symlinks before dirname so npm-linked binaries (e.g. NVM → /opt/nemoclaw) + // yield the real source root rather than the NVM install directory. + NEMOCLAW_BIN.includes("/") ? path.dirname(path.dirname(resolveSymlink(NEMOCLAW_BIN))) : undefined, NEMOCLAW_BIN.includes("/") ? path.dirname(path.dirname(NEMOCLAW_BIN)) : undefined, HOME ? path.join(HOME, "NemoClaw") : undefined, HOME ? path.join(HOME, "nemoclaw") : undefined, diff --git a/package-lock.json b/package-lock.json index 889a6e6..f5709d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,18 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -67,6 +79,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -746,6 +769,19 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@next/env": { "version": "15.5.15", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz", @@ -991,6 +1027,17 @@ "tslib": "^2.8.0" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/js-cookie": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", @@ -1027,6 +1074,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1114,6 +1162,7 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -1358,6 +1407,104 @@ "dev": true, "license": "ISC" }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", @@ -1386,6 +1533,149 @@ "linux" ] }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@xobotyi/scrollbar-width": { "version": "1.9.5", "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", @@ -1436,6 +1726,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1966,6 +2257,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2769,6 +3061,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2938,6 +3231,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3572,6 +3866,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3851,6 +4160,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -4462,6 +4772,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5333,6 +5644,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5616,6 +5928,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5628,6 +5941,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6746,6 +7060,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6824,7 +7139,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tweetnacl": { "version": "0.14.5", @@ -6981,6 +7297,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7271,7 +7588,8 @@ "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/xterm-addon-fit": { "version": "0.8.0", @@ -7301,6 +7619,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server.mjs b/server.mjs index 33cdfaa..ad09f1e 100644 --- a/server.mjs +++ b/server.mjs @@ -729,6 +729,7 @@ function tunnelDashboardUpgrade(req, socket, head, upstreamWsUrl, instanceId, co socket.pipe(upstreamSocket, { end: false }) upstreamSocket.pipe(socket, { end: false }) upstreamSocket.resume() + socket.allowHalfOpen = true socket.resume() logBridge('dashboard-tunnel-open', { path: req.url || '/', @@ -772,7 +773,18 @@ function tunnelDashboardUpgrade(req, socket, head, upstreamWsUrl, instanceId, co }) }) - socket.on('error', () => upstreamSocket.destroy()) + socket.on('error', (e) => { + logBridge('dashboard-tunnel-client-error', { + path: req.url || '/', + upstreamUrl: upstreamWsUrl.toString(), + instanceId, + code: e?.code, + message: e?.message, + upstreamBytes, + clientBytes, + }) + upstreamSocket.destroy() + }) socket.on('close', (hadError) => { logBridge('dashboard-tunnel-client-close', { path: req.url || '/', diff --git a/tests/openclaw-dashboard-same-origin-ws-check.mjs b/tests/openclaw-dashboard-same-origin-ws-check.mjs new file mode 100644 index 0000000..7694f65 --- /dev/null +++ b/tests/openclaw-dashboard-same-origin-ws-check.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +const root = process.cwd() +const openRoutePath = path.join(root, 'app/api/openshell/dashboard/open/route.ts') +const sharedPath = path.join(root, 'app/api/openshell/dashboard/proxy/shared.ts') + +const openRouteSource = await readFile(openRoutePath, 'utf8') +const sharedSource = await readFile(sharedPath, 'utf8') + +// The env var must use ?? '' not || '3001' so that an unset env var defaults to same-origin (empty port) +assert.match( + openRouteSource, + /OPENCLAW_DASHBOARD_WS_PROXY_PORT\?\.trim\(\) \?\? ''/, + 'dashboard open route: unset OPENCLAW_DASHBOARD_WS_PROXY_PORT must default to same-origin (empty), not hardcoded :3001' +) +assert.doesNotMatch( + openRouteSource, + /OPENCLAW_DASHBOARD_WS_PROXY_PORT\?\.trim\(\) \|\| '3001'/, + 'dashboard open route: must not fall back to :3001 when env var is unset' +) + +assert.match( + sharedSource, + /OPENCLAW_DASHBOARD_WS_PROXY_PORT\?\.trim\(\) \?\? ''/, + 'dashboard bootstrap: unset OPENCLAW_DASHBOARD_WS_PROXY_PORT must default to same-origin (empty), not hardcoded :3001' +) +assert.doesNotMatch( + sharedSource, + /OPENCLAW_DASHBOARD_WS_PROXY_PORT\?\.trim\(\) \|\| '3001'/, + 'dashboard bootstrap: must not fall back to :3001 when env var is unset' +) + +// When wsProxyPort is empty the gateway host must use window.location.host (includes port if non-standard) +assert.match( + sharedSource, + /const sidecarHost = wsProxyPort \? window\.location\.hostname \+ ':' \+ wsProxyPort : window\.location\.host/, + 'dashboard bootstrap: empty wsProxyPort must use window.location.host for same-origin websocket' +) + +// The gatewayUrl hash param takes precedence over the sidecar default +assert.match( + sharedSource, + /const gatewayUrl = \(hashParams\.get\('gatewayUrl'\) \|\| ''\)\.trim\(\) \|\| sidecarGatewayUrl/, + 'dashboard bootstrap: explicit gatewayUrl from launch hash must override computed sidecar gateway' +) + +// Server-side: when wsProxyPort is empty the gateway host falls through to host (forwarded or direct) +assert.match( + openRouteSource, + /const gatewayHost = wsProxyPort\s*\n\s*\? new URL\(`\$\{requestUrl\.protocol\}\/\/\$\{host\}`\)\.hostname \+ `:\$\{wsProxyPort\}`\s*\n\s*: host/, + 'dashboard open route: empty wsProxyPort must use the full host (respects x-forwarded-host) for same-origin websocket' +) + +// Token must be embedded directly in gatewayUrl stored to localStorage +assert.match( + sharedSource, + /const effectiveGatewayUrl = token/, + 'dashboard bootstrap: token must be embedded in gatewayUrl via effectiveGatewayUrl' +) +assert.match( + sharedSource, + /settings\.gatewayUrl = effectiveGatewayUrl/, + 'dashboard bootstrap: settings.gatewayUrl must use effectiveGatewayUrl (with embedded token)' +) +assert.doesNotMatch( + sharedSource, + /settings\.gatewayUrl = gatewayUrl(?!.*effectiveGatewayUrl)/s, + 'dashboard bootstrap: must not store bare gatewayUrl without token embedding' +) + +console.log('openclaw-dashboard-same-origin-ws-check: PASS same-origin WebSocket default assertions') From c042d8323d8ff6e706c9c67264e44b833e4b37b6 Mon Sep 17 00:00:00 2001 From: ivobrett Date: Mon, 18 May 2026 19:58:42 +0100 Subject: [PATCH 002/102] Fix dashboard WebSocket disconnect and Hermes sandbox half-onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.mjs: Next.js's NextCustomServer lazily attaches its own 'upgrade' listener to the http server on the first request (setupWebSocketHandler). EventEmitter dispatches the 'upgrade' event to every listener, so the controller's dashboard upgrade handler ran first and sent 101 Switching Protocols, then Next.js's listener ran and destroyed the socket — killing the dashboard WebSocket ~3 ms after upgrade. Setting the internal flag app.didWebSocketSetup = true before any requests prevents Next.js from attaching that listener. sandbox/create/route.ts: the previous commit's runCreateCommandUntilReady sends SIGTERM as soon as the sandbox is Ready, which is fine for OpenClaw (skips a step-8 policy timeout) but cuts Hermes off mid-onboarding — agent_setup runs after sandbox-ready and is where Hermes's API server is configured. Killing early left Hermes half-installed, the sandbox missing from /root/.nemoclaw/sandboxes.json, and the UI showing the wrong button (Start OpenClaw Gateway Dashboard instead of Connect to Hermes). Use runCreateCommandBounded for Hermes so the full onboard runs; the existing readiness fallback still tolerates non-zero exit codes when the sandbox itself comes up Ready. --- app/api/sandbox/create/route.ts | 38 +++++++++++++++++++++------------ server.mjs | 7 ++++++ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/api/sandbox/create/route.ts b/app/api/sandbox/create/route.ts index 75de29d..4624851 100644 --- a/app/api/sandbox/create/route.ts +++ b/app/api/sandbox/create/route.ts @@ -799,17 +799,26 @@ export async function POST(request: Request) { applyCreateInferenceEnv(env, createInference, body) - // Use runCreateCommandUntilReady so the controller resolves as soon as the sandbox - // reaches Ready — before nemoclaw's step 8/8 policy application, which exits 1 on timeout. - const result = await runCreateCommandUntilReady( - createCommand.file, - createCommand.mode === "legacy-setup" ? [...createCommand.args, sandboxName] : createCommand.args, - env, - sandboxName, - 600000, - 5000, - 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 + const result = agent === "hermes" + ? await runCreateCommandBounded(createCommand.file, createCommandArgs, env, 900000) + : await runCreateCommandUntilReady( + createCommand.file, + createCommandArgs, + env, + sandboxName, + 600000, + 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. @@ -842,8 +851,9 @@ export async function POST(request: Request) { error: error instanceof Error ? error.message : "Failed to repair OpenClaw exec approvals file", })) : null const deviceApproval = created && isOpenClawAgent ? await approveOpenClawDeviceRequests(sandboxName) : null + const forcedReady = "forcedReady" in result ? result.forcedReady : false console.log( - `[sandbox/create] request:complete sandbox=${sandboxName} created=${created} agent=${agent} forcedReady=${result.forcedReady} 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)}`, ) return NextResponse.json({ @@ -860,7 +870,7 @@ export async function POST(request: Request) { createInference, createCommand: { ...createCommand, - forcedReady: result.forcedReady, + forcedReady, timedOut: result.timedOut, exitCode: result.exitCode, signal: result.signal, @@ -878,7 +888,7 @@ export async function POST(request: Request) { ? appendNote( agent === "hermes" ? "NemoClaw Hermes workflow completed. Hermes exposes an API endpoint from the sandbox rather than an OpenClaw browser dashboard." - : result.forcedReady + : 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." diff --git a/server.mjs b/server.mjs index ad09f1e..c919f5b 100644 --- a/server.mjs +++ b/server.mjs @@ -490,6 +490,13 @@ function startInterSandboxChatSidecar() { } const app = next({ dev, hostname, port }) +// Next.js's NextCustomServer lazily attaches its own 'upgrade' listener to the +// http server on the first request (see node_modules/next/dist/server/next.js +// setupWebSocketHandler). When EventEmitter dispatches an 'upgrade', that +// listener runs alongside ours and destroys the socket, killing our dashboard +// WebSocket bridge ~3 ms after we send 101 Switching Protocols. Setting the +// internal flag prevents Next.js from ever attaching that listener. +app.didWebSocketSetup = true const handle = app.getRequestHandler() await startLocalTerminalServerIfNeeded() From 3e374c8052abffe8b06ee99b7aced8c49147221f Mon Sep 17 00:00:00 2001 From: ivobrett Date: Mon, 18 May 2026 20:17:30 +0100 Subject: [PATCH 003/102] Document proposed mcpauth forwardAuth integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a subsection under Authentication describing how to move the shared- password gate to the internal mcpauth IdP via Traefik's forwardAuth middleware. Covers the minimal-change approach (middleware on both HTTP and WS routers, OPENSHELL_CONTROL_AUTH_DISABLED=true), the identity-aware variant (read Remote-Email instead of decoding the password JWT), and the open questions to resolve before implementing — chiefly whether mcpauth returns 401 vs 302 for unauthenticated WebSocket upgrades. No behaviour change. --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index 57578a2..cc471e9 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,52 @@ pkill -f 'node server.mjs|npm run dev|npm run start' || true npm run start ``` +### Proposed: Delegate auth to the mcpauth IdP via Traefik forwardAuth + +The shared-password gate above is the only thing standing between a request and the controller's API. For deployments behind Traefik we can replace it with an existing internal IdP (`mcpauth`, repo: `Projects/mcpauth`) using Traefik's `forwardAuth` middleware. This keeps controller code untouched and moves all identity into the IdP. + +**Approach (minimal change, no controller code edits):** + +1. Define a Traefik middleware that points at mcpauth's forward-auth endpoint, and attach it to both controller routers — the HTTP router and the WebSocket router (`8-openshell-controller-router-auth@file` and `8-openshell-controller-ws-router@file` in the current Pangolin-managed config). The middleware must be set on the WS router too, because the dashboard WebSocket upgrade currently relies on the controller's own cookie check (`server.mjs:149 isAuthenticatedUpgrade`). +2. Set `OPENSHELL_CONTROL_AUTH_DISABLED=true` in `.env.local` so the controller trusts whatever mcpauth has already approved. The `/login`, `/setup-account`, `/forgot-password` pages become inert. +3. Decide whether to leave Pangolin's `badger@http` middleware in place (defence in depth) or remove it so mcpauth is the sole gate. If both are kept, the user logs in twice. + +Example Traefik dynamic config (sketch — adjust paths and headers to match mcpauth's actual forwardAuth endpoint and cookie names): + +```yaml +http: + middlewares: + mcpauth-forward: + forwardAuth: + address: "http://mcpauth:11000/auth/forward" + trustForwardHeader: true + authResponseHeaders: + - "Remote-Email" + - "Remote-User" + routers: + 8-openshell-controller-router-auth: + middlewares: + - mcpauth-forward + # - badger@http # optional: remove once mcpauth is verified + 8-openshell-controller-ws-router: + middlewares: + - mcpauth-forward +``` + +**Trade-off — identity vs zero code change:** with `OPENSHELL_CONTROL_AUTH_DISABLED=true` the controller no longer knows *who* the caller is — every authenticated user becomes "operator". That is fine for shared-team use but loses per-user audit, sandbox ownership, and the `sub` claim baked into the session cookie. If per-user identity matters later, swap the password cookie for header-based identity: + +- Wire `controlAuth.ts` and `server.mjs:isAuthenticatedUpgrade` to read `Remote-Email` (or whichever header mcpauth forwards) instead of decoding the password JWT. +- Stop minting the local session cookie; treat the absence of the forwarded identity header as unauthenticated. +- Drop the `/login`, `/setup-account`, `/forgot-password` routes (or have them no-op when auth is delegated). + +**Items to nail down when implementing:** + +- Confirm the exact mcpauth forwardAuth path and which cookie / header it consumes (Cloudflare-style `CF_Authorization`? OAuth2 PKCE cookie? See `mcpauth/server/edge_auth.go`). +- Verify mcpauth's forward-auth endpoint returns 401 (not a 302 redirect) for unauthenticated WebSocket upgrade requests — WebSocket clients cannot follow redirects, so mcpauth must surface its own login flow on a separate HTTP route, the way Pangolin's `badger` already does. +- Decide whether the redirect target on 401 lives on the controller subdomain or on a dedicated `auth.…` subdomain (cleaner cookie scoping). +- Register the controller's host as an authorised redirect URI in whichever OAuth provider mcpauth fronts. +- Audit any controller endpoints that should remain reachable without auth (health checks, etc.) and add a Traefik `priority` override or path exclusion for them. + ## OpenShell And OpenClaw Notes The dashboard shells out to the OpenShell CLI for several operations: From 8e4f6ea870d533b012e50b915582f641db472414 Mon Sep 17 00:00:00 2001 From: ivobrett Date: Fri, 22 May 2026 20:50:32 +0100 Subject: [PATCH 004/102] Bump pinned OpenClaw version to 2026.5.20 Co-Authored-By: Claude Sonnet 4.6 --- install_versioned_nemoclaw_openshell.sh | 2 +- tests/sandbox-lifecycle-check.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/install_versioned_nemoclaw_openshell.sh b/install_versioned_nemoclaw_openshell.sh index 07adf28..2638660 100755 --- a/install_versioned_nemoclaw_openshell.sh +++ b/install_versioned_nemoclaw_openshell.sh @@ -12,7 +12,7 @@ OPENSHELL_VERSION="${OPENSHELL_VERSION:-v0.0.36}" OPENSHELL_INSTALL_URL="${OPENSHELL_INSTALL_URL:-https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh}" NEMOCLAW_INSTALL_TAG="${NEMOCLAW_INSTALL_TAG:-v0.0.37}" NEMOCLAW_ZIP_URL="${NEMOCLAW_ZIP_URL:-https://github.com/NVIDIA/NemoClaw/archive/refs/tags/${NEMOCLAW_INSTALL_TAG}.zip}" -OPENCLAW_VERSION="${OPENCLAW_VERSION:-2026.4.27}" +OPENCLAW_VERSION="${OPENCLAW_VERSION:-2026.5.20}" NEMOCLAW_BASE_IMAGE="${NEMOCLAW_BASE_IMAGE:-ghcr.io/nvidia/nemoclaw/sandbox-base:latest}" NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE="${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-1}" NEMOCLAW_NON_INTERACTIVE="${NEMOCLAW_NON_INTERACTIVE:-1}" diff --git a/tests/sandbox-lifecycle-check.mjs b/tests/sandbox-lifecycle-check.mjs index 32c7a8d..8a1efb8 100644 --- a/tests/sandbox-lifecycle-check.mjs +++ b/tests/sandbox-lifecycle-check.mjs @@ -98,7 +98,7 @@ assert.match(createRouteSource, /NEMOCLAW_EXPERIMENTAL = "1"/, 'blueprint create assert.match(createRouteSource, /NEMOCLAW_PROVIDER = "vllm"/, 'blueprint create must support the vLLM onboarding provider') assert.match(createRouteSource, /NEMOCLAW_PROVIDER = "nim-local"/, 'blueprint create must support the NVIDIA NIM onboarding provider') assert.match(createRouteSource, /NEMOCLAW_PROVIDER_KEY = apiKey/, 'blueprint create must pass NVIDIA API keys through provider env without changing NemoClaw') -assert.match(versionedInstallerSource, /OPENCLAW_VERSION="\$\{OPENCLAW_VERSION:-2026\.4\.27\}"/, 'versioned installer must pin the dashboard-compatible NemoClaw OpenClaw version') +assert.match(versionedInstallerSource, /OPENCLAW_VERSION="\$\{OPENCLAW_VERSION:-2026\.5\.20\}"/, 'versioned installer must pin the dashboard-compatible NemoClaw OpenClaw version') assert.match(versionedInstallerSource, /docker build[\s\S]*Dockerfile\.base[\s\S]*--build-arg "OPENCLAW_VERSION=\$OPENCLAW_VERSION"[\s\S]*"\$source_dir"/, 'versioned installer must rebuild the stock NemoClaw base image with the pinned OpenClaw version') assert.doesNotMatch(createRouteSource, /repairOpenClawRuntimePolicy|runtimePolicyRepair/, 'sandbox create must not mutate OpenShell filesystem policy for OpenClaw') assert.doesNotMatch(createRouteSource, /stabilizeOpenClawGatewayConfig|gatewayConfigRepair|repairOpenClawWorkspacePermissions|workspaceRepair/, 'sandbox create must not patch OpenClaw internals after the Monday rollback') From 68be3be46a40b8c11e41db98beeb94c53a9b65a6 Mon Sep 17 00:00:00 2001 From: ivobrett Date: Sat, 23 May 2026 18:20:07 +0100 Subject: [PATCH 005/102] trying to fix hermes dashboard --- .../[sandboxId]/hermes/dashboard/route.ts | 31 +++++++ .../hermes/proxy/[...path]/route.ts | 15 ++++ .../sandbox/[sandboxId]/hermes/proxy/route.ts | 15 ++++ .../[sandboxId]/hermes/proxy/shared.ts | 83 +++++++++++++++++++ app/api/sandbox/create/route.ts | 11 ++- app/components/SandboxList.tsx | 39 +++++++-- app/lib/openshellHost.ts | 78 +++++++++++++++++ 7 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 app/api/sandbox/[sandboxId]/hermes/dashboard/route.ts create mode 100644 app/api/sandbox/[sandboxId]/hermes/proxy/[...path]/route.ts create mode 100644 app/api/sandbox/[sandboxId]/hermes/proxy/route.ts create mode 100644 app/api/sandbox/[sandboxId]/hermes/proxy/shared.ts diff --git a/app/api/sandbox/[sandboxId]/hermes/dashboard/route.ts b/app/api/sandbox/[sandboxId]/hermes/dashboard/route.ts new file mode 100644 index 0000000..8b385cb --- /dev/null +++ b/app/api/sandbox/[sandboxId]/hermes/dashboard/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { ensureHermesDashboardTunnel, resolveSandboxRef } from '@/app/lib/openshellHost' + +export async function GET( + _request: Request, + { params }: { params: Promise<{ sandboxId: string }> }, +) { + try { + const { sandboxId } = await params + const sandbox = await resolveSandboxRef(sandboxId) + const { port, listenerPresent } = await ensureHermesDashboardTunnel(sandbox.name) + const proxyUrl = `/api/sandbox/${encodeURIComponent(sandboxId)}/hermes/proxy` + + return NextResponse.json({ + ok: true, + sandboxId, + sandboxName: sandbox.name, + port, + listenerPresent, + proxyUrl, + note: listenerPresent + ? 'Hermes dashboard tunnel is active.' + : 'Tunnel setup attempted but not yet confirmed. The dashboard may take a moment to start.', + }) + } catch (error) { + return NextResponse.json( + { ok: false, error: error instanceof Error ? error.message : 'Failed to start Hermes dashboard' }, + { status: 500 }, + ) + } +} diff --git a/app/api/sandbox/[sandboxId]/hermes/proxy/[...path]/route.ts b/app/api/sandbox/[sandboxId]/hermes/proxy/[...path]/route.ts new file mode 100644 index 0000000..93e52ef --- /dev/null +++ b/app/api/sandbox/[sandboxId]/hermes/proxy/[...path]/route.ts @@ -0,0 +1,15 @@ +import { hermesDashboardProxyErrorResponse, proxyHermesDashboard } from '../shared' + +async function handleRequest( + request: Request, + { params }: { params: Promise<{ sandboxId: string; path: string[] }> }, +) { + try { + const { sandboxId, path } = await params + return await proxyHermesDashboard(request, sandboxId, `/${path.join('/')}`) + } catch (error) { + return hermesDashboardProxyErrorResponse(error) + } +} + +export { handleRequest as DELETE, handleRequest as GET, handleRequest as HEAD, handleRequest as OPTIONS, handleRequest as PATCH, handleRequest as POST, handleRequest as PUT } diff --git a/app/api/sandbox/[sandboxId]/hermes/proxy/route.ts b/app/api/sandbox/[sandboxId]/hermes/proxy/route.ts new file mode 100644 index 0000000..8aad478 --- /dev/null +++ b/app/api/sandbox/[sandboxId]/hermes/proxy/route.ts @@ -0,0 +1,15 @@ +import { hermesDashboardProxyErrorResponse, proxyHermesDashboard } from './shared' + +async function handleRequest( + request: Request, + { params }: { params: Promise<{ sandboxId: string }> }, +) { + try { + const { sandboxId } = await params + return await proxyHermesDashboard(request, sandboxId, '/') + } catch (error) { + return hermesDashboardProxyErrorResponse(error) + } +} + +export { handleRequest as DELETE, handleRequest as GET, handleRequest as HEAD, handleRequest as OPTIONS, handleRequest as PATCH, handleRequest as POST, handleRequest as PUT } diff --git a/app/api/sandbox/[sandboxId]/hermes/proxy/shared.ts b/app/api/sandbox/[sandboxId]/hermes/proxy/shared.ts new file mode 100644 index 0000000..cda7471 --- /dev/null +++ b/app/api/sandbox/[sandboxId]/hermes/proxy/shared.ts @@ -0,0 +1,83 @@ +import { NextResponse } from 'next/server' +import { getHermesDashboardPortForSandbox, resolveSandboxRef } from '@/app/lib/openshellHost' + +const HOP_BY_HOP_HEADERS = new Set([ + 'accept-encoding', 'connection', 'keep-alive', 'proxy-authenticate', + 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'content-length', +]) + +function injectBaseTag(html: string, proxyBase: string) { + const base = `` + return html.replace(/]*)?>/i, (match) => `${match}${base}`) +} + +export async function proxyHermesDashboard(request: Request, sandboxId: string, upstreamPath: string) { + const sandbox = await resolveSandboxRef(sandboxId) + const port = getHermesDashboardPortForSandbox(sandbox.name) + const normalized = upstreamPath.startsWith('/') ? upstreamPath : `/${upstreamPath}` + const target = new URL(normalized, `http://127.0.0.1:${port}`) + + const requestUrl = new URL(request.url) + requestUrl.searchParams.forEach((value, key) => { + target.searchParams.append(key, value) + }) + + const method = request.method.toUpperCase() + const headers = new Headers() + request.headers.forEach((value, key) => { + const lower = key.toLowerCase() + if (!HOP_BY_HOP_HEADERS.has(lower) && lower !== 'host' && lower !== 'origin' && lower !== 'referer') { + headers.set(key, value) + } + }) + headers.set('host', `127.0.0.1:${port}`) + + const shouldSendBody = !['GET', 'HEAD'].includes(method) + const init: RequestInit & { duplex?: 'half' } = { + method, + headers, + body: shouldSendBody ? request.body : undefined, + redirect: 'manual', + cache: 'no-store', + } + if (shouldSendBody) init.duplex = 'half' + + const upstream = await fetch(target.toString(), init) + + const responseHeaders = new Headers() + upstream.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) { + responseHeaders.set(key, value) + } + }) + responseHeaders.set('cache-control', 'no-store') + + const proxyBase = `/api/sandbox/${encodeURIComponent(sandboxId)}/hermes/proxy` + const location = upstream.headers.get('location') + if (location) { + responseHeaders.set('location', location.startsWith('/') ? `${proxyBase}${location}` : location) + } + + const contentType = upstream.headers.get('content-type') || '' + if (contentType.includes('text/html')) { + const body = injectBaseTag(await upstream.text(), proxyBase) + responseHeaders.set('content-type', contentType) + return new NextResponse(body, { status: upstream.status, headers: responseHeaders }) + } + + return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders }) +} + +export function hermesDashboardProxyErrorResponse(error: unknown) { + const message = error instanceof Error ? error.message : 'Hermes dashboard proxy error' + const isConnectionRefused = /ECONNREFUSED|fetch failed|ENOTFOUND/i.test(message) + return NextResponse.json( + { + ok: false, + error: isConnectionRefused + ? 'Hermes dashboard is not reachable. Use "Launch Hermes Dashboard" to start it first.' + : message, + }, + { status: isConnectionRefused ? 503 : 502 }, + ) +} diff --git a/app/api/sandbox/create/route.ts b/app/api/sandbox/create/route.ts index 4624851..80718a8 100644 --- a/app/api/sandbox/create/route.ts +++ b/app/api/sandbox/create/route.ts @@ -3,7 +3,7 @@ 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 { repairOpenClawExecApprovalsFile } from "@/app/lib/sandboxPrivilegedFiles" import { commandExists, @@ -851,6 +851,14 @@ export async function POST(request: Request) { error: error instanceof Error ? error.message : "Failed to repair OpenClaw exec approvals file", })) : null const deviceApproval = created && isOpenClawAgent ? await approveOpenClawDeviceRequests(sandboxName) : null + // Pre-build the Hermes dashboard web UI so "Launch Hermes Dashboard" is instant on first use. + 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 const forcedReady = "forcedReady" in result ? result.forcedReady : false console.log( `[sandbox/create] request:complete sandbox=${sandboxName} created=${created} agent=${agent} forcedReady=${forcedReady} readinessAttempts=${readiness.attempts} deviceApproval=${deviceApproval?.approved ?? false} elapsedMs=${elapsedMs(requestStartedAt)}`, @@ -882,6 +890,7 @@ export async function POST(request: Request) { }, execApprovalsRepair, deviceApproval, + hermesDashboardBuild, stdout: result.stdout, stderr: result.stderr, note: created diff --git a/app/components/SandboxList.tsx b/app/components/SandboxList.tsx index ee2a529..5fc2c9e 100644 --- a/app/components/SandboxList.tsx +++ b/app/components/SandboxList.tsx @@ -200,6 +200,7 @@ export default function SandboxList({ dashboardSessionId, }: SandboxListProps) { const [dashboardMessage, setDashboardMessage] = useState('') + const [hermesDashboardLaunching, setHermesDashboardLaunching] = useState(false) const [restartInProgress, setRestartInProgress] = useState(false) const [permissionMessage, setPermissionMessage] = useState('') const [permissionFeeds, setPermissionFeeds] = useState>({}) @@ -334,6 +335,23 @@ export default function SandboxList({ window.open(route, '_blank', 'noopener,noreferrer') } + const launchHermesDashboard = async (sandbox: SandboxInventoryItem) => { + if (hermesDashboardLaunching) return + try { + setHermesDashboardLaunching(true) + setDashboardMessage(`Starting Hermes dashboard for ${sandbox.name}…`) + const res = await fetch(`/api/sandbox/${encodeURIComponent(sandbox.id)}/hermes/dashboard`) + const data = await res.json() + if (!res.ok || !data.ok) throw new Error(data.error || 'Failed to start Hermes dashboard') + setDashboardMessage(data.note || 'Hermes dashboard started.') + window.open(data.proxyUrl, '_blank', 'noopener,noreferrer') + } catch (error) { + setDashboardMessage(error instanceof Error ? error.message : 'Failed to start Hermes dashboard.') + } finally { + setHermesDashboardLaunching(false) + } + } + const updateMcpServerAccess = async ( server: McpServerAccess, body: Partial>, @@ -666,12 +684,21 @@ export default function SandboxList({ Start OpenClaw Gateway Dashboard ) : ( - + <> + + + )} - )} + {mcpAuthLoginUrl && ( + <> +
+
+ Or +
+
+ + + Sign In via Company Portal + + + )} +
Setup Account Forgot Password? @@ -70,3 +97,4 @@ export default function LoginPage() { ) } + diff --git a/middleware.ts b/middleware.ts index 5ca1370..e20f78c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from "next/server" -import { getAuthSettings, verifySessionCookieValue } from "./app/lib/controlAuth" +import { getAuthSettings, verifySessionCookieValue, verifyCFAuthorizationJWT, isUserAuthorizedForSandbox } from "./app/lib/controlAuth" const PUBLIC_PATHS = [ "/login", "/api/auth/login", "/api/auth/logout", + "/api/auth/callback", "/setup-account", "/forgot-password", "/api/auth/setup", @@ -89,17 +90,57 @@ function hasTrustedOrigin(request: NextRequest) { } } +function getSandboxIdFromRequest(request: NextRequest): string | null { + const { pathname, searchParams } = request.nextUrl + + if (pathname.startsWith("/api/sandbox/")) { + const parts = pathname.split("/") + if (parts[3] && parts[3] !== "create" && parts[3] !== "delete") { + return parts[3] + } + } + + if (pathname.startsWith("/api/telemetry/sandbox/")) { + const parts = pathname.split("/") + if (parts[4]) { + return parts[4] + } + } + + if (pathname.startsWith("/api/openshell/instances/")) { + const parts = pathname.split("/") + const instanceId = parts[4] + if (instanceId) { + const decodedInstanceId = decodeURIComponent(instanceId) + const match = decodedInstanceId.match(/^sandbox-(\d+)-(.+)$/) + if (match) return match[2] + } + } + + const querySandboxId = searchParams.get("sandboxId") + if (querySandboxId) { + return querySandboxId + } + + return null +} + export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl const settings = getAuthSettings() + 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}` + if (isStateChangingMethod(request.method) && !hasTrustedOrigin(request)) { return withSecurityHeaders(NextResponse.json({ ok: false, error: "Untrusted request origin" }, { status: 403 })) } if (settings.disabled || isAssetPath(pathname)) { if (pathname === "/login") { - return withSecurityHeaders(NextResponse.redirect(new URL("/", request.url))) + return withSecurityHeaders(NextResponse.redirect(new URL("/", baseUrl))) } return withSecurityHeaders(NextResponse.next()) } @@ -108,23 +149,59 @@ export async function middleware(request: NextRequest) { return withSecurityHeaders(NextResponse.next()) } + // Intercept and validate the MCPAuth CF_Authorization JWT cookie + const cfAuthCookie = request.cookies.get("CF_Authorization")?.value + const cfAuthPayload = await verifyCFAuthorizationJWT(cfAuthCookie) + const cfUserEmail = cfAuthPayload?.email || cfAuthPayload?.sub || null + if (isPublicPath(pathname)) { - if (pathname === "/login" && await verifySessionCookieValue(request.cookies.get(settings.cookieName)?.value)) { - return withSecurityHeaders(NextResponse.redirect(new URL("/", request.url))) + if (pathname === "/login") { + const hasOperatorSession = await verifySessionCookieValue(request.cookies.get(settings.cookieName)?.value) + if (hasOperatorSession || cfUserEmail) { + return withSecurityHeaders(NextResponse.redirect(new URL("/", baseUrl))) + } } return withSecurityHeaders(NextResponse.next()) } + // 1. Operator Auth Check const authenticated = await verifySessionCookieValue(request.cookies.get(settings.cookieName)?.value) if (authenticated) { return withSecurityHeaders(NextResponse.next()) } + // 2. MCPAuth Auth Check + if (cfUserEmail) { + // Non-operator enterprise users cannot create/delete sandboxes + if (pathname === "/api/sandbox/create" || pathname === "/api/sandbox/delete" || pathname.startsWith("/api/actions/nemoclaw-create")) { + return withSecurityHeaders(NextResponse.json({ ok: false, error: "Forbidden: Operator role required" }, { status: 403 })) + } + + // Gate access to specific sandbox resources + const sandboxId = getSandboxIdFromRequest(request) + if (sandboxId && !isUserAuthorizedForSandbox(cfUserEmail, sandboxId)) { + if (pathname.startsWith("/api/")) { + return withSecurityHeaders(NextResponse.json({ ok: false, error: `Forbidden: No access to sandbox ${sandboxId}` }, { status: 403 })) + } + return new NextResponse("Forbidden: Access denied to this sandbox", { status: 403 }) + } + + // Forward the authenticated email identity downstream + const requestHeaders = new Headers(request.headers) + requestHeaders.set("x-forwarded-user", cfUserEmail) + return withSecurityHeaders(NextResponse.next({ + request: { + headers: requestHeaders, + } + })) + } + + // 3. Fallback: unauthenticated if (pathname.startsWith("/api/")) { return withSecurityHeaders(NextResponse.json({ ok: false, error: "Authentication required" }, { status: 401 })) } - const loginUrl = new URL("/login", request.url) + const loginUrl = new URL("/login", baseUrl) loginUrl.searchParams.set("next", `${pathname}${request.nextUrl.search}`) return withSecurityHeaders(NextResponse.redirect(loginUrl)) } @@ -132,3 +209,4 @@ export async function middleware(request: NextRequest) { export const config = { matcher: ["/((?!_next/static|_next/image).*)"], } + diff --git a/tests/control-auth-mcpauth-check.mjs b/tests/control-auth-mcpauth-check.mjs new file mode 100644 index 0000000..62619ba --- /dev/null +++ b/tests/control-auth-mcpauth-check.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import vm from 'node:vm' +import ts from 'typescript' +import { createRequire } from 'node:module' + +const root = process.cwd() +const sourcePath = path.join(root, 'app/lib/controlAuth.ts') +const source = readFileSync(sourcePath, 'utf8') +const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, +}).outputText + +// Helper to generate a Node-signed HS256 JWT for validation tests +function signJWT(payload, secret) { + const header = { alg: 'HS256', typ: 'JWT' } + const base64Url = (str) => Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') + const h = base64Url(JSON.stringify(header)) + const p = base64Url(JSON.stringify(payload)) + const signature = crypto.createHmac('sha256', secret) + .update(`${h}.${p}`) + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') + return `${h}.${p}.${signature}` +} + +const originalEnv = { + mcpauthSecret: process.env.MCPAUTH_JWT_SECRET, + cfauthSecret: process.env.CF_AUTH_JWT_SECRET, + sandboxUsers: process.env.SANDBOX_ACCESS_USERS, +} + +try { + delete process.env.MCPAUTH_JWT_SECRET + delete process.env.CF_AUTH_JWT_SECRET + delete process.env.SANDBOX_ACCESS_USERS + + const module = { exports: {} } + // Next.js Edge Runtime mock global context + const mockGlobal = { + require: createRequire(sourcePath), + exports: module.exports, + module, + process, + URL, + crypto: { + subtle: { + async importKey(format, keyData, algorithm, extractable, keyUsages) { + const keyBuffer = Buffer.from(keyData) + return { keyBuffer, algorithm } + }, + async sign(algorithm, key, data) { + const hmac = crypto.createHmac('sha256', key.keyBuffer) + hmac.update(Buffer.from(data)) + return hmac.digest() + } + } + }, + TextEncoder: class { + encode(str) { return Buffer.from(str) } + }, + TextDecoder: class { + decode(buf) { return Buffer.from(buf).toString() } + }, + btoa(str) { return Buffer.from(str, 'binary').toString('base64') }, + atob(str) { return Buffer.from(str, 'base64').toString('binary') }, + } + + vm.runInNewContext(compiled, mockGlobal, { filename: sourcePath }) + + const auth = module.exports + + // Test 1: getCFAuthSecret + assert.equal(auth.getCFAuthSecret(), 'my-secret-key') + process.env.CF_AUTH_JWT_SECRET = 'cf-custom-secret' + assert.equal(auth.getCFAuthSecret(), 'cf-custom-secret') + process.env.MCPAUTH_JWT_SECRET = 'mcp-custom-secret' + assert.equal(auth.getCFAuthSecret(), 'mcp-custom-secret') + + // Test 2: getSandboxAccessMap + process.env.SANDBOX_ACCESS_USERS = 'sandbox-1:alice@co.com,sandbox-1:bob@co.com,sandbox-2:charlie@co.com' + const map = auth.getSandboxAccessMap() + assert.ok(map) + assert.equal(map.constructor.name, 'Map') + assert.equal(map.get('sandbox-1').has('alice@co.com'), true) + assert.equal(map.get('sandbox-1').has('bob@co.com'), true) + assert.equal(map.get('sandbox-1').has('charlie@co.com'), false) + assert.equal(map.get('sandbox-2').has('charlie@co.com'), true) + + // Test 3: isUserAuthorizedForSandbox + assert.equal(auth.isUserAuthorizedForSandbox('alice@co.com', 'sandbox-1'), true) + assert.equal(auth.isUserAuthorizedForSandbox('bob@co.com', 'sandbox-1'), true) + assert.equal(auth.isUserAuthorizedForSandbox('charlie@co.com', 'sandbox-1'), false) + assert.equal(auth.isUserAuthorizedForSandbox('charlie@co.com', 'sandbox-2'), true) + assert.equal(auth.isUserAuthorizedForSandbox('operator', 'sandbox-2'), false) // strict mapping checks + + // Test 4: verifyCFAuthorizationJWT - Valid Token + const now = Math.floor(Date.now() / 1000) + const validPayload = { sub: 'alice@co.com', email: 'alice@co.com', exp: now + 3600 } + const validSecret = 'mcp-custom-secret' + const validToken = signJWT(validPayload, validSecret) + + const parsedPayload = await auth.verifyCFAuthorizationJWT(validToken) + assert.ok(parsedPayload) + assert.equal(parsedPayload.email, 'alice@co.com') + + // Test 5: verifyCFAuthorizationJWT - Expired Token + const expiredPayload = { sub: 'alice@co.com', email: 'alice@co.com', exp: now - 60 } + const expiredToken = signJWT(expiredPayload, validSecret) + const parsedExpired = await auth.verifyCFAuthorizationJWT(expiredToken) + assert.equal(parsedExpired, null) + + // Test 6: verifyCFAuthorizationJWT - Signature Mismatch / Tampered + const wrongSecretToken = signJWT(validPayload, 'wrong-secret') + const parsedWrongSecret = await auth.verifyCFAuthorizationJWT(wrongSecretToken) + assert.equal(parsedWrongSecret, null) + + console.log('control-auth-mcpauth-check: PASS all MCPAuth IDP JWT and sandbox authorization assertions') +} finally { + // Restore process.env + if (originalEnv.mcpauthSecret === undefined) delete process.env.MCPAUTH_JWT_SECRET + else process.env.MCPAUTH_JWT_SECRET = originalEnv.mcpauthSecret + + if (originalEnv.cfauthSecret === undefined) delete process.env.CF_AUTH_JWT_SECRET + else process.env.CF_AUTH_JWT_SECRET = originalEnv.cfauthSecret + + if (originalEnv.sandboxUsers === undefined) delete process.env.SANDBOX_ACCESS_USERS + else process.env.SANDBOX_ACCESS_USERS = originalEnv.sandboxUsers +} From 467a528f7301c5d359f94d9ea36fb6a22e165f97 Mon Sep 17 00:00:00 2001 From: ivobrett Date: Wed, 27 May 2026 14:32:07 +0100 Subject: [PATCH 009/102] fix create url blocking --- SANDBOX_ACCESS_CONTROL.md | 148 ++++++++++++++++++++++++++++++++++++++ middleware.ts | 5 +- 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 SANDBOX_ACCESS_CONTROL.md diff --git a/SANDBOX_ACCESS_CONTROL.md b/SANDBOX_ACCESS_CONTROL.md new file mode 100644 index 0000000..69ee0be --- /dev/null +++ b/SANDBOX_ACCESS_CONTROL.md @@ -0,0 +1,148 @@ +# Per-Sandbox Access Control via MCPAuth + +Allows enterprise users to authenticate via the internal IDP (MCPAuth) 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) + └── CF_Authorization JWT cookie (from MCPAuth) → read-only, own sandboxes only +``` + +The key insight: MCPAuth sets a `CF_Authorization` 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 MCPAuth) 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 +# MCPAuth IDP Configuration for Enterprise Sandbox Access +MCPAUTH_JWT_SECRET=my-secret-key # must match MCPAuth server/session.go jwtSecret +SANDBOX_ACCESS_USERS=sandbox-name:user@example.com,other-sandbox:other@example.com +MCPAUTH_LOGIN_URL=https://idp.ag-*.nemoclaw.dpdns.org/internal/login +MCPAUTH_CLIENT_ID= +MCPAUTH_CLIENT_SECRET= +MCPAUTH_CALLBACK_URL=https://openshell-controller.ag-*.nemoclaw.dpdns.org/api/auth/callback +``` + +`SANDBOX_ACCESS_USERS` is a comma-separated list of `sandboxName:email` pairs. +A sandbox can have at most one assigned user. Operator (password login) bypasses +this check and sees all sandboxes. + +### 3. `middleware.ts` — CF_Authorization validation + +Added to the existing Next.js middleware: + +- Reads `CF_Authorization` cookie on every request +- Validates the JWT signature (HS256, `MCPAUTH_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/controlAuth.ts` — New helper functions + +**`verifyCFAuthorizationJWT(token)`** — validates HMAC HS256 JWT, returns `{ email, sub, exp }` or null. + +**`isUserAuthorizedForSandbox(email, sandboxId)`** — parses `SANDBOX_ACCESS_USERS` env var 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). + +## 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. MCPAuth sets `CF_Authorization` 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 + +Edit `.env.local` on the server and add the sandbox name and email to `SANDBOX_ACCESS_USERS`: + +```env +SANDBOX_ACCESS_USERS=alice-sandbox:alice@company.com,bob-sandbox:bob@company.com +``` + +Restart the controller for the change to take effect: +```bash +# On the VPS +cd /opt/openshell-controller && pm2 restart all # or however it's run +``` + +To create a user in MCPAuth's internal IDP, use the Pangolin admin panel at +`https://pangolin.ag-*.nemoclaw.dpdns.org` or the MCPAuth internal API. + +## MCPAuth JWT Secret + +The JWT is signed with HMAC HS256 using the `jwtSecret` variable in +`mcpauth/server/session.go` (currently hardcoded as `"my-secret-key"`). + +`MCPAUTH_JWT_SECRET` in `.env.local` must match this value. If MCPAuth is ever +updated to read the key from an env var, update `.env.local` accordingly. + +**Security note:** The hardcoded `my-secret-key` in MCPAuth is a known value. +For production, update `mcpauth/server/session.go` to read the JWT secret from +an env var (`JWT_SECRET`) and set a strong random value in both MCPAuth and +`MCPAUTH_JWT_SECRET`. + +## 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 MCPAuth + users without needing to use the Pangolin admin panel. + +- **Stronger JWT secret**: Move MCPAuth'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 MCPAuth (a new + `/auth/browser` endpoint that redirects to login instead of returning 401). diff --git a/middleware.ts b/middleware.ts index e20f78c..bd878be 100644 --- a/middleware.ts +++ b/middleware.ts @@ -172,8 +172,9 @@ export async function middleware(request: NextRequest) { // 2. MCPAuth Auth Check if (cfUserEmail) { - // Non-operator enterprise users cannot create/delete sandboxes - if (pathname === "/api/sandbox/create" || pathname === "/api/sandbox/delete" || pathname.startsWith("/api/actions/nemoclaw-create")) { + // Non-operator enterprise users cannot create/delete sandboxes (state-changing requests only) + const isWriteRequest = !["GET", "HEAD", "OPTIONS"].includes(request.method.toUpperCase()) + if (isWriteRequest && (pathname === "/api/sandbox/create" || pathname === "/api/sandbox/delete" || pathname.startsWith("/api/actions/nemoclaw-create"))) { return withSecurityHeaders(NextResponse.json({ ok: false, error: "Forbidden: Operator role required" }, { status: 403 })) } From 0f2f700524c6d60b5db455a05b3d65f9219bf20a Mon Sep 17 00:00:00 2001 From: ivobrett Date: Wed, 27 May 2026 17:08:37 +0100 Subject: [PATCH 010/102] Fix sandbox per-user auth: gate by name, not UUID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSandboxIdFromRequest was extracting UUID from /api/sandbox/ and /api/telemetry/sandbox/ paths but SANDBOX_ACCESS_USERS stores names. Only the instance proxy path (sandbox-{port}-{name}) was accessible to MCPAuth users anyway — UUID-based API paths are operator-level GETs, already read-only due to the write block. Co-Authored-By: Claude Sonnet 4.6 --- middleware.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/middleware.ts b/middleware.ts index bd878be..0f576cb 100644 --- a/middleware.ts +++ b/middleware.ts @@ -93,20 +93,7 @@ function hasTrustedOrigin(request: NextRequest) { function getSandboxIdFromRequest(request: NextRequest): string | null { const { pathname, searchParams } = request.nextUrl - if (pathname.startsWith("/api/sandbox/")) { - const parts = pathname.split("/") - if (parts[3] && parts[3] !== "create" && parts[3] !== "delete") { - return parts[3] - } - } - - if (pathname.startsWith("/api/telemetry/sandbox/")) { - const parts = pathname.split("/") - if (parts[4]) { - return parts[4] - } - } - + // Gate dashboard proxy access by sandbox name (extracted from instance ID format: sandbox-{port}-{name}) if (pathname.startsWith("/api/openshell/instances/")) { const parts = pathname.split("/") const instanceId = parts[4] From 90f1018015cce48f6e4b8b3d8cb48de818bf5ec1 Mon Sep 17 00:00:00 2001 From: ivobrett Date: Wed, 27 May 2026 17:10:12 +0100 Subject: [PATCH 011/102] Update sandbox access control doc to use sandbox name not UUID SANDBOX_ACCESS_USERS uses the sandbox name (e.g. my-first-claw), not the UUID. The middleware gates dashboard proxy paths by name extracted from the instance ID format sandbox-{port}-{name}. Co-Authored-By: Claude Sonnet 4.6 --- SANDBOX_ACCESS_CONTROL.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/SANDBOX_ACCESS_CONTROL.md b/SANDBOX_ACCESS_CONTROL.md index 69ee0be..b9771cf 100644 --- a/SANDBOX_ACCESS_CONTROL.md +++ b/SANDBOX_ACCESS_CONTROL.md @@ -59,6 +59,8 @@ MCPAUTH_CALLBACK_URL=https://openshell-controller.ag-*.nemoclaw.dpdns.org/api/au ``` `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. @@ -101,7 +103,9 @@ Added to the existing Next.js middleware: ## Assigning a Sandbox to a User -Edit `.env.local` on the server and add the sandbox name and email to `SANDBOX_ACCESS_USERS`: +Edit `.env.local` on the server and add the sandbox **name** and email to `SANDBOX_ACCESS_USERS`. +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. ```env SANDBOX_ACCESS_USERS=alice-sandbox:alice@company.com,bob-sandbox:bob@company.com From 1e49edc3ff04142fcfff4ac955160828d7647a23 Mon Sep 17 00:00:00 2001 From: ivobrett Date: Wed, 27 May 2026 17:21:04 +0100 Subject: [PATCH 012/102] Allow MCPAuth users through WebSocket dashboard upgrade handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server.mjs's upgrade handler only checked openshell_control_session (operator cookie), so MCPAuth enterprise users got 401 on the WebSocket handshake → 1006 disconnect in the OpenClaw UI. Added isMCPAuthDashboardUpgradeAuthorized: verifies CF_Authorization JWT (HMAC HS256, MCPAUTH_JWT_SECRET) and checks per-sandbox access via SANDBOX_ACCESS_USERS before allowing the upgrade. Co-Authored-By: Claude Sonnet 4.6 --- server.mjs | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/server.mjs b/server.mjs index c919f5b..2df2b49 100644 --- a/server.mjs +++ b/server.mjs @@ -169,6 +169,91 @@ function rejectUnauthorizedUpgrade(req, socket, path) { socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') } +function getCFAuthJWTSecret() { + return process.env.MCPAUTH_JWT_SECRET || process.env.CF_AUTH_JWT_SECRET || 'my-secret-key' +} + +function cfAuthHmac(payload) { + return crypto.createHmac('sha256', getCFAuthJWTSecret()).update(payload).digest('base64url') +} + +function verifyCFAuthorizationJWT(token) { + if (!token) return null + const parts = token.split('.') + if (parts.length !== 3) return null + const [headerB64, payloadB64, signatureB64] = parts + try { + if (!safeEqual(signatureB64, cfAuthHmac(`${headerB64}.${payloadB64}`))) return null + const payload = JSON.parse(base64UrlDecode(payloadB64)) + const now = Math.floor(Date.now() / 1000) + if (typeof payload.exp === 'number' && payload.exp < now) return null + return payload + } catch { + return null + } +} + +function getSandboxAccessMapForServer() { + const map = new Map() + const env = process.env.SANDBOX_ACCESS_USERS || '' + if (!env) return map + for (const pair of env.split(',')) { + const trimmed = pair.trim() + if (!trimmed) continue + const colonIndex = trimmed.indexOf(':') + if (colonIndex === -1) continue + const sandboxName = trimmed.slice(0, colonIndex).trim() + const email = trimmed.slice(colonIndex + 1).trim().toLowerCase() + if (sandboxName && email) { + if (!map.has(sandboxName)) map.set(sandboxName, new Set()) + map.get(sandboxName).add(email) + } + } + return map +} + +function getSandboxIdFromUpgradeUrl(url) { + try { + const parsed = new URL(url || '/', `http://localhost:${port}`) + const { pathname, searchParams } = parsed + if (pathname.startsWith(instancesProxyPrefix)) { + const suffixIndex = pathname.indexOf(dashboardProxySuffix, instancesProxyPrefix.length) + if (suffixIndex !== -1) { + const instanceId = decodeURIComponent(pathname.slice(instancesProxyPrefix.length, suffixIndex)) + const match = instanceId.match(/^sandbox-(\d+)-(.+)$/) + if (match) return match[2] + } + } + const querySandboxId = searchParams.get('sandboxId') + if (querySandboxId) return querySandboxId + return null + } catch { + return null + } +} + +function isMCPAuthDashboardUpgradeAuthorized(req) { + const cfAuthToken = readCookieValue(req.headers.cookie, 'CF_Authorization') + const payload = verifyCFAuthorizationJWT(cfAuthToken) + if (!payload) return false + const email = (String(payload.email || payload.sub || '')).trim() + if (!email) return false + const sandboxId = getSandboxIdFromUpgradeUrl(req.url) + if (!sandboxId) return false + const map = getSandboxAccessMapForServer() + const authorizedEmails = map.get(sandboxId) + if (!authorizedEmails) return false + const authorized = authorizedEmails.has(email.toLowerCase()) + if (authorized) { + logBridge('dashboard-ws-mcpauth-authorized', { + path: req.url || '/', + sandboxId, + remoteAddress: req.socket.remoteAddress || 'unknown', + }) + } + return authorized +} + function buildTerminalUpstreamUrl(req) { const incoming = new URL(req.url || '/', `http://${req.headers.host || `${hostname}:${port}`}`) const upstream = new URL('/ws', `${terminalWsProtocol}//${terminalServerUrl.host}`) @@ -930,7 +1015,7 @@ server.on('upgrade', (req, socket, head) => { (req.url || '').startsWith(legacyDashboardProxyPrefix) || (req.url || '').startsWith(instancesProxyPrefix) ) { - if (!isAuthenticatedUpgrade(req)) { + if (!isAuthenticatedUpgrade(req) && !isMCPAuthDashboardUpgradeAuthorized(req)) { rejectUnauthorizedUpgrade(req, socket, req.url || '/') return } @@ -970,7 +1055,7 @@ if (dashboardWsProxyServer) { (req.url || '').startsWith(legacyDashboardProxyPrefix) || (req.url || '').startsWith(instancesProxyPrefix) ) { - if (!isAuthenticatedUpgrade(req)) { + if (!isAuthenticatedUpgrade(req) && !isMCPAuthDashboardUpgradeAuthorized(req)) { rejectUnauthorizedUpgrade(req, socket, req.url || '/') return } From ff6521a59c7be049f7cddce6854a640bbab2837b Mon Sep 17 00:00:00 2001 From: ivobrett Date: Wed, 27 May 2026 17:29:12 +0100 Subject: [PATCH 013/102] Enforce global read-only access for MCPAuth enterprise users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only blocked create/delete sandbox endpoints for MCPAuth users, leaving all other POST/PUT/DELETE endpoints (settings, inference, etc.) wide open. MCPAuth users are intended to be read-only — block all state-changing methods (POST/PUT/DELETE/PATCH) globally, not just a narrow path list. Co-Authored-By: Claude Sonnet 4.6 --- middleware.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/middleware.ts b/middleware.ts index 0f576cb..6d5c677 100644 --- a/middleware.ts +++ b/middleware.ts @@ -159,9 +159,9 @@ export async function middleware(request: NextRequest) { // 2. MCPAuth Auth Check if (cfUserEmail) { - // Non-operator enterprise users cannot create/delete sandboxes (state-changing requests only) + // MCPAuth enterprise users are read-only — block all state-changing methods globally const isWriteRequest = !["GET", "HEAD", "OPTIONS"].includes(request.method.toUpperCase()) - if (isWriteRequest && (pathname === "/api/sandbox/create" || pathname === "/api/sandbox/delete" || pathname.startsWith("/api/actions/nemoclaw-create"))) { + if (isWriteRequest) { return withSecurityHeaders(NextResponse.json({ ok: false, error: "Forbidden: Operator role required" }, { status: 403 })) } From f2ad3fde6af34d12f74f7ce6f8b590288c253e9e Mon Sep 17 00:00:00 2001 From: ivobrett Date: Wed, 27 May 2026 18:56:37 +0100 Subject: [PATCH 014/102] Fix password reset and add Security page for sandbox access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edge runtime middleware snapshots process.env at sandbox init, so when updateLocalAuthCredentials() rotated OPENSHELL_CONTROL_AUTH_SECRET in .env.local, the middleware kept verifying with the stale secret while the login route signed cookies with the new one — fresh logins were rejected with a redirect back to /login. Schedule process.exit() after writes to .env.local; systemd restarts the unit and the Edge sandbox picks up the new values. Frontend shows an "Applying changes — controller restarting…" message and reloads. Repurposes the existing /setup-account page as the operator-only Security page. Adds a Sandbox Access section that GETs/POSTs against the new /api/security/sandbox-access endpoint to edit SANDBOX_ACCESS_USERS as structured rows; both methods require the operator session cookie so MCPAuth users cannot read or modify the list. --- app/api/auth/recover/route.ts | 4 +- app/api/auth/setup/route.ts | 4 +- app/api/security/sandbox-access/route.ts | 71 +++++ app/components/Sidebar.tsx | 2 +- app/lib/controlAuthConfig.ts | 26 ++ app/login/page.tsx | 2 +- app/setup-account/page.tsx | 326 +++++++++++++++++++---- 7 files changed, 377 insertions(+), 58 deletions(-) create mode 100644 app/api/security/sandbox-access/route.ts diff --git a/app/api/auth/recover/route.ts b/app/api/auth/recover/route.ts index 21d64a5..7734fbe 100644 --- a/app/api/auth/recover/route.ts +++ b/app/api/auth/recover/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { createSessionCookieValue, getAuthSettings, sessionCookieOptionsForRequest, verifyRecoveryToken } from "@/app/lib/controlAuth" -import { updateLocalAuthCredentials } from "@/app/lib/controlAuthConfig" +import { scheduleControllerRestart, updateLocalAuthCredentials } from "@/app/lib/controlAuthConfig" import { checkRateLimit, clearRateLimit, rateLimitKey, recordRateLimitFailure } from "@/app/lib/rateLimit" const RECOVERY_WINDOW_MS = 15 * 60 * 1000 @@ -32,10 +32,12 @@ export async function POST(request: NextRequest) { clearRateLimit(limitKey) const result = await updateLocalAuthCredentials(password) const settings = getAuthSettings() + const willRestart = scheduleControllerRestart() const response = NextResponse.json({ ok: true, recoveryToken: result.recoveryToken, note: "Password reset. Save the new recovery token from .env.local.", + willRestart, }) 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..74f1aaa 100644 --- a/app/api/auth/setup/route.ts +++ b/app/api/auth/setup/route.ts @@ -7,7 +7,7 @@ import { verifyRecoveryToken, verifySessionCookieValue, } from "@/app/lib/controlAuth" -import { updateLocalAuthCredentials } from "@/app/lib/controlAuthConfig" +import { scheduleControllerRestart, updateLocalAuthCredentials } from "@/app/lib/controlAuthConfig" import { checkRateLimit, clearRateLimit, rateLimitKey, recordRateLimitFailure } from "@/app/lib/rateLimit" const SETUP_WINDOW_MS = 15 * 60 * 1000 @@ -49,10 +49,12 @@ export async function POST(request: NextRequest) { clearRateLimit(limitKey) const result = await updateLocalAuthCredentials(password) + const willRestart = scheduleControllerRestart() const response = NextResponse.json({ ok: true, recoveryToken: result.recoveryToken, note: "Password updated. Save the new recovery token from .env.local.", + willRestart, }) response.cookies.set(settings.cookieName, await createSessionCookieValue(), sessionCookieOptionsForRequest(request)) return response diff --git a/app/api/security/sandbox-access/route.ts b/app/api/security/sandbox-access/route.ts new file mode 100644 index 0000000..8386266 --- /dev/null +++ b/app/api/security/sandbox-access/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server" +import { getAuthSettings, getSandboxAccessMap, verifySessionCookieValue } from "@/app/lib/controlAuth" +import { + SandboxAccessEntry, + scheduleControllerRestart, + updateSandboxAccessUsers, +} from "@/app/lib/controlAuthConfig" + +const EMAIL_RE = /^[^\s,:@]+@[^\s,:@]+\.[^\s,:@]+$/ +const SANDBOX_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/ + +async function requireOperator(request: NextRequest) { + const settings = getAuthSettings() + const ok = await verifySessionCookieValue(request.cookies.get(settings.cookieName)?.value) + return ok +} + +function listEntries(): SandboxAccessEntry[] { + const map = getSandboxAccessMap() + const entries: SandboxAccessEntry[] = [] + for (const [sandboxName, emails] of map.entries()) { + for (const email of emails) { + entries.push({ sandboxName, email }) + } + } + entries.sort((a, b) => a.sandboxName.localeCompare(b.sandboxName) || a.email.localeCompare(b.email)) + return entries +} + +export async function GET(request: NextRequest) { + if (!(await requireOperator(request))) { + return NextResponse.json({ ok: false, error: "Operator session required." }, { status: 401 }) + } + return NextResponse.json({ ok: true, entries: listEntries() }) +} + +export async function POST(request: NextRequest) { + if (!(await requireOperator(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 }) + } + + await updateSandboxAccessUsers(normalized) + const willRestart = scheduleControllerRestart() + return NextResponse.json({ ok: true, entries: normalized, willRestart }) +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index b5c3cea..f54777b 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -324,7 +324,7 @@ export default function Sidebar({ - Account + Security diff --git a/app/lib/controlAuthConfig.ts b/app/lib/controlAuthConfig.ts index 3524825..1c4fbc6 100644 --- a/app/lib/controlAuthConfig.ts +++ b/app/lib/controlAuthConfig.ts @@ -58,3 +58,29 @@ export async function updateLocalAuthCredentials(password: string) { return { recoveryToken } } + +export type SandboxAccessEntry = { sandboxName: string; email: string } + +export function serializeSandboxAccessEntries(entries: SandboxAccessEntry[]) { + return entries + .map((entry) => `${entry.sandboxName.trim()}:${entry.email.trim().toLowerCase()}`) + .join(",") +} + +export async function updateSandboxAccessUsers(entries: SandboxAccessEntry[]) { + const value = serializeSandboxAccessEntries(entries) + let content = await readEnvFile() + content = upsertEnv(content, "SANDBOX_ACCESS_USERS", value) + await writeFile(ENV_PATH, content, "utf8") + process.env.SANDBOX_ACCESS_USERS = value + return { value } +} + +export function scheduleControllerRestart(delayMs = 500) { + if (process.env.NODE_ENV !== "production") return false + setTimeout(() => { + console.log("[security] restarting controller to pick up .env.local changes") + process.exit(0) + }, delayMs).unref?.() + return true +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 6892671..679ee7b 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -90,7 +90,7 @@ export default function LoginPage() { )} diff --git a/app/setup-account/page.tsx b/app/setup-account/page.tsx index d10e3f8..49e6dce 100644 --- a/app/setup-account/page.tsx +++ b/app/setup-account/page.tsx @@ -1,9 +1,24 @@ "use client" -import { FormEvent, useState } from "react" +import { FormEvent, useCallback, useEffect, useState } from "react" import AuthShell from "../components/AuthShell" -export default function SetupAccountPage() { +type AccessEntry = { sandboxName: string; email: string } + +const RESTART_RELOAD_MS = 6000 + +export default function SecurityPage() { + return ( + + +
+ + Back to Sign In + + ) +} + +function PasswordSection() { const [currentPassword, setCurrentPassword] = useState("") const [password, setPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("") @@ -27,10 +42,13 @@ export default function SetupAccountPage() { }) const data = await response.json() if (!response.ok) throw new Error(data.error || "Could not update account.") - setMessage("Account updated. Redirecting...") - window.setTimeout(() => { - window.location.href = "/" - }, 600) + if (data.willRestart) { + setMessage("Password updated. Applying changes — controller restarting…") + window.setTimeout(() => { window.location.href = "/" }, RESTART_RELOAD_MS) + } else { + setMessage("Password updated. Redirecting…") + window.setTimeout(() => { window.location.href = "/" }, 600) + } } catch (error) { setMessage(error instanceof Error ? error.message : "Could not update account.") } finally { @@ -39,54 +57,254 @@ export default function SetupAccountPage() { } return ( - -
- - - - - - - {message && ( -
- {message} + +

Change Password

+ + + + + + + {message && ( +
+ {message} +
+ )} + + + + ) +} + +function SandboxAccessSection() { + const [entries, setEntries] = useState([]) + const [sandboxOptions, setSandboxOptions] = useState([]) + const [pickedSandbox, setPickedSandbox] = useState("") + const [newEmail, setNewEmail] = useState("") + const [loading, setLoading] = useState(true) + const [authorized, setAuthorized] = useState(true) + const [dirty, setDirty] = useState(false) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState("") + + const load = useCallback(async () => { + setLoading(true) + try { + const response = await fetch("/api/security/sandbox-access", { cache: "no-store" }) + if (response.status === 401) { + setAuthorized(false) + return + } + const data = await response.json() + if (!response.ok) throw new Error(data.error || "Failed to load sandbox access list.") + setAuthorized(true) + setEntries(Array.isArray(data.entries) ? data.entries : []) + setDirty(false) + } catch (error) { + setMessage(error instanceof Error ? error.message : "Failed to load sandbox access list.") + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + load() + fetch("/api/telemetry/real", { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return + const names: string[] = [] + if (Array.isArray(data.sandboxes)) { + for (const s of data.sandboxes) if (typeof s?.name === "string") names.push(s.name) + } else if (Array.isArray(data?.pods?.items)) { + for (const p of data.pods.items) { + const n = p?.metadata?.labels?.["nemoclaw.ai/sandbox-name"] || p?.metadata?.name + if (typeof n === "string") names.push(n) + } + } + const unique = Array.from(new Set(names)).sort() + setSandboxOptions(unique) + if (unique.length > 0) setPickedSandbox((prev) => prev || unique[0]) + }) + .catch(() => null) + }, [load]) + + const addEntry = () => { + setMessage("") + const sandboxName = pickedSandbox.trim() + const email = newEmail.trim().toLowerCase() + if (!sandboxName || !email) { + setMessage("Pick a sandbox and enter an email.") + return + } + if (entries.some((e) => e.sandboxName === sandboxName && e.email === email)) { + setMessage("That sandbox/email pair is already in the list.") + return + } + setEntries([...entries, { sandboxName, email }]) + setNewEmail("") + setDirty(true) + } + + const removeEntry = (index: number) => { + setEntries(entries.filter((_, i) => i !== index)) + setDirty(true) + } + + const save = async () => { + setBusy(true) + setMessage("") + try { + const response = await fetch("/api/security/sandbox-access", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entries }), + }) + const data = await response.json() + if (!response.ok) throw new Error(data.error || "Failed to save.") + setEntries(Array.isArray(data.entries) ? data.entries : entries) + setDirty(false) + if (data.willRestart) { + setMessage("Saved. Applying changes — controller restarting…") + window.setTimeout(() => window.location.reload(), RESTART_RELOAD_MS) + } else { + setMessage("Saved.") + } + } catch (error) { + setMessage(error instanceof Error ? error.message : "Failed to save.") + } finally { + setBusy(false) + } + } + + if (!authorized) { + return ( +
+

Sandbox Access

+

+ Sign in as an operator to manage per-sandbox access for company users. +

+
+ ) + } + + return ( +
+

Sandbox Access

+

+ Authorize MCPAuth (company) users for specific sandboxes. Changes apply after the controller restarts. +

+ + {loading ? ( +

Loading…

+ ) : ( + <> +
    + {entries.length === 0 && ( +
  • No assignments yet.
  • + )} + {entries.map((entry, index) => ( +
  • + {entry.sandboxName} + {entry.email} + +
  • + ))} +
+ +
+ Add Assignment + {sandboxOptions.length > 0 ? ( + + ) : ( + setPickedSandbox(event.target.value)} + placeholder="sandbox name" + className="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--background)] px-2 py-1.5 text-xs text-[var(--foreground)] outline-none focus:border-[var(--nvidia-green)]" + /> + )} + setNewEmail(event.target.value)} + placeholder="user@example.com" + className="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--background)] px-2 py-1.5 text-xs text-[var(--foreground)] outline-none focus:border-[var(--nvidia-green)]" + /> +
- )} - - - - Back to Sign In - - + + {message && ( +
+ {message} +
+ )} + + + + )} +
) } From 61fc8317774dd9562d17bcf8a684d570dc8c02b4 Mon Sep 17 00:00:00 2001 From: ivobrett Date: Wed, 27 May 2026 19:07:01 +0100 Subject: [PATCH 015/102] Hide and gate password change for non-operator users Add /api/auth/me returning { operator, configured } so the Security page can decide which sections to render. Show the password form only when an operator session is present, or on first-run when no password is configured yet. Hide the sandbox-access section too unless operator. Non-operators see "Operator session required" instead of forms they can't submit. Backend defense in depth: /api/auth/setup and /api/auth/recover now reject requests carrying a valid CF_Authorization (MCPAuth) cookie without an operator session. This closes the rate-limited brute-force surface where a company-SSO user could attempt to guess the operator password via currentPassword or the recovery token. --- app/api/auth/me/route.ts | 8 +++++ app/api/auth/recover/route.ts | 17 +++++++++-- app/api/auth/setup/route.ts | 6 ++++ app/setup-account/page.tsx | 55 ++++++++++++++++++++++++++--------- 4 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 app/api/auth/me/route.ts 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 7734fbe..d2c0f6f 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, + verifyCFAuthorizationJWT, + verifyRecoveryToken, + verifySessionCookieValue, +} from "@/app/lib/controlAuth" import { scheduleControllerRestart, updateLocalAuthCredentials } from "@/app/lib/controlAuthConfig" import { checkRateLimit, clearRateLimit, rateLimitKey, recordRateLimitFailure } from "@/app/lib/rateLimit" @@ -20,6 +27,13 @@ export async function POST(request: NextRequest) { ) } + const settings = getAuthSettings() + const signedIn = await verifySessionCookieValue(request.cookies.get(settings.cookieName)?.value) + const cfAuth = await verifyCFAuthorizationJWT(request.cookies.get("CF_Authorization")?.value) + if (cfAuth && !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,7 +45,6 @@ export async function POST(request: NextRequest) { clearRateLimit(limitKey) const result = await updateLocalAuthCredentials(password) - const settings = getAuthSettings() const willRestart = scheduleControllerRestart() const response = NextResponse.json({ ok: true, diff --git a/app/api/auth/setup/route.ts b/app/api/auth/setup/route.ts index 74f1aaa..623b8fe 100644 --- a/app/api/auth/setup/route.ts +++ b/app/api/auth/setup/route.ts @@ -3,6 +3,7 @@ import { createSessionCookieValue, getAuthSettings, sessionCookieOptionsForRequest, + verifyCFAuthorizationJWT, verifyPassword, verifyRecoveryToken, verifySessionCookieValue, @@ -38,6 +39,11 @@ export async function POST(request: NextRequest) { } const signedIn = await verifySessionCookieValue(request.cookies.get(settings.cookieName)?.value) + const cfAuth = await verifyCFAuthorizationJWT(request.cookies.get("CF_Authorization")?.value) + if (cfAuth && !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 diff --git a/app/setup-account/page.tsx b/app/setup-account/page.tsx index 49e6dce..55d4b09 100644 --- a/app/setup-account/page.tsx +++ b/app/setup-account/page.tsx @@ -4,21 +4,46 @@ import { FormEvent, useCallback, useEffect, useState } from "react" import AuthShell from "../components/AuthShell" type AccessEntry = { sandboxName: string; email: string } +type AuthMe = { operator: boolean; configured: boolean } const RESTART_RELOAD_MS = 6000 export default function SecurityPage() { + const [me, setMe] = useState(null) + + useEffect(() => { + fetch("/api/auth/me", { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setMe(data ?? { operator: false, configured: true })) + .catch(() => setMe({ operator: false, configured: true })) + }, []) + + const showPassword = me ? me.operator || !me.configured : false + const showSandboxAccess = me?.operator === true + const showLockedNotice = me ? !me.operator && me.configured : false + return ( - -
- + {me === null ? ( +

Loading…

+ ) : ( + <> + {showPassword && } + {showPassword && showSandboxAccess &&
} + {showSandboxAccess && } + {showLockedNotice && ( +

+ Operator session required to manage security settings. Sign in with the operator password to change the password or edit sandbox access. +

+ )} + + )} Back to Sign In ) } -function PasswordSection() { +function PasswordSection({ firstRun }: { firstRun: boolean }) { const [currentPassword, setCurrentPassword] = useState("") const [password, setPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("") @@ -58,16 +83,18 @@ function PasswordSection() { return (
-

Change Password

- +

{firstRun ? "Set Password" : "Change Password"}

+ {!firstRun && ( + + )}
+
+ )} ) } From 65c8af7069dd51a44b8d877e51132a46646fc5a0 Mon Sep 17 00:00:00 2001 From: ivobrett Date: Mon, 22 Jun 2026 21:01:35 +0100 Subject: [PATCH 088/102] docs: add upstream divergence security audit + CLAUDE.md cross-refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs/upstream-divergence-audit.md catalogs every meaningful divergence from upstream (mmckeen-nv/openshell_controller), ranked by security risk tier and tagged with upstream-fixability. Designed to be walked after each upstream merge and version bump — items tagged HIGHLY upstream-fixable are the first candidates for deletion when NemoClaw/OpenShell updates land. CLAUDE.md additions: - New §4 explaining when to consult the audit (5 triggers + refresh cadence) - §5 step 9: walk the audit after every upstream merge - §6 intro: pointer for new agents - §9 intro: keep Don't-do-this list in sync between CLAUDE.md and the audit - §10: read the audit before bumping NEMOCLAW/OPENSHELL/HERMES versions Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 75 +++- docs/upstream-divergence-audit.md | 671 ++++++++++++++++++++++++++++++ 2 files changed, 744 insertions(+), 2 deletions(-) create mode 100644 docs/upstream-divergence-audit.md diff --git a/CLAUDE.md b/CLAUDE.md index 3a910e5..2de3907 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,57 @@ Repeat for each sandbox that stays unhealthy after the host gateway is back. --- +## 4. Upstream divergence audit — when to consult `docs/upstream-divergence-audit.md` + +This fork is ~94 commits ahead of upstream as of 2026-06-22, with several +workarounds for current NemoClaw / OpenShell / Hermes limitations. +`docs/upstream-divergence-audit.md` catalogs every meaningful divergence, +ranks each by security risk (🔴 HIGH / 🟠 MEDIUM / 🟢 LOW), and tags each +with whether an upstream update is likely to obsolete it +("HIGHLY upstream-fixable" vs "OURS — permanent"). + +**Read it (or at minimum the prioritized summary table at the top) when:** + +1. **You finish a `sync/upstream-YYYY-MM-DD` merge** (see §5). For every + item tagged "HIGHLY upstream-fixable", check whether the new upstream + delivers the underlying capability — and if so, *delete* the workaround + in the same merge. Don't carry obsolete shims forward. +2. **You bump `NEMOCLAW_INSTALL_REF`, `NEMOCLAW_INSTALL_TAG`, + `OPENSHELL_VERSION`, `OPENCLAW_VERSION`, or `HERMES`** in + `install_versioned_nemoclaw_openshell.sh` (see §10). The audit lists + the specific shims a version bump should let you remove (e.g. + `upgrade-hermes.sh` when base ships Hermes ≥0.16; the Dockerfile + apt-pin sed-patch when NemoClaw stops Debian-pinning on Ubuntu). +3. **You are about to touch a file marked 🔴 HIGH-risk** in the audit + (anything under `app/lib/auth/*`, `app/lib/sandboxPrivilegedFiles.ts`, + `app/lib/sandboxOpenClawMcpConfig.ts`, `app/lib/mcpBroker*.ts`, + `app/lib/hermesRemote.ts`, `scripts/hermes-remote/*`, the dashboard + token chain in `server.mjs`, or restore handling in `server.mjs`). + Confirm the relevant regression tests still pass and you aren't + violating the audit's open security questions or §9 Don'ts. +4. **A security review or external pentest is requested.** The audit is + the starting point — it lists the six open security questions worth + resolving (file perms on the access stores, restore rate-limiting, + Hermes URL guessability, etc.) and the threat model assumptions behind + each high-risk area. +5. **You are deciding scope for a refactor or feature.** If you're tempted + to "while I'm in here, clean up X", check the audit first — X may be + a load-bearing workaround. + +**Refresh the audit when:** + +- After every merge from upstream (the file list at the top of the audit + goes stale immediately). +- Whenever a new fork-only feature lands that touches sandbox isolation, + auth, or public exposure. +- Target cadence: every 2 weeks if active development is happening, or + immediately after an upstream merge. + +Regeneration commands are in the "How to use this document" section at +the bottom of the audit itself. + +--- + ## 5. Pulling from upstream — the safe procedure This is the procedure I'd recommend whenever `git fetch upstream` shows @@ -238,6 +289,12 @@ 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 to reflect the new divergence. + ### Common conflict patterns and the right resolution | Pattern | Resolution | @@ -258,7 +315,11 @@ git push origin --delete "sync/upstream-$DATE" ## 6. Architecture pointers (where the load-bearing code lives) -Read these in this order if you're a new agent picking up the codebase: +Read these in this order if you're a new agent picking up the codebase. +**For a security-prioritized 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 (see +§4).** 1. **`middleware.ts`** — entry point. Uses `resolveAuthContext()` to build an `AuthContext` discriminated union, then dispatches per kind. @@ -401,7 +462,9 @@ Update those files when your work would change their accuracy. ## 9. Don'ts -These have all been tried and rejected — don't reintroduce them: +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. @@ -443,6 +506,14 @@ cause was a pinned `tmux=3.5a-3` (and friends) in NemoClaw's Ubuntu noble base. Apt returned exit 100, the docker build died at step 11 / 86, and the controller's readiness polls never saw a sandbox. +> **Before bumping any of these versions:** read `docs/upstream-divergence-audit.md` +> (see §4). The audit lists every fork-local shim that exists *specifically +> because* of the current upstream version (e.g. `scripts/hermes-remote/upgrade-hermes.sh` +> only exists because the NemoClaw base ships Hermes 0.14; the apt-pin +> sed-patch only exists because NemoClaw pins Debian package versions on +> Ubuntu). A version bump may obsolete several of these — check, then +> delete what's no longer needed in the same change set. + ### What can break when a pin changes NemoClaw upstream's Dockerfiles install: diff --git a/docs/upstream-divergence-audit.md b/docs/upstream-divergence-audit.md new file mode 100644 index 0000000..ad20102 --- /dev/null +++ b/docs/upstream-divergence-audit.md @@ -0,0 +1,671 @@ +# Upstream Divergence Audit — Security Triage + +**Snapshot date:** 2026-06-22 +**Fork:** `ivobrett/openshell_controller`, branch `gatewaydashboard` +**Upstream:** `mmckeen-nv/openshell_controller`, branch `main` +**Merge base:** `511d313c` (94 commits ahead, 0 behind) +**Change size:** ~73 files changed, +7,372 / −351 lines + +This document is an as-of-today catalog of every meaningful divergence from +upstream, grouped by area, with a security risk rating and a note on whether +the change is likely to become obsolete once NVIDIA's `openshell` and +`NemoClaw` projects ship their next round of updates. + +> **Purpose.** When upstream updates land, walk this list in priority order +> and ask, for each item: "is the underlying capability now available in +> upstream NemoClaw/OpenShell, and can we drop our workaround?" Items tagged +> **HIGHLY upstream-fixable** are the first to revisit. + +--- + +## Risk legend + +| Tier | Meaning | +|---|---| +| **🔴 HIGH** | Either (a) actively touches sandbox isolation, public network exposure, or auth-bypass surface, or (b) silently regressing it would create a real incident. Review carefully on every merge; lock in invariants with tests. | +| **🟠 MEDIUM** | Touches authenticated paths or modifies sandbox-internal files. Bugs here would degrade defence-in-depth but not by themselves let an attacker in. | +| **🟢 LOW** | Operational hygiene, docs, UI, installer scripts. Low likelihood of contributing to a security incident. | + +| Tag | Meaning | +|---|---| +| **OURS — permanent** | Project-specific feature; upstream is unlikely to add this. Will stay in the fork indefinitely. | +| **OURS — could upstream** | Generally useful; we could PR upstream and remove the fork-local copy. | +| **HIGHLY upstream-fixable** | A direct workaround for a NemoClaw / OpenShell limitation. A version bump should let us delete most of it. | + +--- + +## Prioritized summary (read this first) + +| # | Area | Risk | Upstream-fixable? | Bytes ahead-of-upstream (approx) | +|---:|---|:---:|---|---:| +| 1 | [Hermes Remote Desktop public exposure](#7-hermes-remote-desktop-public-exposure) | 🔴 HIGH | OURS — permanent | ~1,200 LoC + 9 shell scripts | +| 2 | [MCP broker + sandbox-side handshake](#5-mcp-broker--inter-sandbox-chat) | 🔴 HIGH | OURS — could upstream | ~600 LoC | +| 3 | [Privileged in-sandbox file writes](#6-privileged-in-sandbox-file-writes) | 🔴 HIGH | **HIGHLY upstream-fixable** | ~250 LoC | +| 4 | [Auth stack: dual-auth + per-sandbox access](#1-authentication--authorization-stack) | 🟠 MEDIUM | OURS — permanent | ~1,000 LoC (auth lib + middleware + server) | +| 5 | [Restore endpoint bypassing Next.js routing](#9-restore-endpoint-bypass) | 🟠 MEDIUM | OURS — could upstream | ~400 LoC in server.mjs | +| 6 | [Dashboard token cookie-wins fix chain](#4-dashboard-token--ws-tunnel-fix-chain) | 🟠 MEDIUM | **HIGHLY upstream-fixable** | ~250 LoC + 2 regression tests | +| 7 | [Auto-approve sandbox network rule for broker URL](#5-mcp-broker--inter-sandbox-chat) | 🟠 MEDIUM | OURS — could upstream | (subset of #2) | +| 8 | [Vendored NemoClaw preloads (safety-net, ciao-guard)](#8-vendored-nemoclaw-preloads) | 🟢 LOW | **HIGHLY upstream-fixable** | 231 LoC | +| 9 | [Hermes auto-upgrade shim at expose time](#10-hermes-in-sandbox-auto-upgrade-shim) | 🟢 LOW | **HIGHLY upstream-fixable** | 103 LoC | +| 10 | [NemoClaw Dockerfile apt-pin unpinning](#11-nemoclaw-dockerfile-apt-unpinning) | 🟢 LOW | **HIGHLY upstream-fixable** | 18 LoC | +| 11 | [Production install script + systemd unit + ensure-mtls safety](#12-production-install--ensure-mtls) | 🟢 LOW | OURS — could upstream | ~200 LoC | +| 12 | [Sandbox-create UX: agent-aware Quick Deploy, gateway-token verify, first-build timeout](#13-sandbox-create-ux--robustness) | 🟢 LOW | Partly upstream-fixable | ~150 LoC | +| 13 | [UI/UX additions (Hermes panel, Security page, terminal fullscreen, activity-log pagination)](#14-uiux-additions) | 🟢 LOW | OURS — permanent | ~450 LoC | + +--- + +## 1. Authentication & Authorization Stack + +**What it is:** A consolidated `app/lib/auth/` library (610 LoC across +`policy.mjs`, `edge.ts`, `node.mjs`, `context.ts`, `sandboxAccessStore.ts`, +`policy.d.ts`), a Node-runtime `middleware.ts` (+175 lines vs upstream's +Edge-runtime version), and ~570 lines of WS-upgrade auth in `server.mjs`. +Defines an `AuthContext` discriminated union with kinds +`"operator" | "oauth" | "anonymous" | "disabled"` and dispatches every +request accordingly. Operator session cookie is `openshell_control_session`; +OAuth session cookie is `oauth_session` (renamed from upstream-equivalent +`CF_Authorization`). + +**Why we need it:** Upstream ships single-tenant operator-password auth. +We deploy this as a multi-user system behind an OAuth IDP (Pangolin / +mcpauth), so we need both auth modes side-by-side and a way for different +users to be allowed into different sandboxes. + +**Security risk:** 🟠 MEDIUM +- The library itself adds protection (verified JWT, fail-closed on missing + secret, file-backed access map). Bugs here would expose sandboxes to + unauthorised users; the existing regression tests + (`tests/control-auth-oauth-check.mjs`, `tests/dashboard-session-check.mjs`) + catch the obvious failure modes. +- Specific footgun: `server.mjs` strips client-supplied `x-forwarded-user` + on WS upstream so an attacker can't impersonate a verified user (see + `copyHeaders()`). Don't weaken that. + +**Upstream-fixability:** **OURS — permanent.** Upstream is unlikely to add +multi-IDP support; this stays in the fork. + +**Re-evaluation triggers:** None from upstream. Re-audit if we add a new +auth provider, change cookie names, or move middleware back to Edge runtime +(don't — see [Don'ts](#dont-do-this-list)). + +**Files:** +- `app/lib/auth/` (whole new directory) +- `app/lib/controlAuth.ts` (now a deprecated re-export shim) +- `middleware.ts` (rewrite) +- `server.mjs` (WS upgrade auth, ~570 LoC of changes) +- `app/api/auth/{login,logout,recover,setup,callback,me}/route.ts` + +--- + +## 2. Per-Sandbox Access Control + +**What it is:** A file-backed access store at +`data/sandbox-access.json`, written atomically; allows mapping +{sandbox name → list of OAuth emails} so non-operator users can be +granted access to specific sandboxes. Surfaced as a Security page +(`/setup-account`). + +**Why we need it:** Multi-tenant deployments need per-sandbox isolation +between OAuth users. Upstream has nothing in this space. + +**Security risk:** 🟠 MEDIUM +- The store is the source of truth for "can this user see this sandbox" — + bugs would cross-contaminate tenants. Atomic writes already in place. +- An older project memory note flags that we switched from UUID to **name** + for sandbox identification in access checks; if upstream ever has two + sandboxes with the same name in different namespaces, our check would + cross them. + +**Upstream-fixability:** **OURS — permanent.** + +**Files:** +- `app/lib/auth/sandboxAccessStore.ts` +- `app/api/security/sandbox-access/route.ts` +- `app/setup-account/page.tsx` (+317 LoC, was much smaller upstream) +- `SANDBOX_ACCESS_CONTROL.md` (design doc) + +--- + +## 3. OAuth IDP Integration (commit `46cb197`, `a88aa69`) + +**What it is:** End-to-end OAuth2 IDP flow — login URL, callback, JWT +verification, session cookie issuance — with backward-compat reads of +the legacy `MCPAUTH_*` / `CF_Authorization` cookies/env vars. + +**Why we need it:** SSO for the controller. + +**Security risk:** 🟠 MEDIUM +- 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`. The legacy fallback exists to avoid breaking existing + deployments; new deployments should set `OAUTH_*` only. +- Don't add a fallback secret. The fail-closed-on-missing-secret behaviour + is intentional (per `getOAuthSecret`). + +**Upstream-fixability:** **OURS — permanent.** + +**Files:** `app/api/auth/callback/route.ts`, login/me routes, parts of +the auth lib above. + +--- + +## 4. Dashboard Token + WS Tunnel Fix Chain + +**What it is:** The four-commit chain (`c35fea5`, `48bbfa5`, `a2e8ddb`, +`b42b323`) plus two regression tests +(`tests/dashboard-token-cookie-wins-check.mjs`, +`tests/dashboard-token-runtime-check.mjs`) and the +`bootstrapScriptResponse` session-storage cleanup +(commit `617bbc3`) that together stop the OpenClaw "Open Dashboard" +flow from replaying a stale token across sandbox-recreate cycles. + +**Why we need it:** The OpenClaw dashboard SPA caches the gateway token +in `localStorage` and `sessionStorage` keyed by controller origin. Deleting +and recreating a sandbox with the same name re-uses the same controller +origin, so the cached token bleeds into the new sandbox and authentication +silently fails ("Auth did not match — gateway token mismatch"). The fix +makes the controller's HttpOnly cookie *always* override any client-supplied +`?token=` query and `Authorization: Bearer` header on the WS upstream, and +the bootstrap script wipes scoped sessionStorage entries on load. + +**Security risk:** 🟠 MEDIUM — but the change *adds* protection (defends +against stale-token replay). The risk is **regression**: if a future refactor +removes the unconditional override or the sessionStorage cleanup, the bug +returns. Both regression tests will catch a revert. + +**Upstream-fixability:** **HIGHLY upstream-fixable.** If upstream OpenShell +delivers a proper per-connection token (e.g. via an authenticated handshake +RPC) instead of asking the SPA to juggle localStorage, the entire chain +becomes unnecessary. **Re-evaluation trigger:** any major OpenShell release +notes mentioning dashboard auth, token handshake, or gateway-WS protocol. + +**Files:** +- `server.mjs` (`withDashboardTokenQuery`, `copyDashboardWebSocketHeaders`) +- `app/api/openshell/dashboard/proxy/shared.ts` +- `tests/dashboard-token-*.mjs` (DO NOT DELETE) + +--- + +## 5. MCP Broker + Inter-Sandbox Chat + +**What it is:** A controller-side MCP broker (`/api/mcp/broker/{mcp,call,capabilities}`) +that fronts all MCP server access from sandboxes. Each sandbox gets a +bearer token written into its manifest plus an `openshell-control` server +entry in `openclaw.json`. The broker enforces which MCP tools are +available per-sandbox; the underlying servers' credentials never leave the +controller. Inter-Sandbox Chat is one of the baseline MCP servers exposed +through this broker. + +Also includes `syncBrokerNetworkAccess` (`app/lib/sandboxPermissions.ts`) +which, when Issue Broker Config runs, **auto-approves any pending OpenShell +L4 network rule whose endpoints match the broker URL**. + +**Why we need it:** Without a broker the sandbox would need direct +credentials for each MCP server; that's the inverse of OpenShell's +principle of least privilege. The broker is the choke point that lets us +audit and restrict tool access centrally. + +**Security risk:** 🔴 HIGH +- The broker is the single gatekeeper for what tools a sandboxed agent can + invoke. Bypass = complete loss of MCP-level control. +- `syncBrokerNetworkAccess` **auto-approves** network rules in the + sandbox's pending queue, which is acceptable because the URL it + approves is the broker URL it just generated (sandbox-bound bearer + token, content-validated). Still: the auto-approve writes a permanent + approval into the sandbox's OpenShell rule store. Any future bug that + generated the wrong URL here could pre-approve outbound traffic to + somewhere unintended. +- L7 SSRF block at the sandbox egress proxy (`10.200.0.1:3128`) was the + unexpected gate that ended up actually blocking end-to-end this session. + Not our code's fault — that's NemoClaw's egress proxy doing its job. + The broker URL written into the manifest still points at an internal + address (`host.docker.internal:3000`), so sandbox-to-broker traffic is + blocked by design. **This means in-sandbox agents currently CANNOT post + to the broker** — only operator→sandbox dispatch works. (See open + question at the end.) + +**Upstream-fixability:** **OURS — could upstream.** The broker pattern +itself is reusable; upstream may build one eventually. + +**Re-evaluation triggers:** +- If upstream NemoClaw adds a first-class "MCP capability registry" for + sandboxes, our broker may become redundant. +- If the egress proxy gains a per-URL allowlist API, we can stop trying to + route via `host.docker.internal` and unblock the in-sandbox→broker path. + +**Files:** +- `app/lib/mcpBrokerStore.ts`, `mcpBrokerProtocol.ts`, `mcpBrokerClient.ts`, + `mcpBrokerUrl.ts`, `mcpServerStore.ts`, `mcpSandboxAutoSync.ts`, + `mcpPreflight.ts`, `mcpPreflightRepair.ts` +- `app/lib/sandboxPermissions.ts` (auto-approve logic) +- `app/api/mcp/broker/{mcp,call,capabilities}/route.ts` +- `app/api/mcp/health/route.ts` +- `scripts/inter-sandbox-chat-*.mjs` (4 files) + +--- + +## 6. Privileged In-Sandbox File Writes + +**What it is:** Helpers in `app/lib/sandboxPrivilegedFiles.ts` and +`app/lib/sandboxOpenClawMcpConfig.ts` that write into sandbox-internal +"protected" paths: +- `/sandbox/openshell_control_mcp.md` (the broker manifest) +- `/sandbox/.openclaw/openclaw.json` (OpenClaw gateway config) +- `/sandbox/.openclaw/.config-hash` (matching sha256) +- `/sandbox/.openclaw/exec-approvals.json` (tool-call approval state) + +Upstream calls these helpers via `docker exec openshell-cluster-nemoclaw +kubectl exec -n openshell -- ...`, which runs **as root in the +pod** (kubectl-cluster driver). On a Docker-driver deployment that +container doesn't exist; we migrated to `openshell sandbox exec` which +runs **as the sandbox user**. Because the sandbox user can't `chown +root:root`, the writes now leave the files `444 sandbox:sandbox` instead +of `444 root:root`, and the write strategy uses a 444 temp file + atomic +rename to avoid a transient-writable window. + +**Why we need it:** On NemoClaw's Docker-driver deployments, the +controller has no kubectl access; without these helpers, "Issue Broker +Config" fails with `No such container: openshell-cluster-nemoclaw`. + +**Security risk:** 🔴 HIGH +- On the kubectl-cluster driver, the openclaw.json + .config-hash pair was + **root-owned 444** — sandbox user couldn't tamper. NemoClaw verifies the + config matches the hash before each gateway run. +- On the Docker driver, the file is sandbox-owned 444, which means **the + sandbox user can `chmod +w` and rewrite both files** (then OpenClaw's + hash check still passes because the sandbox can recompute the hash too). +- We did **not** weaken the security from what NemoClaw itself ships on + the Docker driver — the file lands as sandbox-owned 444 from NemoClaw's + own bootstrap. So our changes match the existing baseline. But the + baseline on Docker driver is genuinely weaker than the kubectl-cluster + driver's root-owned 444. +- True tamper-resistance on the Docker driver would require either + `docker exec -u root` from the controller (couples to driver), an + immutable-flag (`chattr +i`) approach, or NemoClaw setting the file + ownership from a root-context bootstrap. + +**Upstream-fixability:** **HIGHLY upstream-fixable.** Two paths: +1. **NemoClaw exposes a "register MCP server" API** that takes a URL + token + and writes the openclaw.json from a root-context bootstrap. The + controller would call that API instead of poking the file. The entire + `sandboxOpenClawMcpConfig.ts` + the privileged-file plumbing in + `sandboxPrivilegedFiles.ts` could be deleted (~250 LoC). +2. **OpenShell's Docker driver gains a `--user root` flag for `sandbox exec`** — + then we could do the chown-to-root step. Less likely as that subverts + sandbox isolation by design. + +**Re-evaluation triggers:** +- Any NemoClaw release notes mentioning "MCP", "broker", or + "config-integrity API". +- Any OpenShell release notes mentioning `sandbox exec --user` or similar. + +**Files:** +- `app/lib/sandboxPrivilegedFiles.ts` (4 helpers; we changed the writer + pattern) +- `app/lib/sandboxOpenClawMcpConfig.ts` (sync/revoke + the 444 + atomic + rename writer) +- `app/lib/sandboxInferenceApply.ts` (same writer pattern for inference + config) +- `tests/mcp-configuration-page-check.mjs` (guard flipped from "must + contain kubectl" to "must use openshell sandbox exec") +- `tests/sandbox-lifecycle-check.mjs` (guard updated for atomic rename) + +--- + +## 7. Hermes Remote Desktop Public Exposure + +**What it is:** The `/hermes/` public URL on the controller host +that lets the Hermes Desktop app drive a sandbox over WebSocket. Built +from: +- `scripts/hermes-remote/expose.sh` — writes a Traefik file-provider rule, + opens UFW for the chosen port, installs a per-sandbox systemd `forward` + unit + a re-run watchdog timer. +- `scripts/hermes-remote/launch.sh` — provisions `API_SERVER_KEY` and + pins the Hermes session-token gate (`HERMES_DASHBOARD_SESSION_TOKEN`) + per-sandbox. +- `scripts/hermes-remote/upgrade-hermes.sh` — pip-upgrades in-sandbox + Hermes from 0.14 (NemoClaw base image) to ≥0.16 at expose time. (See + #10 — that's a temporary shim.) +- `scripts/hermes-remote/watchdog.sh` — re-runs `launch.sh` every 2 min. +- `scripts/hermes-remote/ensure-recovery-guards.sh` — installs gateway + recovery guards on the host. +- `scripts/hermes-remote/preloads/{sandbox-safety-net,ciao-network-guard}.js` — + see #8. +- `app/lib/hermesRemote.ts`, `app/api/sandbox/[sandboxId]/hermes-remote/route.ts`, + `app/components/HermesRemotePanel.tsx`. + +**Why we need it:** Hermes Desktop, the user's preferred client, talks +WebSocket to a sandbox-internal API. Without the public URL it can't +reach the sandbox. + +**Security risk:** 🔴 HIGH +- Every exposed sandbox is a new public attack surface on the controller + host's public hostname. Mitigations: + - In `desktop` mode the Traefik rule serves **only** `/hermes//api/*`, + not the SPA shell — so the token-embedding HTML is never public. (The + expose script hard-fails if `GET /` returns non-404.) + - Per-sandbox `HERMES_DASHBOARD_SESSION_TOKEN` distributed via the + controller UI/API to authorised users only. + - UFW restricts gateway-port traffic to the docker bridge. +- The public URL **deliberately bypasses Pangolin/SSO** because the + desktop's `/api/status` probe can't follow SSO redirects. So the session + token is the ONLY thing standing between an attacker who guesses the + URL and access to the sandbox dashboard API. +- Token leakage in the access file is a real risk — the file at + `/var/lib/openshell-controller/hermes-remote-access.json` (or wherever + the access store points) contains the gateway session tokens. Lock down + read perms. + +**Upstream-fixability:** **OURS — permanent.** This is product-level +architecture, not a NemoClaw deficiency. + +**Re-evaluation triggers:** +- Hermes Desktop adding SSO support (then we can route via Pangolin and + drop the public-URL bypass). +- Hermes ≥0.16 in the NemoClaw base image (lets us delete `upgrade-hermes.sh`, + see #10). + +**Files (all new):** +- `app/lib/hermesRemote.ts` +- `app/api/sandbox/[sandboxId]/hermes-remote/route.ts` +- `app/components/HermesRemotePanel.tsx` +- `scripts/hermes-remote/*.sh` (8 scripts) +- `HERMES_REMOTE_DESKTOP.md` + +--- + +## 8. Vendored NemoClaw Preloads + +**What it is:** Two Node.js `--require` preload scripts copied directly +from NemoClaw under `scripts/hermes-remote/preloads/`: +- `sandbox-safety-net.js` (131 LoC) — installs an `uncaughtException` / + `unhandledRejection` handler that keeps the in-sandbox gateway alive + when user code throws (so plugins can't kill the shared gateway). +- `ciao-network-guard.js` (100 LoC) — patches `@homebridge/ciao` mDNS + library to not crash when `os.networkInterfaces()` fails (which happens + in restricted sandbox network namespaces). + +Both carry NVIDIA Apache-2.0 license headers; we have not modified them. + +**Why we need it:** NemoClaw's base image doesn't ship these by default. +Without them, the Hermes gateway crashes inside the sandbox. + +**Security risk:** 🟢 LOW +- They don't grant new permissions; they suppress crashes. +- The risk is **drift**: if NemoClaw updates the upstream copies of these + files (e.g. to patch a real bug), our vendored copy stays stale. There's + no automation watching for updates. + +**Upstream-fixability:** **HIGHLY upstream-fixable.** When NemoClaw's base +image bundles these (or makes them unnecessary), delete the whole +directory. + +**Re-evaluation triggers:** +- Any NemoClaw base-image release notes touching the Hermes gateway + resilience or mDNS handling. + +**Files:** `scripts/hermes-remote/preloads/*.js` + a vendoring README. + +--- + +## 9. Restore Endpoint Bypass + +**What it is:** The 7 `fix(restore): ...` commits at the top of our +history move the entire `POST /api/sandbox/[id]/restore` path into +`server.mjs` (the custom Next.js server) instead of letting Next.js +handle the route. The reasons accreted: Next.js 15 streams the multipart +body as a Web stream that gets "disturbed" by the hand-rolled parser, +the gRPC layer has a 1 MiB stdin limit so we bypass to `docker cp + +docker exec`, and macOS-created tar archives have metadata that needs +tolerance. + +**Why we need it:** Backup/restore is the user's escape hatch — it must +work for archives of any size. + +**Security risk:** 🟠 MEDIUM +- The bypass means **the operator session cookie is the only auth check** + before the restore stream is accepted (see `authVerifyOperatorSession` + in `server.mjs`). Middleware doesn't get a chance to run — so any + defence we add in middleware (e.g. rate limiting, audit logging) won't + apply to restore. +- The restore writes into the sandbox via `docker exec`, which runs as + root in the container. This is more permission than `openshell sandbox + exec` would have — by design, since restore needs to overwrite + arbitrary paths. +- Path validation in `app/lib/sandboxFiles.ts` (`normalizeSandboxPath`) + restricts targets to `/sandbox` or `/tmp`. **If that validation is + bypassed or weakened, an authenticated operator could write anywhere + in the container's filesystem.** Don't relax it. + +**Upstream-fixability:** **OURS — could upstream.** The Next.js 15 streaming +bug should eventually get fixed; until then a long-form upload bypass +is the practical answer. + +**Re-evaluation triggers:** +- Next.js 16+ release notes on multipart streaming. +- OpenShell gRPC raising the 1 MiB stdin limit. + +**Files:** +- `server.mjs` (~400 LoC of restore handling) +- `app/lib/sandboxFiles.ts` (path validation, archive sanity checks) + +--- + +## 10. Hermes In-Sandbox Auto-Upgrade Shim + +**What it is:** `scripts/hermes-remote/upgrade-hermes.sh` — at first +`expose.sh` run, `pip install --break-system-packages` upgrades the +in-sandbox Hermes from `0.14` (NemoClaw base image) to `≥0.16` (what the +desktop app needs). + +**Why we need it:** Hermes Desktop calls endpoints introduced in 0.16; +the desktop and the in-sandbox backend MUST match minor versions. + +**Security risk:** 🟢 LOW +- Runs `pip install` from inside the sandbox, which already has network + egress via the NemoClaw proxy. Source is PyPI — usual supply-chain + caveats apply. +- The file already documents its removal conditions at the top. + +**Upstream-fixability:** **HIGHLY upstream-fixable.** When NemoClaw's +base image ships Hermes ≥0.16, delete `upgrade-hermes.sh` and the +version-check block in `launch.sh`. + +**Re-evaluation triggers:** +- Any NemoClaw base-image release notes touching the Hermes version. +- `docker exec openshell--* /opt/hermes/.venv/bin/hermes --version` + reporting ≥0.16 on a fresh sandbox without our shim having run. + +**Files:** `scripts/hermes-remote/upgrade-hermes.sh` (103 LoC). + +--- + +## 11. NemoClaw Dockerfile apt Unpinning + +**What it is:** `install_versioned_nemoclaw_openshell.sh` runs `sed` over +NemoClaw's extracted `Dockerfile` and `Dockerfile.base` to drop the +Debian-style version pin (`tmux=3.5a-3`, `procps=2:4.0.4-9`, +`e2fsprogs=1.47.2-3+b11`) on three apt packages — those pins target Debian, +but the base image is Ubuntu noble, so apt errors with exit 100. + +**Why we need it:** Without this, every fresh-image NemoClaw install hangs +sandbox creation for ~90 s with no usable error. + +**Security risk:** 🟢 LOW +- Unpinning means apt picks whatever's in the Ubuntu noble index, which + could in principle pick up a newer-but-vulnerable version. In practice + Debian/Ubuntu's stable channels patch fast, and we're unpinning + long-stable tools (tmux, procps, e2fsprogs). +- The cleanest mitigation is **upstream NemoClaw stops Debian-pinning on + Ubuntu base** — at which point we delete the sed block. + +**Upstream-fixability:** **HIGHLY upstream-fixable** (NemoClaw bug, +not ours). + +**Re-evaluation triggers:** Any NemoClaw release where +`grep -E 'apt-get install.*=[0-9]+' Dockerfile` returns nothing. + +**Files:** `install_versioned_nemoclaw_openshell.sh` (18 LoC of patches). + +--- + +## 12. Production Install + ensure-mtls Safety + +**What it is:** +- `scripts/setup/install-production.sh` (166 LoC) — writes the systemd + unit, UFW rules for sandbox-bridge ports 8080/18789, the needrestart + guard, the openshell-gateway DB parent dir, and starts linger. + Idempotent. +- `scripts/setup/openshell-controller.service` — systemd unit template + with `HOME` / `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` for + ssh-via-openshell-gateway support. +- A safety guard in the (host-side) `openshell-gateway-ensure-mtls.sh` + that **refuses to flip from plaintext to mTLS when any + `openshell-*` sandbox container is running** — flipping with live + sandboxes used to orphan their plaintext supervisors permanently + (2026-06-11 incident). + +**Why we need it:** NVIDIA's install.sh is dev-mode. Real deployments need +the systemd unit + UFW + the mTLS guard. + +**Security risk:** 🟢 LOW (install-time, not request-time). The mTLS +guard is the most important bit — without it, a controller restart can +silently brick every live sandbox. + +**Upstream-fixability:** **OURS — could upstream** (the production install +patterns could be reusable across deployments). The mTLS guard is the +kind of thing NemoClaw might absorb upstream. + +**Files:** `scripts/setup/install-production.sh`, +`scripts/setup/openshell-controller.service`, +`tests/production-setup-check.mjs`. + +--- + +## 13. Sandbox-Create UX & Robustness + +**What it is:** Several fork-only additions in `app/api/sandbox/create/route.ts`: +- **Agent-aware Quick Deploy** — filters image-redeploy candidates by + matching agent type (`agentForName` + `agentFilter.ts`). +- **OpenClaw gateway-token verify after create** — `ensureOpenClawGatewayToken` + polls the in-sandbox gateway WS handshake to confirm the token is live + before declaring success. Without this, "Open Dashboard" fails immediately + after create with the §13 token-mismatch error. +- **First-build timeout extended to 20 min** — fresh sandboxes on a clean + VPS can take that long for the Docker image to build. +- **Pre-build of baseline sandboxes** — surfaces ready-baked + baseline sandboxes (`baseline-openclaw`, `baseline-hermes`) so the user + doesn't wait for the first build. +- **Hermes-remote hook** — calls `hermesRemote.expose()` on Hermes + sandbox create (see #7). +- **NemoClaw registry agent patch** — atomically writes the `agent` field + into the NemoClaw registry after a verified create (commit `774e32c`). + Without this, Quick Deploy can't tell which agent type a sandbox uses. + +**Why we need it:** Each item targets a specific user-visible failure mode +we hit during fork operation. + +**Security risk:** 🟢 LOW. The new code paths run after verified +sandbox creation, behind operator auth. + +**Upstream-fixability:** Partly — the gateway-token verify and the +registry-agent patch are working around NemoClaw deficiencies that upstream +could fix. The Quick Deploy filter and baseline pre-build are fork-specific +UX. + +**Re-evaluation triggers:** +- NemoClaw populating `agent` in its registry by default → drop the + registry patch. +- NemoClaw guaranteeing the gateway token is live by the time `nemoclaw + onboard` returns → drop `ensureOpenClawGatewayToken`. + +**Files:** +- `app/api/sandbox/create/route.ts` (+354 LoC vs upstream) +- `app/lib/sandboxCreate/agentFilter.ts`, `policy.ts` +- `tests/openclaw-create-gateway-token-verify-check.mjs` + +--- + +## 14. UI/UX Additions + +**What it is:** Operator-facing UI added/changed: +- `app/components/HermesRemotePanel.tsx` (174 LoC) — drawer that exposes + the Hermes remote-desktop URL + token to authorised users. +- `app/setup-account/page.tsx` (+317 LoC) — Security page for managing + per-sandbox access (#2) and password rotation. +- `app/components/SandboxList.tsx` (+127 LoC) — Hermes Remote drawer, + Issue Broker Config button, copy-link icons, file-transfer drawer. +- `app/components/ActivityPanel.tsx` (+49 LoC) — pagination (commit + `cd7890f`). +- `app/components/WizardPanel.tsx`, `ConfigurationPanel.tsx` (smaller). +- `app/operator-terminal/page.tsx` — terminal fullscreen toggle. + +**Security risk:** 🟢 LOW. UI gates on the auth context (operator vs +OAuth user vs anonymous). Drawer reveals (Hermes token, broker token, +sandbox access list) are scoped behind operator session or per-sandbox +access. + +**Upstream-fixability:** **OURS — permanent.** + +--- + +## Don't-do-this list + +(Pulled from `CLAUDE.md §9` for at-a-glance reference. Each has been tried and rejected.) + +- Don't switch middleware back to Edge runtime — Node runtime is required so password rotation takes effect without a service restart. +- Don't rsync source files to the VPS — git only, so every deployed state corresponds to a pushed commit. +- Don't remove the legacy `CF_Authorization` cookie reader from `context.ts` / `server.mjs` without explicit OK — browsers may still hold sessions under that name. +- Don't add a fallback secret to `getOAuthSecret` — fail-closed on missing secret is intentional. +- Don't weaken `server.mjs` `copyHeaders()` stripping of client-supplied `x-forwarded-user` on WS upstream. +- Don't delete or weaken `tests/dashboard-token-cookie-wins-check.mjs` or `tests/dashboard-token-runtime-check.mjs` — they are the only mechanical guards against the 2026-06-13 dashboard regression. +- Don't push to `gatewaydashboard` without `npm run build` + smoke-test on the VPS first. Don't force-push to `gatewaydashboard`. + +--- + +## Open security questions worth resolving + +These are real questions the audit surfaced that I don't have answers to yet: + +1. **Hermes remote desktop access store ownership/perms.** The file + storing per-sandbox `HERMES_DASHBOARD_SESSION_TOKEN` values needs to + be 600 (operator-readable only). Worth verifying on the live VPS. +2. **`data/sandbox-access.json` ownership/perms.** Same concern — this + determines who can see which sandbox. +3. **`MCPAUTH_WRITE_ALLOWED_PATHS`.** Currently only + `/api/openshell/terminal/live`. New entries require route-level + per-sandbox gating; document the discipline anywhere a contributor + might add an entry. +4. **Restore endpoint rate limiting.** Currently none — an authenticated + operator could DoS the controller by streaming many large archives. +5. **Hermes-remote URL guessability.** The public URL is + `/hermes/` — sandbox names are operator-chosen and could + be guessable. The session token is the real gate, but URL discovery + shortens the attacker's reconnaissance. +6. **Sandbox-to-broker connectivity is currently blocked** by the + NemoClaw egress SSRF proxy (`10.200.0.1:3128`) — so the broker right + now is reachable only by the operator-side sidecar dispatch path, + not by agents inside sandboxes. Decide whether to (a) configure the + proxy allowlist, (b) route broker traffic via the public controller + URL, or (c) accept the limitation. (Open per 2026-06-22 session.) + +--- + +## How to use this document + +1. **After each upstream merge.** Read it top-to-bottom. For every item + tagged "HIGHLY upstream-fixable", check whether the underlying + NemoClaw / OpenShell / Hermes version bump now makes our workaround + unnecessary. Delete what you can. +2. **Before any change to a 🔴 HIGH-risk area.** Verify the regression + tests in that section still pass, and that no `Don't-do-this` rule is + being violated. +3. **When the divergence audit goes stale** (target: every 2 weeks or + after every upstream merge). Regenerate the file lists: + ``` + git diff --diff-filter=A --name-only upstream/main...HEAD + git diff --diff-filter=M --name-only upstream/main...HEAD + git log --oneline upstream/main..HEAD + ``` + and update the section bodies + the prioritized summary table. From 4774f698e4488cf93e1a97088e3da716da959a55 Mon Sep 17 00:00:00 2001 From: ivobrett Date: Mon, 22 Jun 2026 21:01:56 +0100 Subject: [PATCH 089/102] feat(sandbox): identify Custom sandboxes + clean Connect-to-Terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coupled improvements driven by user-visible bugs on the dashboard: 1. Bare openshell sandboxes (created via `openshell sandbox create` without NemoClaw onboarding) were labelled "OpenClaw" and surfaced a "Start OpenClaw Gateway Dashboard" button that would never work, because resolveSandboxAgent() in app/api/telemetry/real/route.ts defaulted to "openclaw" whenever the NemoClaw registry had no entry for the sandbox. Add a single docker-ps lookup per inventory call to read each sandbox's container image; if it's not an openshell/sandbox-from:* (NemoClaw-built) image, treat the sandbox as agent="custom". The registry remains authoritative when present. 2. SandboxList now renders Custom sandboxes with their own label + a generic terminal icon, and groups Hermes + Custom under a single "Connect to Terminal" button (selectedSandboxUsesTerminal). The "Start OpenClaw Gateway Dashboard" button is reserved for actual OpenClaw sandboxes. Hermes-specific Remote Desktop drawer stays gated on agent === 'hermes' only. 3. The pre-existing "Connect to Hermes" flow was typing a multi-line sh wrapper (`printf '\n%s\n' ...; if command -v nemohermes; ...`) as keyboard input into a host bash shell, so the operator saw the wrapper script echoed before bash executed it. Move the attach to the server side: terminal-server.mjs buildAttachCommand now defaults to `ssh openshell-` when sandboxId is provided and no OPENSHELL_TERMINAL_ATTACH_TEMPLATE override is set. The operator terminal lands directly inside the sandbox shell — no wrapper text, no auto-hermes invocation. User can type `hermes` manually if/when they want the Hermes CLI. Drop the buildHermesTerminalConnectCommand / HERMES_AGENT_COMMAND / isHermesAttachReadyOutput plumbing from operator-terminal/page.tsx, and the launch=hermes param from buildOperatorTerminalRoute. Auth: unchanged. /api/openshell/terminal/live POST still enforces isUserAuthorizedForSandbox(idpUser, sandboxId) and the WS upgrade in server.mjs still validates the session cookie before the upstream ws upgrade proceeds. The change only swaps what command runs in the pty after the authorised session is created. Test guards flipped: - post-authority-terminal-contract-check.mjs: must NOT contain buildHermesTerminalConnectCommand / HERMES_AGENT_COMMAND etc. - sandbox-lifecycle-check.mjs: button text is "Connect to Terminal", selectedSandboxUsesTerminal covers Hermes + Custom. - dashboard-session-check.mjs: route builder must NOT thread a `launch` param. Co-Authored-By: Claude Opus 4.7 --- app/api/telemetry/real/route.ts | 52 +++++++++++-- app/components/SandboxList.tsx | 67 ++++++++++------- app/lib/dashboardSession.ts | 4 - app/operator-terminal/page.tsx | 74 ++----------------- terminal-server.mjs | 12 ++- tests/dashboard-session-check.mjs | 8 +- ...post-authority-terminal-contract-check.mjs | 40 ++-------- tests/sandbox-lifecycle-check.mjs | 7 +- 8 files changed, 112 insertions(+), 152 deletions(-) diff --git a/app/api/telemetry/real/route.ts b/app/api/telemetry/real/route.ts index 5a1b8f8..4be4e7b 100644 --- a/app/api/telemetry/real/route.ts +++ b/app/api/telemetry/real/route.ts @@ -164,12 +164,49 @@ function readNemoClawRegistry(): NemoClawRegistryData { } } -function resolveSandboxAgent(name: string, id: string | null, registry: NemoClawRegistryData) { +async function readSandboxContainerImageMap(): Promise> { + // One docker-ps lookup powers the agent fallback for every sandbox in this + // inventory call. The image tells us whether NemoClaw built the sandbox + // ("openshell/sandbox-from:...") or it's a bare `openshell sandbox create` + // ("ghcr.io/nvidia/openshell-community/sandboxes/base:..."). Empty map on + // error so resolveSandboxAgent silently falls through to its prior default. + try { + const { stdout } = await execFileAsync( + "docker", + ["ps", "--filter", "label=openshell.ai/managed-by=openshell", "--format", "{{.Label \"openshell.ai/sandbox-name\"}}|{{.Image}}"], + { env: hostCommandEnv(), timeout: 5000, maxBuffer: 1024 * 1024 }, + ) + const map = new Map() + for (const line of String(stdout).split(/\r?\n/)) { + const [name, image] = line.split("|") + if (name && image) map.set(name.trim(), image.trim()) + } + return map + } catch { + return new Map() + } +} + +function resolveSandboxAgent( + name: string, + id: string | null, + registry: NemoClawRegistryData, + imageMap: Map, +) { 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 (/openshell\/sandbox-from/i.test(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[]) { @@ -216,7 +253,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: Map): Promise<{ summary: SandboxSummary; pod: SandboxItem }> { try { const [{ stdout: detailsStdout }, { stdout: sshStdout }] = await Promise.all([ execOpenShell(["sandbox", "get", name]), @@ -230,7 +267,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: { @@ -275,7 +312,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, @@ -337,7 +374,8 @@ export async function GET(request: Request) { : [null, null] const defaultSandboxNames = parseDefaultSandboxNames(nemoclawListResult?.stdout ?? "") const registry = readNemoClawRegistry() - const results = await Promise.all(names.map((name) => readSandbox(name, defaultSandboxNames, registry))) + const imageMap = 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) diff --git a/app/components/SandboxList.tsx b/app/components/SandboxList.tsx index ee138c6..0a52fb1 100644 --- a/app/components/SandboxList.tsx +++ b/app/components/SandboxList.tsx @@ -80,6 +80,7 @@ function openDashboardUrl(url: string, openInNewTab: boolean) { function displaySandboxAgent(agent?: string) { if (agent === 'hermes') return 'Hermes' + if (agent === 'custom') return 'Custom' return 'OpenClaw' } @@ -108,19 +109,28 @@ function CopyLinkButton({ label, copied, onClick }: { label: string; copied: boo 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 ( -