diff --git a/docs/cli/tui.md b/docs/cli/tui.md index 62905a2a192a..411563f07a6e 100644 --- a/docs/cli/tui.md +++ b/docs/cli/tui.md @@ -69,6 +69,10 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with - With no URL/host target or explicit `--url`, `tui` resolves configured Gateway auth SecretRefs for token/password auth when possible (`env`/`file`/`exec`/`store` providers). +- When the configured remote Gateway is behind an identity-aware proxy, `tui` + resolves `gateway.remote.edgeAuth` SecretInputs and sends those headers only + to that configured Gateway scope. URL or host targets for other origins never + inherit them. - With no explicit URL or port, `tui` follows the active local Gateway port recorded by the running Gateway. Explicit `--url`, `OPENCLAW_GATEWAY_URL`, `OPENCLAW_GATEWAY_PORT`, and remote Gateway config keep precedence. @@ -94,6 +98,7 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with | The Gateway predates short-link resolution | Copy the full session key from that Gateway's Control UI. | | Session missing or short ref ambiguous | For the configured/local Gateway, run `openclaw sessions list`; for a URL/host target, choose a longer or full key in that Gateway's Control UI. | | Gateway unreachable | The error names the selected origin. For a `*.ts.net` host, connect Tailscale and confirm the Gateway is reachable on the tailnet. | +| Identity-aware proxy rejected the upgrade | Configure `gateway.remote.edgeAuth` for the configured remote Gateway; the error includes the relevant remote-access docs link. | | Stored device token revoked or rotated | Rotate it with `openclaw devices rotate --device --role operator`, then reconnect. | | TLS certificate pin mismatch | The original TLS fingerprint error passes through unchanged; verify the configured or explicit pin before retrying. | diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index a5a5d46f7e6d..36bb2e63430c 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -91,6 +91,87 @@ For a Gateway already reachable on a trusted LAN or Tailnet, use direct mode: } ``` +## Gateway behind an identity-aware proxy + +Use `gateway.remote.edgeAuth` when an identity-aware proxy must authenticate the +WebSocket upgrade before traffic reaches the Gateway. Header values are +`SecretInput` fields, so they can come from `env`, `file`, `exec`, or `store` +secret providers without placing credentials directly in the config. + +For Cloudflare Access, a generic exec secret provider can obtain a short-lived +application token from an operator-installed `cloudflared` binary: + +```json5 +{ + secrets: { + providers: { + "cloudflare-access": { + source: "exec", + command: "/usr/local/bin/cloudflared", + args: ["access", "token", "-app=https://gateway.example"], + jsonOnly: false, + trustedDirs: ["/usr/local/bin"], + }, + }, + }, + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example", + edgeAuth: { + "Cf-Access-Token": { + source: "exec", + provider: "cloudflare-access", + id: "token", + }, + }, + }, + }, +} +``` + +`secrets.providers.*.command` must be an absolute path; replace +`/usr/local/bin/cloudflared` with the real, non-symlink install location on your +host, such as the resolved executable under a Homebrew prefix. + +For a Cloudflare Access service token, provide the two fixed headers from any +supported secret provider. This example reads them from environment-backed +SecretRefs: + +```json5 +{ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example", + edgeAuth: { + "CF-Access-Client-Id": { + source: "env", + provider: "default", + id: "CF_ACCESS_CLIENT_ID", + }, + "CF-Access-Client-Secret": { + source: "env", + provider: "default", + id: "CF_ACCESS_CLIENT_SECRET", + }, + }, + }, + }, +} +``` + +OpenClaw's Gateway connection code never runs `cloudflared` itself and has no +Cloudflare dependency or login flow. Only the generic exec secret provider +invokes the exact command an operator configures. Resolved edge-auth headers are +sent only when the target matches the configured `gateway.remote.url` scope, +only over `wss://`, and never across redirects. + ## Credential precedence Gateway credential resolution follows one shared contract across call/probe/status paths and Discord exec-approval monitoring. Node-host uses the same contract with one local-mode exception (it ignores `gateway.remote.*`). diff --git a/packages/gateway-client/src/client.cloudflare-access.test.ts b/packages/gateway-client/src/client.cloudflare-access.test.ts index e72eecc20ae8..a132408cd1bc 100644 --- a/packages/gateway-client/src/client.cloudflare-access.test.ts +++ b/packages/gateway-client/src/client.cloudflare-access.test.ts @@ -1,99 +1,119 @@ import { X509Certificate } from "node:crypto"; import { createServer as createHttpsServer } from "node:https"; +import type { AddressInfo } from "node:net"; import { afterEach, expect, test } from "vitest"; import { WebSocketServer } from "ws"; import { TEST_TLS_CERT_PEM, TEST_TLS_KEY_PEM } from "../../../test/helpers/tls-fixture.js"; import { GatewayClient } from "./client.js"; -let server: WebSocketServer | undefined; -let httpsServer: ReturnType | undefined; +const tlsFingerprint = new X509Certificate(TEST_TLS_CERT_PEM).fingerprint256; +const websocketServers: WebSocketServer[] = []; +const httpsServers: Array> = []; + +async function listen(server: ReturnType): Promise { + httpsServers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + return (server.address() as AddressInfo).port; +} afterEach(async () => { - if (!server) { - return; + for (const server of websocketServers.splice(0).toReversed()) { + await new Promise((resolve) => { + server.close(() => resolve()); + }); } - const closing = new Promise((resolve, reject) => { - server?.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); + for (const server of httpsServers.splice(0).toReversed()) { + await new Promise((resolve) => { + server.close(() => resolve()); }); - }); - server = undefined; - await closing; - if (httpsServer) { - const closingHttps = new Promise((resolve, reject) => { - httpsServer?.close((error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - httpsServer = undefined; - await closingHttps; } }); -test("sends the closed Cloudflare Access header pair through a rejecting edge", async () => { - const clientId = ["cf", "gateway", "id"].join("-"); - const clientSecret = ["cf", "gateway", "secret"].join("-"); - httpsServer = createHttpsServer({ key: TEST_TLS_KEY_PEM, cert: TEST_TLS_CERT_PEM }); - server = new WebSocketServer({ +test("sends resolved edge auth headers through the WebSocket upgrade", async () => { + const edgeAuthValue = "test-secret"; + const httpsServer = createHttpsServer({ key: TEST_TLS_KEY_PEM, cert: TEST_TLS_CERT_PEM }); + const websocketServer = new WebSocketServer({ server: httpsServer, verifyClient: ({ req }, done) => { - const accepted = - req.headers["cf-access-client-id"] === clientId && - req.headers["cf-access-client-secret"] === clientSecret; + const accepted = req.headers["x-edge-auth"] === edgeAuthValue; done(accepted, accepted ? undefined : 403, accepted ? undefined : "Access denied"); }, }); - await new Promise((resolve, reject) => { - httpsServer?.once("error", reject); - httpsServer?.listen(0, "127.0.0.1", resolve); - }); - const address = httpsServer.address(); - if (!address || typeof address === "string") { - throw new Error("test edge did not allocate a port"); - } + websocketServers.push(websocketServer); + const port = await listen(httpsServer); const received = new Promise>((resolve) => { - server?.once("connection", (_socket, request) => resolve(request.headers)); + websocketServer.once("connection", (_socket, request) => resolve(request.headers)); }); const client = new GatewayClient({ - url: `wss://127.0.0.1:${address.port}`, + url: `wss://127.0.0.1:${port}`, connectChallengeTimeoutMs: 0, - cloudflareAccess: { clientId, clientSecret }, - tlsFingerprint: new X509Certificate(TEST_TLS_CERT_PEM).fingerprint256, + edgeAuthHeaders: { "X-Edge-Auth": edgeAuthValue }, + tlsFingerprint, }); client.start(); - await expect(received).resolves.toMatchObject({ - "cf-access-client-id": clientId, - "cf-access-client-secret": clientSecret, - }); + await expect(received).resolves.toMatchObject({ "x-edge-auth": edgeAuthValue }); await client.stopAndWait(); }); -test("rejects the Access pair before a plaintext WebSocket dial", async () => { +test("rejects non-empty edge auth headers before a plaintext WebSocket dial", async () => { let resolveConnectError: (error: Error) => void = () => {}; const connectError = new Promise((resolve) => { resolveConnectError = resolve; }); const client = new GatewayClient({ url: "ws://127.0.0.1:18789", - cloudflareAccess: { - clientId: "cf-plaintext-id", - clientSecret: "cf-plaintext-secret", - }, + edgeAuthHeaders: { "X-Edge-Auth": "test-secret" }, onConnectError: resolveConnectError, }); client.start(); await expect(connectError).resolves.toMatchObject({ - message: "Cloudflare Access credentials require a wss:// Gateway URL", + message: "edge auth headers require a wss:// Gateway URL", }); client.stop(); }); + +test("does not follow an edge redirect and redacts its Location URL", async () => { + let redirected = false; + const targetHttpsServer = createHttpsServer({ key: TEST_TLS_KEY_PEM, cert: TEST_TLS_CERT_PEM }); + const targetWebSocketServer = new WebSocketServer({ server: targetHttpsServer }); + websocketServers.push(targetWebSocketServer); + targetWebSocketServer.on("connection", (socket) => { + redirected = true; + socket.close(); + }); + const targetPort = await listen(targetHttpsServer); + + const edgeHttpsServer = createHttpsServer({ key: TEST_TLS_KEY_PEM, cert: TEST_TLS_CERT_PEM }); + edgeHttpsServer.on("upgrade", (_request, socket) => { + socket.end( + `HTTP/1.1 302 Found\r\nLocation: wss://127.0.0.1:${targetPort}/?access_token=test-token&safe=1\r\nConnection: close\r\n\r\n`, + ); + }); + const edgePort = await listen(edgeHttpsServer); + let resolveConnectError: (error: Error) => void = () => {}; + const connectError = new Promise((resolve) => { + resolveConnectError = resolve; + }); + const client = new GatewayClient({ + url: `wss://127.0.0.1:${edgePort}`, + edgeAuthHeaders: { "X-Edge-Auth": "test-secret" }, + tlsFingerprint, + onConnectError: resolveConnectError, + }); + client.start(); + + await expect(connectError).resolves.toMatchObject({ + details: { + reason: "websocket-upgrade-rejected", + httpStatus: 302, + location: `wss://127.0.0.1:${targetPort}/?access_token=***&safe=1`, + }, + }); + expect(redirected).toBe(false); + await client.stopAndWait(); +}); diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index bce7f1391101..3892a8fa58f8 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -18,16 +18,13 @@ import { MIN_PROBE_PROTOCOL_VERSION, PROTOCOL_VERSION, } from "@openclaw/gateway-protocol/version"; +import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { WebSocket } from "ws"; import { isSensitiveUrlQueryParamName, normalizeTlsFingerprint, normalizeGatewayErrorText, } from "./client-address-utils.js"; -import { - buildCloudflareAccessHeaders, - type CloudflareAccessCredentials, -} from "./cloudflare-access.js"; import { buildGatewayConnectAuth, type GatewayConnectAuthSelection, @@ -241,8 +238,8 @@ export function isGatewayConnectAssemblyError(value: unknown): value is Error { export type GatewayClientOptions = { url?: string; // ws://127.0.0.1:18789 origin?: string; - /** Closed Cloudflare Access service-token pair for this configured Gateway origin. */ - cloudflareAccess?: CloudflareAccessCredentials; + /** Already-resolved edge-proxy auth headers (identity-aware proxy in front of the Gateway). */ + edgeAuthHeaders?: Readonly>; connectChallengeTimeoutMs?: number; /** * Server-side pre-auth handshake budget. Config-derived local clients use @@ -501,9 +498,14 @@ export class GatewayClient { private createSocket(handlers: GatewayProtocolSocketHandlers): GatewayProtocolSocket { const url = this.opts.url ?? DEFAULT_GATEWAY_CLIENT_URL; - if (this.opts.cloudflareAccess && new URL(url).protocol !== "wss:") { + const configuredEdgeAuthHeaders = this.opts.edgeAuthHeaders; + const edgeAuthHeaders = + configuredEdgeAuthHeaders && Object.keys(configuredEdgeAuthHeaders).length > 0 + ? configuredEdgeAuthHeaders + : undefined; + if (edgeAuthHeaders && new URL(url).protocol !== "wss:") { throw new GatewayWebSocketTransportConfigurationError( - "Cloudflare Access credentials require a wss:// Gateway URL", + "edge auth headers require a wss:// Gateway URL", ); } // Block plaintext before device-token lookup. Credentials may be loaded from @@ -523,10 +525,10 @@ export class GatewayClient { maxPayload: 25 * 1024 * 1024, handshakeTimeout: handshakeTimeoutMs, ...(this.opts.origin ? { origin: this.opts.origin } : {}), - ...(this.opts.cloudflareAccess + ...(edgeAuthHeaders ? { followRedirects: false, - headers: buildCloudflareAccessHeaders(this.opts.cloudflareAccess), + headers: edgeAuthHeaders, } : {}), }, @@ -576,6 +578,12 @@ export class GatewayClient { ws.on("unexpected-response", (request: ClientRequest, response: IncomingMessage) => { void readUpgradeErrorBody(response).then((body) => { const statusCode = response.statusCode; + const rawLocation = response.headers.location; + const location = rawLocation + ? redactSensitiveUrlLikeString( + Array.isArray(rawLocation) ? (rawLocation[0] ?? "") : rawLocation, + ) + : undefined; const message = `gateway rejected websocket upgrade (HTTP ${statusCode ?? "unknown"})${body ? `: ${body}` : ""}`; upgradeError = new GatewayClientRequestError({ code: "UNAVAILABLE", @@ -584,6 +592,7 @@ export class GatewayClient { details: { reason: "websocket-upgrade-rejected", ...(statusCode === undefined ? {} : { httpStatus: statusCode }), + ...(location ? { location } : {}), }, }); handlers.error(upgradeError); diff --git a/packages/gateway-protocol/src/connect-error-details.test.ts b/packages/gateway-protocol/src/connect-error-details.test.ts index 4fa0830dee4c..29d2779d3caf 100644 --- a/packages/gateway-protocol/src/connect-error-details.test.ts +++ b/packages/gateway-protocol/src/connect-error-details.test.ts @@ -237,6 +237,26 @@ describe("classifyGatewayConnectFailure", () => { message: "connect failed; retry later", remediation: undefined, }, + { + name: "identity proxy redirect rejection", + input: { + details: { reason: "websocket-upgrade-rejected", httpStatus: 302 }, + message: "gateway rejected websocket upgrade (HTTP 302)", + }, + kind: "identity-proxy", + message: "gateway rejected websocket upgrade (HTTP 302)", + remediation: "gateway.remote.edgeAuth", + }, + { + name: "identity proxy forbidden rejection", + input: { + details: { reason: "websocket-upgrade-rejected", httpStatus: 403 }, + message: "gateway rejected websocket upgrade (HTTP 403)", + }, + kind: "identity-proxy", + message: "gateway rejected websocket upgrade (HTTP 403)", + remediation: "identity-aware proxy", + }, { name: "unreachable endpoint", input: { message: "connect ECONNREFUSED 127.0.0.1:18789" }, @@ -258,6 +278,28 @@ describe("classifyGatewayConnectFailure", () => { expect(result.remediation).toContain("--token/--password"); } }); + + it("adds a Cloudflare hint only for Cloudflare Access redirect hosts", () => { + const cloudflare = classifyGatewayConnectFailure({ + details: { + reason: "websocket-upgrade-rejected", + httpStatus: 302, + location: "https://team.cloudflareaccess.com/cdn-cgi/access/login?token=***", + }, + }); + const generic = classifyGatewayConnectFailure({ + details: { + reason: "websocket-upgrade-rejected", + httpStatus: 302, + location: "https://login.example/authorize", + }, + }); + + expect(cloudflare.kind).toBe("identity-proxy"); + expect(cloudflare.kind).not.toBe("unreachable"); + expect(cloudflare.remediation).toContain("Cloudflare Access"); + expect(generic.remediation).not.toContain("Cloudflare"); + }); }); describe("resolveAuthConnectErrorDetailCode", () => { diff --git a/packages/gateway-protocol/src/connect-error-details.ts b/packages/gateway-protocol/src/connect-error-details.ts index cfb293b058d8..24bc69828ba1 100644 --- a/packages/gateway-protocol/src/connect-error-details.ts +++ b/packages/gateway-protocol/src/connect-error-details.ts @@ -507,8 +507,36 @@ const SCOPE_MISMATCH_REMEDIATION = "`openclaw devices approve --latest`, approve the printed request, then reconnect."; const RATE_LIMITED_REMEDIATION = "Wait for the temporary authentication lockout to expire, then retry."; +const IDENTITY_PROXY_REMEDIATION = + "An identity-aware proxy rejected the WebSocket upgrade. Configure gateway.remote.edgeAuth for the configured Gateway origin, then reconnect. See https://docs.openclaw.ai/gateway/remote#gateway-behind-an-identity-aware-proxy."; +const CLOUDFLARE_ACCESS_REMEDIATION = + "Cloudflare Access detected: configure its token header or service-token headers in gateway.remote.edgeAuth."; +const IDENTITY_PROXY_HTTP_STATUSES = new Set([301, 302, 303, 307, 308, 401, 403]); const GATEWAY_CLOSED_MESSAGE_PATTERN = /\bgateway closed \(\d+\):/i; +function readIdentityProxyRejection(details: unknown): { cloudflareAccess: boolean } | null { + if (!isProtocolRecord(details)) { + return null; + } + if ( + details.reason !== "websocket-upgrade-rejected" || + typeof details.httpStatus !== "number" || + !IDENTITY_PROXY_HTTP_STATUSES.has(details.httpStatus) + ) { + return null; + } + const location = normalizeOptionalProtocolString(details.location); + if (!location) { + return { cloudflareAccess: false }; + } + try { + const hostname = new URL(location).hostname.toLowerCase().replace(/\.+$/u, ""); + return { cloudflareAccess: hostname.endsWith(".cloudflareaccess.com") }; + } catch { + return { cloudflareAccess: false }; + } +} + /** Classifies Gateway connect failures from structured details, with one legacy text fallback. */ export function classifyGatewayConnectFailure(input: { details?: unknown; @@ -536,6 +564,16 @@ export function classifyGatewayConnectFailure(input: { remediation: PAIRING_APPROVAL_REMEDIATION, }; } + const identityProxy = readIdentityProxyRejection(input.details); + if (identityProxy) { + return { + kind: "identity-proxy" as const, + userMessage: userMessage ?? "identity-aware proxy rejected websocket upgrade", + remediation: identityProxy.cloudflareAccess + ? `${IDENTITY_PROXY_REMEDIATION}\n${CLOUDFLARE_ACCESS_REMEDIATION}` + : IDENTITY_PROXY_REMEDIATION, + }; + } const deviceIdentityRequired = code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED || code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED || diff --git a/src/cli/daemon-cli/probe.test.ts b/src/cli/daemon-cli/probe.test.ts index fa4334b8c0bc..f6232f0398ef 100644 --- a/src/cli/daemon-cli/probe.test.ts +++ b/src/cli/daemon-cli/probe.test.ts @@ -352,6 +352,7 @@ describe("probeGatewayStatus", () => { expect(probeGatewayMock).toHaveBeenCalledWith({ url: "ws://127.0.0.1:19191", + config, auth: { token: "temp-token", password: undefined, diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index c7916d0541fd..4cd88789b7c0 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -96,6 +96,7 @@ export async function probeGatewayStatus(opts: { const { probeGateway } = await loadProbeGatewayModule(); const probeOpts = { url: opts.url, + ...(opts.config ? { config: opts.config } : {}), auth: { token: opts.token, password: opts.password, diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index b4c810ec5d1e..ab3018986619 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -6,6 +6,7 @@ import process from "node:process"; import { expectDefined } from "@openclaw/normalization-core"; import { CommanderError } from "commander"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; import { loggingState } from "../logging/state.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -4138,6 +4139,30 @@ describe("runCli exit behavior", () => { expectBoundTui({ url, token: "loopback-remote-auth" }); }); + it("passes configured remote edge auth into the bare-root onboarding probe", async () => { + const url = "wss://gateway.example/ws"; + const config: OpenClawConfig = { + gateway: { + mode: "remote", + remote: { + url, + token: "test-token", + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }, + }, + }; + primeBareRootConfig(config); + + await runBareCli(); + + expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({ + url, + config, + token: "test-token", + }); + expectBoundTui({ url, token: "test-token" }); + }); + it("keeps configured remote password authoritative from preflight through TUI launch", async () => { const url = "ws://127.0.0.1:18789"; primeBareRootConfig({ diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 8a2bdc37ffbb..e9385ba5c52b 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -512,11 +512,15 @@ async function resolveReachableGateway( } const probeOptions: { url: string; + config?: OpenClawConfig; token?: string; password?: string; tlsFingerprint?: string; preauthHandshakeTimeoutMs?: number; } = { url: target.url }; + if (config.gateway?.remote?.edgeAuth) { + probeOptions.config = config; + } if (auth.token) { probeOptions.token = auth.token; } diff --git a/src/cli/session-target.test.ts b/src/cli/session-target.test.ts new file mode 100644 index 000000000000..af900f732b5f --- /dev/null +++ b/src/cli/session-target.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GatewayClientRequestError } from "../gateway/client.js"; + +const callGatewayMock = vi.hoisted(() => vi.fn()); + +vi.mock("../gateway/call.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, callGateway: callGatewayMock }; +}); + +const { resolveSessionTarget } = await import("./session-target.js"); + +describe("session target connection errors", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + }); + + it("surfaces identity-proxy remediation without tailnet or SSH tunnel advice", async () => { + callGatewayMock.mockRejectedValue( + new GatewayClientRequestError({ + code: "UNAVAILABLE", + message: "gateway rejected websocket upgrade (HTTP 302)", + details: { reason: "websocket-upgrade-rejected", httpStatus: 302 }, + }), + ); + + let error: unknown; + try { + await resolveSessionTarget({ raw: "gateway.example/main/a1166b81" }); + } catch (caught) { + error = caught; + } + expect(String(error)).toContain("gateway.remote.edgeAuth"); + expect(String(error)).not.toContain("tailnet"); + expect(String(error)).not.toContain("SSH tunnel"); + }); +}); diff --git a/src/cli/session-target.ts b/src/cli/session-target.ts index e7c459a8b9e8..ea19589b88c1 100644 --- a/src/cli/session-target.ts +++ b/src/cli/session-target.ts @@ -167,6 +167,9 @@ function shapeTargetError( ...(error instanceof GatewayTransportError ? { reason: error.reason } : {}), message: error.message, }); + if (failure.kind === "identity-proxy") { + return new Error(`${failure.userMessage}\n${failure.remediation}`); + } if (failure.kind === "unreachable") { const effectiveGatewayUrl = gatewayUrl ?? diff --git a/src/commands/configure.wizard.gateway.test.ts b/src/commands/configure.wizard.gateway.test.ts new file mode 100644 index 000000000000..048326429284 --- /dev/null +++ b/src/commands/configure.wizard.gateway.test.ts @@ -0,0 +1,704 @@ +// Configure wizard Gateway tests cover run-mode probes, auth routing, and cancellation. +import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { withEnvAsync } from "../test-utils/env.js"; +import { + createWizardTestRuntime, + queueWizardTestPrompts, + setupBaseWizardTestState, +} from "./configure.wizard-test-helpers.js"; + +const mocks = vi.hoisted(() => { + const writeConfigFile = vi.fn(); + return { + clackIntro: vi.fn(), + clackOutro: vi.fn(), + clackSelect: vi.fn(), + clackText: vi.fn(), + clackConfirm: vi.fn(), + clackPassword: vi.fn(), + resolveSearchProviderOptions: vi.fn(), + resolvePluginContributionOwners: vi.fn(), + setupSearch: vi.fn(), + assertConfigPathForWrite: vi.fn(), + readConfigFileSnapshot: vi.fn(), + writeConfigFile, + replaceConfigFile: vi.fn( + async (params: { + nextConfig: unknown; + writeOptions?: { assertConfigPathForWrite?: () => void }; + }) => { + params.writeOptions?.assertConfigPathForWrite?.(); + await writeConfigFile(params.nextConfig); + }, + ), + resolveGatewayPort: vi.fn(), + createClackPrompter: vi.fn(), + note: vi.fn(), + printWizardHeader: vi.fn(), + probeGatewayReachable: vi.fn(), + waitForGatewayReachable: vi.fn(async () => ({ ok: true })), + resolveAdvertisedControlUiLinks: vi.fn(), + resolveControlUiLinks: vi.fn(), + resolveLocalControlUiProbeLinks: vi.fn(), + inspectWindowsGatewayFirewall: vi.fn(), + summarizeExistingConfig: vi.fn(), + healthCommand: vi.fn(), + promptAuthConfig: vi.fn(), + promptGatewayConfig: vi.fn(), + promptRemoteGatewayConfig: vi.fn( + async (cfg: OpenClawConfig): Promise => ({ + ...cfg, + gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } }, + }), + ), + isCodexNativeWebSearchRelevant: vi.fn(({ config }: { config: OpenClawConfig }) => + Boolean(config.auth?.profiles?.["openai:default"]), + ), + setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg), + guardCancel: vi.fn((value: unknown, _runtime: RuntimeEnv, _exitCode?: number) => value), + }; +}); + +vi.mock("@clack/prompts", () => ({ + intro: mocks.clackIntro, + outro: mocks.clackOutro, + select: mocks.clackSelect, + text: mocks.clackText, + confirm: mocks.clackConfirm, + password: mocks.clackPassword, +})); + +vi.mock("../config/config.js", () => ({ + CONFIG_PATH: "~/.openclaw/openclaw.json", + createConfigIO: () => ({ + readConfigFileSnapshotForWrite: async () => ({ + snapshot: await mocks.readConfigFileSnapshot(), + writeOptions: { + assertConfigPathForWrite: mocks.assertConfigPathForWrite, + expectedConfigPath: "/tmp/openclaw.json", + ownedConfigPathForWrite: "/tmp/openclaw.json", + }, + }), + }), + readConfigFileSnapshot: mocks.readConfigFileSnapshot, + readConfigFileSnapshotForWrite: async () => ({ + snapshot: await mocks.readConfigFileSnapshot(), + writeOptions: { + assertConfigPathForWrite: mocks.assertConfigPathForWrite, + envSnapshotForRestore: { SECRET: "resolved-secret" }, + expectedConfigPath: "/tmp/openclaw.json", + includeFileHashesForWrite: { "/tmp/plugins.json5": "stale-hash" }, + ownedConfigPathForWrite: "/tmp/openclaw.json", + }, + }), + resolveConfigWriteAfterWrite: (afterWrite?: { mode: string }) => afterWrite ?? { mode: "auto" }, + transformConfigFileWithRetry: async ( + params: Parameters[0], + ) => { + const maxAttempts = params.maxAttempts ?? 5; + for (let attempt = 0; ; attempt += 1) { + const snapshot = await mocks.readConfigFileSnapshot(); + const previousHash = snapshot.hash ?? null; + const config = + params.base === "runtime" + ? (snapshot.runtimeConfig ?? snapshot.config) + : (snapshot.sourceConfig ?? snapshot.config); + try { + const transformed = await params.transform(config, { snapshot, previousHash, attempt }); + const committed = await params.commit!({ + nextConfig: transformed.nextConfig, + snapshot, + ...(previousHash ? { baseHash: previousHash } : {}), + writeOptions: params.writeOptions, + afterWrite: { mode: "auto" }, + }); + return { nextConfig: committed.config }; + } catch (error) { + if ( + !(error instanceof Error) || + error.name !== "ConfigMutationConflictError" || + (error as { retryable?: boolean }).retryable === false || + attempt === maxAttempts - 1 + ) { + throw error; + } + } + } + }, + writeConfigFile: mocks.writeConfigFile, + replaceConfigFile: mocks.replaceConfigFile, + resolveGatewayPort: mocks.resolveGatewayPort, +})); + +vi.mock("../infra/windows-gateway-firewall-diagnostics.js", () => ({ + inspectWindowsGatewayFirewall: mocks.inspectWindowsGatewayFirewall, + formatWindowsGatewayFirewallGuidance: (params: { bind?: string }) => + params.bind === "lan" + ? [ + "Windows firewall: if another device cannot connect to the LAN URL, run `openclaw gateway status --deep` from this Windows host.", + ] + : [], +})); + +vi.mock("../wizard/clack-prompter.js", () => ({ + createClackPrompter: mocks.createClackPrompter, +})); + +vi.mock("../../packages/terminal-core/src/note.js", () => ({ + note: mocks.note, +})); + +vi.mock("./onboard-helpers.js", () => ({ + DEFAULT_WORKSPACE: "~/.openclaw/workspace", + applyWizardMetadata: (cfg: OpenClawConfig) => cfg, + ensureWorkspaceAndSessions: vi.fn(), + guardCancel: mocks.guardCancel, + printWizardHeader: mocks.printWizardHeader, + probeGatewayReachable: mocks.probeGatewayReachable, + resolveAdvertisedControlUiLinks: mocks.resolveAdvertisedControlUiLinks, + resolveControlUiLinks: mocks.resolveControlUiLinks, + resolveLocalControlUiProbeLinks: mocks.resolveLocalControlUiProbeLinks, + summarizeExistingConfig: mocks.summarizeExistingConfig, + waitForGatewayReachable: mocks.waitForGatewayReachable, +})); + +vi.mock("./health.js", () => ({ + healthCommand: mocks.healthCommand, +})); + +vi.mock("./health-format.js", () => ({ + formatHealthCheckFailure: vi.fn(), +})); + +vi.mock("./configure.gateway.js", () => ({ + promptGatewayConfig: mocks.promptGatewayConfig, +})); + +vi.mock("./configure.gateway-auth.js", () => ({ + promptAuthConfig: mocks.promptAuthConfig, +})); + +vi.mock("./configure.channels.js", () => ({ + removeChannelConfigWizard: vi.fn(), +})); + +vi.mock("./configure.daemon.js", () => ({ + maybeInstallDaemon: vi.fn(), +})); + +vi.mock("./onboard-remote.js", () => ({ + promptRemoteGatewayConfig: mocks.promptRemoteGatewayConfig, +})); + +vi.mock("./onboard-skills.js", () => ({ + setupSkills: vi.fn(), +})); + +vi.mock("./onboard-channels.js", () => ({ + setupChannels: mocks.setupChannels, +})); + +vi.mock("../flows/search-setup.js", () => ({ + resolveSearchProviderOptions: mocks.resolveSearchProviderOptions, + runSearchSetupFlow: mocks.setupSearch, +})); + +vi.mock("../plugins/plugin-registry.js", () => ({ + resolvePluginContributionOwners: mocks.resolvePluginContributionOwners, +})); + +vi.mock("../agents/codex-native-web-search.js", () => ({ + isCodexNativeWebSearchRelevant: mocks.isCodexNativeWebSearchRelevant, +})); + +vi.mock("../config/mutate.js", async () => { + const actual = await vi.importActual("../config/mutate.js"); + return { + ...actual, + ConfigMutationConflictError: actual.ConfigMutationConflictError, + }; +}); + +import { WizardCancelledError } from "../wizard/prompts.js"; +import { maybeInstallDaemon } from "./configure.daemon.js"; +import { runConfigureWizard } from "./configure.wizard.js"; + +const createRuntime = createWizardTestRuntime; + +function setupBaseWizardState(config: OpenClawConfig = {}) { + setupBaseWizardTestState(mocks, config); +} + +const requireRecord = createRequireRecord("object", "expected-label"); + +function mockCallArg( + mock: { mock: { calls: ReadonlyArray> } }, + label: string, + callIndex = 0, +): unknown { + const call = mock.mock.calls[callIndex]; + if (!call) { + throw new Error(`Expected ${label} call ${callIndex}`); + } + return call[0]; +} + +function requireWriteConfig(callIndex = 0) { + return requireRecord( + mockCallArg(mocks.writeConfigFile, "writeConfigFile", callIndex), + "written config", + ); +} + +function getGateway(config: Record) { + return requireRecord(config.gateway, "gateway config"); +} + +function queueWizardPrompts(params: { select: string[]; confirm: boolean[]; text?: string }) { + queueWizardTestPrompts(mocks, params); +} + +describe("runConfigureWizard", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.healthCommand.mockReset(); + mocks.assertConfigPathForWrite.mockImplementation(() => {}); + mocks.resolvePluginContributionOwners.mockReturnValue(["firecrawl"]); + mocks.resolveSearchProviderOptions.mockReturnValue([ + { + id: "firecrawl", + label: "Firecrawl Search", + hint: "Structured results with optional result scraping", + credentialLabel: "Firecrawl API key", + envVars: ["FIRECRAWL_API_KEY"], + placeholder: "fc-...", + signupUrl: "https://www.firecrawl.dev/", + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + }, + ]); + mocks.setupSearch.mockReset(); + mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => ({ + outcome: "completed", + config: cfg, + })); + mocks.promptAuthConfig.mockReset(); + mocks.promptAuthConfig.mockImplementation(async (cfg: OpenClawConfig) => cfg); + mocks.promptGatewayConfig.mockReset(); + mocks.promptGatewayConfig.mockImplementation(async (cfg: OpenClawConfig) => ({ + config: cfg, + port: 18789, + })); + mocks.guardCancel.mockReset(); + mocks.guardCancel.mockImplementation((value: unknown) => value); + }); + + it("runs selected sections in canonical order and commits their combined config once", async () => { + setupBaseWizardState(); + queueWizardPrompts({ select: ["local", "configure"], confirm: [] }); + const events: string[] = []; + mocks.promptAuthConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => { + events.push("model"); + return cfg; + }); + mocks.promptGatewayConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => { + events.push("gateway"); + return { config: cfg, port: 18789 }; + }); + mocks.setupChannels.mockImplementationOnce(async (cfg: OpenClawConfig) => { + events.push("channels"); + return cfg; + }); + mocks.writeConfigFile.mockImplementationOnce(async () => { + events.push("commit"); + }); + + await runConfigureWizard( + { command: "configure", sections: ["channels", "gateway", "model"] }, + createRuntime(), + ); + + expect(events).toEqual(["model", "gateway", "channels", "commit"]); + expect(mocks.writeConfigFile).toHaveBeenCalledOnce(); + }); + + it("commits every interactive section before running the next section", async () => { + setupBaseWizardState(); + queueWizardPrompts({ + select: ["local", "model", "gateway", "channels", "configure", "__continue"], + confirm: [], + }); + const events: string[] = []; + mocks.promptAuthConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => { + events.push("model"); + return cfg; + }); + mocks.promptGatewayConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => { + events.push("gateway"); + return { config: cfg, port: 18789 }; + }); + mocks.setupChannels.mockImplementationOnce(async (cfg: OpenClawConfig) => { + events.push("channels"); + return cfg; + }); + for (let index = 0; index < 3; index += 1) { + mocks.writeConfigFile.mockImplementationOnce(async () => { + events.push("commit"); + }); + } + + await runConfigureWizard({ command: "configure" }, createRuntime()); + + expect(events).toEqual(["model", "commit", "gateway", "commit", "channels", "commit"]); + expect(mocks.writeConfigFile).toHaveBeenCalledTimes(3); + }); + + it("commits selected gateway config before installing its configured daemon port", async () => { + setupBaseWizardState(); + queueWizardPrompts({ select: ["local"], confirm: [] }); + const events: string[] = []; + mocks.promptGatewayConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => { + events.push("gateway"); + return { config: cfg, port: 18991 }; + }); + mocks.writeConfigFile.mockImplementationOnce(async () => { + events.push("commit"); + }); + vi.mocked(maybeInstallDaemon).mockImplementationOnce(async () => { + events.push("daemon"); + return "succeeded"; + }); + + await runConfigureWizard( + { command: "configure", sections: ["daemon", "gateway"] }, + createRuntime(), + ); + + expect(events).toEqual(["gateway", "commit", "daemon"]); + expect(maybeInstallDaemon).toHaveBeenCalledWith(expect.objectContaining({ port: 18991 })); + expect(mocks.clackText).not.toHaveBeenCalled(); + }); + + it("keeps remote password health when the configured token ref is unresolved", async () => { + const remotePassword = "remote-password"; // pragma: allowlist secret + const remoteConfig: OpenClawConfig = { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example.test", + token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, + password: remotePassword, + }, + }, + secrets: { providers: { default: { source: "env" } } }, + }; + setupBaseWizardState(remoteConfig); + queueWizardPrompts({ select: ["remote"], confirm: [] }); + mocks.promptRemoteGatewayConfig.mockResolvedValueOnce(remoteConfig); + + await runConfigureWizard({ command: "configure", sections: ["health"] }, createRuntime()); + + expect(mocks.healthCommand).toHaveBeenCalledWith( + expect.objectContaining({ + config: remoteConfig, + token: undefined, + password: remotePassword, + ignoreEnvUrlOverride: true, + }), + expect.anything(), + ); + }); + + it.each([false, true])("reports failed remote health checks (reachable: %s)", async (probeOk) => { + setupBaseWizardState(); + queueWizardPrompts({ select: ["remote"], confirm: [] }); + mocks.waitForGatewayReachable.mockResolvedValueOnce({ ok: probeOk }); + mocks.healthCommand.mockRejectedValueOnce(new Error("health request failed")); + + await runConfigureWizard({ command: "configure", sections: ["health"] }, createRuntime()); + + expect(mocks.clackOutro).toHaveBeenCalledWith(expect.stringContaining("health check failed")); + }); + + it("skips remote health when a configured SecretRef is unresolved", async () => { + const unresolvedConfig: OpenClawConfig = { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example.test", + token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, + }, + }, + secrets: { providers: { default: { source: "env" } } }, + }; + setupBaseWizardState(unresolvedConfig); + queueWizardPrompts({ select: ["remote"], confirm: [] }); + mocks.promptRemoteGatewayConfig.mockResolvedValueOnce(unresolvedConfig); + await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "ambient-password" }, async () => { + await runConfigureWizard({ command: "configure", sections: ["health"] }, createRuntime()); + }); + + const authNote = mocks.note.mock.calls.find(([, title]) => title === "Gateway auth")?.[0]; + expect(authNote).toContain("Health check skipped"); + expect(mocks.healthCommand).not.toHaveBeenCalled(); + expect(mocks.clackOutro).toHaveBeenCalledWith( + "Remote gateway configured; health check skipped.", + ); + }); + + it("persists gateway.mode=local when only the run mode is selected", async () => { + setupBaseWizardState(); + queueWizardPrompts({ + select: ["local", "__continue"], + confirm: [false], + }); + + await runConfigureWizard({ command: "configure" }, createRuntime()); + + expect(getGateway(requireWriteConfig()).mode).toBe("local"); + const replaceParams = requireRecord( + mockCallArg(mocks.replaceConfigFile, "replaceConfigFile"), + "replace config params", + ); + const writeOptions = requireRecord(replaceParams.writeOptions, "write options"); + expect(Object.keys(writeOptions).toSorted()).toEqual([ + "assertConfigPathForWrite", + "expectedConfigPath", + "ownedConfigPathForWrite", + ]); + }); + + it("persists edge auth returned by the shared remote Gateway prompt", async () => { + const remoteConfig: OpenClawConfig = { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example.test", + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }, + }, + }; + setupBaseWizardState(remoteConfig); + queueWizardPrompts({ select: ["remote"], confirm: [] }); + mocks.promptRemoteGatewayConfig.mockResolvedValueOnce(remoteConfig); + + await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); + + const remote = requireRecord(getGateway(requireWriteConfig()).remote, "remote config"); + expect(remote.edgeAuth).toEqual({ + "X-Edge-Auth": "test-secret", + }); + }); + + it("keeps startup gateway hint probes bounded", async () => { + setupBaseWizardState({ + gateway: { + mode: "local", + remote: { + url: "wss://gateway.example.test", + token: "token", + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }, + }, + }); + await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "env-password" }, async () => { + await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); + }); + + const probeRequests = mocks.probeGatewayReachable.mock.calls.map(([request]) => + requireRecord(request, "probe request"), + ); + const localProbe = probeRequests.find((request) => request.url === "ws://127.0.0.1:18789"); + const remoteProbe = probeRequests.find( + (request) => request.url === "wss://gateway.example.test", + ); + expect(localProbe?.timeoutMs).toBe(300); + expect(remoteProbe).toEqual({ + url: "wss://gateway.example.test", + config: expect.objectContaining({ + gateway: expect.objectContaining({ + remote: expect.objectContaining({ + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }), + }), + }), + token: "token", + timeoutMs: 300, + }); + }); + + it("ignores blank gateway env credentials when probing the local gateway", async () => { + setupBaseWizardState({ + gateway: { + mode: "local", + auth: { token: "configured-token", password: "configured-password" }, + }, + }); + process.env.OPENCLAW_GATEWAY_TOKEN = ""; + process.env.OPENCLAW_GATEWAY_PASSWORD = ""; + try { + await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); + } finally { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + } + + const probeRequests = mocks.probeGatewayReachable.mock.calls.map(([request]) => + requireRecord(request, "probe request"), + ); + const localProbe = probeRequests.find((request) => request.url === "ws://127.0.0.1:18789"); + expect(localProbe?.token).toBe("configured-token"); + expect(localProbe?.password).toBe("configured-password"); + }); + + it("uses the resolved configured port for the local gateway startup hint", async () => { + setupBaseWizardState({ + gateway: { + mode: "local", + port: 18991, + }, + }); + mocks.resolveGatewayPort.mockReturnValue(18991); + mocks.probeGatewayReachable + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValue({ ok: false }); + mocks.clackSelect.mockResolvedValue("local"); + + await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); + + expect(mocks.probeGatewayReachable).toHaveBeenCalledWith( + expect.objectContaining({ url: "ws://127.0.0.1:18991", timeoutMs: 300 }), + ); + expect(mocks.clackSelect).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Where will the Gateway run?", + options: expect.arrayContaining([ + expect.objectContaining({ + value: "local", + hint: "Gateway reachable (ws://127.0.0.1:18991)", + }), + ]), + }), + ); + }); + + it("advertises LAN Control UI links while probing the local gateway", async () => { + setupBaseWizardState({ + gateway: { + mode: "local", + bind: "lan", + auth: { token: "token" }, + }, + }); + mocks.resolveAdvertisedControlUiLinks.mockResolvedValueOnce({ + httpUrl: "http://10.211.55.3:18789/", + wsUrl: "ws://10.211.55.3:18789", + }); + mocks.resolveLocalControlUiProbeLinks.mockReturnValueOnce({ + httpUrl: "http://127.0.0.1:18789/", + wsUrl: "ws://127.0.0.1:18789", + }); + await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); + + expect(mocks.resolveAdvertisedControlUiLinks).toHaveBeenCalledWith( + expect.objectContaining({ bind: "lan", port: 18789 }), + ); + expect(mocks.probeGatewayReachable).toHaveBeenCalledWith( + expect.objectContaining({ url: "ws://127.0.0.1:18789" }), + ); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("Web UI: http://10.211.55.3:18789/"), + "Control UI", + ); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("Gateway WS: ws://10.211.55.3:18789"), + "Control UI", + ); + }); + + it("shows static Windows Firewall guidance for LAN Gateway links without inspection", async () => { + setupBaseWizardState({ + gateway: { + mode: "local", + bind: "lan", + auth: { token: "token" }, + }, + }); + + await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); + + expect(mocks.inspectWindowsGatewayFirewall).not.toHaveBeenCalled(); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("Windows firewall: if another device cannot connect to the LAN URL"), + "Control UI", + ); + }); + + it("exits with code 1 when configure wizard is cancelled", async () => { + const runtime = createRuntime(); + setupBaseWizardState(); + mocks.clackSelect.mockRejectedValueOnce(new WizardCancelledError()); + + await runConfigureWizard({ command: "configure" }, runtime); + + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("uses nonzero exit semantics for cancellation at the first direct Clack prompt", async () => { + const runtime = createRuntime(); + setupBaseWizardState(); + mocks.guardCancel.mockImplementationOnce( + (_value: unknown, promptRuntime: RuntimeEnv, exitCode?: number) => { + promptRuntime.exit(exitCode ?? 0); + throw new Error("direct prompt cancelled"); + }, + ); + + await expect(runConfigureWizard({ command: "configure" }, runtime)).rejects.toThrow( + "direct prompt cancelled", + ); + + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("does not gate model-only configure behind Gateway run-mode selection", async () => { + setupBaseWizardState(); + + await runConfigureWizard({ command: "configure", sections: ["model"] }, createRuntime()); + + expect(mocks.promptAuthConfig).toHaveBeenCalledOnce(); + expect(mocks.clackSelect).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Where will the Gateway run?" }), + ); + expect(mocks.probeGatewayReachable).not.toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 300 }), + ); + expect(mocks.resolveControlUiLinks).not.toHaveBeenCalled(); + expect(requireWriteConfig().gateway).toBeUndefined(); + }); + + it("runs model-only configure for existing remote Gateway configs", async () => { + setupBaseWizardState({ + gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } }, + }); + + await runConfigureWizard({ command: "configure", sections: ["model"] }, createRuntime()); + + expect(mocks.promptAuthConfig).toHaveBeenCalledOnce(); + expect(mocks.promptRemoteGatewayConfig).not.toHaveBeenCalled(); + expect(getGateway(requireWriteConfig()).mode).toBe("remote"); + expect(mocks.resolveControlUiLinks).not.toHaveBeenCalled(); + expect(mocks.probeGatewayReachable).not.toHaveBeenCalled(); + expect(mocks.note).toHaveBeenCalledWith( + [ + "Remote Gateway:", + "wss://gateway.example.test", + "Docs: https://docs.openclaw.ai/gateway/remote", + ].join("\n"), + "Gateway", + ); + }); +}); diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index d31c3364cd3b..1594b866bd73 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -3,7 +3,6 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; -import { withEnvAsync } from "../test-utils/env.js"; import { createEnabledWebSearchConfig, createSearchProviderOption, @@ -226,8 +225,6 @@ vi.mock("../config/mutate.js", async () => { }); import { ConfigMutationConflictError } from "../config/mutate.js"; -import { WizardCancelledError } from "../wizard/prompts.js"; -import { maybeInstallDaemon } from "./configure.daemon.js"; import { runConfigureWizard } from "./configure.wizard.js"; const createRuntime = createWizardTestRuntime; @@ -257,10 +254,6 @@ function requireWriteConfig(callIndex = 0) { ); } -function getGateway(config: Record) { - return requireRecord(config.gateway, "gateway config"); -} - function getWebSearch(config: Record) { const tools = requireRecord(config.tools, "tools config"); const web = requireRecord(tools.web, "web config"); @@ -315,382 +308,6 @@ describe("runConfigureWizard", () => { mocks.guardCancel.mockImplementation((value: unknown) => value); }); - it("runs selected sections in canonical order and commits their combined config once", async () => { - setupBaseWizardState(); - queueWizardPrompts({ select: ["local", "configure"], confirm: [] }); - const events: string[] = []; - mocks.promptAuthConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => { - events.push("model"); - return cfg; - }); - mocks.promptGatewayConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => { - events.push("gateway"); - return { config: cfg, port: 18789 }; - }); - mocks.setupChannels.mockImplementationOnce(async (cfg: OpenClawConfig) => { - events.push("channels"); - return cfg; - }); - mocks.writeConfigFile.mockImplementationOnce(async () => { - events.push("commit"); - }); - - await runConfigureWizard( - { command: "configure", sections: ["channels", "gateway", "model"] }, - createRuntime(), - ); - - expect(events).toEqual(["model", "gateway", "channels", "commit"]); - expect(mocks.writeConfigFile).toHaveBeenCalledOnce(); - }); - - it("commits every interactive section before running the next section", async () => { - setupBaseWizardState(); - queueWizardPrompts({ - select: ["local", "model", "gateway", "channels", "configure", "__continue"], - confirm: [], - }); - const events: string[] = []; - mocks.promptAuthConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => { - events.push("model"); - return cfg; - }); - mocks.promptGatewayConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => { - events.push("gateway"); - return { config: cfg, port: 18789 }; - }); - mocks.setupChannels.mockImplementationOnce(async (cfg: OpenClawConfig) => { - events.push("channels"); - return cfg; - }); - for (let index = 0; index < 3; index += 1) { - mocks.writeConfigFile.mockImplementationOnce(async () => { - events.push("commit"); - }); - } - - await runConfigureWizard({ command: "configure" }, createRuntime()); - - expect(events).toEqual(["model", "commit", "gateway", "commit", "channels", "commit"]); - expect(mocks.writeConfigFile).toHaveBeenCalledTimes(3); - }); - - it("commits selected gateway config before installing its configured daemon port", async () => { - setupBaseWizardState(); - queueWizardPrompts({ select: ["local"], confirm: [] }); - const events: string[] = []; - mocks.promptGatewayConfig.mockImplementationOnce(async (cfg: OpenClawConfig) => { - events.push("gateway"); - return { config: cfg, port: 18991 }; - }); - mocks.writeConfigFile.mockImplementationOnce(async () => { - events.push("commit"); - }); - vi.mocked(maybeInstallDaemon).mockImplementationOnce(async () => { - events.push("daemon"); - return "succeeded"; - }); - - await runConfigureWizard( - { command: "configure", sections: ["daemon", "gateway"] }, - createRuntime(), - ); - - expect(events).toEqual(["gateway", "commit", "daemon"]); - expect(maybeInstallDaemon).toHaveBeenCalledWith(expect.objectContaining({ port: 18991 })); - expect(mocks.clackText).not.toHaveBeenCalled(); - }); - - it("keeps remote password health when the configured token ref is unresolved", async () => { - const remotePassword = "remote-password"; // pragma: allowlist secret - const remoteConfig: OpenClawConfig = { - gateway: { - mode: "remote", - remote: { - url: "wss://gateway.example.test", - token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, - password: remotePassword, - }, - }, - secrets: { providers: { default: { source: "env" } } }, - }; - setupBaseWizardState(remoteConfig); - queueWizardPrompts({ select: ["remote"], confirm: [] }); - mocks.promptRemoteGatewayConfig.mockResolvedValueOnce(remoteConfig); - - await runConfigureWizard({ command: "configure", sections: ["health"] }, createRuntime()); - - expect(mocks.healthCommand).toHaveBeenCalledWith( - expect.objectContaining({ - config: remoteConfig, - token: undefined, - password: remotePassword, - ignoreEnvUrlOverride: true, - }), - expect.anything(), - ); - }); - - it.each([false, true])("reports failed remote health checks (reachable: %s)", async (probeOk) => { - setupBaseWizardState(); - queueWizardPrompts({ select: ["remote"], confirm: [] }); - mocks.waitForGatewayReachable.mockResolvedValueOnce({ ok: probeOk }); - mocks.healthCommand.mockRejectedValueOnce(new Error("health request failed")); - - await runConfigureWizard({ command: "configure", sections: ["health"] }, createRuntime()); - - expect(mocks.clackOutro).toHaveBeenCalledWith(expect.stringContaining("health check failed")); - }); - - it("skips remote health when a configured SecretRef is unresolved", async () => { - const unresolvedConfig: OpenClawConfig = { - gateway: { - mode: "remote", - remote: { - url: "wss://gateway.example.test", - token: { source: "env", provider: "default", id: "MISSING_REMOTE_TOKEN" }, - }, - }, - secrets: { providers: { default: { source: "env" } } }, - }; - setupBaseWizardState(unresolvedConfig); - queueWizardPrompts({ select: ["remote"], confirm: [] }); - mocks.promptRemoteGatewayConfig.mockResolvedValueOnce(unresolvedConfig); - await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "ambient-password" }, async () => { - await runConfigureWizard({ command: "configure", sections: ["health"] }, createRuntime()); - }); - - const authNote = mocks.note.mock.calls.find(([, title]) => title === "Gateway auth")?.[0]; - expect(authNote).toContain("Health check skipped"); - expect(mocks.healthCommand).not.toHaveBeenCalled(); - expect(mocks.clackOutro).toHaveBeenCalledWith( - "Remote gateway configured; health check skipped.", - ); - }); - - it("persists gateway.mode=local when only the run mode is selected", async () => { - setupBaseWizardState(); - queueWizardPrompts({ - select: ["local", "__continue"], - confirm: [false], - }); - - await runConfigureWizard({ command: "configure" }, createRuntime()); - - expect(getGateway(requireWriteConfig()).mode).toBe("local"); - const replaceParams = requireRecord( - mockCallArg(mocks.replaceConfigFile, "replaceConfigFile"), - "replace config params", - ); - const writeOptions = requireRecord(replaceParams.writeOptions, "write options"); - expect(Object.keys(writeOptions).toSorted()).toEqual([ - "assertConfigPathForWrite", - "expectedConfigPath", - "ownedConfigPathForWrite", - ]); - }); - it("keeps startup gateway hint probes bounded", async () => { - setupBaseWizardState({ - gateway: { - mode: "local", - remote: { - url: "wss://gateway.example.test", - token: "token", - }, - }, - }); - await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "env-password" }, async () => { - await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); - }); - - const probeRequests = mocks.probeGatewayReachable.mock.calls.map(([request]) => - requireRecord(request, "probe request"), - ); - const localProbe = probeRequests.find((request) => request.url === "ws://127.0.0.1:18789"); - const remoteProbe = probeRequests.find( - (request) => request.url === "wss://gateway.example.test", - ); - expect(localProbe?.timeoutMs).toBe(300); - expect(remoteProbe).toEqual({ - url: "wss://gateway.example.test", - token: "token", - timeoutMs: 300, - }); - }); - - it("ignores blank gateway env credentials when probing the local gateway", async () => { - setupBaseWizardState({ - gateway: { - mode: "local", - auth: { token: "configured-token", password: "configured-password" }, - }, - }); - process.env.OPENCLAW_GATEWAY_TOKEN = ""; - process.env.OPENCLAW_GATEWAY_PASSWORD = ""; - try { - await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); - } finally { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_PASSWORD; - } - - const probeRequests = mocks.probeGatewayReachable.mock.calls.map(([request]) => - requireRecord(request, "probe request"), - ); - const localProbe = probeRequests.find((request) => request.url === "ws://127.0.0.1:18789"); - expect(localProbe?.token).toBe("configured-token"); - expect(localProbe?.password).toBe("configured-password"); - }); - - it("uses the resolved configured port for the local gateway startup hint", async () => { - setupBaseWizardState({ - gateway: { - mode: "local", - port: 18991, - }, - }); - mocks.resolveGatewayPort.mockReturnValue(18991); - mocks.probeGatewayReachable - .mockResolvedValueOnce({ ok: true }) - .mockResolvedValue({ ok: false }); - mocks.clackSelect.mockResolvedValue("local"); - - await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); - - expect(mocks.probeGatewayReachable).toHaveBeenCalledWith( - expect.objectContaining({ url: "ws://127.0.0.1:18991", timeoutMs: 300 }), - ); - expect(mocks.clackSelect).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Where will the Gateway run?", - options: expect.arrayContaining([ - expect.objectContaining({ - value: "local", - hint: "Gateway reachable (ws://127.0.0.1:18991)", - }), - ]), - }), - ); - }); - - it("advertises LAN Control UI links while probing the local gateway", async () => { - setupBaseWizardState({ - gateway: { - mode: "local", - bind: "lan", - auth: { token: "token" }, - }, - }); - mocks.resolveAdvertisedControlUiLinks.mockResolvedValueOnce({ - httpUrl: "http://10.211.55.3:18789/", - wsUrl: "ws://10.211.55.3:18789", - }); - mocks.resolveLocalControlUiProbeLinks.mockReturnValueOnce({ - httpUrl: "http://127.0.0.1:18789/", - wsUrl: "ws://127.0.0.1:18789", - }); - await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); - - expect(mocks.resolveAdvertisedControlUiLinks).toHaveBeenCalledWith( - expect.objectContaining({ bind: "lan", port: 18789 }), - ); - expect(mocks.probeGatewayReachable).toHaveBeenCalledWith( - expect.objectContaining({ url: "ws://127.0.0.1:18789" }), - ); - expect(mocks.note).toHaveBeenCalledWith( - expect.stringContaining("Web UI: http://10.211.55.3:18789/"), - "Control UI", - ); - expect(mocks.note).toHaveBeenCalledWith( - expect.stringContaining("Gateway WS: ws://10.211.55.3:18789"), - "Control UI", - ); - }); - - it("shows static Windows Firewall guidance for LAN Gateway links without inspection", async () => { - setupBaseWizardState({ - gateway: { - mode: "local", - bind: "lan", - auth: { token: "token" }, - }, - }); - - await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); - - expect(mocks.inspectWindowsGatewayFirewall).not.toHaveBeenCalled(); - expect(mocks.note).toHaveBeenCalledWith( - expect.stringContaining("Windows firewall: if another device cannot connect to the LAN URL"), - "Control UI", - ); - }); - - it("exits with code 1 when configure wizard is cancelled", async () => { - const runtime = createRuntime(); - setupBaseWizardState(); - mocks.clackSelect.mockRejectedValueOnce(new WizardCancelledError()); - - await runConfigureWizard({ command: "configure" }, runtime); - - expect(runtime.exit).toHaveBeenCalledWith(1); - }); - - it("uses nonzero exit semantics for cancellation at the first direct Clack prompt", async () => { - const runtime = createRuntime(); - setupBaseWizardState(); - mocks.guardCancel.mockImplementationOnce( - (_value: unknown, promptRuntime: RuntimeEnv, exitCode?: number) => { - promptRuntime.exit(exitCode ?? 0); - throw new Error("direct prompt cancelled"); - }, - ); - - await expect(runConfigureWizard({ command: "configure" }, runtime)).rejects.toThrow( - "direct prompt cancelled", - ); - - expect(runtime.exit).toHaveBeenCalledWith(1); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); - }); - - it("does not gate model-only configure behind Gateway run-mode selection", async () => { - setupBaseWizardState(); - - await runConfigureWizard({ command: "configure", sections: ["model"] }, createRuntime()); - - expect(mocks.promptAuthConfig).toHaveBeenCalledOnce(); - expect(mocks.clackSelect).not.toHaveBeenCalledWith( - expect.objectContaining({ message: "Where will the Gateway run?" }), - ); - expect(mocks.probeGatewayReachable).not.toHaveBeenCalledWith( - expect.objectContaining({ timeoutMs: 300 }), - ); - expect(mocks.resolveControlUiLinks).not.toHaveBeenCalled(); - expect(requireWriteConfig().gateway).toBeUndefined(); - }); - - it("runs model-only configure for existing remote Gateway configs", async () => { - setupBaseWizardState({ - gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } }, - }); - - await runConfigureWizard({ command: "configure", sections: ["model"] }, createRuntime()); - - expect(mocks.promptAuthConfig).toHaveBeenCalledOnce(); - expect(mocks.promptRemoteGatewayConfig).not.toHaveBeenCalled(); - expect(getGateway(requireWriteConfig()).mode).toBe("remote"); - expect(mocks.resolveControlUiLinks).not.toHaveBeenCalled(); - expect(mocks.probeGatewayReachable).not.toHaveBeenCalled(); - expect(mocks.note).toHaveBeenCalledWith( - [ - "Remote Gateway:", - "wss://gateway.example.test", - "Docs: https://docs.openclaw.ai/gateway/remote", - ].join("\n"), - "Gateway", - ); - }); - it("persists provider-owned web search config changes returned by setupSearch", async () => { setupBaseWizardState(); mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => { diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 71654cab38d3..4293c5408920 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -527,6 +527,7 @@ export async function runConfigureWizard( }); return probeGatewayReachable({ url: remoteUrl, + ...(baseConfig.gateway?.remote?.edgeAuth ? { config: baseConfig } : {}), token: remoteProbeAuth.auth.token, ...(remoteProbeAuth.auth.password ? { password: remoteProbeAuth.auth.password } : {}), timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS, diff --git a/src/commands/gateway-status/probe-run.ts b/src/commands/gateway-status/probe-run.ts index f5e39d6b801f..675190757128 100644 --- a/src/commands/gateway-status/probe-run.ts +++ b/src/commands/gateway-status/probe-run.ts @@ -131,6 +131,7 @@ export async function runGatewayStatusProbePass(params: { }); const probe = await probeGateway({ url: target.url, + config: params.cfg, // Explicit, configured-remote, and SSH targets must not inherit the // local Gateway's device token, even when the transport is loopback. ...(target.kind === "sshTunnel" diff --git a/src/commands/onboard-helpers.remote-probe.test.ts b/src/commands/onboard-helpers.remote-probe.test.ts new file mode 100644 index 000000000000..28a68944b552 --- /dev/null +++ b/src/commands/onboard-helpers.remote-probe.test.ts @@ -0,0 +1,306 @@ +// Onboarding Gateway probe tests cover reachability and configured-model classification. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { probeGatewayConfiguredModel, probeGatewayReachable } from "./onboard-helpers.js"; + +const mocks = vi.hoisted(() => ({ probeGateway: vi.fn() })); + +vi.mock("../gateway/probe.js", () => ({ probeGateway: mocks.probeGateway })); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("probeGatewayReachable", () => { + it("uses a hello-only probe for onboarding reachability", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: true, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 42, + error: null, + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + const result = await probeGatewayReachable({ + url: "ws://127.0.0.1:18789", + token: "tok_test", + timeoutMs: 2500, + }); + + expect(result).toEqual({ ok: true }); + expect(mocks.probeGateway).toHaveBeenCalledWith({ + url: "ws://127.0.0.1:18789", + timeoutMs: 2500, + auth: { + token: "tok_test", + password: undefined, + }, + detailLevel: "none", + }); + }); + + it("forwards configured remote edge auth to the gateway probe", async () => { + mocks.probeGateway.mockResolvedValueOnce({ ok: true, configSnapshot: null }); + const config: OpenClawConfig = { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example", + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }, + }, + }; + + await expect(probeGatewayReachable({ url: "wss://gateway.example", config })).resolves.toEqual({ + ok: true, + }); + expect(mocks.probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ url: "wss://gateway.example", config }), + ); + }); + + it("returns the probe error detail on failure", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connect failed: timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + const result = await probeGatewayReachable({ + url: "ws://127.0.0.1:18789", + }); + + expect(result).toEqual({ + ok: false, + detail: "connect failed: timeout", + }); + }); + + it("bounds thrown probe errors without splitting UTF-16", async () => { + const detail = `${"x".repeat(118)}…`; + const params = { url: "ws://127.0.0.1:18789" }; + mocks.probeGateway.mockRejectedValue(new Error(`${"x".repeat(118)}🚀tail\nignored`)); + expect(await probeGatewayReachable(params)).toEqual({ ok: false, detail }); + expect(await probeGatewayConfiguredModel(params)).toEqual({ kind: "unreachable", detail }); + }); + + it("forwards a configured TLS fingerprint to the gateway probe", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: true, + configSnapshot: null, + }); + + await expect( + probeGatewayReachable({ + url: "wss://gateway.example.com:18789", + tlsFingerprint: "sha256:11:22:33:44", + }), + ).resolves.toEqual({ ok: true }); + + expect(mocks.probeGateway).toHaveBeenCalledWith({ + url: "wss://gateway.example.com:18789", + timeoutMs: 1500, + auth: { + token: undefined, + password: undefined, + }, + tlsFingerprint: "sha256:11:22:33:44", + detailLevel: "none", + }); + }); + + it("lets a configured preauth handshake timeout widen the default probe budget", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: true, + configSnapshot: null, + }); + + await expect( + probeGatewayReachable({ + url: "wss://gateway.example.com:18789", + preauthHandshakeTimeoutMs: 30_000, + }), + ).resolves.toEqual({ ok: true }); + + expect(mocks.probeGateway).toHaveBeenCalledWith({ + url: "wss://gateway.example.com:18789", + timeoutMs: 30_000, + auth: { + token: undefined, + password: undefined, + }, + preauthHandshakeTimeoutMs: 30_000, + detailLevel: "none", + }); + }); + + it("classifies configured and missing default-agent models from config-only probes", async () => { + mocks.probeGateway + .mockResolvedValueOnce({ + ok: true, + server: { version: "2026.7.2", connId: "conn-configured" }, + configSnapshot: { + valid: true, + config: { agents: { list: [{ id: "work", default: true, model: "openai/gpt-5.5" }] } }, + }, + }) + .mockResolvedValueOnce({ + ok: true, + server: { version: "2026.7.2", connId: "conn-missing" }, + configSnapshot: { valid: true, config: { gateway: { mode: "local" } } }, + }); + + await expect( + probeGatewayConfiguredModel({ + url: "ws://127.0.0.1:18789", + }), + ).resolves.toEqual({ kind: "configured" }); + await expect( + probeGatewayConfiguredModel({ + url: "ws://127.0.0.1:18789", + }), + ).resolves.toEqual({ + kind: "missing-configured-model", + detail: "Gateway default agent has no configured model", + }); + expect(mocks.probeGateway).toHaveBeenCalledWith( + expect.objectContaining({ detailLevel: "config" }), + ); + }); + + it("keeps post-Hello config read failures on the reachable Gateway path", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + connectLatencyMs: 42, + error: "config.get: unauthorized", + auth: { role: null, scopes: [], capability: "unknown" }, + server: { version: "2026.7.2", connId: "conn-1" }, + }); + + await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ + kind: "reachable-unverified", + detail: "config.get: unauthorized", + }); + }); + + it("keeps typed pre-Hello Gateway auth failures on the reachable path", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + connectLatencyMs: 42, + error: "device pairing required", + connectErrorDetails: { code: ConnectErrorDetailCodes.PAIRING_REQUIRED }, + auth: { role: null, scopes: [], capability: "pairing_pending" }, + server: { version: null, connId: null }, + }); + + await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ + kind: "reachable-unverified", + detail: "device pairing required", + }); + }); + + it("does not mistake an arbitrary open WebSocket for a Gateway", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + connectLatencyMs: 42, + error: "websocket closed", + auth: { role: null, scopes: [], capability: "unknown" }, + server: { version: null, connId: null }, + }); + + await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ + kind: "unreachable", + detail: "websocket closed", + }); + }); + + it("does not trust an unrecognized connect error code as Gateway evidence", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + connectLatencyMs: 42, + error: "foreign protocol error", + connectErrorDetails: { code: "NOT_AN_OPENCLAW_CONNECT_ERROR" }, + auth: { role: null, scopes: [], capability: "unknown" }, + server: { version: null, connId: null }, + }); + + await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ + kind: "unreachable", + detail: "foreign protocol error", + }); + }); + + it("does not trust a config-shaped response without Gateway handshake evidence", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: true, + connectLatencyMs: 42, + error: null, + auth: { role: null, scopes: [], capability: "unknown" }, + server: { version: "foreign-server", connId: null }, + configSnapshot: { + valid: true, + config: { agents: { defaults: { model: "openai/foreign-model" } } }, + }, + }); + + await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ + kind: "unreachable", + }); + }); + + it("keeps a first-time connect-only auth result on the reachable Gateway path", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + connectLatencyMs: 42, + error: "missing scope: operator.read", + auth: { role: "operator", scopes: [], capability: "connected_no_operator_scope" }, + server: { version: "2026.7.2", connId: "conn-1" }, + }); + + await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ + kind: "reachable-unverified", + detail: "missing scope: operator.read", + }); + }); + + it("treats an invalid config snapshot as reachable but unverified", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: true, + connectLatencyMs: 42, + auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" }, + server: { version: "2026.7.2", connId: "conn-1" }, + configSnapshot: { valid: false }, + }); + + await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ + kind: "reachable-unverified", + detail: "Gateway returned an invalid config snapshot", + }); + }); + + it("distinguishes pre-Hello connection failures from reachable Gateway failures", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: false, + connectLatencyMs: null, + error: "connect failed: timeout", + auth: { role: null, scopes: [], capability: "unknown" }, + server: { version: null, connId: null }, + }); + + await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ + kind: "unreachable", + detail: "connect failed: timeout", + }); + }); +}); diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 28170379d6f0..7988ff8c1039 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -4,7 +4,6 @@ import fsPromises from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ConnectErrorDetailCodes } from "../../packages/gateway-protocol/src/connect-error-details.js"; import { stripAnsi } from "../../packages/terminal-core/src/ansi.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { SpawnResult } from "../process/exec-result.js"; @@ -17,8 +16,6 @@ import { normalizeGatewayTokenInput, openUrl, printWizardHeader, - probeGatewayConfiguredModel, - probeGatewayReachable, resolveBrowserOpenCommand, resolveAdvertisedControlUiLinks, resolveControlUiLinks, @@ -545,279 +542,6 @@ describe("formatControlUiSshHint", () => { }); }); -describe("probeGatewayReachable", () => { - it("uses a hello-only probe for onboarding reachability", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: true, - url: "ws://127.0.0.1:18789", - connectLatencyMs: 42, - error: null, - close: null, - health: null, - status: null, - presence: null, - configSnapshot: null, - }); - - const result = await probeGatewayReachable({ - url: "ws://127.0.0.1:18789", - token: "tok_test", - timeoutMs: 2500, - }); - - expect(result).toEqual({ ok: true }); - expect(mocks.probeGateway).toHaveBeenCalledWith({ - url: "ws://127.0.0.1:18789", - timeoutMs: 2500, - auth: { - token: "tok_test", - password: undefined, - }, - detailLevel: "none", - }); - }); - - it("returns the probe error detail on failure", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - url: "ws://127.0.0.1:18789", - connectLatencyMs: null, - error: "connect failed: timeout", - close: null, - health: null, - status: null, - presence: null, - configSnapshot: null, - }); - - const result = await probeGatewayReachable({ - url: "ws://127.0.0.1:18789", - }); - - expect(result).toEqual({ - ok: false, - detail: "connect failed: timeout", - }); - }); - - it("bounds thrown probe errors without splitting UTF-16", async () => { - const detail = `${"x".repeat(118)}…`; - const params = { url: "ws://127.0.0.1:18789" }; - mocks.probeGateway.mockRejectedValue(new Error(`${"x".repeat(118)}🚀tail\nignored`)); - expect(await probeGatewayReachable(params)).toEqual({ ok: false, detail }); - expect(await probeGatewayConfiguredModel(params)).toEqual({ kind: "unreachable", detail }); - }); - - it("forwards a configured TLS fingerprint to the gateway probe", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: true, - configSnapshot: null, - }); - - await expect( - probeGatewayReachable({ - url: "wss://gateway.example.com:18789", - tlsFingerprint: "sha256:11:22:33:44", - }), - ).resolves.toEqual({ ok: true }); - - expect(mocks.probeGateway).toHaveBeenCalledWith({ - url: "wss://gateway.example.com:18789", - timeoutMs: 1500, - auth: { - token: undefined, - password: undefined, - }, - tlsFingerprint: "sha256:11:22:33:44", - detailLevel: "none", - }); - }); - - it("lets a configured preauth handshake timeout widen the default probe budget", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: true, - configSnapshot: null, - }); - - await expect( - probeGatewayReachable({ - url: "wss://gateway.example.com:18789", - preauthHandshakeTimeoutMs: 30_000, - }), - ).resolves.toEqual({ ok: true }); - - expect(mocks.probeGateway).toHaveBeenCalledWith({ - url: "wss://gateway.example.com:18789", - timeoutMs: 30_000, - auth: { - token: undefined, - password: undefined, - }, - preauthHandshakeTimeoutMs: 30_000, - detailLevel: "none", - }); - }); - - it("classifies configured and missing default-agent models from config-only probes", async () => { - mocks.probeGateway - .mockResolvedValueOnce({ - ok: true, - server: { version: "2026.7.2", connId: "conn-configured" }, - configSnapshot: { - valid: true, - config: { agents: { list: [{ id: "work", default: true, model: "openai/gpt-5.5" }] } }, - }, - }) - .mockResolvedValueOnce({ - ok: true, - server: { version: "2026.7.2", connId: "conn-missing" }, - configSnapshot: { valid: true, config: { gateway: { mode: "local" } } }, - }); - - await expect( - probeGatewayConfiguredModel({ - url: "ws://127.0.0.1:18789", - }), - ).resolves.toEqual({ kind: "configured" }); - await expect( - probeGatewayConfiguredModel({ - url: "ws://127.0.0.1:18789", - }), - ).resolves.toEqual({ - kind: "missing-configured-model", - detail: "Gateway default agent has no configured model", - }); - expect(mocks.probeGateway).toHaveBeenCalledWith( - expect.objectContaining({ detailLevel: "config" }), - ); - }); - - it("keeps post-Hello config read failures on the reachable Gateway path", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - connectLatencyMs: 42, - error: "config.get: unauthorized", - auth: { role: null, scopes: [], capability: "unknown" }, - server: { version: "2026.7.2", connId: "conn-1" }, - }); - - await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ - kind: "reachable-unverified", - detail: "config.get: unauthorized", - }); - }); - - it("keeps typed pre-Hello Gateway auth failures on the reachable path", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - connectLatencyMs: 42, - error: "device pairing required", - connectErrorDetails: { code: ConnectErrorDetailCodes.PAIRING_REQUIRED }, - auth: { role: null, scopes: [], capability: "pairing_pending" }, - server: { version: null, connId: null }, - }); - - await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ - kind: "reachable-unverified", - detail: "device pairing required", - }); - }); - - it("does not mistake an arbitrary open WebSocket for a Gateway", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - connectLatencyMs: 42, - error: "websocket closed", - auth: { role: null, scopes: [], capability: "unknown" }, - server: { version: null, connId: null }, - }); - - await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ - kind: "unreachable", - detail: "websocket closed", - }); - }); - - it("does not trust an unrecognized connect error code as Gateway evidence", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - connectLatencyMs: 42, - error: "foreign protocol error", - connectErrorDetails: { code: "NOT_AN_OPENCLAW_CONNECT_ERROR" }, - auth: { role: null, scopes: [], capability: "unknown" }, - server: { version: null, connId: null }, - }); - - await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ - kind: "unreachable", - detail: "foreign protocol error", - }); - }); - - it("does not trust a config-shaped response without Gateway handshake evidence", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: true, - connectLatencyMs: 42, - error: null, - auth: { role: null, scopes: [], capability: "unknown" }, - server: { version: "foreign-server", connId: null }, - configSnapshot: { - valid: true, - config: { agents: { defaults: { model: "openai/foreign-model" } } }, - }, - }); - - await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ - kind: "unreachable", - }); - }); - - it("keeps a first-time connect-only auth result on the reachable Gateway path", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - connectLatencyMs: 42, - error: "missing scope: operator.read", - auth: { role: "operator", scopes: [], capability: "connected_no_operator_scope" }, - server: { version: "2026.7.2", connId: "conn-1" }, - }); - - await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ - kind: "reachable-unverified", - detail: "missing scope: operator.read", - }); - }); - - it("treats an invalid config snapshot as reachable but unverified", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: true, - connectLatencyMs: 42, - auth: { role: "operator", scopes: ["operator.read"], capability: "read_only" }, - server: { version: "2026.7.2", connId: "conn-1" }, - configSnapshot: { valid: false }, - }); - - await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ - kind: "reachable-unverified", - detail: "Gateway returned an invalid config snapshot", - }); - }); - - it("distinguishes pre-Hello connection failures from reachable Gateway failures", async () => { - mocks.probeGateway.mockResolvedValueOnce({ - ok: false, - connectLatencyMs: null, - error: "connect failed: timeout", - auth: { role: null, scopes: [], capability: "unknown" }, - server: { version: null, connId: null }, - }); - - await expect(probeGatewayConfiguredModel({ url: "ws://127.0.0.1:18789" })).resolves.toEqual({ - kind: "unreachable", - detail: "connect failed: timeout", - }); - }); -}); - describe("waitForGatewayReachable", () => { it("keeps oversized poll intervals within the overall deadline", async () => { mocks.probeGateway.mockResolvedValue({ diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 7387c89d5c4a..9e6e90d56393 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -302,6 +302,7 @@ function throwIfResetFailed(failures: string[]): void { type OnboardingGatewayProbeParams = { url: string; + config?: OpenClawConfig; token?: string; password?: string; tlsFingerprint?: string; @@ -317,6 +318,7 @@ function runOnboardingGatewayProbe( const timeoutMs = params.timeoutMs ?? Math.max(1500, params.preauthHandshakeTimeoutMs ?? 0); return probeGateway({ url, + ...(params.config ? { config: params.config } : {}), timeoutMs, auth: { token: params.token, diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts index ebe2888358c8..4a4c62d2a47b 100644 --- a/src/commands/onboard-remote.test.ts +++ b/src/commands/onboard-remote.test.ts @@ -90,6 +90,32 @@ describe("promptRemoteGatewayConfig", () => { delete process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS; }); + it.each([ + ["preserves", "wss://gateway.example/rpc", { "X-Edge-Auth": "test-secret" }], + ["clears", "wss://other.example/rpc", undefined], + ])("%s edge auth based on the remote Gateway scope", async (_label, nextUrl, expected) => { + const cfg: OpenClawConfig = { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example/rpc/", + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }, + }, + }; + const prompter = createPrompter({ + confirm: vi.fn(async () => false), + select: createSelectPrompter({ "Gateway auth": "off" }), + text: vi.fn(async (params) => + params.message === "Gateway WebSocket URL" ? nextUrl : "", + ) as WizardPrompter["text"], + }); + + const next = await promptRemoteGatewayConfig(cfg, prompter); + + expect(next.gateway?.remote?.edgeAuth).toEqual(expected); + }); + it("defaults discovered direct remote URLs to wss://", async () => { detectBinary.mockResolvedValue(true); discoverGatewayBeacons.mockResolvedValue([createGatewayDiscoveryBeacon()]); diff --git a/src/commands/onboard-remote.ts b/src/commands/onboard-remote.ts index d6120fef099f..9dca7cdf8fcc 100644 --- a/src/commands/onboard-remote.ts +++ b/src/commands/onboard-remote.ts @@ -1,4 +1,5 @@ import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/number-coercion"; +import { gatewayOriginScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; /** * Interactive remote gateway onboarding. * @@ -55,7 +56,7 @@ export function validateGatewayWebSocketUrl(value: string): string | undefined { export async function promptRemoteGatewayConfig( cfg: OpenClawConfig, prompter: WizardPrompter, - options?: { secretInputMode?: SecretInputMode }, + options?: { secretInputMode?: SecretInputMode; edgeAuthOriginUrl?: string }, ): Promise { let selectedBeacon: GatewayBonjourBeacon | null = null; let suggestedUrl = cfg.gateway?.remote?.url ?? DEFAULT_GATEWAY_URL; @@ -284,6 +285,11 @@ export async function promptRemoteGatewayConfig( token = undefined; password = undefined; } + const edgeAuthOriginUrl = options?.edgeAuthOriginUrl ?? cfg.gateway?.remote?.url; + const edgeAuth = + edgeAuthOriginUrl && gatewayOriginScope(url) === gatewayOriginScope(edgeAuthOriginUrl) + ? cfg.gateway?.remote?.edgeAuth + : undefined; return { ...cfg, @@ -292,6 +298,7 @@ export async function promptRemoteGatewayConfig( mode: "remote", remote: { url, + ...(edgeAuth !== undefined ? { edgeAuth } : {}), ...(token !== undefined ? { token } : {}), ...(password !== undefined ? { password } : {}), ...(pinnedDiscoveryFingerprint ? { tlsFingerprint: pinnedDiscoveryFingerprint } : {}), diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index af0930c5ea39..b3f66ed77483 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -310,6 +310,7 @@ export async function resolveGatewayProbeSnapshot(params: { .then(({ probeGateway }) => probeGateway({ url: gatewayConnection.url, + config: params.cfg, auth: gatewayProbeAuthResolution.auth, timeoutMs: probeTimeoutMs, detailLevel: params.opts.detailLevel ?? "presence", diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index f44bddd53328..10a5516afe6f 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -320,6 +320,11 @@ describe("scanStatus", () => { }); it("keeps status --json on read-only channel metadata when channel config exists", async () => { + const resolvedConfig = createStatusScanConfig({ + marker: "resolved-preload", + plugins: { enabled: false }, + channels: { telegram: { enabled: false } }, + }); configureScanStatus({ hasConfiguredChannels: true, sourceConfig: createStatusScanConfig({ @@ -327,11 +332,7 @@ describe("scanStatus", () => { plugins: { enabled: false }, channels: { telegram: { enabled: false } }, }), - resolvedConfig: createStatusScanConfig({ - marker: "resolved-preload", - plugins: { enabled: false }, - channels: { telegram: { enabled: false } }, - }), + resolvedConfig, summary: createStatusSummary({ linkChannel: { linked: false } }), }); @@ -343,6 +344,7 @@ describe("scanStatus", () => { expect(mocks.probeGateway).toHaveBeenCalledOnce(); expect(firstCallArg(mocks.probeGateway, "probeGateway args")).toStrictEqual({ url: "ws://127.0.0.1:18789", + config: resolvedConfig, auth: {}, timeoutMs: 2500, detailLevel: "presence", diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index daef243ed488..7a0dc1d33cbd 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -880,6 +880,53 @@ describe("gateway.remote.transport", () => { }); }); +describe("gateway.remote.edgeAuth", () => { + it("accepts valid header names with literal and SecretRef values", () => { + const res = validateConfigObjectRaw({ + gateway: { + remote: { + edgeAuth: { + "X-Edge-Literal": "test-secret", + "X-Edge-Ref": { source: "env", provider: "default", id: "EDGE_AUTH_TOKEN" }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it.each([ + { + name: "empty map", + edgeAuth: {}, + expected: "header map must not be empty", + }, + { + name: "transport-owned header", + edgeAuth: { Host: "test-secret" }, + expected: 'transport-owned header "Host"', + }, + { + name: "invalid header name", + edgeAuth: { "Bad Header": "test-secret" }, + expected: 'invalid gateway.remote.edgeAuth header name: "Bad Header"', + }, + { + name: "case-duplicate headers", + edgeAuth: { "X-Edge-Auth": "one", "x-edge-auth": "two" }, + expected: 'header names "X-Edge-Auth" and "x-edge-auth" differ only by case', + }, + ])("rejects $name", ({ edgeAuth, expected }) => { + const res = validateConfigObjectRaw({ gateway: { remote: { edgeAuth } } }); + + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.map((issue) => issue.message).join("\n")).toContain(expected); + } + }); +}); + describe("gateway.tools config", () => { it("accepts gateway.tools allow and deny lists", () => { const res = validateConfigObject({ diff --git a/src/config/redact-snapshot.schema.test.ts b/src/config/redact-snapshot.schema.test.ts index d5d163b52076..067e331b03d9 100644 --- a/src/config/redact-snapshot.schema.test.ts +++ b/src/config/redact-snapshot.schema.test.ts @@ -72,6 +72,25 @@ describe("realredactConfigSnapshot_real", () => { ); }); + it("redacts remote edge-auth header values from generated schema hints", () => { + const hints = buildConfigSchemaCore().uiHints; + const snapshot = makeSnapshot({ + gateway: { + remote: { + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }, + }, + }); + + const result = redactConfigSnapshot(snapshot, hints); + const gateway = expectDefined(result.config.gateway, "redacted gateway config"); + const remote = expectDefined(gateway.remote, "redacted remote gateway config"); + const edgeAuth = expectDefined(remote.edgeAuth, "redacted edge auth config"); + expect(edgeAuth["X-Edge-Auth"]).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.gateway.remote.edgeAuth["X-Edge-Auth"]).toBe("test-secret"); + }); + it("redacts Discord Activity client secrets registered on plain string schemas", () => { const hints = buildConfigSchemaCore().uiHints; expect(hints["channels.discord.activities.clientSecret"]?.sensitive).toBe(true); diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index 44d142c22bb8..3330306ca803 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -176,6 +176,8 @@ export const CORE_FIELD_HELP: Record = { "Bearer token used to authenticate this client to a remote gateway in token-auth deployments. Store via secret/env substitution and rotate alongside remote gateway auth changes.", "gateway.remote.password": "Password credential used for remote gateway authentication when password mode is enabled. Keep this secret managed externally and avoid plaintext values in committed config.", + "gateway.remote.edgeAuth": + "Secret-backed HTTP headers presented to an identity-aware proxy in front of the configured remote Gateway. Headers are sent only to the exact gateway.remote.url origin over WSS and never follow redirects.", "gateway.remote.tlsFingerprint": "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", "gateway.remote.sshTarget": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 3cb049e4efde..4ce771f237b4 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -160,6 +160,7 @@ export const FIELD_LABELS: Record = { "gateway.remote.sshHostKeyPolicy": "Remote Gateway SSH Host-Key Policy", "gateway.remote.token": "Remote Gateway Token", "gateway.remote.password": "Remote Gateway Password", + "gateway.remote.edgeAuth": "Remote Gateway Edge Auth Headers", "gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint", "gateway.auth.token": "Gateway Token", "gateway.auth.password": "Gateway Password", diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index d57ccdf30dd1..6b37362832c5 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -276,6 +276,8 @@ export type GatewayRemoteConfig = { token?: SecretInput; /** Password for remote auth (when the gateway requires password auth). */ password?: SecretInput; + /** Headers presented to an identity-aware proxy in front of the Gateway (values are secrets). */ + edgeAuth?: Record; /** Expected TLS certificate fingerprint (sha256) for remote gateways. */ tlsFingerprint?: string; /** SSH target for tunneling remote Gateway (user@host). */ diff --git a/src/config/zod-schema.root-support.ts b/src/config/zod-schema.root-support.ts index a5c5d986ae0e..781b80fbbdf7 100644 --- a/src/config/zod-schema.root-support.ts +++ b/src/config/zod-schema.root-support.ts @@ -1,6 +1,7 @@ import { isHttpsUrl, isHttpUrl } from "@openclaw/net-policy/url-protocol"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { z } from "zod"; +import { findEdgeAuthIssue } from "../shared/gateway-edge-auth-headers.js"; import type { GatewayRemoteConfig } from "./types.gateway.js"; import { MemorySearchSchema } from "./zod-schema.agent-runtime.js"; import { SecretInputSchema } from "./zod-schema.core.js"; @@ -11,6 +12,20 @@ type ConfigSchemaShape = { [Key in keyof T]-?: z.ZodType; }; +const EdgeAuthHeadersSchema = z + .record(z.string(), SecretInputSchema.register(sensitive)) + .superRefine((headers, ctx) => { + const issue = findEdgeAuthIssue(headers); + if (!issue) { + return; + } + ctx.addIssue({ + code: "custom", + message: issue.message, + ...(issue.headerName ? { path: [issue.headerName] } : {}), + }); + }); + const GatewayRemoteSchemaShape = { url: z.string().optional(), @@ -21,6 +36,7 @@ const GatewayRemoteSchemaShape = { token: SecretInputSchema.optional().register(sensitive), password: SecretInputSchema.optional().register(sensitive), + edgeAuth: EdgeAuthHeadersSchema.optional(), tlsFingerprint: z.string().optional(), sshTarget: z.string().optional(), sshIdentity: z.string().optional(), diff --git a/src/gateway/call.ts b/src/gateway/call.ts index f5f6d889823f..12470a9fb89c 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -76,6 +76,12 @@ import { trimToUndefined, type ExplicitGatewayAuth, } from "./credentials.js"; +import { + gatewayEdgeAuthValueForTarget, + normalizeEdgeAuthHeadersConfig, + resolveEdgeAuthHeaders, + type EdgeAuthHeadersConfig, +} from "./edge-auth.js"; import { canSkipGatewayConfigLoad } from "./explicit-connection-policy.js"; import { resolvePreauthHandshakeTimeoutMs } from "./handshake-timeouts.js"; import { @@ -820,6 +826,7 @@ async function executeGatewayRequestWithScopes(params: { url: string; token?: string; password?: string; + edgeAuthHeaders?: Readonly>; tlsFingerprint?: string; preauthHandshakeTimeoutMs?: number; timeoutMs: number | null; @@ -837,6 +844,7 @@ async function executeGatewayRequestWithScopes(params: { url, token, password, + edgeAuthHeaders, tlsFingerprint, preauthHandshakeTimeoutMs, timeoutMs, @@ -919,6 +927,7 @@ async function executeGatewayRequestWithScopes(params: { url, token, password, + edgeAuthHeaders, tlsFingerprint, preauthHandshakeTimeoutMs, instanceId: opts.instanceId ?? randomUUID(), @@ -1158,6 +1167,15 @@ async function callGatewayWithScopes>( } } const tlsFingerprint = bootstrap.tlsFingerprint; + const edgeAuthConfig: EdgeAuthHeadersConfig | undefined = normalizeEdgeAuthHeadersConfig( + gatewayEdgeAuthValueForTarget({ config: context.config, targetUrl: url }), + ); + const edgeAuthHeaders = await resolveEdgeAuthHeaders({ + config: context.config, + value: edgeAuthConfig, + targetUrl: url, + env: process.env, + }); if (useStoredDeviceAuth) { if (!storedAuth?.token) { throw new GatewayCredentialsRequiredError({ @@ -1199,6 +1217,7 @@ async function callGatewayWithScopes>( url, token, password, + edgeAuthHeaders, tlsFingerprint, timeoutMs, startupTimeoutMs, diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index e07045278f74..ee87181d4ba6 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -2407,11 +2407,11 @@ describe("GatewayClient connect auth payload", () => { client.stop(); }); - it("never logs a registered Cloudflare Access credential from connection errors", async () => { - const clientSecret = ["cf", "redaction", "secret"].join("-"); + it("never logs a registered edge auth header value from connection errors", async () => { + const clientSecret = "test-secret"; const client = new GatewayClient({ url: "wss://gateway.example", - cloudflareAccess: { clientId: "cf-redaction-id", clientSecret }, + edgeAuthHeaders: { "X-Edge-Auth": clientSecret }, deviceIdentity: null, }); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index dbe10ec39808..1986eb755943 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -139,9 +139,8 @@ export class GatewayClient { const suppressOriginDeviceAuth = Boolean( deviceAuthScope && (baseOptions.token?.trim() || baseOptions.password?.trim()), ); - if (baseOptions.cloudflareAccess) { - registerSecretValueForRedaction(baseOptions.cloudflareAccess.clientId); - registerSecretValueForRedaction(baseOptions.cloudflareAccess.clientSecret); + for (const value of Object.values(baseOptions.edgeAuthHeaders ?? {})) { + registerSecretValueForRedaction(value); } this.#client = new BaseGatewayClient({ ...baseOptions, diff --git a/src/gateway/edge-auth.test.ts b/src/gateway/edge-auth.test.ts new file mode 100644 index 000000000000..be2710952699 --- /dev/null +++ b/src/gateway/edge-auth.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isSecretValueRegisteredForRedaction } from "../logging/secret-redaction-registry.js"; +import { resetSecretRedactionRegistryForTest } from "../logging/secret-redaction-registry.test-support.js"; + +const materializeSecretInput = vi.hoisted(() => vi.fn()); + +vi.mock("../secrets/resolve-secret-input-string.js", async (importOriginal) => { + const actual = await importOriginal(); + materializeSecretInput.mockImplementation(actual.materializeSecretInput); + return { ...actual, materializeSecretInput }; +}); + +import { + gatewayEdgeAuthValueForTarget, + normalizeEdgeAuthHeadersConfig, + resolveEdgeAuthHeaders, +} from "./edge-auth.js"; + +describe("gateway edge auth headers", () => { + afterEach(() => { + materializeSecretInput.mockClear(); + resetSecretRedactionRegistryForTest(); + }); + + it("normalizes valid literal and SecretRef header values", () => { + expect( + normalizeEdgeAuthHeadersConfig({ + "X-Edge-Literal": " literal-value ", + "X-Edge-Ref": { source: "env", provider: "default", id: "EDGE_AUTH_TOKEN" }, + }), + ).toEqual({ + "X-Edge-Literal": "literal-value", + "X-Edge-Ref": { source: "env", provider: "default", id: "EDGE_AUTH_TOKEN" }, + }); + }); + + it.each([ + "host", + "connection", + "upgrade", + "content-length", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-protocol", + "sec-websocket-extensions", + ])("rejects transport-owned header %s case-insensitively", (headerName) => { + const mixedCaseName = headerName + .split("-") + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join("-"); + expect(() => normalizeEdgeAuthHeadersConfig({ [mixedCaseName]: "test-secret" })).toThrow( + /transport-owned header/u, + ); + }); + + it("rejects non-records, invalid names, empty maps, and case-duplicate names", () => { + expect(() => normalizeEdgeAuthHeadersConfig(["X-Edge-Auth", "test-secret"])).toThrow( + /expected a header map/u, + ); + expect(() => normalizeEdgeAuthHeadersConfig({ "Bad Header": "test-secret" })).toThrow( + /invalid gateway\.remote\.edgeAuth header name/u, + ); + expect(() => normalizeEdgeAuthHeadersConfig({})).toThrow(/must not be empty/u); + expect(() => + normalizeEdgeAuthHeadersConfig({ "X-Edge-Auth": "one", "x-edge-auth": "two" }), + ).toThrow(/differ only by case/u); + }); + + it("materializes SecretRefs and registers every resolved value for redaction", async () => { + const first = "test-token"; + const second = "test-secret"; + const value = normalizeEdgeAuthHeadersConfig({ + "X-Edge-Token": { source: "env", provider: "default", id: "EDGE_AUTH_TOKEN" }, + "X-Edge-Secret": { source: "env", provider: "default", id: "EDGE_AUTH_SECRET" }, + }); + + await expect( + resolveEdgeAuthHeaders({ + config: {}, + value, + targetUrl: "wss://gateway.example/rpc", + env: { EDGE_AUTH_TOKEN: first, EDGE_AUTH_SECRET: second }, + }), + ).resolves.toEqual({ "X-Edge-Token": first, "X-Edge-Secret": second }); + expect(isSecretValueRegisteredForRedaction(first)).toBe(true); + expect(isSecretValueRegisteredForRedaction(second)).toBe(true); + }); + + it("names the header when a resolved value is empty", async () => { + await expect( + resolveEdgeAuthHeaders({ + config: {}, + value: { "X-Empty-Edge-Auth": " " }, + targetUrl: "wss://gateway.example/rpc", + env: {}, + }), + ).rejects.toThrow('gateway.remote.edgeAuth header "X-Empty-Edge-Auth" resolved empty'); + }); + + it("binds configured headers to the exact configured remote Gateway scope", async () => { + const config = { + gateway: { + mode: "remote" as const, + remote: { + url: "wss://gateway.example/rpc", + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }, + }, + }; + + const matchingConfig = normalizeEdgeAuthHeadersConfig( + gatewayEdgeAuthValueForTarget({ + config, + targetUrl: "wss://gateway.example/rpc", + }), + ); + expect(matchingConfig).toEqual({ "X-Edge-Auth": "test-secret" }); + await expect( + resolveEdgeAuthHeaders({ + config, + value: matchingConfig, + targetUrl: "wss://gateway.example/rpc", + env: {}, + }), + ).resolves.toEqual({ "X-Edge-Auth": "test-secret" }); + expect( + gatewayEdgeAuthValueForTarget({ config, targetUrl: "wss://other.example/rpc" }), + ).toBeUndefined(); + }); + + it("rejects non-WSS targets before materializing edge-auth secrets", async () => { + const value = normalizeEdgeAuthHeadersConfig({ + "X-Edge-Auth": { source: "env", provider: "default", id: "EDGE_AUTH_TOKEN" }, + }); + + await expect( + resolveEdgeAuthHeaders({ + config: {}, + value, + targetUrl: "ws://gateway.example/rpc", + env: { EDGE_AUTH_TOKEN: "test-token" }, + }), + ).rejects.toThrow(/gateway\.remote\.edgeAuth.*wss:\/\//u); + expect(materializeSecretInput).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/edge-auth.ts b/src/gateway/edge-auth.ts new file mode 100644 index 000000000000..7b676a74b027 --- /dev/null +++ b/src/gateway/edge-auth.ts @@ -0,0 +1,91 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { gatewayOriginScope } from "../../packages/gateway-client/src/gateway-origin-scope.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + coerceSecretRef, + normalizeSecretInputString, + type SecretInput, +} from "../config/types.secrets.js"; +import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js"; +import { materializeSecretInput } from "../secrets/resolve-secret-input-string.js"; +import { findEdgeAuthIssue } from "../shared/gateway-edge-auth-headers.js"; + +export type EdgeAuthHeadersConfig = Record; + +function normalizeEdgeAuthSecretInput(value: unknown, headerName: string): SecretInput { + const ref = coerceSecretRef(value); + if (ref) { + return ref; + } + const literal = normalizeSecretInputString(value); + if (literal) { + return literal; + } + throw new Error( + `invalid gateway.remote.edgeAuth header "${headerName}": expected a non-empty SecretInput`, + ); +} + +export function normalizeEdgeAuthHeadersConfig(value: unknown): EdgeAuthHeadersConfig | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (!isRecord(value)) { + throw new Error("invalid gateway.remote.edgeAuth: expected a header map"); + } + const shapeIssue = findEdgeAuthIssue(value); + if (shapeIssue) { + throw new Error(shapeIssue.message); + } + const entries = Object.entries(value); + const normalizedEntries = entries.map(([headerName, input]) => { + return [headerName, normalizeEdgeAuthSecretInput(input, headerName)] as const; + }); + return Object.fromEntries(normalizedEntries); +} + +export async function resolveEdgeAuthHeaders(params: { + config: OpenClawConfig; + value?: EdgeAuthHeadersConfig; + targetUrl: string; + env: NodeJS.ProcessEnv; +}): Promise> | undefined> { + if (!params.value) { + return undefined; + } + let protocol: string; + try { + protocol = new URL(params.targetUrl).protocol; + } catch { + throw new Error("gateway.remote.edgeAuth requires a wss:// connection target"); + } + if (protocol !== "wss:") { + throw new Error("gateway.remote.edgeAuth requires a wss:// connection target"); + } + const resolvedEntries = await Promise.all( + Object.entries(params.value).map(async ([headerName, input]) => { + const value = await materializeSecretInput({ + config: params.config, + value: input, + env: params.env, + }); + if (!value) { + throw new Error(`gateway.remote.edgeAuth header "${headerName}" resolved empty`); + } + registerSecretValueForRedaction(value); + return [headerName, value] as const; + }), + ); + return Object.freeze(Object.fromEntries(resolvedEntries)); +} + +export function gatewayEdgeAuthValueForTarget(params: { + config: OpenClawConfig; + targetUrl: string; +}): unknown { + const remote = params.config.gateway?.remote; + if (!remote?.url || gatewayOriginScope(params.targetUrl) !== gatewayOriginScope(remote.url)) { + return undefined; + } + return remote.edgeAuth; +} diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 0228f2ce2669..5f6b6de927a6 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -11,6 +11,7 @@ import { readMissingScopeError, type MissingScopeErrorDetails, } from "../../packages/gateway-protocol/src/gateway-error-details.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadDeviceAuthTokenReadOnly, loadOriginDeviceTokenReadOnly, @@ -20,6 +21,12 @@ import type { SystemPresence } from "../infra/system-presence.js"; import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js"; import { startGatewayClientWhenEventLoopReady } from "./client-start-readiness.js"; import { GatewayClient, GatewayClientRequestError } from "./client.js"; +import { + gatewayEdgeAuthValueForTarget, + normalizeEdgeAuthHeadersConfig, + resolveEdgeAuthHeaders, + type EdgeAuthHeadersConfig, +} from "./edge-auth.js"; import { READ_SCOPE } from "./method-scopes.js"; import { isLoopbackHost } from "./net.js"; @@ -254,6 +261,7 @@ export async function probeGateway(opts: { /** Disable persisted device auth when the transport does not identify a stable Gateway origin. */ suppressStoredDeviceAuth?: boolean; auth?: GatewayProbeAuth; + config?: OpenClawConfig; timeoutMs: number; preauthHandshakeTimeoutMs?: number; includeDetails?: boolean; @@ -318,6 +326,15 @@ export async function probeGateway(opts: { return makeDeviceRequiredShortCircuitResult(opts.url); } const initialProbeTimeoutMs = clampProbeTimeoutMs(opts.timeoutMs); + const edgeAuthConfig: EdgeAuthHeadersConfig | undefined = normalizeEdgeAuthHeadersConfig( + gatewayEdgeAuthValueForTarget({ config: opts.config ?? {}, targetUrl: opts.url }), + ); + const edgeAuthHeaders = await resolveEdgeAuthHeaders({ + config: opts.config ?? {}, + value: edgeAuthConfig, + targetUrl: opts.url, + env: opts.env ?? process.env, + }); return await new Promise((resolve) => { let settled = false; @@ -407,6 +424,7 @@ export async function probeGateway(opts: { ...(deviceAuthScope ? { deviceAuthScope } : {}), token: opts.auth?.token, password: opts.auth?.password, + edgeAuthHeaders, tlsFingerprint: opts.tlsFingerprint, preauthHandshakeTimeoutMs: opts.preauthHandshakeTimeoutMs, env: opts.env, diff --git a/src/node-host/gateway-candidate-connection.test.ts b/src/node-host/gateway-candidate-connection.test.ts index 451cc010851a..478708786c9c 100644 --- a/src/node-host/gateway-candidate-connection.test.ts +++ b/src/node-host/gateway-candidate-connection.test.ts @@ -183,10 +183,13 @@ describe("gateway candidate connection", () => { }); it("never carries origin-bound Access credentials to another candidate host", async () => { - const credentials = { clientId: "cf-pinned-id", clientSecret: "cf-pinned-secret" }; + const credentials = { clientId: "test-key", clientSecret: "test-secret" }; createConnection(new Map([[candidates[0]!, credentials]])); - expect(mocks.options[0]?.cloudflareAccess).toEqual(credentials); + expect(mocks.options[0]?.edgeAuthHeaders).toEqual({ + "CF-Access-Client-Id": credentials.clientId, + "CF-Access-Client-Secret": credentials.clientSecret, + }); mocks.options[0]?.onClose?.(1006, "transport unavailable", { phase: "pre-hello", socketOpened: false, @@ -197,6 +200,6 @@ describe("gateway candidate connection", () => { await vi.waitFor(() => expect(mocks.clients).toHaveLength(2)); expect(mocks.options[1]?.url).toBe("wss://gateway.tailnet.example:443"); - expect(mocks.options[1]?.cloudflareAccess).toBeUndefined(); + expect(mocks.options[1]?.edgeAuthHeaders).toBeUndefined(); }); }); diff --git a/src/node-host/gateway-candidate-connection.ts b/src/node-host/gateway-candidate-connection.ts index 7602db5011fd..64a0706835f0 100644 --- a/src/node-host/gateway-candidate-connection.ts +++ b/src/node-host/gateway-candidate-connection.ts @@ -1,4 +1,7 @@ -import type { CloudflareAccessCredentials } from "../../packages/gateway-client/src/cloudflare-access.js"; +import { + buildCloudflareAccessHeaders, + type CloudflareAccessCredentials, +} from "../../packages/gateway-client/src/cloudflare-access.js"; import { GatewayClient, type GatewayClientCloseInfo, @@ -16,7 +19,7 @@ type CandidateConnectionOptions = Omit< GatewayClientOptions, | "url" | "tlsFingerprint" - | "cloudflareAccess" + | "edgeAuthHeaders" | "onEvent" | "onHelloOk" | "onConnectError" @@ -83,7 +86,9 @@ export function createNodeHostGatewayCandidateConnection(params: GatewayCandidat ...params.clientOptions, url, tlsFingerprint: candidate.tlsFingerprint, - ...(cloudflareAccess ? { cloudflareAccess } : {}), + ...(cloudflareAccess + ? { edgeAuthHeaders: buildCloudflareAccessHeaders(cloudflareAccess) } + : {}), onEvent: (event) => { if (currentCandidateIndex === candidateIndex) { params.onEvent(event); diff --git a/src/shared/gateway-edge-auth-headers.ts b/src/shared/gateway-edge-auth-headers.ts new file mode 100644 index 000000000000..5534681336d5 --- /dev/null +++ b/src/shared/gateway-edge-auth-headers.ts @@ -0,0 +1,48 @@ +const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; +const TRANSPORT_OWNED_HEADERS = new Set([ + "host", + "connection", + "upgrade", + "content-length", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-protocol", + "sec-websocket-extensions", +]); + +type EdgeAuthShapeIssue = { + message: string; + headerName?: string; +}; + +export function findEdgeAuthIssue(headers: Record): EdgeAuthShapeIssue | null { + const entries = Object.entries(headers); + if (entries.length === 0) { + return { message: "invalid gateway.remote.edgeAuth: header map must not be empty" }; + } + const originalNames = new Map(); + for (const [headerName] of entries) { + if (!HTTP_HEADER_NAME_PATTERN.test(headerName)) { + return { + headerName, + message: `invalid gateway.remote.edgeAuth header name: ${JSON.stringify(headerName)}`, + }; + } + const normalizedName = headerName.toLowerCase(); + if (TRANSPORT_OWNED_HEADERS.has(normalizedName)) { + return { + headerName, + message: `gateway.remote.edgeAuth cannot set transport-owned header "${headerName}"`, + }; + } + const originalName = originalNames.get(normalizedName); + if (originalName) { + return { + headerName, + message: `gateway.remote.edgeAuth header names "${originalName}" and "${headerName}" differ only by case`, + }; + } + originalNames.set(normalizedName, headerName); + } + return null; +} diff --git a/src/tui/gateway-chat.connection.test.ts b/src/tui/gateway-chat.connection.test.ts index 6d1dc9ecb244..ce9463cf9b22 100644 --- a/src/tui/gateway-chat.connection.test.ts +++ b/src/tui/gateway-chat.connection.test.ts @@ -60,9 +60,9 @@ vi.mock("../infra/device-identity.js", async (importOriginal) => { const { GatewayChatClient } = await import("./gateway-chat.js"); -const resolveBoundGatewayConnection = ( +const resolveBoundGatewayConnection = async ( opts: Parameters[0], -) => GatewayChatClient.connectBound(opts).connection; +) => (await GatewayChatClient.connectBound(opts)).connection; const resolveGatewayConnection = async (opts: Parameters[0]) => (await GatewayChatClient.connect(opts)).connection; @@ -182,10 +182,10 @@ describe("resolveGatewayConnection", () => { await withEnvAsync( { OPENCLAW_GATEWAY_URL: "wss://env.example/ws", - OPENCLAW_GATEWAY_TOKEN: "bound-global-shell-auth", + OPENCLAW_GATEWAY_TOKEN: "test-token", }, async () => { - const result = resolveBoundGatewayConnection({ + const result = await resolveBoundGatewayConnection({ config: { gateway: { mode: "remote", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index b1d09c56cbcc..feac4ac333d9 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -41,6 +41,12 @@ import { import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js"; import { GatewayClient, GatewayClientRequestError } from "../gateway/client.js"; import { resolveExplicitGatewayAuth } from "../gateway/credentials.js"; +import { + gatewayEdgeAuthValueForTarget, + normalizeEdgeAuthHeadersConfig, + resolveEdgeAuthHeaders, + type EdgeAuthHeadersConfig, +} from "../gateway/edge-auth.js"; import { loadOriginDeviceToken } from "../infra/device-auth-store.js"; import { loadDeviceIdentityIfPresent } from "../infra/device-identity.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -82,6 +88,7 @@ type ResolvedGatewayConnection = { deviceAuthScope?: string; token?: string; password?: string; + edgeAuthHeaders?: Readonly>; tlsFingerprint?: string; preauthHandshakeTimeoutMs?: number; }; @@ -194,6 +201,7 @@ export class GatewayChatClient implements TuiBackend { ...(connection.deviceAuthScope ? { deviceAuthScope: connection.deviceAuthScope } : {}), token: connection.token, password: connection.password, + edgeAuthHeaders: connection.edgeAuthHeaders, tlsFingerprint: connection.tlsFingerprint, preauthHandshakeTimeoutMs: connection.preauthHandshakeTimeoutMs, clientName: GATEWAY_CLIENT_NAMES.TUI, @@ -253,10 +261,10 @@ export class GatewayChatClient implements TuiBackend { } /** Connect to a target already selected and authenticated by a preceding Gateway probe. */ - static connectBound( + static async connectBound( opts: GatewayConnectionOptions & { config: OpenClawConfig; url: string }, - ): GatewayChatClient { - return new GatewayChatClient(resolveBoundGatewayConnection(opts)); + ): Promise { + return new GatewayChatClient(await resolveBoundGatewayConnection(opts)); } start() { @@ -542,20 +550,30 @@ export class GatewayChatClient implements TuiBackend { * deliberately ignores global config and Gateway env overrides, including * credentials, while still applying the normal remote URL safety policy. */ -function resolveBoundGatewayConnection( +async function resolveBoundGatewayConnection( opts: GatewayConnectionOptions & { config: OpenClawConfig; url: string }, -): ResolvedGatewayConnection { +): Promise { const url = buildGatewayConnectionDetails({ config: opts.config, url: opts.url, ignoreEnvUrlOverride: true, }).url; const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password }); + const edgeAuthConfig: EdgeAuthHeadersConfig | undefined = normalizeEdgeAuthHeadersConfig( + gatewayEdgeAuthValueForTarget({ config: opts.config, targetUrl: url }), + ); + const edgeAuthHeaders = await resolveEdgeAuthHeaders({ + config: opts.config, + value: edgeAuthConfig, + targetUrl: url, + env: process.env, + }); return { url, deviceAuthScope: gatewayOriginScope(url), token: explicitAuth.token, password: explicitAuth.password, + ...(edgeAuthHeaders ? { edgeAuthHeaders } : {}), ...(opts.tlsFingerprint ? { tlsFingerprint: opts.tlsFingerprint } : {}), }; } @@ -619,11 +637,21 @@ async function resolveGatewayConnection( if (bootstrap.authFailureReason && (!missingSharedAuth || !hasStoredOriginAuth)) { throwGatewayAuthResolutionError(bootstrap.authFailureReason); } + const edgeAuthConfig: EdgeAuthHeadersConfig | undefined = normalizeEdgeAuthHeadersConfig( + gatewayEdgeAuthValueForTarget({ config, targetUrl: bootstrap.url }), + ); + const edgeAuthHeaders = await resolveEdgeAuthHeaders({ + config, + value: edgeAuthConfig, + targetUrl: bootstrap.url, + env, + }); return { url: bootstrap.url, deviceAuthScope: bootstrap.deviceAuthScope, token: bootstrap.auth.token, password: bootstrap.auth.password, + ...(edgeAuthHeaders ? { edgeAuthHeaders } : {}), ...(bootstrap.tlsFingerprint ? { tlsFingerprint: bootstrap.tlsFingerprint } : {}), }; } diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 0686da954460..ee87785090ac 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -516,6 +516,15 @@ describe("resolveGatewayDisconnectState", () => { expect(state.remediation).not.toContain("devices rotate"); }); + it("shows edge-auth guidance for an identity-proxy rejection", () => { + const state = resolveGatewayDisconnectState({ + details: { reason: "websocket-upgrade-rejected", httpStatus: 302 }, + reason: "gateway rejected websocket upgrade (HTTP 302)", + }); + expect(state.activityStatus).toBe("identity-aware proxy rejected connection"); + expect(state.remediation).toContain("gateway.remote.edgeAuth"); + }); + it("falls back to idle for generic disconnect reasons", () => { const state = resolveGatewayDisconnectState({ reason: "network timeout" }); expect(state.connectionStatus).toBe("gateway disconnected: network timeout"); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 1f405d0f6d1b..01a6840c0116 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -339,6 +339,13 @@ export function resolveGatewayDisconnectState( remediation: failure.remediation, }; } + if (failure.kind === "identity-proxy") { + return { + connectionStatus: `gateway disconnected: ${reasonLabel}`, + activityStatus: "identity-aware proxy rejected connection", + remediation: failure.remediation, + }; + } return { connectionStatus: `gateway disconnected: ${reasonLabel}`, activityStatus: failure.remediation ? "gateway authentication needs attention" : "idle", @@ -873,7 +880,7 @@ async function runTuiUnlocked(opts: RunTuiOptions): Promise { } else { const { GatewayChatClient } = await import("./gateway-chat.js"); client = opts.boundGateway - ? GatewayChatClient.connectBound({ config, ...opts.boundGateway }) + ? await GatewayChatClient.connectBound({ config, ...opts.boundGateway }) : await GatewayChatClient.connect({ url: opts.url, token: opts.token, diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 49a7cc8542ce..bf8ae65803f1 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -1067,7 +1067,10 @@ describe("runSetupWizard", () => { }), }), expect.any(Object), - { secretInputMode: undefined }, + { + secretInputMode: undefined, + edgeAuthOriginUrl: "wss://stored.example.com:18789", + }, ); expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining(remoteToken)); }); @@ -1099,6 +1102,33 @@ describe("runSetupWizard", () => { }); }); + it("passes configured remote edge auth to the setup reachability probe", async () => { + const config: OpenClawConfig = { + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example.test", + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }, + }, + }; + readConfigFileSnapshot.mockResolvedValueOnce(configSnapshot(config)); + + await runSetupWizard( + { acceptRisk: true, flow: "advanced", mode: "remote" }, + createRuntime(), + buildWizardPrompter({}), + ); + + expect(probeGatewayReachable).toHaveBeenCalledWith({ + url: "wss://gateway.example.test", + config: expect.objectContaining({ + gateway: config.gateway, + }), + token: undefined, + }); + }); + it("keeps a configured remote token authoritative over an environment password", async () => { readConfigFileSnapshot.mockResolvedValueOnce( configSnapshot({ @@ -1180,6 +1210,7 @@ describe("runSetupWizard", () => { url: "wss://stored.example.com:18789", token: { source: "env", provider: "default", id: "STORED_GATEWAY_TOKEN" }, password: { source: "env", provider: "default", id: "STORED_GATEWAY_PASSWORD" }, + edgeAuth: { "X-Edge-Auth": "test-secret" }, }, }, }, @@ -1206,6 +1237,14 @@ describe("runSetupWizard", () => { expect(probeGatewayReachable).toHaveBeenCalledWith({ url: "wss://flag.example.com:18789", + config: expect.objectContaining({ + gateway: expect.objectContaining({ + remote: expect.objectContaining({ + url: "wss://stored.example.com:18789", + edgeAuth: { "X-Edge-Auth": "test-secret" }, + }), + }), + }), token: undefined, }); expect(promptRemoteGatewayConfig).toHaveBeenCalledWith( @@ -1215,11 +1254,15 @@ describe("runSetupWizard", () => { url: "wss://flag.example.com:18789", token: undefined, password: undefined, + edgeAuth: { "X-Edge-Auth": "test-secret" }, }, }), }), expect.any(Object), - { secretInputMode: undefined }, + { + secretInputMode: undefined, + edgeAuthOriginUrl: "wss://stored.example.com:18789", + }, ); }); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 128cb52b86c9..23a435144b60 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -416,6 +416,7 @@ async function runSetupWizardOnce( const remoteProbe = remoteUrl ? await onboardHelpers.probeGatewayReachable({ url: remoteUrl, + ...(baseConfig.gateway?.remote?.edgeAuth ? { config: baseConfig } : {}), token: remoteProbeAuth?.auth.token, ...(remoteProbeAuth?.auth.password ? { password: remoteProbeAuth.auth.password } : {}), }) @@ -454,6 +455,9 @@ async function runSetupWizardOnce( const { logConfigUpdated } = await loadConfigLoggingModule(); let nextConfig = await promptRemoteGatewayConfig(remoteSeedConfig, prompter, { secretInputMode: opts.secretInputMode, + ...(opts.remoteUrl !== undefined && storedRemoteUrl + ? { edgeAuthOriginUrl: storedRemoteUrl } + : {}), }); if (opts.skipBootstrap) { nextConfig = applySkipBootstrapConfig(nextConfig);