fix: silence unused hook token url param (#9436)

* fix: Gateway authentication token exposed in URL query parameters

* fix: silence unused hook token url param

* fix: remove gateway auth tokens from URLs (#9436) (thanks @coygeek)

* test: fix Windows path separators in audit test (#9436)

---------

Co-authored-by: George Pickett <gpickett00@gmail.com>
This commit is contained in:
Coy Geek
2026-02-05 18:08:29 -08:00
committed by GitHub
parent b1430aaaca
commit 717129f7f9
22 changed files with 107 additions and 172 deletions

View File

@@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai
- Exec approvals: coerce bare string allowlist entries to objects to prevent allowlist corruption. (#9903, fixes #9790) Thanks @mcaxtr. - Exec approvals: coerce bare string allowlist entries to objects to prevent allowlist corruption. (#9903, fixes #9790) Thanks @mcaxtr.
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411. - Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras. - TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.
- Security: stop exposing Gateway auth tokens via URL query parameters in Control UI entrypoints, and reject hook tokens in query parameters. (#9436) Thanks @coygeek.
- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard. - Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.
- Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo. - Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.
- Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman. - Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman.

View File

@@ -3173,8 +3173,7 @@ Defaults:
Requests must include the hook token: Requests must include the hook token:
- `Authorization: Bearer <token>` **or** - `Authorization: Bearer <token>` **or**
- `x-openclaw-token: <token>` **or** - `x-openclaw-token: <token>`
- `?token=<token>`
Endpoints: Endpoints:

View File

@@ -334,21 +334,21 @@ If you don't have a global install yet, run it via `pnpm openclaw onboard`.
### How do I open the dashboard after onboarding ### How do I open the dashboard after onboarding
The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn't launch, copy/paste the printed URL on the same machine. Tokens stay local to your host-nothing is fetched from the browser. The wizard opens your browser with a clean (non-tokenized) dashboard URL right after onboarding and also prints the link in the summary. Keep that tab open; if it didn't launch, copy/paste the printed URL on the same machine.
### How do I authenticate the dashboard token on localhost vs remote ### How do I authenticate the dashboard token on localhost vs remote
**Localhost (same machine):** **Localhost (same machine):**
- Open `http://127.0.0.1:18789/`. - Open `http://127.0.0.1:18789/`.
- If it asks for auth, run `openclaw dashboard` and use the tokenized link (`?token=...`). - If it asks for auth, paste the token from `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) into Control UI settings.
- The token is the same value as `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) and is stored by the UI after first load. - Retrieve it from the gateway host: `openclaw config get gateway.auth.token` (or generate one: `openclaw doctor --generate-gateway-token`).
**Not on localhost:** **Not on localhost:**
- **Tailscale Serve** (recommended): keep bind loopback, run `openclaw gateway --tailscale serve`, open `https://<magicdns>/`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy auth (no token). - **Tailscale Serve** (recommended): keep bind loopback, run `openclaw gateway --tailscale serve`, open `https://<magicdns>/`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy auth (no token).
- **Tailnet bind**: run `openclaw gateway --bind tailnet --token "<token>"`, open `http://<tailscale-ip>:18789/`, paste token in dashboard settings. - **Tailnet bind**: run `openclaw gateway --bind tailnet --token "<token>"`, open `http://<tailscale-ip>:18789/`, paste token in dashboard settings.
- **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...` from `openclaw dashboard`. - **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/` and paste the token in Control UI settings.
See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth details. See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth details.
@@ -2383,15 +2383,14 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not
Facts (from code): Facts (from code):
- The Control UI stores the token in browser localStorage key `openclaw.control.settings.v1`. - The Control UI stores the token in browser localStorage key `openclaw.control.settings.v1`.
- The UI can import `?token=...` (and/or `?password=...`) once, then strips it from the URL.
Fix: Fix:
- Fastest: `openclaw dashboard` (prints + copies tokenized link, tries to open; shows SSH hint if headless). - Fastest: `openclaw dashboard` (prints + copies the dashboard URL, tries to open; shows SSH hint if headless).
- If you don't have a token yet: `openclaw doctor --generate-gateway-token`. - If you don't have a token yet: `openclaw doctor --generate-gateway-token`.
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...`. - If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`.
- Set `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) on the gateway host. - Set `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) on the gateway host.
- In the Control UI settings, paste the same token (or refresh with a one-time `?token=...` link). - In the Control UI settings, paste the same token.
- Still stuck? Run `openclaw status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details. - Still stuck? Run `openclaw status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details.
### I set gatewaybind tailnet but it cant bind nothing listens ### I set gatewaybind tailnet but it cant bind nothing listens

View File

@@ -56,7 +56,7 @@ After it finishes:
- Open `http://127.0.0.1:18789/` in your browser. - Open `http://127.0.0.1:18789/` in your browser.
- Paste the token into the Control UI (Settings → token). - Paste the token into the Control UI (Settings → token).
- Need the tokenized URL again? Run `docker compose run --rm openclaw-cli dashboard --no-open`. - Need the URL again? Run `docker compose run --rm openclaw-cli dashboard --no-open`.
It writes config/workspace on the host: It writes config/workspace on the host:

View File

@@ -103,9 +103,10 @@ server {
## 5) Access OpenClaw and grant privileges ## 5) Access OpenClaw and grant privileges
Access `https://<vm-name>.exe.xyz/?token=YOUR-TOKEN-FROM-TERMINAL` (see the Control UI output from onboarding). Approve Access `https://<vm-name>.exe.xyz/` (see the Control UI output from onboarding). If it prompts for auth, paste the
devices with `openclaw devices list` and `openclaw devices approve <requestId>`. When in doubt, token from `gateway.auth.token` on the VM (retrieve with `openclaw config get gateway.auth.token`, or generate one
use Shelley from your browser! with `openclaw doctor --generate-gateway-token`). Approve devices with `openclaw devices list` and
`openclaw devices approve <requestId>`. When in doubt, use Shelley from your browser!
## Remote Access ## Remote Access

View File

@@ -74,7 +74,7 @@ openclaw gateway --port 18789
Now message the assistant number from your allowlisted phone. Now message the assistant number from your allowlisted phone.
When onboarding finishes, we auto-open the dashboard with your gateway token and print the tokenized link. To reopen later: `openclaw dashboard`. When onboarding finishes, we auto-open the dashboard and print a clean (non-tokenized) link. If it prompts for auth, paste the token from `gateway.auth.token` into Control UI settings. To reopen later: `openclaw dashboard`.
## Give the agent a workspace (AGENTS) ## Give the agent a workspace (AGENTS)

View File

@@ -29,18 +29,18 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
## Fast path (recommended) ## Fast path (recommended)
- After onboarding, the CLI now auto-opens the dashboard with your token and prints the same tokenized link. - After onboarding, the CLI auto-opens the dashboard and prints a clean (non-tokenized) link.
- Re-open anytime: `openclaw dashboard` (copies link, opens browser if possible, shows SSH hint if headless). - Re-open anytime: `openclaw dashboard` (copies link, opens browser if possible, shows SSH hint if headless).
- The token stays local (query param only); the UI strips it after first load and saves it in localStorage. - If the UI prompts for auth, paste the token from `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) into Control UI settings.
## Token basics (local vs remote) ## Token basics (local vs remote)
- **Localhost**: open `http://127.0.0.1:18789/`. If you see “unauthorized,” run `openclaw dashboard` and use the tokenized link (`?token=...`). - **Localhost**: open `http://127.0.0.1:18789/`.
- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores it after first load. - **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect.
- **Not localhost**: use Tailscale Serve (tokenless if `gateway.auth.allowTailscale: true`), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web). - **Not localhost**: use Tailscale Serve (tokenless if `gateway.auth.allowTailscale: true`), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
## If you see “unauthorized” / 1008 ## If you see “unauthorized” / 1008
- Run `openclaw dashboard` to get a fresh tokenized link. - Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/`).
- Ensure the gateway is reachable (local: `openclaw status`; remote: SSH tunnel `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...`). - Retrieve the token from the gateway host: `openclaw config get gateway.auth.token` (or generate one: `openclaw doctor --generate-gateway-token`).
- In the dashboard settings, paste the same token you configured in `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). - In the dashboard settings, paste the token into the auth field, then connect.

View File

@@ -18,34 +18,6 @@ const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const;
type CleanupSignal = (typeof CLEANUP_SIGNALS)[number]; type CleanupSignal = (typeof CLEANUP_SIGNALS)[number];
const cleanupHandlers = new Map<CleanupSignal, () => void>(); const cleanupHandlers = new Map<CleanupSignal, () => void>();
/**
* Release all held locks - called on process exit to prevent orphaned locks
*/
async function releaseAllLocks(): Promise<void> {
const locks = Array.from(HELD_LOCKS.values());
HELD_LOCKS.clear();
for (const lock of locks) {
try {
await lock.handle.close();
await fs.rm(lock.lockPath, { force: true });
} catch {
// Best effort cleanup
}
}
}
if (process.env.NODE_ENV !== "test" && !process.env.VITEST) {
// Register cleanup handlers to release locks on unexpected termination
process.on("exit", releaseAllLocks);
process.on("SIGTERM", () => {
void releaseAllLocks().then(() => process.exit(0));
});
process.on("SIGINT", () => {
void releaseAllLocks().then(() => process.exit(0));
});
// Note: unhandledRejection handler will call process.exit() which triggers 'exit'
}
function isAlive(pid: number): boolean { function isAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) { if (!Number.isFinite(pid) || pid <= 0) {
return false; return false;

View File

@@ -292,31 +292,32 @@ export async function dispatchReplyFromConfig(params: {
let accumulatedBlockText = ""; let accumulatedBlockText = "";
let blockCount = 0; let blockCount = 0;
const shouldSendToolSummaries = ctx.ChatType !== "group" && ctx.CommandSource !== "native";
const replyResult = await (params.replyResolver ?? getReplyFromConfig)( const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx, ctx,
{ {
...params.replyOptions, ...params.replyOptions,
onToolResult: onToolResult: shouldSendToolSummaries
ctx.ChatType !== "group" && ctx.CommandSource !== "native" ? (payload: ReplyPayload) => {
? (payload: ReplyPayload) => { const run = async () => {
const run = async () => { const ttsPayload = await maybeApplyTtsToPayload({
const ttsPayload = await maybeApplyTtsToPayload({ payload,
payload, cfg,
cfg, channel: ttsChannel,
channel: ttsChannel, kind: "tool",
kind: "tool", inboundAudio,
inboundAudio, ttsAuto: sessionTtsAuto,
ttsAuto: sessionTtsAuto, });
}); if (shouldRouteToOriginating) {
if (shouldRouteToOriginating) { await sendPayloadAsync(ttsPayload, undefined, false);
await sendPayloadAsync(ttsPayload, undefined, false); } else {
} else { dispatcher.sendToolResult(ttsPayload);
dispatcher.sendToolResult(ttsPayload); }
} };
}; return run();
return run(); }
} : undefined,
: undefined,
onBlockReply: (payload: ReplyPayload, context) => { onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => { const run = async () => {
// Accumulate block text for TTS generation after streaming // Accumulate block text for TTS generation after streaming

View File

@@ -83,8 +83,8 @@ describe("dashboardCommand", () => {
customBindHost: undefined, customBindHost: undefined,
basePath: undefined, basePath: undefined,
}); });
expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/?token=abc123"); expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(mocks.openUrl).toHaveBeenCalledWith("http://127.0.0.1:18789/?token=abc123"); expect(mocks.openUrl).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(runtime.log).toHaveBeenCalledWith( expect(runtime.log).toHaveBeenCalledWith(
"Opened in your browser. Keep that tab to control OpenClaw.", "Opened in your browser. Keep that tab to control OpenClaw.",
); );

View File

@@ -23,7 +23,6 @@ export async function dashboardCommand(
const bind = cfg.gateway?.bind ?? "loopback"; const bind = cfg.gateway?.bind ?? "loopback";
const basePath = cfg.gateway?.controlUi?.basePath; const basePath = cfg.gateway?.controlUi?.basePath;
const customBindHost = cfg.gateway?.customBindHost; const customBindHost = cfg.gateway?.customBindHost;
const token = cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
const links = resolveControlUiLinks({ const links = resolveControlUiLinks({
port, port,
@@ -31,11 +30,11 @@ export async function dashboardCommand(
customBindHost, customBindHost,
basePath, basePath,
}); });
const authedUrl = token ? `${links.httpUrl}?token=${encodeURIComponent(token)}` : links.httpUrl; const dashboardUrl = links.httpUrl;
runtime.log(`Dashboard URL: ${authedUrl}`); runtime.log(`Dashboard URL: ${dashboardUrl}`);
const copied = await copyToClipboard(authedUrl).catch(() => false); const copied = await copyToClipboard(dashboardUrl).catch(() => false);
runtime.log(copied ? "Copied to clipboard." : "Copy to clipboard unavailable."); runtime.log(copied ? "Copied to clipboard." : "Copy to clipboard unavailable.");
let opened = false; let opened = false;
@@ -43,13 +42,12 @@ export async function dashboardCommand(
if (!options.noOpen) { if (!options.noOpen) {
const browserSupport = await detectBrowserOpenSupport(); const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) { if (browserSupport.ok) {
opened = await openUrl(authedUrl); opened = await openUrl(dashboardUrl);
} }
if (!opened) { if (!opened) {
hint = formatControlUiSshHint({ hint = formatControlUiSshHint({
port, port,
basePath, basePath,
token: token || undefined,
}); });
} }
} else { } else {

View File

@@ -179,23 +179,16 @@ export async function detectBrowserOpenSupport(): Promise<BrowserOpenSupport> {
return { ok: true, command: resolved.command }; return { ok: true, command: resolved.command };
} }
export function formatControlUiSshHint(params: { export function formatControlUiSshHint(params: { port: number; basePath?: string }): string {
port: number;
basePath?: string;
token?: string;
}): string {
const basePath = normalizeControlUiBasePath(params.basePath); const basePath = normalizeControlUiBasePath(params.basePath);
const uiPath = basePath ? `${basePath}/` : "/"; const uiPath = basePath ? `${basePath}/` : "/";
const localUrl = `http://localhost:${params.port}${uiPath}`; const localUrl = `http://localhost:${params.port}${uiPath}`;
const tokenParam = params.token ? `?token=${encodeURIComponent(params.token)}` : "";
const authedUrl = params.token ? `${localUrl}${tokenParam}` : undefined;
const sshTarget = resolveSshTargetHint(); const sshTarget = resolveSshTargetHint();
return [ return [
"No GUI detected. Open from your computer:", "No GUI detected. Open from your computer:",
`ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`, `ssh -N -L ${params.port}:127.0.0.1:${params.port} ${sshTarget}`,
"Then open:", "Then open:",
localUrl, localUrl,
authedUrl,
"Docs:", "Docs:",
"https://docs.openclaw.ai/gateway/remote", "https://docs.openclaw.ai/gateway/remote",
"https://docs.openclaw.ai/web/control-ui", "https://docs.openclaw.ai/web/control-ui",

View File

@@ -39,29 +39,25 @@ describe("gateway hooks helpers", () => {
expect(() => resolveHooksConfig(cfg)).toThrow("hooks.path may not be '/'"); expect(() => resolveHooksConfig(cfg)).toThrow("hooks.path may not be '/'");
}); });
test("extractHookToken prefers bearer > header > query", () => { test("extractHookToken prefers bearer > header", () => {
const req = { const req = {
headers: { headers: {
authorization: "Bearer top", authorization: "Bearer top",
"x-openclaw-token": "header", "x-openclaw-token": "header",
}, },
} as unknown as IncomingMessage; } as unknown as IncomingMessage;
const url = new URL("http://localhost/hooks/wake?token=query"); const result1 = extractHookToken(req);
const result1 = extractHookToken(req, url); expect(result1).toBe("top");
expect(result1.token).toBe("top");
expect(result1.fromQuery).toBe(false);
const req2 = { const req2 = {
headers: { "x-openclaw-token": "header" }, headers: { "x-openclaw-token": "header" },
} as unknown as IncomingMessage; } as unknown as IncomingMessage;
const result2 = extractHookToken(req2, url); const result2 = extractHookToken(req2);
expect(result2.token).toBe("header"); expect(result2).toBe("header");
expect(result2.fromQuery).toBe(false);
const req3 = { headers: {} } as unknown as IncomingMessage; const req3 = { headers: {} } as unknown as IncomingMessage;
const result3 = extractHookToken(req3, url); const result3 = extractHookToken(req3);
expect(result3.token).toBe("query"); expect(result3).toBeUndefined();
expect(result3.fromQuery).toBe(true);
}); });
test("normalizeWakePayload trims + validates", () => { test("normalizeWakePayload trims + validates", () => {

View File

@@ -43,18 +43,13 @@ export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | n
}; };
} }
export type HookTokenResult = { export function extractHookToken(req: IncomingMessage): string | undefined {
token: string | undefined;
fromQuery: boolean;
};
export function extractHookToken(req: IncomingMessage, url: URL): HookTokenResult {
const auth = const auth =
typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : ""; typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : "";
if (auth.toLowerCase().startsWith("bearer ")) { if (auth.toLowerCase().startsWith("bearer ")) {
const token = auth.slice(7).trim(); const token = auth.slice(7).trim();
if (token) { if (token) {
return { token, fromQuery: false }; return token;
} }
} }
const headerToken = const headerToken =
@@ -62,13 +57,9 @@ export function extractHookToken(req: IncomingMessage, url: URL): HookTokenResul
? req.headers["x-openclaw-token"].trim() ? req.headers["x-openclaw-token"].trim()
: ""; : "";
if (headerToken) { if (headerToken) {
return { token: headerToken, fromQuery: false }; return headerToken;
} }
const queryToken = url.searchParams.get("token"); return undefined;
if (queryToken) {
return { token: queryToken.trim(), fromQuery: true };
}
return { token: undefined, fromQuery: false };
} }
export async function readJsonBody( export async function readJsonBody(

View File

@@ -147,20 +147,22 @@ export function createHooksRequestHandler(
return false; return false;
} }
const { token, fromQuery } = extractHookToken(req, url); if (url.searchParams.has("token")) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(
"Hook token must be provided via Authorization: Bearer <token> or X-OpenClaw-Token header (query parameters are not allowed).",
);
return true;
}
const token = extractHookToken(req);
if (!token || token !== hooksConfig.token) { if (!token || token !== hooksConfig.token) {
res.statusCode = 401; res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized"); res.end("Unauthorized");
return true; return true;
} }
if (fromQuery) {
logHooks.warn(
"Hook token provided via query parameter is deprecated for security reasons. " +
"Tokens in URLs appear in logs, browser history, and referrer headers. " +
"Use Authorization: Bearer <token> or X-OpenClaw-Token header instead.",
);
}
if (req.method !== "POST") { if (req.method !== "POST") {
res.statusCode = 405; res.statusCode = 405;

View File

@@ -88,10 +88,7 @@ describe("gateway server hooks", () => {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Query auth" }), body: JSON.stringify({ text: "Query auth" }),
}); });
expect(resQuery.status).toBe(200); expect(resQuery.status).toBe(400);
const queryEvents = await waitForSystemEvent();
expect(queryEvents.some((e) => e.includes("Query auth"))).toBe(true);
drainSystemEvents(resolveMainKey());
const resBadChannel = await fetch(`http://127.0.0.1:${port}/hooks/agent`, { const resBadChannel = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
method: "POST", method: "POST",

View File

@@ -85,7 +85,7 @@ function formatGatewayAuthFailureMessage(params: {
const isCli = isGatewayCliClient(client); const isCli = isGatewayCliClient(client);
const isControlUi = client?.id === GATEWAY_CLIENT_IDS.CONTROL_UI; const isControlUi = client?.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const isWebchat = isWebchatClient(client); const isWebchat = isWebchatClient(client);
const uiHint = "open a tokenized dashboard URL or paste token in Control UI settings"; const uiHint = "open the dashboard URL and paste the token in Control UI settings";
const tokenHint = isCli const tokenHint = isCli
? "set gateway.remote.token to match gateway.auth.token" ? "set gateway.remote.token to match gateway.auth.token"
: isControlUi || isWebchat : isControlUi || isWebchat

View File

@@ -255,11 +255,7 @@ export async function finalizeOnboardingWizard(
customBindHost: settings.customBindHost, customBindHost: settings.customBindHost,
basePath: controlUiBasePath, basePath: controlUiBasePath,
}); });
const tokenParam = const dashboardUrl = links.httpUrl;
settings.authMode === "token" && settings.gatewayToken
? `?token=${encodeURIComponent(settings.gatewayToken)}`
: "";
const authedUrl = `${links.httpUrl}${tokenParam}`;
const gatewayProbe = await probeGatewayReachable({ const gatewayProbe = await probeGatewayReachable({
url: links.wsUrl, url: links.wsUrl,
token: settings.authMode === "token" ? settings.gatewayToken : undefined, token: settings.authMode === "token" ? settings.gatewayToken : undefined,
@@ -279,8 +275,7 @@ export async function finalizeOnboardingWizard(
await prompter.note( await prompter.note(
[ [
`Web UI: ${links.httpUrl}`, `Web UI: ${dashboardUrl}`,
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
`Gateway WS: ${links.wsUrl}`, `Gateway WS: ${links.wsUrl}`,
gatewayStatusLine, gatewayStatusLine,
"Docs: https://docs.openclaw.ai/web/control-ui", "Docs: https://docs.openclaw.ai/web/control-ui",
@@ -313,8 +308,11 @@ export async function finalizeOnboardingWizard(
[ [
"Gateway token: shared auth for the Gateway + Control UI.", "Gateway token: shared auth for the Gateway + Control UI.",
"Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.", "Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.",
`View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`,
`Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`,
"Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).", "Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).",
`Get the tokenized link anytime: ${formatCliCommand("openclaw dashboard --no-open")}`, `Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`,
"Paste the token into Control UI settings if prompted.",
].join("\n"), ].join("\n"),
"Token", "Token",
); );
@@ -343,24 +341,22 @@ export async function finalizeOnboardingWizard(
} else if (hatchChoice === "web") { } else if (hatchChoice === "web") {
const browserSupport = await detectBrowserOpenSupport(); const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) { if (browserSupport.ok) {
controlUiOpened = await openUrl(authedUrl); controlUiOpened = await openUrl(dashboardUrl);
if (!controlUiOpened) { if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({ controlUiOpenHint = formatControlUiSshHint({
port: settings.port, port: settings.port,
basePath: controlUiBasePath, basePath: controlUiBasePath,
token: settings.gatewayToken,
}); });
} }
} else { } else {
controlUiOpenHint = formatControlUiSshHint({ controlUiOpenHint = formatControlUiSshHint({
port: settings.port, port: settings.port,
basePath: controlUiBasePath, basePath: controlUiBasePath,
token: settings.gatewayToken,
}); });
} }
await prompter.note( await prompter.note(
[ [
`Dashboard link (with token): ${authedUrl}`, `Dashboard link: ${dashboardUrl}`,
controlUiOpened controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw." ? "Opened in your browser. Keep that tab to control OpenClaw."
: "Copy/paste this URL in a browser on this machine to control OpenClaw.", : "Copy/paste this URL in a browser on this machine to control OpenClaw.",
@@ -446,25 +442,23 @@ export async function finalizeOnboardingWizard(
if (shouldOpenControlUi) { if (shouldOpenControlUi) {
const browserSupport = await detectBrowserOpenSupport(); const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) { if (browserSupport.ok) {
controlUiOpened = await openUrl(authedUrl); controlUiOpened = await openUrl(dashboardUrl);
if (!controlUiOpened) { if (!controlUiOpened) {
controlUiOpenHint = formatControlUiSshHint({ controlUiOpenHint = formatControlUiSshHint({
port: settings.port, port: settings.port,
basePath: controlUiBasePath, basePath: controlUiBasePath,
token: settings.gatewayToken,
}); });
} }
} else { } else {
controlUiOpenHint = formatControlUiSshHint({ controlUiOpenHint = formatControlUiSshHint({
port: settings.port, port: settings.port,
basePath: controlUiBasePath, basePath: controlUiBasePath,
token: settings.gatewayToken,
}); });
} }
await prompter.note( await prompter.note(
[ [
`Dashboard link (with token): ${authedUrl}`, `Dashboard link: ${dashboardUrl}`,
controlUiOpened controlUiOpened
? "Opened in your browser. Keep that tab to control OpenClaw." ? "Opened in your browser. Keep that tab to control OpenClaw."
: "Copy/paste this URL in a browser on this machine to control OpenClaw.", : "Copy/paste this URL in a browser on this machine to control OpenClaw.",
@@ -511,10 +505,10 @@ export async function finalizeOnboardingWizard(
await prompter.outro( await prompter.outro(
controlUiOpened controlUiOpened
? "Onboarding complete. Dashboard opened with your token; keep that tab to control OpenClaw." ? "Onboarding complete. Dashboard opened; keep that tab to control OpenClaw."
: seededInBackground : seededInBackground
? "Onboarding complete. Web UI seeded in the background; open it anytime with the tokenized link above." ? "Onboarding complete. Web UI seeded in the background; open it anytime with the dashboard link above."
: "Onboarding complete. Use the tokenized dashboard link above to control OpenClaw.", : "Onboarding complete. Use the dashboard link above to control OpenClaw.",
); );
return { launchedTui }; return { launchedTui };

View File

@@ -112,19 +112,11 @@ export function applySettingsFromUrl(host: SettingsHost) {
let shouldCleanUrl = false; let shouldCleanUrl = false;
if (tokenRaw != null) { if (tokenRaw != null) {
const token = tokenRaw.trim();
if (token && token !== host.settings.token) {
applySettings(host, { ...host.settings, token });
}
params.delete("token"); params.delete("token");
shouldCleanUrl = true; shouldCleanUrl = true;
} }
if (passwordRaw != null) { if (passwordRaw != null) {
const password = passwordRaw.trim();
if (password) {
(host as unknown as { password: string }).password = password;
}
params.delete("password"); params.delete("password");
shouldCleanUrl = true; shouldCleanUrl = true;
} }

View File

@@ -151,25 +151,25 @@ describe("control UI routing", () => {
expect(container.scrollTop).toBe(maxScroll); expect(container.scrollTop).toBe(maxScroll);
}); });
it("hydrates token from URL params and strips it", async () => { it("strips token URL params without importing them", async () => {
const app = mountApp("/ui/overview?token=abc123"); const app = mountApp("/ui/overview?token=abc123");
await app.updateComplete; await app.updateComplete;
expect(app.settings.token).toBe("abc123"); expect(app.settings.token).toBe("");
expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe(""); expect(window.location.search).toBe("");
}); });
it("hydrates password from URL params and strips it", async () => { it("strips password URL params without importing them", async () => {
const app = mountApp("/ui/overview?password=sekret"); const app = mountApp("/ui/overview?password=sekret");
await app.updateComplete; await app.updateComplete;
expect(app.password).toBe("sekret"); expect(app.password).toBe("");
expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe(""); expect(window.location.search).toBe("");
}); });
it("hydrates token from URL params even when settings already set", async () => { it("does not override stored settings from URL token params", async () => {
localStorage.setItem( localStorage.setItem(
"openclaw.control.settings.v1", "openclaw.control.settings.v1",
JSON.stringify({ token: "existing-token" }), JSON.stringify({ token: "existing-token" }),
@@ -177,7 +177,7 @@ describe("control UI routing", () => {
const app = mountApp("/ui/overview?token=abc123"); const app = mountApp("/ui/overview?token=abc123");
await app.updateComplete; await app.updateComplete;
expect(app.settings.token).toBe("abc123"); expect(app.settings.token).toBe("existing-token");
expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.pathname).toBe("/ui/overview");
expect(window.location.search).toBe(""); expect(window.location.search).toBe("");
}); });

View File

@@ -26,23 +26,23 @@ describe("iconForTab", () => {
}); });
it("returns stable icons for known tabs", () => { it("returns stable icons for known tabs", () => {
expect(iconForTab("chat")).toBe("💬"); expect(iconForTab("chat")).toBe("messageSquare");
expect(iconForTab("overview")).toBe("📊"); expect(iconForTab("overview")).toBe("barChart");
expect(iconForTab("channels")).toBe("🔗"); expect(iconForTab("channels")).toBe("link");
expect(iconForTab("instances")).toBe("📡"); expect(iconForTab("instances")).toBe("radio");
expect(iconForTab("sessions")).toBe("📄"); expect(iconForTab("sessions")).toBe("fileText");
expect(iconForTab("cron")).toBe(""); expect(iconForTab("cron")).toBe("loader");
expect(iconForTab("skills")).toBe("⚡️"); expect(iconForTab("skills")).toBe("zap");
expect(iconForTab("nodes")).toBe("🖥️"); expect(iconForTab("nodes")).toBe("monitor");
expect(iconForTab("config")).toBe("⚙️"); expect(iconForTab("config")).toBe("settings");
expect(iconForTab("debug")).toBe("🐞"); expect(iconForTab("debug")).toBe("bug");
expect(iconForTab("logs")).toBe("🧾"); expect(iconForTab("logs")).toBe("scrollText");
}); });
it("returns a fallback icon for unknown tab", () => { it("returns a fallback icon for unknown tab", () => {
// TypeScript won't allow this normally, but runtime could receive unexpected values // TypeScript won't allow this normally, but runtime could receive unexpected values
const unknownTab = "unknown" as Tab; const unknownTab = "unknown" as Tab;
expect(iconForTab(unknownTab)).toBe("📁"); expect(iconForTab(unknownTab)).toBe("folder");
}); });
}); });

View File

@@ -44,7 +44,7 @@ export function renderOverview(props: OverviewProps) {
<div class="muted" style="margin-top: 8px"> <div class="muted" style="margin-top: 8px">
This gateway requires auth. Add a token or password, then click Connect. This gateway requires auth. Add a token or password, then click Connect.
<div style="margin-top: 6px"> <div style="margin-top: 6px">
<span class="mono">openclaw dashboard --no-open</span> → tokenized URL<br /> <span class="mono">openclaw dashboard --no-open</span> → open the Control UI<br />
<span class="mono">openclaw doctor --generate-gateway-token</span> → set token <span class="mono">openclaw doctor --generate-gateway-token</span> → set token
</div> </div>
<div style="margin-top: 6px"> <div style="margin-top: 6px">
@@ -62,8 +62,7 @@ export function renderOverview(props: OverviewProps) {
} }
return html` return html`
<div class="muted" style="margin-top: 8px"> <div class="muted" style="margin-top: 8px">
Auth failed. Re-copy a tokenized URL with Auth failed. Update the token or password in Control UI settings, then click Connect.
<span class="mono">openclaw dashboard --no-open</span>, or update the token, then click Connect.
<div style="margin-top: 6px"> <div style="margin-top: 6px">
<a <a
class="session-link" class="session-link"