mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
+19
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user