diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index cadfad4f7835..1ddb46adf1eb 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -37565,21 +37565,21 @@ "kind": "conditional-branch", "line": 72, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", - "source": "Paste the token configured on the gateway host. On the gateway host, run `openclaw config get gateway.auth.token`. If the gateway uses an environment variable instead, use `OPENCLAW_GATEWAY_TOKEN`.", + "source": "Paste the token configured on the gateway host. On the gateway host, run `openclaw gateway auth-token --show` in an interactive terminal, then paste its output.", "surface": "apple", - "id": "native.apple.b52cb4aae7ca6573" + "id": "native.apple.90b00f3f5418b439" }, { "kind": "conditional-branch", "line": 76, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", - "source": "Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again.", + "source": "On the gateway host, run `openclaw gateway auth-token --show` in an interactive terminal, then replace the token and try again.", "surface": "apple", - "id": "native.apple.bbb87d21ce8a291e" + "id": "native.apple.b79d3c42bd5438d5" }, { "kind": "conditional-branch", - "line": 78, + "line": 79, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway.", "surface": "apple", @@ -37587,7 +37587,7 @@ }, { "kind": "conditional-branch", - "line": 82, + "line": 83, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "Scan or paste a fresh setup code from an already-paired OpenClaw client, then try again.", "surface": "apple", @@ -37595,7 +37595,7 @@ }, { "kind": "conditional-branch", - "line": 84, + "line": 85, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry.", "surface": "apple", @@ -37603,7 +37603,7 @@ }, { "kind": "conditional-branch", - "line": 87, + "line": 88, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "Approve this device from an already-paired OpenClaw client. In your OpenClaw chat, run `/pair approve`, then click **Check connection** again.", "surface": "apple", @@ -37611,23 +37611,23 @@ }, { "kind": "conditional-branch", - "line": 110, + "line": 111, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", - "source": "This gateway requires an auth token from the gateway host.", + "source": "This gateway requires an auth token. Run openclaw gateway auth-token --show on the gateway host.", "surface": "apple", - "id": "native.apple.e73170e4ab1dbe8f" + "id": "native.apple.a1cb5542806f0ec9" }, { "kind": "conditional-branch", - "line": 112, + "line": 113, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", - "source": "Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host.", + "source": "Gateway token mismatch. Run openclaw gateway auth-token --show on the gateway host.", "surface": "apple", - "id": "native.apple.e3f983b8c517da29" + "id": "native.apple.e12c96574b97ed4a" }, { "kind": "conditional-branch", - "line": 114, + "line": 115, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "This gateway has token auth enabled, but no gateway.auth.token is configured on the host.", "surface": "apple", @@ -37635,7 +37635,7 @@ }, { "kind": "conditional-branch", - "line": 116, + "line": 117, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "Setup code expired or already used. Scan a fresh setup code, then try again.", "surface": "apple", @@ -37643,7 +37643,7 @@ }, { "kind": "conditional-branch", - "line": 118, + "line": 119, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet.", "surface": "apple", @@ -37651,7 +37651,7 @@ }, { "kind": "conditional-branch", - "line": 120, + "line": 121, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "Pairing required. In an already-paired OpenClaw client, run /pair approve, then check the connection again.", "surface": "apple", @@ -37659,7 +37659,7 @@ }, { "kind": "conditional-branch", - "line": 138, + "line": 139, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "Connected via paired device", "surface": "apple", @@ -37667,7 +37667,7 @@ }, { "kind": "conditional-branch", - "line": 140, + "line": 141, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "Connected with setup code", "surface": "apple", @@ -37675,7 +37675,7 @@ }, { "kind": "conditional-branch", - "line": 142, + "line": 143, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "Connected with gateway token", "surface": "apple", @@ -37683,7 +37683,7 @@ }, { "kind": "conditional-branch", - "line": 144, + "line": 145, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "Connected with password", "surface": "apple", @@ -37691,7 +37691,7 @@ }, { "kind": "conditional-branch", - "line": 146, + "line": 147, "path": "apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift", "source": "Remote gateway ready", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift index e6933138fb16..6963f1be5445 100644 --- a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift +++ b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift @@ -70,10 +70,11 @@ enum RemoteGatewayAuthIssue: Equatable { switch self { case .tokenRequired: "Paste the token configured on the gateway host. " - + "On the gateway host, run `openclaw config get gateway.auth.token`. " - + "If the gateway uses an environment variable instead, use `OPENCLAW_GATEWAY_TOKEN`." + + "On the gateway host, run `openclaw gateway auth-token --show` " + + "in an interactive terminal, then paste its output." case .tokenMismatch: - "Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again." + "On the gateway host, run `openclaw gateway auth-token --show` " + + "in an interactive terminal, then replace the token and try again." case .gatewayTokenNotConfigured: "This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. " + "If the gateway uses an environment variable instead, " @@ -107,9 +108,9 @@ enum RemoteGatewayAuthIssue: Equatable { var statusMessage: String { switch self { case .tokenRequired: - "This gateway requires an auth token from the gateway host." + "This gateway requires an auth token. Run openclaw gateway auth-token --show on the gateway host." case .tokenMismatch: - "Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host." + "Gateway token mismatch. Run openclaw gateway auth-token --show on the gateway host." case .gatewayTokenNotConfigured: "This gateway has token auth enabled, but no gateway.auth.token is configured on the host." case .setupCodeExpired: diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift index f9240adb23c7..691ad80a5b74 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift @@ -120,6 +120,15 @@ struct OnboardingRemoteAuthPromptTests { #expect(issue.footnote?.contains("`openclaw devices approve`") == true) } + @Test func `gateway token copy points to explicit interactive recovery`() { + for issue in [RemoteGatewayAuthIssue.tokenRequired, .tokenMismatch] { + #expect(issue.body.contains("`openclaw gateway auth-token --show`")) + #expect(issue.body.contains("interactive terminal")) + #expect(!issue.body.contains("config get gateway.auth.token")) + #expect(issue.statusMessage.contains("openclaw gateway auth-token --show")) + } + } + @Test func `paired device success copy explains auth source`() { let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken) let bootstrap = RemoteGatewayProbeSuccess(authSource: .bootstrapToken) diff --git a/docs/cli/config.md b/docs/cli/config.md index 779688d9a956..7bfa1365f8e0 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -57,7 +57,7 @@ openclaw config set 'agents.entries.work.tools.exec.node' "node-id-or-name" ### `config get` -Reads a value from the redacted config snapshot (secrets never print). `--json` prints the raw value as JSON; otherwise strings/numbers/booleans print bare and objects/arrays print as formatted JSON. +Reads a value from the redacted config snapshot (secrets never print). `--json` prints the same redacted value as JSON; otherwise strings/numbers/booleans print bare and objects/arrays print as formatted JSON. When the path is missing, `--json` writes `{ "error": "Config path not found: " }` to stdout and exits with status 1. Without `--json`, the diagnostic remains on stderr. @@ -123,7 +123,7 @@ openclaw config set gateway.port 19001 --strict-json openclaw config set channels.whatsapp.groups '["*"]' --strict-json ``` -`config get --json` prints the raw value as JSON instead of terminal-formatted text. +`config get --json` prints the redacted value as JSON instead of terminal-formatted text. When a write changes `agents.defaults.model` or a per-agent `agents.entries.*.model`, OpenClaw resolves each changed primary or fallback through the configured provider catalogs before writing. Unknown model references are rejected without changing the active config; run `openclaw models list` to see available models. diff --git a/docs/cli/devices.md b/docs/cli/devices.md index ec2102f29f3d..1e5ca4081f2f 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -141,9 +141,11 @@ Use this when Control UI or other clients keep failing with `AUTH_TOKEN_MISMATCH 1. Confirm current gateway token source: ```bash - openclaw config get gateway.auth.token + openclaw gateway auth-token --show ``` + Run the command in an interactive terminal on the Gateway host and treat its output as a secret. + 2. List paired devices and identify the affected device id: ```bash diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 5d126b57dc7a..7fcf523160fc 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -107,6 +107,18 @@ openclaw gateway run # equivalent, explicit form For `--bind custom`, set `gateway.customBindHost` to an IPv4 address. Any address other than `127.0.0.1` or `0.0.0.0` also requires `127.0.0.1` on the same port for same-host clients; startup fails if either listener cannot bind. Wildcard `0.0.0.0` does not add a separate required alias. IPv6-only bring-your-own-host setups need an IPv4 sidecar or proxy in front of the Gateway. +## Reveal the configured token + +Run this on the Gateway host when a client needs the configured shared token: + +```bash +openclaw gateway auth-token --show +``` + +The command resolves `gateway.auth.token`, `OPENCLAW_GATEWAY_TOKEN`, and configured SecretRefs, then prints only the token. It requires an interactive terminal and refuses redirected or piped output so the credential does not silently enter command logs. Treat the terminal output as a secret. + +If no persistent token is configured, run `openclaw doctor --generate-gateway-token`, restart the Gateway, and then rerun the command. Generic `openclaw config get` output remains redacted, including `--json`. + ## Restart the Gateway ```bash diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 298a1e668e32..479a5feaeda9 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -348,7 +348,7 @@ Use `error.details.code` from the failed `connect` response to pick the next act | Detail code | Meaning | Recommended action | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `AUTH_TOKEN_MISSING` | Client did not send a required shared token. | Paste/set token in the client and retry. For dashboard paths: `openclaw config get gateway.auth.token` then paste into Control UI settings. | +| `AUTH_TOKEN_MISSING` | Client did not send a required shared token. | On the Gateway host, run `openclaw gateway auth-token --show` in an interactive terminal, paste the output into the client, and retry. | | `AUTH_TOKEN_MISMATCH` | Shared token did not match gateway auth token. | If `canRetryWithDeviceToken=true`, allow one trusted retry. Cached-token retries reuse stored approved scopes; explicit `deviceToken` / `scopes` callers keep requested scopes. If still failing, run the [token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). | | `AUTH_DEVICE_TOKEN_MISMATCH` | Cached per-device token is stale or revoked. | Rotate/re-approve device token using [devices CLI](/cli/devices), then reconnect. | | `AUTH_SCOPE_MISMATCH` | Device token is valid, but its approved role/scopes do not cover this connect request. | Re-pair the device or approve the requested scope contract; do not treat this as shared-token drift. | diff --git a/docs/install/exe-dev.md b/docs/install/exe-dev.md index ebbf482e2379..c8be9746de1c 100644 --- a/docs/install/exe-dev.md +++ b/docs/install/exe-dev.md @@ -106,7 +106,7 @@ Set up OpenClaw (https://docs.openclaw.ai/install) on this VM. Use the non-inter Open `https://.exe.xyz/` (see the Control UI output from onboarding). If it prompts for auth, paste the configured shared secret from the VM. - This guide uses token auth by default, so retrieve `gateway.auth.token` with `openclaw config get gateway.auth.token`, or generate a new one with `openclaw doctor --n`. If you switched the gateway to password auth, use `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` instead. + This guide uses token auth by default, so run `openclaw gateway auth-token --show` in an interactive terminal to retrieve the configured token. If no token is configured, generate one with `openclaw doctor --generate-gateway-token` and restart the Gateway. If you switched the gateway to password auth, use `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` instead. Approve devices with `openclaw devices list` and `openclaw devices approve `. When in doubt, use Shelley from your browser. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index fe57bc48e42f..1ca72acb7a58 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -43,7 +43,7 @@ Auth is supplied during the WebSocket handshake via: Gateway auth runs before device pairing. A direct loopback connection does not bypass token or password auth. The dashboard settings panel keeps a token for the current browser tab session and selected gateway URL; passwords are not persisted. After pairing, the browser can use its stored per-device token on later connections. -Onboarding usually configures a gateway token for shared-secret auth. If the Gateway starts in token mode without a configured token, it generates an ephemeral runtime token for that process instead. The runtime token is not written to config, so `openclaw config get gateway.auth.token` cannot retrieve it and a loopback browser without that token is rejected. Run `openclaw doctor --generate-gateway-token`, restart the Gateway, then paste the configured token in Control UI settings. Password auth works instead when `gateway.auth.mode` is `"password"`. +Onboarding usually configures a gateway token for shared-secret auth. If the Gateway starts in token mode without a configured token, it generates an ephemeral runtime token for that process instead. The runtime token is not written to config, so it cannot be recovered and a loopback browser without that token is rejected. 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. Password auth works instead when `gateway.auth.mode` is `"password"`. ## Device pairing (first connection) diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 9f2354c94d76..0728eb3d1e70 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -43,7 +43,7 @@ The Control UI is an **admin surface** (chat, config, exec approvals). Do not ex - **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. -- **Missing-config runtime token**: if startup says it generated a runtime token, that token is ephemeral and is not available through `openclaw config get gateway.auth.token`. Loopback still requires auth. Run `openclaw doctor --generate-gateway-token`, restart the Gateway, then paste the configured token in Control UI settings. +- **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. - **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. @@ -78,9 +78,9 @@ Non-goals for v1: - On the async Tailscale Serve path, failed attempts for the same `{scope, ip}` are serialized before the failed-auth limiter records them, so a second concurrent bad retry can already show `retry later`. - For token drift repair steps, see [Token drift recovery checklist](/cli/devices#token-drift-recovery-checklist). - Retrieve or supply the shared secret from the gateway host: - - Token: `openclaw config get gateway.auth.token` + - Token: run `openclaw gateway auth-token --show` in an interactive terminal on the Gateway host - Password: resolve the configured `gateway.auth.password` or `OPENCLAW_GATEWAY_PASSWORD` - - SecretRef-managed token: resolve the external secret provider, or export `OPENCLAW_GATEWAY_TOKEN` in this shell and rerun `openclaw dashboard` + - SecretRef-managed token: run `openclaw gateway auth-token --show`; if resolution fails, repair the external secret provider and rerun it - Runtime token generated because no shared secret was configured: run `openclaw doctor --generate-gateway-token`, restart the Gateway, then use the configured token - In the dashboard settings, paste the token or password into the auth field, then connect. - The UI language picker lives in **Settings → Appearance → Language**. diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 32b5f0c3da2c..1eefe78ee868 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1277,6 +1277,24 @@ describe("config cli", () => { expect(mockWriteStdout).toHaveBeenCalledWith("__OPENCLAW_REDACTED__\n"); }); + it("redacts sensitive values in JSON output", async () => { + const resolved: OpenClawConfig = { + gateway: { + auth: { + token: "super-secret-token", + }, + }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "get", "gateway.auth.token", "--json"]); + + expect(parseLastLogPayload()).toBe("__OPENCLAW_REDACTED__"); + expect(mockWriteStdout).not.toHaveBeenCalledWith( + expect.stringContaining("super-secret-token"), + ); + }); + it("prints materialized subagent archive default", async () => { const resolved: OpenClawConfig = {}; const config: OpenClawConfig = { diff --git a/src/cli/gateway-cli/register.option-collisions.test.ts b/src/cli/gateway-cli/register.option-collisions.test.ts index e63e762354ba..d9174e1c790f 100644 --- a/src/cli/gateway-cli/register.option-collisions.test.ts +++ b/src/cli/gateway-cli/register.option-collisions.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ emitReachableGatewayAuthDiagnostic: vi.fn(async (_params: unknown) => false), formatHealthChannelLines: vi.fn(() => []), gatewayStatusCommand: vi.fn(async (_opts: unknown, _runtime: unknown) => {}), + gatewayAuthTokenCommand: vi.fn(async (_runtime: unknown) => {}), defaultRuntime: { log: vi.fn(), error: vi.fn(), @@ -46,6 +47,10 @@ vi.mock("../../commands/gateway-status.js", () => ({ mocks.gatewayStatusCommand(opts, runtime), })); +vi.mock("../../commands/gateway-auth-token.js", () => ({ + gatewayAuthTokenCommand: (runtime: unknown) => mocks.gatewayAuthTokenCommand(runtime), +})); + vi.mock("../gateway-rpc.js", async () => ({ ...(await vi.importActual("../gateway-rpc.js")), callGatewayFromCliWithTransport: (method: string, opts: unknown, params?: unknown) => @@ -143,6 +148,7 @@ describe("gateway register option collisions", () => { emitReachableGatewayAuthDiagnostic.mockClear(); mocks.formatHealthChannelLines.mockClear(); gatewayStatusCommand.mockClear(); + mocks.gatewayAuthTokenCommand.mockClear(); defaultRuntime.log.mockClear(); defaultRuntime.error.mockClear(); defaultRuntime.writeStdout.mockClear(); @@ -150,6 +156,23 @@ describe("gateway register option collisions", () => { defaultRuntime.exit.mockClear(); }); + it("requires explicit confirmation before revealing the Gateway token", async () => { + await sharedProgram.parseAsync(["gateway", "auth-token"], { from: "user" }); + + expect(mocks.gatewayAuthTokenCommand).not.toHaveBeenCalled(); + expect(defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("Pass --show to confirm"), + ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("routes an explicitly confirmed token reveal through the output runtime", async () => { + await sharedProgram.parseAsync(["gateway", "auth-token", "--show"], { from: "user" }); + + expect(mocks.gatewayAuthTokenCommand).toHaveBeenCalledWith(defaultRuntime); + expect(defaultRuntime.error).not.toHaveBeenCalled(); + }); + it.each([ { name: "forwards --token to gateway call when parent and child option names collide", diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 806d5d1abeb0..ae923c87a7f5 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -532,6 +532,7 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw gateway run", "Run the gateway in the foreground."], ["openclaw gateway status", "Show service status plus connectivity/capability."], + ["openclaw gateway auth-token --show", "Reveal the shared token interactively."], ["openclaw gateway discover", "Find local and wide-area gateway beacons."], ["openclaw gateway stability", "Show recent stability diagnostics."], ["openclaw gateway call health", "Call a gateway RPC method directly."], @@ -549,6 +550,22 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie addGatewayRestartHandoffCommands(gateway); setCommandJsonMode(gateway, "output", ({ argv }) => isGatewayMachineOutput(argv)); + gateway + .command("auth-token") + .description("Reveal the configured shared Gateway token") + .option("--show", "Print the token to an interactive terminal", false) + .action(async (opts) => { + await runGatewayCommand(async () => { + if (!opts.show) { + throw new Error( + "Pass --show to confirm that you want to print the Gateway token to this terminal.", + ); + } + const { gatewayAuthTokenCommand } = await import("../../commands/gateway-auth-token.js"); + await gatewayAuthTokenCommand(defaultRuntime); + }, "Gateway auth token failed"); + }); + gatewayCallOpts( gateway .command("call") diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index 96d50e85b227..4a2b94ce672b 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -85,6 +85,7 @@ const JSON_NOT_APPLICABLE = { "configure", "config", "acp client", + "gateway auth-token", "promos claim", "infer model auth login", "models auth add", diff --git a/src/commands/gateway-auth-token.test.ts b/src/commands/gateway-auth-token.test.ts new file mode 100644 index 000000000000..8f6ea67cc491 --- /dev/null +++ b/src/commands/gateway-auth-token.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { gatewayAuthTokenCommand } from "./gateway-auth-token.js"; + +const mocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(), + resolveCommandSecretRefsViaGateway: vi.fn(), +})); + +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: mocks.readConfigFileSnapshot, +})); + +vi.mock("../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, +})); + +const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + writeJson: vi.fn(), + writeStdout: vi.fn(), +}; + +describe("gatewayAuthTokenCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + sourceConfig: { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "GATEWAY_TOKEN" }, + }, + }, + }, + config: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + gateway: { auth: { mode: "token", token: "resolved-value" } }, + }, + diagnostics: [], + targetStatesByPath: { "gateway.auth.token": "resolved_gateway" }, + hadUnresolvedTargets: false, + }); + }); + + it("prints only the resolved token for an interactive operator", async () => { + const env = { OPENCLAW_GATEWAY_TOKEN: "environment-value" }; + + await gatewayAuthTokenCommand(runtime, { env, interactive: true }); + + expect(mocks.resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith({ + config: { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "GATEWAY_TOKEN" }, + }, + }, + }, + commandName: "gateway auth-token", + targetIds: new Set(["gateway.auth.token"]), + mode: "enforce_resolved", + allowedPaths: new Set(["gateway.auth.token"]), + }); + expect(runtime.writeStdout).toHaveBeenCalledWith("resolved-value\n"); + expect(runtime.log).not.toHaveBeenCalled(); + expect(runtime.error).not.toHaveBeenCalled(); + }); + + it("refuses non-interactive output before reading config or secrets", async () => { + await expect(gatewayAuthTokenCommand(runtime, { interactive: false })).rejects.toThrow( + "outside an interactive terminal", + ); + + expect(mocks.readConfigFileSnapshot).not.toHaveBeenCalled(); + expect(mocks.resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); + expect(runtime.writeStdout).not.toHaveBeenCalled(); + }); + + it("fails actionably when no configured token is available", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + sourceConfig: { gateway: { auth: { mode: "token" } } }, + config: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { gateway: { auth: { mode: "token" } } }, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + + await expect(gatewayAuthTokenCommand(runtime, { interactive: true })).rejects.toThrow( + "openclaw doctor --generate-gateway-token", + ); + + expect(runtime.writeStdout).not.toHaveBeenCalled(); + }); + + it("does not fall back to ambient env when the configured SecretRef is unresolved", async () => { + mocks.resolveCommandSecretRefsViaGateway.mockRejectedValue( + new Error("gateway.auth.token SecretRef is unresolved"), + ); + + await expect( + gatewayAuthTokenCommand(runtime, { + interactive: true, + env: { OPENCLAW_GATEWAY_TOKEN: "ambient-fallback" }, + }), + ).rejects.toThrow("gateway.auth.token SecretRef is unresolved"); + + expect(runtime.writeStdout).not.toHaveBeenCalled(); + }); + + it.each(["password", "none", "trusted-proxy"] as const)( + "refuses an inactive token when auth mode is %s", + async (mode) => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + sourceConfig: { + gateway: { auth: { mode, token: "stale-token", password: "active-password" } }, + }, + config: {}, + }); + + await expect(gatewayAuthTokenCommand(runtime, { interactive: true })).rejects.toThrow( + `Gateway auth mode is ${mode}`, + ); + + expect(mocks.resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); + expect(runtime.writeStdout).not.toHaveBeenCalled(); + }, + ); + + it("requires an explicit mode when token and password are both configured", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + sourceConfig: { + gateway: { auth: { token: "candidate-token", password: "candidate-password" } }, + }, + config: {}, + }); + + await expect(gatewayAuthTokenCommand(runtime, { interactive: true })).rejects.toThrow( + "gateway.auth.mode", + ); + + expect(mocks.resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); + expect(runtime.writeStdout).not.toHaveBeenCalled(); + }); + + it("does not reveal a configured token when an environment password selects password auth", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + sourceConfig: { gateway: { auth: { token: "inactive-token" } } }, + config: {}, + }); + + await expect( + gatewayAuthTokenCommand(runtime, { + interactive: true, + env: { OPENCLAW_GATEWAY_PASSWORD: "active-password" }, + }), + ).rejects.toThrow("Gateway auth mode is password"); + + expect(mocks.resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); + expect(runtime.writeStdout).not.toHaveBeenCalled(); + }); + + it("refuses to recover credentials from a remote-client config", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + sourceConfig: { + gateway: { mode: "remote", auth: { mode: "token", token: "stale-local-token" } }, + }, + config: {}, + }); + + await expect(gatewayAuthTokenCommand(runtime, { interactive: true })).rejects.toThrow( + "must run on the Gateway host", + ); + + expect(mocks.resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled(); + expect(runtime.writeStdout).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/gateway-auth-token.ts b/src/commands/gateway-auth-token.ts new file mode 100644 index 000000000000..ca4bc111dc9c --- /dev/null +++ b/src/commands/gateway-auth-token.ts @@ -0,0 +1,69 @@ +import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; +import { isTerminalInteractive } from "../cli/terminal-interactivity.js"; +import { readConfigFileSnapshot } from "../config/config.js"; +import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; +import { resolveGatewayAuth } from "../gateway/auth.js"; +import { defaultRuntime, type OutputRuntimeEnv } from "../runtime.js"; + +type GatewayAuthTokenOptions = { + env?: NodeJS.ProcessEnv; + interactive?: boolean; +}; + +/** Reveal the configured shared Gateway token only to an explicitly interactive operator. */ +export async function gatewayAuthTokenCommand( + runtime: OutputRuntimeEnv = defaultRuntime, + options: GatewayAuthTokenOptions = {}, +): Promise { + // Keep bearer credentials out of pipes and captured command logs. Operators must + // explicitly own both sides of the terminal before the token reaches stdout. + if (!(options.interactive ?? isTerminalInteractive())) { + throw new Error( + "Refusing to print the Gateway token outside an interactive terminal. Run `openclaw gateway auth-token --show` directly in a terminal on the Gateway host.", + ); + } + + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid) { + throw new Error("Gateway config is invalid. Run `openclaw doctor --fix`, then try again."); + } + + const cfg = snapshot.sourceConfig ?? snapshot.config; + if (cfg.gateway?.mode === "remote") { + throw new Error( + "This command must run on the Gateway host; the current config is in remote mode.", + ); + } + const env = options.env ?? process.env; + assertExplicitGatewayAuthModeWhenBothConfigured(cfg); + const configuredAuth = resolveGatewayAuth({ + authConfig: cfg.gateway?.auth, + env, + tailscaleMode: cfg.gateway?.tailscale?.mode, + }); + if (configuredAuth.mode !== "token") { + throw new Error( + `Gateway auth mode is ${configuredAuth.mode}; there is no active shared token to reveal.`, + ); + } + + const { resolvedConfig } = await resolveCommandSecretRefsViaGateway({ + config: cfg, + commandName: "gateway auth-token", + targetIds: new Set(["gateway.auth.token"]), + mode: "enforce_resolved", + allowedPaths: new Set(["gateway.auth.token"]), + }); + const resolvedAuth = resolveGatewayAuth({ + authConfig: resolvedConfig.gateway?.auth, + env, + tailscaleMode: resolvedConfig.gateway?.tailscale?.mode, + }); + if (resolvedAuth.mode !== "token" || !resolvedAuth.token) { + throw new Error( + "No configured Gateway token is available. Run `openclaw doctor --generate-gateway-token`, restart the Gateway, then try again.", + ); + } + + runtime.writeStdout(`${resolvedAuth.token}\n`); +} diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index bce0d1b06f06..19548151a3df 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -637,7 +637,7 @@ export async function finalizeSetupWizard( t("wizard.finalize.gatewayTokenShared"), t("wizard.finalize.gatewayTokenStored"), t("wizard.finalize.gatewayTokenView", { - command: formatCliCommand("openclaw config get gateway.auth.token"), + command: formatCliCommand("openclaw gateway auth-token --show"), }), t("wizard.finalize.gatewayTokenGenerate", { command: formatCliCommand("openclaw doctor --generate-gateway-token"), diff --git a/test/scripts/native-app-i18n.test.ts b/test/scripts/native-app-i18n.test.ts index f5da95ed02bb..737d4ed563d3 100644 --- a/test/scripts/native-app-i18n.test.ts +++ b/test/scripts/native-app-i18n.test.ts @@ -537,7 +537,7 @@ describe("native app i18n inventory", () => { entries.some( (entry) => entry.source === - "Paste the token configured on the gateway host. On the gateway host, run `openclaw config get gateway.auth.token`. If the gateway uses an environment variable instead, use `OPENCLAW_GATEWAY_TOKEN`.", + "Paste the token configured on the gateway host. On the gateway host, run `openclaw gateway auth-token --show` in an interactive terminal, then paste its output.", ), ).toBe(true); expect( diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 81c8fbcceaa8..020faa17036c 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4313,7 +4313,7 @@ export const en: TranslationMap = { summary: "The Gateway is reachable, but it needs a matching token or password before this browser can connect.", stepPaste: - "Paste the token from openclaw dashboard --no-open or enter the configured password.", + "Paste the token from openclaw gateway auth-token --show or enter the configured password.", stepGenerate: "If no token is configured, run openclaw doctor --generate-gateway-token on the gateway host.", stepConnect: "Click Connect again after updating the credential.", @@ -4323,7 +4323,7 @@ export const en: TranslationMap = { summary: "The supplied credential was rejected. The most common cause is a stale token or a token copied from another Gateway URL.", stepDashboard: - "Run openclaw dashboard --no-open and open the fresh URL or paste its token.", + "Run openclaw dashboard --no-open for a fresh URL, or openclaw gateway auth-token --show to recover the token.", stepReplace: "Replace stale token/password values; do not reuse a token from another Gateway URL.", stepMode: