fix: recover Control UI access after device-auth upgrades (#118231)

* fix: recover Control UI pairing from dashboard

* test: prove dashboard credential rotation

* test: complete gateway auth generation fixture

* test: restore QR device token verifier

* fix: preserve headless dashboard recovery
This commit is contained in:
Jason (Json)
2026-08-02 16:40:53 -06:00
committed by GitHub
parent 474c4e671e
commit 8df95ccd08
14 changed files with 316 additions and 75 deletions
+19 -6
View File
@@ -1,14 +1,16 @@
---
summary: "CLI reference for `openclaw dashboard` (open the Control UI)"
summary: "CLI reference for `openclaw dashboard` (securely open the Control UI)"
read_when:
- You want to open the Control UI with your current token
- You want to open or re-pair the Control UI from the Gateway host
- You want to print the URL without launching a browser
title: "Dashboard"
---
# `openclaw dashboard`
Open the Control UI using your current auth.
Open the Control UI with a short-lived, one-time browser pairing link. A successful handoff leaves
that browser with its own durable device credential, so reopening the dashboard does not depend on
the shared Gateway token.
```bash
openclaw dashboard
@@ -29,17 +31,28 @@ Use `--json` for desktop integrations and scripts that need the resolved Control
openclaw dashboard --json
```
The response includes `url`, `httpUrl`, `wsUrl`, `port`, and `tokenIncluded`. If the Gateway is not ready, the command returns `{"ok":false,"reason":"..."}` and exits non-zero. SecretRef-managed tokens are never included in `url`.
The response includes the backward-compatible shared-auth `url`, plus `browserUrl`,
`browserBootstrapExpiresAtMs`, `httpUrl`, `wsUrl`, `port`, and `tokenIncluded`. Browser integrations
should open `browserUrl`; native RPC clients that need the shared Gateway credential can continue to
use `url`. If the Gateway is not ready or a browser handoff cannot be issued, the command returns
`{"ok":false,"reason":"..."}` and exits non-zero. SecretRef-managed shared tokens are never included
in `url`.
Notes:
- Resolves configured `gateway.auth.token` SecretRefs when possible.
- `browserUrl` carries a single-use, ten-minute bootstrap in the URL fragment. The Control UI strips
it immediately, binds it to the browser's signed device identity, and stores only the resulting
per-device credential.
- Follows `gateway.tls.enabled`: TLS-enabled gateways print/open `https://` Control UI URLs and connect over `wss://`.
- For `lan` or a wildcard `custom` bind, same-host launches always use loopback because a wildcard is not a browser destination. Plaintext `tailnet` and `custom` binds also use `127.0.0.1` so the browser has a secure context; TLS-enabled specific hosts keep the configured address so certificate names match.
- Before delivering an authenticated loopback URL for a specific-interface bind, the command probes the configured interface and verifies that it and `127.0.0.1` are owned by the same Gateway process. Ambiguous listener ownership fails closed with status guidance.
- For SecretRef-managed tokens (resolved or unresolved), the printed/copied/opened URL never includes the token, so external secrets do not leak into terminal output, clipboard history, or browser-launch arguments.
- If `gateway.auth.token` is SecretRef-managed but unresolved, the command prints a non-tokenized URL and remediation guidance instead of an invalid token placeholder.
- The interactive command prints only the clean base URL; the clipboard/browser launch receives the
one-time `browserUrl`, never the shared token. SecretRef-managed shared tokens therefore do not leak
into terminal output, clipboard history, or browser-launch arguments.
- If clipboard/browser delivery fails for a token-authenticated URL, the command logs a safe manual-auth hint naming `OPENCLAW_GATEWAY_TOKEN`, `gateway.auth.token`, and the URL fragment key `token`, without printing the token value.
- If the shared token cannot be placed in a URL and clipboard/browser delivery fails, run
`openclaw dashboard --json` and open its short-lived `browserUrl` within ten minutes.
## Related
+1 -1
View File
@@ -47,7 +47,7 @@ Onboarding usually configures a gateway token for shared-secret auth. If the Gat
## Device pairing (first connection)
After gateway auth succeeds, connecting from a new browser or device usually requires a **one-time pairing approval**, shown as `disconnected (1008): pairing required`.
After gateway auth succeeds, connecting from a new browser or device usually requires a **one-time pairing approval**, shown as `disconnected (1008): pairing required`. On the Gateway host, `openclaw dashboard` is the preferred recovery path: it opens a short-lived, single-use pairing link and leaves the browser with a durable per-device credential.
<Warning>
When upgrading directly from a release that used the retired
+14 -5
View File
@@ -33,18 +33,27 @@ The Control UI is an **admin surface** (chat, config, exec approvals). Do not ex
## Fast path (recommended)
- After onboarding, the CLI auto-opens the dashboard and prints a clean (non-tokenized) link.
- Re-open anytime: `openclaw dashboard` (copies the link, opens a browser if possible, prints an SSH hint if headless).
- If clipboard and browser delivery both fail, `openclaw dashboard` still prints the clean URL and tells you to append your token (from `OPENCLAW_GATEWAY_TOKEN` or `gateway.auth.token`) as the URL fragment key `token`; it never prints the token value in logs.
- After onboarding, the CLI auto-opens the dashboard and prints a clean link.
- Re-open or repair a browser anytime: `openclaw dashboard`. It copies/opens a single-use pairing link
that replaces stale browser credentials without granting blanket remote auto-approval.
- If clipboard and browser delivery both fail, `openclaw dashboard` either gives a safe manual-token
hint or tells you to run `openclaw dashboard --json` and open its short-lived `browserUrl`; it never
prints the shared token value in interactive logs.
- If the UI prompts for shared-secret auth, paste the configured token or password into Control UI settings.
## Auth basics (local vs remote)
- **Localhost**: open `http://127.0.0.1:18789/`.
- **Gateway TLS**: when `gateway.tls.enabled: true`, dashboard/status links use `https://` and Control UI WebSocket links use `wss://`.
- **Shared-secret token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). `openclaw dashboard` can pass it via URL fragment for one-time bootstrap; the Control UI keeps it in sessionStorage for the current tab and selected gateway URL, not localStorage.
- **Shared-secret token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). Manual token entry
is kept in sessionStorage for the current tab and selected gateway URL, not localStorage.
- **Host-authorized browser handoff**: `openclaw dashboard` issues a short-lived, single-use bootstrap
instead of putting the shared Gateway token in the browser launch URL. The bootstrap is bound to
that browser's signed device identity and exchanged for a durable per-device credential.
- **Missing-config runtime token**: if startup says it generated a runtime token, that token is ephemeral and cannot be recovered. Loopback still requires auth. Run `openclaw doctor --generate-gateway-token`, restart the Gateway, then run `openclaw gateway auth-token --show` in an interactive terminal and paste the output into Control UI settings.
- If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design, to avoid exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments. If the ref is unresolved in your current shell, it still prints the non-tokenized URL plus actionable auth setup guidance.
- If `gateway.auth.token` is SecretRef-managed, the interactive dashboard handoff still works because
it carries only the short-lived browser bootstrap; the external shared token is not placed in
terminal output, clipboard history, or browser-launch arguments.
- **Shared-secret password**: use the configured `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). The dashboard does not persist passwords across reloads.
- **Identity-bearing modes**: Tailscale Serve satisfies Control UI/WebSocket auth via identity headers when `gateway.auth.allowTailscale: true`; a non-loopback identity-aware reverse proxy satisfies `gateway.auth.mode: "trusted-proxy"`. Neither needs a pasted shared secret for the WebSocket.
- **Not localhost**: use Tailscale Serve, a non-loopback shared-secret bind, a non-loopback identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`, or an SSH tunnel. HTTP APIs still use shared-secret auth unless you intentionally run private-ingress `gateway.auth.mode: "none"` or trusted-proxy HTTP auth. See [Web surfaces](/web).
+10 -5
View File
@@ -165,9 +165,10 @@ describe("cli integration: qr + dashboard token SecretRef", () => {
const joined = runtimeLogs.join("\n");
expect(joined).toContain("Dashboard URL: http://127.0.0.1:18789/");
expect(joined).not.toContain("#token=");
expect(joined).toContain(
"Token auto-auth is disabled for SecretRef-managed gateway.auth.token",
);
expect(joined).toContain("One-time pairing URL not delivered");
expect(joined).toContain("openclaw dashboard --json");
expect(joined).toContain("browserUrl");
expect(joined).not.toContain("Token auto-auth is disabled");
expect(joined).not.toContain("Token auto-auth unavailable");
expect(runtimeErrors).toStrictEqual([]);
});
@@ -193,8 +194,12 @@ describe("cli integration: qr + dashboard token SecretRef", () => {
const joined = runtimeLogs.join("\n");
expect(joined).toContain("Dashboard URL: http://127.0.0.1:18789/");
expect(joined).not.toContain("#token=");
expect(joined).toContain("Token auto-auth unavailable");
expect(joined).toContain("Set OPENCLAW_GATEWAY_TOKEN");
expect(joined).toContain("One-time pairing URL not delivered");
expect(joined).toContain("openclaw dashboard --json");
expect(joined).toContain("browserUrl");
expect(joined).not.toContain("Token auto-auth unavailable");
expect(joined).not.toContain("Set OPENCLAW_GATEWAY_TOKEN");
expect(runtimeErrors).toStrictEqual([]);
});
afterAll(() => {
+76 -24
View File
@@ -9,6 +9,7 @@ const detectBrowserOpenSupportMock = vi.hoisted(() => vi.fn());
const openUrlMock = vi.hoisted(() => vi.fn());
const formatControlUiSshHintMock = vi.hoisted(() => vi.fn());
const copyToClipboardMock = vi.hoisted(() => vi.fn());
const issueDeviceBootstrapTokenMock = vi.hoisted(() => vi.fn());
const resolveSecretRefValuesMock = vi.hoisted(() => vi.fn());
const ensureGatewayReadyForOperationMock = vi.hoisted(() => vi.fn());
@@ -28,6 +29,10 @@ vi.mock("../infra/clipboard.js", () => ({
copyToClipboard: copyToClipboardMock,
}));
vi.mock("../infra/device-bootstrap.js", () => ({
issueDeviceBootstrapToken: issueDeviceBootstrapTokenMock,
}));
vi.mock("./gateway-readiness.js", () => ({
ensureGatewayReadyForOperation: ensureGatewayReadyForOperationMock,
}));
@@ -89,6 +94,11 @@ describe("dashboardCommand", () => {
openUrlMock.mockClear();
formatControlUiSshHintMock.mockClear();
copyToClipboardMock.mockClear();
issueDeviceBootstrapTokenMock.mockReset();
issueDeviceBootstrapTokenMock.mockResolvedValue({
token: "browser-bootstrap",
expiresAtMs: 123_456,
});
ensureGatewayReadyForOperationMock.mockReset();
ensureGatewayReadyForOperationMock.mockResolvedValue({
ready: true,
@@ -121,9 +131,25 @@ describe("dashboardCommand", () => {
basePath: undefined,
tlsEnabled: false,
});
// clipboard and browser still get the full authenticated URL
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123");
expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123");
expect(issueDeviceBootstrapTokenMock).toHaveBeenCalledWith({
profile: {
roles: ["operator"],
scopes: [
"operator.approvals",
"operator.questions",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
purpose: "control-ui",
},
});
expect(copyToClipboardMock).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
);
expect(openUrlMock).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
);
expect(runtime.log).toHaveBeenCalledWith(
"Opened in your browser. Keep that tab to control OpenClaw.",
);
@@ -138,11 +164,13 @@ describe("dashboardCommand", () => {
await dashboardCommand(runtime);
// Clipboard and browser should still receive the tokenized URL.
// Clipboard and browser receive only the short-lived browser bootstrap.
expect(copyToClipboardMock).toHaveBeenCalledWith(
`http://127.0.0.1:18789/#token=${secretToken}`,
"http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
);
expect(openUrlMock).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
);
expect(openUrlMock).toHaveBeenCalledWith(`http://127.0.0.1:18789/#token=${secretToken}`);
// The logged output must never contain the token — it flows into
// console-captured log files readable by operator.read-scoped devices.
@@ -154,7 +182,9 @@ describe("dashboardCommand", () => {
// Base URL should be logged without the fragment.
expect(runtime.log).toHaveBeenCalledWith("Dashboard URL: http://127.0.0.1:18789/");
expect(runtime.log).toHaveBeenCalledWith("Token auto-auth included in browser/clipboard URL.");
expect(runtime.log).toHaveBeenCalledWith(
"One-time browser pairing included in browser/clipboard URL.",
);
});
it("prints SSH hint when browser cannot open", async () => {
@@ -182,7 +212,7 @@ describe("dashboardCommand", () => {
expect(formatControlUiSshHintMock).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(
"Browser launch failed. Open the Dashboard URL above manually.",
"Browser launch failed. Open the one-time pairing URL copied to clipboard.",
);
});
@@ -231,7 +261,7 @@ describe("dashboardCommand", () => {
expect(allLogs).toContain("key `token`");
});
it("respects --no-open and tells user token URL is in clipboard", async () => {
it("respects --no-open and tells user the pairing URL is in clipboard", async () => {
mockSnapshot("abc");
copyToClipboardMock.mockResolvedValue(true);
@@ -240,7 +270,7 @@ describe("dashboardCommand", () => {
expect(detectBrowserOpenSupportMock).not.toHaveBeenCalled();
expect(openUrlMock).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith(
"Browser launch disabled (--no-open). Token-authenticated URL copied to clipboard.",
"Browser launch disabled (--no-open). One-time browser pairing URL copied to clipboard.",
);
});
@@ -257,18 +287,21 @@ describe("dashboardCommand", () => {
expectLogWith("OPENCLAW_GATEWAY_TOKEN");
});
it("respects --no-open with plain URL hint when clipboard fails and no token is configured", async () => {
it("guides no-token users to the explicit JSON handoff when clipboard delivery fails", async () => {
mockSnapshot("");
copyToClipboardMock.mockResolvedValue(false);
await dashboardCommand(runtime, { noOpen: true });
expect(runtime.log).toHaveBeenCalledWith(
expect(runtime.log).not.toHaveBeenCalledWith(
"Browser launch disabled (--no-open). Use the URL above.",
);
expect(runtime.log).toHaveBeenCalledWith(
"One-time pairing URL not delivered. Run `openclaw dashboard --json` and open its `browserUrl` within ten minutes.",
);
});
it("prints non-tokenized URL with guidance when token SecretRef is unresolved", async () => {
it("uses browser bootstrap when the shared-token SecretRef is unresolved", async () => {
mockSnapshot({
source: "env",
provider: "default",
@@ -281,11 +314,10 @@ describe("dashboardCommand", () => {
await dashboardCommand(runtime);
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expectLogWith("Token auto-auth unavailable");
expectLogWith(
"gateway.auth.token SecretRef is unresolved (env:default:MISSING_GATEWAY_TOKEN).",
expect(copyToClipboardMock).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
);
expectNoLogWith("Token auto-auth unavailable");
expectNoLogWith("missing env var");
});
@@ -303,9 +335,13 @@ describe("dashboardCommand", () => {
await dashboardCommand(runtime);
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expectLogWith("Token auto-auth is disabled for SecretRef-managed");
expect(copyToClipboardMock).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
);
expect(openUrlMock).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
);
expectNoLogWith("Token auto-auth is disabled for SecretRef-managed");
expectNoLogWith("Token auto-auth unavailable");
});
@@ -317,11 +353,13 @@ describe("dashboardCommand", () => {
await dashboardCommand(runtime);
expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/");
expectLogWith(
"Token auto-auth unavailable: gateway.auth.token SecretRef is unresolved (env:default:CUSTOM_GATEWAY_TOKEN).",
expect(copyToClipboardMock).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
);
expect(openUrlMock).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
);
expectNoLogWith("Token auto-auth unavailable");
expectNoLogWith("Token auto-auth is disabled for SecretRef-managed");
});
@@ -339,5 +377,19 @@ describe("dashboardCommand", () => {
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
expect(copyToClipboardMock).not.toHaveBeenCalled();
expect(openUrlMock).not.toHaveBeenCalled();
expect(issueDeviceBootstrapTokenMock).not.toHaveBeenCalled();
});
it("fails closed when a browser pairing link cannot be issued", async () => {
mockSnapshot("abc");
issueDeviceBootstrapTokenMock.mockRejectedValue(new Error("state store unavailable"));
await dashboardCommand(runtime);
expect(copyToClipboardMock).not.toHaveBeenCalled();
expect(openUrlMock).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
"Could not create a one-time browser pairing link: state store unavailable",
);
});
});
+16 -1
View File
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
resolveGatewayPort: vi.fn(),
resolveControlUiLinks: vi.fn(),
copyToClipboard: vi.fn(),
issueDeviceBootstrapToken: vi.fn(),
openUrl: vi.fn(),
inspectPortUsage: vi.fn(),
ensureGatewayReadyForOperation: vi.fn(),
@@ -29,6 +30,10 @@ vi.mock("../infra/clipboard.js", () => ({
copyToClipboard: mocks.copyToClipboard,
}));
vi.mock("../infra/device-bootstrap.js", () => ({
issueDeviceBootstrapToken: mocks.issueDeviceBootstrapToken,
}));
vi.mock("../infra/ports-inspect.js", () => ({
inspectPortUsage: mocks.inspectPortUsage,
}));
@@ -116,6 +121,11 @@ describe("dashboardCommand bind selection", () => {
mocks.resolveGatewayPort.mockClear();
mocks.resolveControlUiLinks.mockClear();
mocks.copyToClipboard.mockClear();
mocks.issueDeviceBootstrapToken.mockReset();
mocks.issueDeviceBootstrapToken.mockResolvedValue({
token: "browser-bootstrap",
expiresAtMs: 123_456,
});
mocks.openUrl.mockClear();
mocks.inspectPortUsage.mockReset();
mocks.ensureGatewayReadyForOperation.mockReset();
@@ -186,7 +196,9 @@ describe("dashboardCommand bind selection", () => {
basePath: undefined,
tlsEnabled: false,
});
expect(mocks.copyToClipboard).toHaveBeenCalledWith("http://127.0.0.1:18789/#token=abc123");
expect(mocks.copyToClipboard).toHaveBeenCalledWith(
"http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
);
});
it("refuses an authenticated loopback URL owned by a different process", async () => {
@@ -197,6 +209,7 @@ describe("dashboardCommand bind selection", () => {
await dashboardCommand(runtime, { noOpen: true });
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
expect(mocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("refusing to copy or open an authenticated URL"),
);
@@ -212,6 +225,7 @@ describe("dashboardCommand bind selection", () => {
await dashboardCommand(runtime);
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
expect(mocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(mocks.openUrl).not.toHaveBeenCalled();
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Dashboard URL:"));
});
@@ -256,6 +270,7 @@ describe("dashboardCommand bind selection", () => {
);
expect(mocks.inspectPortUsage).not.toHaveBeenCalled();
expect(mocks.copyToClipboard).not.toHaveBeenCalled();
expect(mocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Dashboard URL:"));
});
+55 -24
View File
@@ -4,10 +4,12 @@ import { resolveSecretInputRef } from "../config/types.secrets.js";
import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { copyToClipboard } from "../infra/clipboard.js";
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
import { isSameProcessSpecificIpv4WithLoopbackListeners } from "../infra/ports-format.js";
import { inspectPortUsage } from "../infra/ports-inspect.js";
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } from "../shared/device-bootstrap-profile.js";
import { ensureGatewayReadyForOperation } from "./gateway-readiness.js";
import {
detectBrowserOpenSupport,
@@ -30,6 +32,25 @@ const quietRuntime: RuntimeEnv = {
const gatewayPasswordJsonKey = ["gateway", "Password"].join("");
async function issueDashboardBrowserHandoff(httpUrl: string): Promise<{
browserUrl: string;
expiresAtMs: number;
}> {
// A host-authorized dashboard launch must leave the browser with a durable
// device grant; a shared gateway token alone still strands remote browsers in pairing.
const issued = await issueDeviceBootstrapToken({
profile: {
roles: ["operator"],
scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
purpose: "control-ui",
},
});
return {
browserUrl: `${httpUrl}#bootstrapToken=${encodeURIComponent(issued.token)}`,
expiresAtMs: issued.expiresAtMs,
};
}
async function resolveDashboardTarget() {
const snapshot = await readConfigFileSnapshot();
const cfg = snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : {};
@@ -193,6 +214,7 @@ async function dashboardJsonCommand(runtime: RuntimeEnv): Promise<void> {
}
tlsFingerprint = tlsRuntime.fingerprintSha256;
}
const browserHandoff = await issueDashboardBrowserHandoff(target.links.httpUrl);
writeRuntimeJson(
runtime,
@@ -203,6 +225,8 @@ async function dashboardJsonCommand(runtime: RuntimeEnv): Promise<void> {
wsUrl: target.links.wsUrl,
port: target.port,
tokenIncluded: target.includeTokenInUrl,
browserUrl: browserHandoff.browserUrl,
browserBootstrapExpiresAtMs: browserHandoff.expiresAtMs,
...(target.gatewayAuthHandoff
? { [gatewayPasswordJsonKey]: target.gatewayAuthHandoff }
: {}),
@@ -257,25 +281,22 @@ export async function dashboardCommand(
runtime.log("Restart the Gateway, then run `openclaw gateway status --deep` for details.");
return;
}
const { port, basePath, links, resolvedToken, token, includeTokenInUrl, dashboardUrl } = target;
let browserUrl: string;
try {
browserUrl = (await issueDashboardBrowserHandoff(target.links.httpUrl)).browserUrl;
} catch (error) {
runtime.error(
`Could not create a one-time browser pairing link: ${error instanceof Error ? error.message : String(error)}`,
);
runtime.log("Run `openclaw doctor`, then retry `openclaw dashboard`.");
return;
}
const { port, basePath, links, includeTokenInUrl } = target;
runtime.log(`Dashboard URL: ${links.httpUrl}`);
if (includeTokenInUrl) {
runtime.log("Token auto-auth included in browser/clipboard URL.");
}
if (resolvedToken.secretRefConfigured && token) {
runtime.log(
"Token auto-auth is disabled for SecretRef-managed gateway.auth.token; use your external token source if prompted.",
);
}
if (resolvedToken.unresolvedRefReason) {
runtime.log(`Token auto-auth unavailable: ${resolvedToken.unresolvedRefReason}`);
runtime.log(
"Set OPENCLAW_GATEWAY_TOKEN in this shell or resolve your secret provider, then rerun `openclaw dashboard`.",
);
}
runtime.log("One-time browser pairing included in browser/clipboard URL.");
const copied = await copyToClipboard(dashboardUrl).catch(() => false);
const copied = await copyToClipboard(browserUrl).catch(() => false);
runtime.log(copied ? "Copied to clipboard." : "Copy to clipboard unavailable.");
let opened = false;
@@ -283,8 +304,12 @@ export async function dashboardCommand(
if (!options.noOpen) {
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
opened = await openUrl(dashboardUrl);
hint = opened ? undefined : "Browser launch failed. Open the Dashboard URL above manually.";
opened = await openUrl(browserUrl);
hint = opened
? undefined
: copied
? "Browser launch failed. Open the one-time pairing URL copied to clipboard."
: "Browser launch failed. Open the Dashboard URL above manually.";
} else {
hint = formatControlUiSshHint({
port,
@@ -292,14 +317,16 @@ export async function dashboardCommand(
});
}
} else {
hint =
copied && includeTokenInUrl
? "Browser launch disabled (--no-open). Token-authenticated URL copied to clipboard."
: "Browser launch disabled (--no-open). Use the URL above.";
hint = copied
? "Browser launch disabled (--no-open). One-time browser pairing URL copied to clipboard."
: "Browser launch disabled (--no-open). Use the URL above.";
}
const fallbackToManualAuth = !copied && !opened && includeTokenInUrl;
const suppressNoOpenHint = options.noOpen === true && fallbackToManualAuth;
const handoffDeliveryFailed = !copied && !opened;
const fallbackToManualAuth = handoffDeliveryFailed && includeTokenInUrl;
const fallbackToJsonHandoff = handoffDeliveryFailed && !includeTokenInUrl;
const suppressNoOpenHint =
options.noOpen === true && (fallbackToManualAuth || fallbackToJsonHandoff);
if (opened) {
runtime.log("Opened in your browser. Keep that tab to control OpenClaw.");
@@ -311,5 +338,9 @@ export async function dashboardCommand(
runtime.log(
"Token auto-auth not delivered. Append your gateway token (from OPENCLAW_GATEWAY_TOKEN or gateway.auth.token) as a URL fragment with key `token` to authenticate.",
);
} else if (fallbackToJsonHandoff) {
runtime.log(
"One-time pairing URL not delivered. Run `openclaw dashboard --json` and open its `browserUrl` within ten minutes.",
);
}
}
+37
View File
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
copyToClipboard: vi.fn(),
ensureGatewayReadyForOperation: vi.fn(),
inspectPortUsage: vi.fn(),
issueDeviceBootstrapToken: vi.fn(),
loadGatewayTlsRuntime: vi.fn(),
openUrl: vi.fn(),
readConfigFileSnapshot: vi.fn(),
@@ -39,6 +40,10 @@ vi.mock("../../infra/clipboard.js", () => ({
copyToClipboard: mocks.copyToClipboard,
}));
vi.mock("../../infra/device-bootstrap.js", () => ({
issueDeviceBootstrapToken: mocks.issueDeviceBootstrapToken,
}));
vi.mock("../../infra/ports-inspect.js", () => ({
inspectPortUsage: mocks.inspectPortUsage,
}));
@@ -113,6 +118,10 @@ describe("dashboardCommand --json", () => {
token: fakeToken,
});
mocks.resolveGatewayAuth.mockReturnValue({ mode: "token", token: fakeToken });
mocks.issueDeviceBootstrapToken.mockResolvedValue({
token: "browser-bootstrap",
expiresAtMs: 123_456,
});
mocks.loadGatewayTlsRuntime.mockResolvedValue({ enabled: false, required: false });
});
@@ -128,6 +137,8 @@ describe("dashboardCommand --json", () => {
wsUrl: "ws://127.0.0.1:18789",
port: 18789,
tokenIncluded: true,
browserUrl: "http://127.0.0.1:18789/#bootstrapToken=browser-bootstrap",
browserBootstrapExpiresAtMs: 123_456,
},
0,
);
@@ -137,6 +148,19 @@ describe("dashboardCommand --json", () => {
expect(mocks.inspectPortUsage).toHaveBeenCalledWith(18789);
expect(mocks.openUrl).not.toHaveBeenCalled();
expect(mocks.loadGatewayTlsRuntime).not.toHaveBeenCalled();
expect(mocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({
profile: {
roles: ["operator"],
scopes: [
"operator.approvals",
"operator.questions",
"operator.read",
"operator.talk.secrets",
"operator.write",
],
purpose: "control-ui",
},
});
});
it("adds the canonical certificate fingerprint for a TLS Gateway", async () => {
@@ -218,6 +242,7 @@ describe("dashboardCommand --json", () => {
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.log).not.toHaveBeenCalled();
expect(mocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
});
it("keeps SecretRef-managed tokens out of the URL", async () => {
@@ -237,4 +262,16 @@ describe("dashboardCommand --json", () => {
0,
);
});
it("fails closed when the browser bootstrap cannot be issued", async () => {
mocks.issueDeviceBootstrapToken.mockRejectedValue(new Error("state store unavailable"));
await dashboardCommand(runtime, { json: true });
expect(runtime.writeJson).toHaveBeenCalledWith(
{ ok: false, reason: "state store unavailable" },
0,
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
});
+42 -1
View File
@@ -1836,6 +1836,8 @@ export function registerControlUiAndPairingSuite(): void {
await import("../infra/device-pairing.js");
const { BOOTSTRAP_HANDOFF_OPERATOR_SCOPES } =
await import("../shared/device-bootstrap-profile.js");
const { resolveSharedGatewaySessionGeneration } =
await import("./server/ws-shared-generation.js");
testState.gatewayControlUi = { allowedOrigins: ["https://localhost"] };
const { server, port, prevToken } = await startControlUiServer("secret");
@@ -1890,14 +1892,53 @@ export function registerControlUiAndPairingSuite(): void {
const paired = await getPairedDevice(identity.deviceId);
expect(paired?.roles).toEqual(["operator"]);
expect(paired?.approvedScopes).toEqual([...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES]);
const wsReload = await openWs(port, {
origin: "https://localhost",
"x-forwarded-for": "203.0.113.50",
});
const reload = await connectReq(wsReload, {
skipDefaultAuth: true,
deviceToken,
role: "operator",
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
client: CONTROL_UI_CLIENT,
deviceIdentityPath: identityPath,
});
expect(reload.ok).toBe(true);
wsReload.close();
const sharedGatewaySessionGeneration = resolveSharedGatewaySessionGeneration({
mode: "token",
token: "secret",
allowTailscale: false,
});
if (!sharedGatewaySessionGeneration) {
throw new Error("expected shared gateway session generation");
}
await expect(
verifyDeviceToken({
deviceId: identity.deviceId,
token: deviceToken,
role: "operator",
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
requiredSharedGatewaySessionGeneration: sharedGatewaySessionGeneration,
}),
).resolves.toEqual({ ok: true });
).resolves.toEqual({
ok: true,
issuer: {
kind: "shared-gateway-auth",
generation: sharedGatewaySessionGeneration,
},
});
await expect(
verifyDeviceToken({
deviceId: identity.deviceId,
token: deviceToken,
role: "operator",
scopes: [...BOOTSTRAP_HANDOFF_OPERATOR_SCOPES],
requiredSharedGatewaySessionGeneration: "rotated-generation",
}),
).resolves.toEqual({ ok: false, reason: "issuer-generation-stale" });
const wsReplay = await openWs(port, {
origin: "https://localhost",
@@ -18,6 +18,7 @@ import { resolveConnectAuthDecision, resolveConnectAuthState } from "./auth-cont
import { formatGatewayAuthFailureMessage } from "./auth-messages.js";
import { admitGatewayConnect, resolveTrustedProxyControlUiScopes } from "./connect-admission.js";
import { emitGatewayAuthSecurityEvent } from "./connect-auth-security.js";
import { isControlUiOperatorBootstrapProfile } from "./connect-device-metadata.js";
import { verifyGatewayConnectDeviceProof } from "./connect-device-proof.js";
import {
evaluateMissingDeviceIdentity,
@@ -411,15 +412,35 @@ export async function authenticateGatewayConnect(
return undefined;
}
advanceHandshakePhase("auth_validated");
const issuedBootstrapProfile =
authMethod === "bootstrap-token" && bootstrapTokenCandidate
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
: null;
const usesSharedGatewayAuth =
authMethod === "token" || authMethod === "password" || authMethod === "trusted-proxy";
const sharedGatewaySessionGeneration = usesSharedGatewayAuth
? resolveSharedGatewaySessionGeneration(resolvedAuth, trustedProxies)
: undefined;
// A host-issued Control UI handoff creates a durable browser token. Bind both
// the bootstrap session and that token to the current shared-auth generation.
const controlUiBootstrapSharedGatewaySessionGeneration =
authMethod === "bootstrap-token" &&
isControlUi &&
role === "operator" &&
isControlUiOperatorBootstrapProfile({
profile: issuedBootstrapProfile,
requestedScopes: scopes,
})
? getRequiredSharedGatewaySessionGeneration?.()
: undefined;
const sessionUsesSharedGatewayAuth =
usesSharedGatewayAuth || deviceTokenSharedGatewaySessionGeneration !== undefined;
usesSharedGatewayAuth ||
deviceTokenSharedGatewaySessionGeneration !== undefined ||
controlUiBootstrapSharedGatewaySessionGeneration !== undefined;
const sessionSharedGatewaySessionGeneration =
sharedGatewaySessionGeneration ?? deviceTokenSharedGatewaySessionGeneration;
sharedGatewaySessionGeneration ??
deviceTokenSharedGatewaySessionGeneration ??
controlUiBootstrapSharedGatewaySessionGeneration;
if (sessionUsesSharedGatewayAuth) {
const requiredSharedGatewaySessionGeneration = getRequiredSharedGatewaySessionGeneration?.();
if (
@@ -433,10 +454,6 @@ export async function authenticateGatewayConnect(
return undefined;
}
}
const issuedBootstrapProfile =
authMethod === "bootstrap-token" && bootstrapTokenCandidate
? await getDeviceBootstrapTokenProfile({ token: bootstrapTokenCandidate })
: null;
const handoffBootstrapProfile: DeviceBootstrapProfile | null = null;
const trustedProxyAuthOk = isTrustedProxyControlUiOperatorAuth({
isControlUi,
@@ -18,7 +18,7 @@ export async function issueGatewayConnectDeviceTokens(params: {
isBrowserOperatorUi,
isWebchat,
trustedProxyAuthOk,
usesSharedGatewayAuth,
sessionUsesSharedGatewayAuth,
sessionSharedGatewaySessionGeneration,
deviceTokenSharedGatewaySessionGeneration,
handoffBootstrapProfile,
@@ -26,7 +26,7 @@ export async function issueGatewayConnectDeviceTokens(params: {
const sharedGatewayAuthIssuer =
sessionSharedGatewaySessionGeneration &&
(deviceTokenSharedGatewaySessionGeneration !== undefined ||
(usesSharedGatewayAuth && (isBrowserOperatorUi || isWebchat)))
(sessionUsesSharedGatewayAuth && (isBrowserOperatorUi || isWebchat)))
? {
kind: "shared-gateway-auth" as const,
generation: sessionSharedGatewaySessionGeneration,
+18
View File
@@ -81,4 +81,22 @@ describe("login gate failure recovery", () => {
expect(element.querySelector(".login-gate__failure")?.getAttribute("data-kind")).toBe(kind);
expect(element.querySelector(".login-gate__failure-refresh")).toBeNull();
});
it("offers a one-command recovery before manual pairing approval", async () => {
const element = await mountFailure(
"pairing required",
ConnectErrorDetailCodes.PAIRING_REQUIRED,
);
const steps = Array.from(
element.querySelectorAll<HTMLElement>(".login-gate__failure-steps li"),
(entry) => entry.textContent?.trim(),
);
expect(steps).toEqual([
"On the Gateway host, run openclaw dashboard to open a secure one-time pairing link.",
"Run openclaw devices list on the Gateway host.",
"Approve the pending browser/device request from that list.",
"Reconnect after the approval completes.",
]);
});
});
+1
View File
@@ -142,6 +142,7 @@ function resolveLoginFailureFeedback(
? "login.failure.pairing.summary"
: "login.failure.pairing.upgradeSummary",
stepKeys: [
"login.failure.pairing.stepDashboard",
"login.failure.pairing.stepList",
pairing.requestId
? "login.failure.pairing.stepApproveId"
+2
View File
@@ -4351,6 +4351,8 @@ export const en: TranslationMap = {
"This browser needs one-time approval from the Gateway host before it can use the Control UI.",
upgradeSummary:
"This browser is already known, but the requested access changed and needs a fresh approval.",
stepDashboard:
"On the Gateway host, run openclaw dashboard to open a secure one-time pairing link.",
stepList: "Run openclaw devices list on the Gateway host.",
stepApproveId: "Approve this request: openclaw devices approve {requestId}.",
stepApprove: "Approve the pending browser/device request from that list.",