diff --git a/docs/cli/dashboard.md b/docs/cli/dashboard.md index 1af094afeab7..e8267c46fda7 100644 --- a/docs/cli/dashboard.md +++ b/docs/cli/dashboard.md @@ -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 diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 1ca72acb7a58..b50926ab9dc8 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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. When upgrading directly from a release that used the retired diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 0728eb3d1e70..5505dc267e01 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -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). diff --git a/src/cli/qr-dashboard.integration.test.ts b/src/cli/qr-dashboard.integration.test.ts index ad869a0b7eab..fcc7d18414f7 100644 --- a/src/cli/qr-dashboard.integration.test.ts +++ b/src/cli/qr-dashboard.integration.test.ts @@ -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(() => { diff --git a/src/commands/dashboard.links.test.ts b/src/commands/dashboard.links.test.ts index b083a39b8dd9..8b540e3038b1 100644 --- a/src/commands/dashboard.links.test.ts +++ b/src/commands/dashboard.links.test.ts @@ -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", + ); }); }); diff --git a/src/commands/dashboard.test.ts b/src/commands/dashboard.test.ts index f91454629a45..650baafc42d8 100644 --- a/src/commands/dashboard.test.ts +++ b/src/commands/dashboard.test.ts @@ -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:")); }); diff --git a/src/commands/dashboard.ts b/src/commands/dashboard.ts index 4f0d9bf953fb..3e8e215163be 100644 --- a/src/commands/dashboard.ts +++ b/src/commands/dashboard.ts @@ -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 { } tlsFingerprint = tlsRuntime.fingerprintSha256; } + const browserHandoff = await issueDashboardBrowserHandoff(target.links.httpUrl); writeRuntimeJson( runtime, @@ -203,6 +225,8 @@ async function dashboardJsonCommand(runtime: RuntimeEnv): Promise { 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.", + ); } } diff --git a/src/commands/dashboard/json.test.ts b/src/commands/dashboard/json.test.ts index 13387f32b620..d906dc0e09d0 100644 --- a/src/commands/dashboard/json.test.ts +++ b/src/commands/dashboard/json.test.ts @@ -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); + }); }); diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index f2f83c45c7ba..2a22eca90920 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -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", diff --git a/src/gateway/server/ws-connection/connect-auth.ts b/src/gateway/server/ws-connection/connect-auth.ts index 80934899b799..31c145e4b702 100644 --- a/src/gateway/server/ws-connection/connect-auth.ts +++ b/src/gateway/server/ws-connection/connect-auth.ts @@ -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, diff --git a/src/gateway/server/ws-connection/connect-device-tokens.ts b/src/gateway/server/ws-connection/connect-device-tokens.ts index 372c49a60799..520145c38e0a 100644 --- a/src/gateway/server/ws-connection/connect-device-tokens.ts +++ b/src/gateway/server/ws-connection/connect-device-tokens.ts @@ -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, diff --git a/ui/src/components/login-gate.test.ts b/ui/src/components/login-gate.test.ts index e4323376b6ec..9fd2945c16f0 100644 --- a/ui/src/components/login-gate.test.ts +++ b/ui/src/components/login-gate.test.ts @@ -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(".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.", + ]); + }); }); diff --git a/ui/src/components/login-gate.ts b/ui/src/components/login-gate.ts index 583cf8dc55a6..3f4c1db78a2e 100644 --- a/ui/src/components/login-gate.ts +++ b/ui/src/components/login-gate.ts @@ -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" diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 5b67486d5fa6..c938f888df94 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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.",