mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
f1b8082d61
commit
d92ebbaf72
@@ -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. |
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -352,6 +352,7 @@ describe("probeGatewayStatus", () => {
|
||||
|
||||
expect(probeGatewayMock).toHaveBeenCalledWith({
|
||||
url: "ws://127.0.0.1:19191",
|
||||
config,
|
||||
auth: {
|
||||
token: "temp-token",
|
||||
password: undefined,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user