From 69aa9d29eb55d4ec837945fc9a4d77b9e99fb9dc Mon Sep 17 00:00:00 2001 From: Deepak Jain Date: Mon, 23 Mar 2026 15:39:18 -0700 Subject: [PATCH] fix(onboard): output dashboard URL with token on completion (Fixes #53) Retrieve gateway auth token from the sandbox via openshell sandbox connect (matching the existing setupOpenclaw pattern) instead of a direct SSH session with StrictHostKeyChecking=no. The token is read from openclaw.json inside the sandbox using a piped script, then appended to the dashboard URL in the onboarding summary so users can log in immediately. Add parseTokenFromOutput helper and unit tests. Fixes #53 Signed-off-by: Deepak Jain --- bin/lib/onboard.js | 27 ++++++++++++++++++++++++--- test/onboard.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 9cabb094d8..041c68036d 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2140,6 +2140,23 @@ async function setupPolicies(sandboxName) { const CONTROL_UI_PORT = 18789; const CONTROL_UI_CHAT_PATH = "/chat?session=main"; +/** + * Extract gateway auth token from sandbox connect output. + * The output may contain shell noise; looks for a line matching TOKEN:. + * Exported for unit tests. + */ +function parseTokenFromOutput(output) { + if (!output || typeof output !== "string") return null; + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("TOKEN:") && trimmed.length > 6) { + const token = trimmed.slice(6).trim(); + if (token) return token; + } + } + return null; +} + function findOpenclawJsonPath(dir) { if (!fs.existsSync(dir)) return null; const entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -2178,8 +2195,8 @@ function fetchGatewayAuthTokenFromSandbox(sandboxName) { } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors + } catch (e) { + console.error(` Failed to remove temp dir ${tmpDir}: ${e.message}`); } } } @@ -2214,10 +2231,13 @@ function printDashboard(sandboxName, model, provider, nimContainer = null) { else if (provider === "ollama-local") providerLabel = "Local Ollama"; const token = fetchGatewayAuthTokenFromSandbox(sandboxName); + const dashboardUrl = token + ? `http://localhost:${CONTROL_UI_PORT}/#token=${encodeURIComponent(token)}` + : `http://localhost:${CONTROL_UI_PORT}/`; console.log(""); console.log(` ${"─".repeat(50)}`); - // console.log(` Dashboard http://localhost:18789/`); + console.log(` Dashboard ${dashboardUrl}`); console.log(` Sandbox ${sandboxName} (Landlock + seccomp + netns)`); console.log(` Model ${model} (${providerLabel})`); console.log(` NIM ${nimLabel}`); @@ -2282,6 +2302,7 @@ module.exports = { hasStaleGateway, isSandboxReady, onboard, + parseTokenFromOutput, pruneStaleSandboxEntry, runCaptureOpenshell, setupInference, diff --git a/test/onboard.test.js b/test/onboard.test.js index f1240a9ed4..3b5f86819b 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -14,6 +14,7 @@ import { getInstalledOpenshellVersion, getSandboxInferenceConfig, getStableGatewayImageRef, + parseTokenFromOutput, patchStagedDockerfile, writeSandboxConfigSyncFile, } from "../bin/lib/onboard"; @@ -782,4 +783,31 @@ const { setupInference } = require(${onboardPath}); assert.equal(commands.length, 3); }); + describe("parseTokenFromOutput", () => { + it("extracts token from a TOKEN: prefixed line", () => { + const out = "shell noise\nTOKEN:test-token-unit-fixture\nexit\n"; + expect(parseTokenFromOutput(out)).toBe("test-token-unit-fixture"); + }); + + it("returns the first TOKEN: match when multiple lines match", () => { + const out = "TOKEN:first\nTOKEN:second\n"; + expect(parseTokenFromOutput(out)).toBe("first"); + }); + + it("trims whitespace around the TOKEN: line", () => { + const out = " TOKEN:abc123 \n"; + expect(parseTokenFromOutput(out)).toBe("abc123"); + }); + + it("returns null when no TOKEN: prefix is present", () => { + expect(parseTokenFromOutput("no token here")).toBe(null); + expect(parseTokenFromOutput("some output\nexit\n")).toBe(null); + }); + + it("returns null for empty or non-string input", () => { + expect(parseTokenFromOutput("")).toBe(null); + expect(parseTokenFromOutput(null)).toBe(null); + expect(parseTokenFromOutput(undefined)).toBe(null); + }); + }); });