From b6c8f6c5ac4df9041faefa2ef7ed3db4aa8d18dc Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Thu, 26 Mar 2026 08:27:32 +0800 Subject: [PATCH 1/3] feat(cli): add gateway-token command to retrieve auth token (#938) Add 'nemoclaw gateway-token' command that prints the gateway auth token for a sandbox, replacing the multi-step workaround with openshell sandbox download + jq. The command: - Downloads openclaw.json from the sandbox via openshell - Extracts gateway.auth.token from the config - Prints the token to stdout (pipe-friendly) - Cleans up temp files in all cases - Provides clear error messages when sandbox is inaccessible or token is not configured Usage: nemoclaw my-assistant gateway-token The token can be used directly in scripts: TOKEN=$(nemoclaw my-assistant gateway-token) openclaw acp --url wss://localhost:18789 --token $TOKEN Closes #938 Signed-off-by: Kagura --- bin/nemoclaw.js | 71 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index b070b7e9a..74dc671ac 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -647,6 +647,61 @@ function sandboxLogs(sandboxName, follow) { runOpenshell(args); } +function sandboxGatewayToken(sandboxName) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-token-")); + try { + const qn = shellQuote(sandboxName); + const destDir = `${tmpDir}${path.sep}`; + const result = spawnSync("bash", ["-c", `openshell sandbox download ${qn} /sandbox/.openclaw/openclaw.json ${shellQuote(destDir)}`], { + stdio: ["ignore", "ignore", "ignore"], + }); + if (result.status !== 0) { + console.error(` ${_RD}Error${R}: Could not retrieve config from sandbox '${sandboxName}'.`); + console.error(` Make sure the sandbox is running and accessible.`); + process.exit(1); + } + // Find the downloaded file (may be nested in subdirectory) + const candidates = [ + path.join(tmpDir, "openclaw.json"), + path.join(tmpDir, "sandbox", ".openclaw", "openclaw.json"), + ]; + let jsonPath = null; + for (const p of candidates) { + if (fs.existsSync(p)) { jsonPath = p; break; } + } + // Fallback: recursive search + if (!jsonPath) { + const findResult = spawnSync("find", [tmpDir, "-name", "openclaw.json", "-type", "f"], { + encoding: "utf-8", + }); + const found = (findResult.stdout || "").trim().split("\n").filter(Boolean)[0]; + if (found) jsonPath = found; + } + if (!jsonPath) { + console.error(` ${_RD}Error${R}: openclaw.json not found in sandbox '${sandboxName}'.`); + process.exit(1); + } + const cfg = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); + const token = cfg && cfg.gateway && cfg.gateway.auth && cfg.gateway.auth.token; + if (typeof token === "string" && token.length > 0) { + console.log(token); + } else { + console.error(` ${_RD}Error${R}: Gateway auth token not found in sandbox config.`); + console.error(` The sandbox may not have been fully onboarded yet.`); + process.exit(1); + } + } catch (e) { + console.error(` ${_RD}Error${R}: ${e.message}`); + process.exit(1); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } +} + async function sandboxPolicyAdd(sandboxName) { const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -724,6 +779,7 @@ function help() { nemoclaw connect Shell into a running sandbox nemoclaw status Sandbox health + NIM status nemoclaw logs ${D}[--follow]${R} Stream sandbox logs + nemoclaw gateway-token Print the gateway auth token nemoclaw destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R} ${G}Policy Presets:${R} @@ -800,15 +856,16 @@ const [cmd, ...args] = process.argv.slice(2); const actionArgs = args.slice(1); switch (action) { - case "connect": await sandboxConnect(cmd); break; - case "status": await sandboxStatus(cmd); break; - case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; - case "policy-add": await sandboxPolicyAdd(cmd); break; - case "policy-list": sandboxPolicyList(cmd); break; - case "destroy": await sandboxDestroy(cmd, actionArgs); break; + case "connect": await sandboxConnect(cmd); break; + case "status": await sandboxStatus(cmd); break; + case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; + case "policy-add": await sandboxPolicyAdd(cmd); break; + case "policy-list": sandboxPolicyList(cmd); break; + case "gateway-token": sandboxGatewayToken(cmd); break; + case "destroy": await sandboxDestroy(cmd, actionArgs); break; default: console.error(` Unknown action: ${action}`); - console.error(` Valid actions: connect, status, logs, policy-add, policy-list, destroy`); + console.error(` Valid actions: connect, status, logs, policy-add, policy-list, gateway-token, destroy`); process.exit(1); } return; From b9269944c242879f59297f71f33f77276e58b28b Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Fri, 27 Mar 2026 13:33:55 +0800 Subject: [PATCH 2/3] feat(cli): add open command, refactor gateway-token to use shared auth (PR #944 feedback) - Export fetchGatewayAuthTokenFromSandbox and buildControlUiUrls from onboard.js - Refactor sandboxGatewayToken() to use shared fetchGatewayAuthTokenFromSandbox() - Add security warning to gateway-token output (stderr before token on stdout) - Add new 'open' command that opens browser with tokenized URL - Ensures port forward is active before opening - Uses platform-appropriate opener (xdg-open/open/start) - Minimizes token exposure compared to manual copy-paste - Update help text to document both commands Co-Authored-By: Claude Opus 4.6 --- bin/lib/onboard.js | 2 + bin/nemoclaw.js | 99 ++++++++++++++++++++++------------------------ 2 files changed, 50 insertions(+), 51 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 955894113..166af7535 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2353,6 +2353,8 @@ async function onboard(opts = {}) { module.exports = { buildSandboxConfigSyncScript, + buildControlUiUrls, + fetchGatewayAuthTokenFromSandbox, getFutureShellPathHint, createSandbox, getSandboxInferenceConfig, diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index 74dc671ac..d8dc5028a 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -22,7 +22,11 @@ const YW = _useColor ? "\x1b[1;33m" : ""; const { ROOT, SCRIPTS, run, runCapture: _runCapture, runInteractive, shellQuote, validateName } = require("./lib/runner"); const { resolveOpenshell } = require("./lib/resolve-openshell"); -const { startGatewayForRecovery } = require("./lib/onboard"); +const { + buildControlUiUrls, + fetchGatewayAuthTokenFromSandbox, + startGatewayForRecovery, +} = require("./lib/onboard"); const { ensureApiKey, ensureGithubToken, @@ -648,57 +652,48 @@ function sandboxLogs(sandboxName, follow) { } function sandboxGatewayToken(sandboxName) { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-token-")); + const token = fetchGatewayAuthTokenFromSandbox(sandboxName); + if (!token) { + console.error(` ${_RD}Error${R}: Could not retrieve gateway auth token from sandbox '${sandboxName}'.`); + console.error(` Make sure the sandbox is running and accessible.`); + process.exit(1); + } + // Print warning to stderr before token to stdout + console.error(` ${YW}⚠️ Treat this token like a password. Do not share it or include it in logs/screencasts.${R}`); + console.log(token); +} + +async function sandboxOpen(sandboxName) { + await ensureLiveSandboxOrExit(sandboxName); + // Ensure port forward is alive before opening + runOpenshell(["forward", "start", "--background", "18789", sandboxName], { ignoreError: true }); + + const token = fetchGatewayAuthTokenFromSandbox(sandboxName); + if (!token) { + console.error(` ${_RD}Error${R}: Could not retrieve gateway auth token from sandbox '${sandboxName}'.`); + console.error(` Make sure the sandbox is running and accessible.`); + process.exit(1); + } + + const urls = buildControlUiUrls(token); + const url = urls[0]; // Use the first URL (localhost) + + // Log without token to avoid exposure in terminal history/screencasts + const safeUrl = url.replace(/#.*$/, ""); + console.log(` Opening Control UI at ${safeUrl} ...`); + + // Open URL in default browser (platform-appropriate) + const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; try { - const qn = shellQuote(sandboxName); - const destDir = `${tmpDir}${path.sep}`; - const result = spawnSync("bash", ["-c", `openshell sandbox download ${qn} /sandbox/.openclaw/openclaw.json ${shellQuote(destDir)}`], { - stdio: ["ignore", "ignore", "ignore"], + const child = require("child_process").spawn(opener, [url], { + stdio: "ignore", + detached: true, }); - if (result.status !== 0) { - console.error(` ${_RD}Error${R}: Could not retrieve config from sandbox '${sandboxName}'.`); - console.error(` Make sure the sandbox is running and accessible.`); - process.exit(1); - } - // Find the downloaded file (may be nested in subdirectory) - const candidates = [ - path.join(tmpDir, "openclaw.json"), - path.join(tmpDir, "sandbox", ".openclaw", "openclaw.json"), - ]; - let jsonPath = null; - for (const p of candidates) { - if (fs.existsSync(p)) { jsonPath = p; break; } - } - // Fallback: recursive search - if (!jsonPath) { - const findResult = spawnSync("find", [tmpDir, "-name", "openclaw.json", "-type", "f"], { - encoding: "utf-8", - }); - const found = (findResult.stdout || "").trim().split("\n").filter(Boolean)[0]; - if (found) jsonPath = found; - } - if (!jsonPath) { - console.error(` ${_RD}Error${R}: openclaw.json not found in sandbox '${sandboxName}'.`); - process.exit(1); - } - const cfg = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); - const token = cfg && cfg.gateway && cfg.gateway.auth && cfg.gateway.auth.token; - if (typeof token === "string" && token.length > 0) { - console.log(token); - } else { - console.error(` ${_RD}Error${R}: Gateway auth token not found in sandbox config.`); - console.error(` The sandbox may not have been fully onboarded yet.`); - process.exit(1); - } - } catch (e) { - console.error(` ${_RD}Error${R}: ${e.message}`); + child.unref(); + } catch (_e) { + console.error(` ${_RD}Error${R}: Could not open browser. Please open this URL manually:`); + console.error(` ${url}`); process.exit(1); - } finally { - try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors - } } } @@ -779,7 +774,8 @@ function help() { nemoclaw connect Shell into a running sandbox nemoclaw status Sandbox health + NIM status nemoclaw logs ${D}[--follow]${R} Stream sandbox logs - nemoclaw gateway-token Print the gateway auth token + nemoclaw open Open Control UI in browser with auth token + nemoclaw gateway-token Print the gateway auth token ${D}(for scripts)${R} nemoclaw destroy Stop NIM + delete sandbox ${D}(--yes to skip prompt)${R} ${G}Policy Presets:${R} @@ -862,10 +858,11 @@ const [cmd, ...args] = process.argv.slice(2); case "policy-add": await sandboxPolicyAdd(cmd); break; case "policy-list": sandboxPolicyList(cmd); break; case "gateway-token": sandboxGatewayToken(cmd); break; + case "open": await sandboxOpen(cmd); break; case "destroy": await sandboxDestroy(cmd, actionArgs); break; default: console.error(` Unknown action: ${action}`); - console.error(` Valid actions: connect, status, logs, policy-add, policy-list, gateway-token, destroy`); + console.error(` Valid actions: connect, status, logs, policy-add, policy-list, gateway-token, open, destroy`); process.exit(1); } return; From 77477f47f4e7a6dab3754e8430fbfe749882e7ce Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Fri, 27 Mar 2026 14:45:27 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(cli):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?reconcile=20before=20token=20fetch,=20wait=20for=20port,=20fix?= =?UTF-8?q?=20Windows=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gateway-token: add ensureLiveSandboxOrExit() before fetching token (consistent with connect/open) - open: wait for port 18789 to accept connections before launching browser (TCP probe with 10s timeout) - open: fix Windows compatibility — 'start' is a cmd built-in, needs shell: true and empty title arg --- bin/nemoclaw.js | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/bin/nemoclaw.js b/bin/nemoclaw.js index d8dc5028a..b2f6d6864 100755 --- a/bin/nemoclaw.js +++ b/bin/nemoclaw.js @@ -651,7 +651,33 @@ function sandboxLogs(sandboxName, follow) { runOpenshell(args); } -function sandboxGatewayToken(sandboxName) { +/** + * Wait for a TCP port to accept connections, with timeout. + */ +function waitForPort(port, timeoutMs = 10000) { + const net = require("net"); + const start = Date.now(); + return new Promise((resolve, reject) => { + function tryConnect() { + const sock = net.createConnection({ port, host: "127.0.0.1" }, () => { + sock.destroy(); + resolve(); + }); + sock.on("error", () => { + sock.destroy(); + if (Date.now() - start > timeoutMs) { + reject(new Error(`Port ${port} not ready after ${timeoutMs}ms`)); + } else { + setTimeout(tryConnect, 500); + } + }); + } + tryConnect(); + }); +} + +async function sandboxGatewayToken(sandboxName) { + await ensureLiveSandboxOrExit(sandboxName); const token = fetchGatewayAuthTokenFromSandbox(sandboxName); if (!token) { console.error(` ${_RD}Error${R}: Could not retrieve gateway auth token from sandbox '${sandboxName}'.`); @@ -665,8 +691,9 @@ function sandboxGatewayToken(sandboxName) { async function sandboxOpen(sandboxName) { await ensureLiveSandboxOrExit(sandboxName); - // Ensure port forward is alive before opening + // Start port forward and wait for it to be ready runOpenshell(["forward", "start", "--background", "18789", sandboxName], { ignoreError: true }); + await waitForPort(18789, 10000); const token = fetchGatewayAuthTokenFromSandbox(sandboxName); if (!token) { @@ -683,11 +710,14 @@ async function sandboxOpen(sandboxName) { console.log(` Opening Control UI at ${safeUrl} ...`); // Open URL in default browser (platform-appropriate) - const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; + // Windows "start" is a cmd built-in, not an executable — requires shell: true + const isWin = process.platform === "win32"; + const opener = process.platform === "darwin" ? "open" : isWin ? "start" : "xdg-open"; try { - const child = require("child_process").spawn(opener, [url], { + const child = require("child_process").spawn(opener, isWin ? ["", url] : [url], { stdio: "ignore", detached: true, + shell: isWin, }); child.unref(); } catch (_e) { @@ -857,7 +887,7 @@ const [cmd, ...args] = process.argv.slice(2); case "logs": sandboxLogs(cmd, actionArgs.includes("--follow")); break; case "policy-add": await sandboxPolicyAdd(cmd); break; case "policy-list": sandboxPolicyList(cmd); break; - case "gateway-token": sandboxGatewayToken(cmd); break; + case "gateway-token": await sandboxGatewayToken(cmd); break; case "open": await sandboxOpen(cmd); break; case "destroy": await sandboxDestroy(cmd, actionArgs); break; default: