feat(gateway): reach a Gateway behind an identity-aware proxy from the CLI (#125700)

* feat(gateway): reach a Gateway behind an identity-aware proxy from the CLI

Operator CLI surfaces (tui, attach, call, probe, onboarding, and configure) can present configured gateway.remote.edgeAuth headers to an identity-aware proxy. Headers are origin-bound, WSS-only, never follow redirects, cannot set transport-owned headers, and are redacted. Identity-proxy upgrade rejections are classified and remediated instead of being reported as an unreachable gateway.

* test(gateway): cover config-aware probe calls

Keep status probe expectations aligned with the resolved configuration forwarded for origin-bound edge-auth resolution.

* fix(gateway): preserve edge auth across wizard saves and enforce wss before secret resolution

Preserve gateway.remote.edgeAuth only when the configured Gateway scope is unchanged, and reject non-WSS targets before resolving any edge-auth SecretInput.
This commit is contained in:
Peter Steinberger
2026-08-18 03:29:56 -07:00
committed by GitHub
parent f1b8082d61
commit d92ebbaf72
44 changed files with 1919 additions and 755 deletions
+5
View File
@@ -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 <deviceId> --role operator`, then reconnect. |
| TLS certificate pin mismatch | The original TLS fingerprint error passes through unchanged; verify the configured or explicit pin before retrying. |
+81
View File
@@ -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.*`).
@@ -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<typeof createHttpsServer> | undefined;
const tlsFingerprint = new X509Certificate(TEST_TLS_CERT_PEM).fingerprint256;
const websocketServers: WebSocketServer[] = [];
const httpsServers: Array<ReturnType<typeof createHttpsServer>> = [];
async function listen(server: ReturnType<typeof createHttpsServer>): Promise<number> {
httpsServers.push(server);
await new Promise<void>((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<void>((resolve) => {
server.close(() => resolve());
});
}
const closing = new Promise<void>((resolve, reject) => {
server?.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
for (const server of httpsServers.splice(0).toReversed()) {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
});
server = undefined;
await closing;
if (httpsServer) {
const closingHttps = new Promise<void>((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<void>((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<Record<string, string | string[] | undefined>>((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<Error>((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<Error>((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();
});
+19 -10
View File
@@ -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<Record<string, string>>;
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);
@@ -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", () => {
@@ -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 ||
+1
View File
@@ -352,6 +352,7 @@ describe("probeGatewayStatus", () => {
expect(probeGatewayMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:19191",
config,
auth: {
token: "temp-token",
password: undefined,
+1
View File
@@ -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,
+25
View File
@@ -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({
+4
View File
@@ -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;
}
+37
View File
@@ -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<typeof import("../gateway/call.js")>();
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");
});
});
+3
View File
@@ -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 ??
@@ -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<OpenClawConfig> => ({
...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<typeof import("../config/config.js").transformConfigFileWithRetry>[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<typeof import("../config/mutate.js")>("../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<ReadonlyArray<unknown>> } },
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<string, unknown>) {
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",
);
});
});
-383
View File
@@ -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<string, unknown>) {
return requireRecord(config.gateway, "gateway config");
}
function getWebSearch(config: Record<string, unknown>) {
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) => {
+1
View File
@@ -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,
+1
View File
@@ -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"
@@ -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",
});
});
});
-276
View File
@@ -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({
+2
View File
@@ -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,
+26
View File
@@ -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()]);
+8 -1
View File
@@ -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<OpenClawConfig> {
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 } : {}),
+1
View File
@@ -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",
+7 -5
View File
@@ -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",
+47
View File
@@ -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({
+19
View File
@@ -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);
+2
View File
@@ -176,6 +176,8 @@ export const CORE_FIELD_HELP: Record<string, string> = {
"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":
+1
View File
@@ -160,6 +160,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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",
+2
View File
@@ -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<string, SecretInput>;
/** Expected TLS certificate fingerprint (sha256) for remote gateways. */
tlsFingerprint?: string;
/** SSH target for tunneling remote Gateway (user@host). */
+16
View File
@@ -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<T extends object> = {
[Key in keyof T]-?: z.ZodType<T[Key]>;
};
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(),
+19
View File
@@ -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<T>(params: {
url: string;
token?: string;
password?: string;
edgeAuthHeaders?: Readonly<Record<string, string>>;
tlsFingerprint?: string;
preauthHandshakeTimeoutMs?: number;
timeoutMs: number | null;
@@ -837,6 +844,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
url,
token,
password,
edgeAuthHeaders,
tlsFingerprint,
preauthHandshakeTimeoutMs,
timeoutMs,
@@ -919,6 +927,7 @@ async function executeGatewayRequestWithScopes<T>(params: {
url,
token,
password,
edgeAuthHeaders,
tlsFingerprint,
preauthHandshakeTimeoutMs,
instanceId: opts.instanceId ?? randomUUID(),
@@ -1158,6 +1167,15 @@ async function callGatewayWithScopes<T = Record<string, unknown>>(
}
}
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<T = Record<string, unknown>>(
url,
token,
password,
edgeAuthHeaders,
tlsFingerprint,
timeoutMs,
startupTimeoutMs,
+3 -3
View File
@@ -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,
});
+2 -3
View File
@@ -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,
+146
View File
@@ -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<typeof import("../secrets/resolve-secret-input-string.js")>();
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();
});
});
+91
View File
@@ -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<string, SecretInput>;
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<Readonly<Record<string, string>> | 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;
}
+18
View File
@@ -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<GatewayProbeResult>((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,
@@ -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();
});
});
@@ -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);
+48
View File
@@ -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<string, unknown>): 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<string, string>();
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;
}
+4 -4
View File
@@ -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<typeof GatewayChatClient.connectBound>[0],
) => GatewayChatClient.connectBound(opts).connection;
) => (await GatewayChatClient.connectBound(opts)).connection;
const resolveGatewayConnection = async (opts: Parameters<typeof GatewayChatClient.connect>[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",
+33 -5
View File
@@ -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<Record<string, string>>;
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<GatewayChatClient> {
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<ResolvedGatewayConnection> {
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 } : {}),
};
}
+9
View File
@@ -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");
+8 -1
View File
@@ -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<TuiResult> {
} 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,
+45 -2
View File
@@ -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",
},
);
});
+4
View File
@@ -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);