diff --git a/docs/cli/node.md b/docs/cli/node.md index 1a906473c11..2cc4d3cb061 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -58,6 +58,7 @@ Options: - `--host `: Gateway WebSocket host (default: `127.0.0.1`) - `--port `: Gateway WebSocket port (default: `18789`) +- `--context-path `: Gateway WebSocket context path (e.g. `/openclaw-gw`). Appended to the WebSocket URL. - `--tls`: Use TLS for the gateway connection - `--tls-fingerprint `: Expected TLS certificate fingerprint (sha256) - `--node-id `: Override node id (clears pairing token) @@ -95,6 +96,7 @@ Options: - `--host `: Gateway WebSocket host (default: `127.0.0.1`) - `--port `: Gateway WebSocket port (default: `18789`) +- `--context-path `: Gateway WebSocket context path (e.g. `/openclaw-gw`). Appended to the WebSocket URL. - `--tls`: Use TLS for the gateway connection - `--tls-fingerprint `: Expected TLS certificate fingerprint (sha256) - `--node-id `: Override node id (clears pairing token) diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 03e74324784..47cbff53570 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -40,6 +40,7 @@ import { formatInvalidConfigPort, formatInvalidPortOption } from "../error-forma type NodeDaemonInstallOptions = { host?: string; port?: string | number; + contextPath?: string; tls?: boolean; tlsFingerprint?: string; nodeId?: string; @@ -86,7 +87,12 @@ function resolveNodeDefaults( return { host, port: null }; } const port = portOverride ?? config?.gateway?.port ?? 18789; - return { host, port }; + const retargeted = opts.host !== undefined || opts.port !== undefined; + const explicitContextPath = opts.contextPath !== undefined; + const contextPath = + normalizeOptionalString(opts.contextPath) || + (explicitContextPath || retargeted ? undefined : config?.gateway?.contextPath); + return { host, port, contextPath }; } export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { @@ -96,7 +102,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { } const config = await loadNodeHostConfig(); - const { host, port } = resolveNodeDefaults(opts, config); + const { host, port, contextPath } = resolveNodeDefaults(opts, config); if (!Number.isFinite(port ?? Number.NaN) || (port ?? 0) <= 0 || (port ?? 0) > 65_535) { fail( opts.port !== undefined @@ -143,6 +149,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { env: process.env, host, port: port ?? 18789, + contextPath, tls, tlsFingerprint: tlsFingerprint || undefined, nodeId: opts.nodeId, diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index 8056496c557..b89de149e56 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -50,6 +50,7 @@ export function registerNodeCli(program: Command) { .description("Run the headless node host (foreground)") .option("--host ", "Gateway host") .option("--port ", "Gateway port") + .option("--context-path ", "Gateway WebSocket context path (e.g. /openclaw-gw)") .option("--tls", "Use TLS for the gateway connection") .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") .option("--node-id ", "Override node id (clears pairing token)") @@ -67,6 +68,7 @@ export function registerNodeCli(program: Command) { return; } const retargetedGateway = opts.host !== undefined || opts.port !== undefined; + const explicitContextPath = opts.contextPath !== undefined; const tlsFingerprint = opts.tlsFingerprint ?? (retargetedGateway ? undefined : existing?.gateway?.tlsFingerprint); const inheritedTls = retargetedGateway ? undefined : existing?.gateway?.tls; @@ -76,6 +78,9 @@ export function registerNodeCli(program: Command) { gatewayTls: typeof opts.tls === "boolean" ? opts.tls : Boolean(tlsFingerprint) || inheritedTls, gatewayTlsFingerprint: tlsFingerprint, + gatewayContextPath: + normalizeOptionalString(opts.contextPath as string | undefined) ?? + (explicitContextPath || retargetedGateway ? undefined : existing?.gateway?.contextPath), nodeId: opts.nodeId, displayName: opts.displayName, }); @@ -94,6 +99,7 @@ export function registerNodeCli(program: Command) { .description("Install the node host service (launchd/systemd/schtasks)") .option("--host ", "Gateway host") .option("--port ", "Gateway port") + .option("--context-path ", "Gateway WebSocket context path (e.g. /openclaw-gw)") .option("--tls", "Use TLS for the gateway connection", false) .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") .option("--node-id ", "Override node id (clears pairing token)") diff --git a/src/commands/node-daemon-install-helpers.ts b/src/commands/node-daemon-install-helpers.ts index 405e0045f53..5269e9a03aa 100644 --- a/src/commands/node-daemon-install-helpers.ts +++ b/src/commands/node-daemon-install-helpers.ts @@ -33,6 +33,7 @@ export async function buildNodeInstallPlan(params: { env: Record; host: string; port: number; + contextPath?: string; tls?: boolean; tlsFingerprint?: string; nodeId?: string; @@ -51,6 +52,7 @@ export async function buildNodeInstallPlan(params: { const { programArguments, workingDirectory } = await resolveNodeProgramArguments({ host: params.host, port: params.port, + contextPath: params.contextPath, tls: params.tls, tlsFingerprint: params.tlsFingerprint, nodeId: params.nodeId, diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index 2c6a09ea116..3bd51836338 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -311,6 +311,7 @@ export async function resolveGatewayProgramArguments(params: { export async function resolveNodeProgramArguments(params: { host: string; port: number; + contextPath?: string; tls?: boolean; tlsFingerprint?: string; nodeId?: string; @@ -326,6 +327,9 @@ export async function resolveNodeProgramArguments(params: { if (params.tlsFingerprint) { args.push("--tls-fingerprint", params.tlsFingerprint); } + if (params.contextPath) { + args.push("--context-path", params.contextPath); + } if (params.nodeId) { args.push("--node-id", params.nodeId); } diff --git a/src/node-host/config.ts b/src/node-host/config.ts index d57d09abb0a..c0fe3a6c40a 100644 --- a/src/node-host/config.ts +++ b/src/node-host/config.ts @@ -17,6 +17,8 @@ export type NodeHostGatewayConfig = { port?: number; tls?: boolean; tlsFingerprint?: string; + /** Gateway WebSocket context path (e.g. "/openclaw-gw"). */ + contextPath?: string; }; type NodeHostConfig = { diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts index 57cb5471e8d..6d618672604 100644 --- a/src/node-host/runner.test.ts +++ b/src/node-host/runner.test.ts @@ -9,11 +9,17 @@ import { const mocks = vi.hoisted(() => ({ capturedGatewayClientOptions: [] as GatewayClientOptions[], + capturedSavedGatewayConfigs: [] as Array<{ contextPath?: string }>, ensureNodeHostConfig: vi.fn(async () => ({ version: 1, nodeId: "node-test", })), - saveNodeHostConfig: vi.fn(async () => undefined), + saveNodeHostConfig: vi.fn(async (cfg: { gateway?: { contextPath?: string } }) => { + if (cfg?.gateway) { + mocks.capturedSavedGatewayConfigs.push(cfg.gateway); + } + return undefined; + }), getRuntimeConfig: vi.fn(() => ({ gateway: { handshakeTimeoutMs: 1_000, @@ -73,6 +79,11 @@ vi.mock("./plugin-node-host.js", () => ({ })), })); +function lastCapturedOptions(): GatewayClientOptions | undefined { + const list = mocks.capturedGatewayClientOptions; + return list[list.length - 1]; +} + describe("runNodeHost", () => { it("maps runtime platforms to gateway platform ids", () => { expect(resolveNodeHostGatewayPlatform("darwin")).toBe("macos"); @@ -102,4 +113,107 @@ describe("runNodeHost", () => { resolveNodeHostGatewayDeviceFamily(process.platform), ); }); + + it("appends context path to the Gateway WebSocket URL", async () => { + await expect( + runNodeHost({ + gatewayHost: "127.0.0.1", + gatewayPort: 18789, + gatewayContextPath: "/gws", + }), + ).rejects.toThrow("event loop readiness timeout"); + + expect(lastCapturedOptions()?.url).toBe("ws://127.0.0.1:18789/gws"); + }); + + it("preserves trailing slash in context path as-is", async () => { + await expect( + runNodeHost({ + gatewayHost: "127.0.0.1", + gatewayPort: 18789, + gatewayContextPath: "/gws/", + }), + ).rejects.toThrow("event loop readiness timeout"); + + expect(lastCapturedOptions()?.url).toBe("ws://127.0.0.1:18789/gws/"); + }); + + it("prepends leading slash when context path is missing one", async () => { + await expect( + runNodeHost({ + gatewayHost: "127.0.0.1", + gatewayPort: 18789, + gatewayContextPath: "gws", + }), + ).rejects.toThrow("event loop readiness timeout"); + + expect(lastCapturedOptions()?.url).toBe("ws://127.0.0.1:18789/gws"); + }); + + it("omits context path when empty or undefined", async () => { + await expect( + runNodeHost({ + gatewayHost: "127.0.0.1", + gatewayPort: 18789, + gatewayContextPath: "", + }), + ).rejects.toThrow("event loop readiness timeout"); + + expect(lastCapturedOptions()?.url).toBe("ws://127.0.0.1:18789"); + }); + + it("saves the gateway config with contextPath to node.json", async () => { + await expect( + runNodeHost({ + gatewayHost: "127.0.0.1", + gatewayPort: 18789, + gatewayContextPath: "/gws", + }), + ).rejects.toThrow("event loop readiness timeout"); + + const lastSaved = + mocks.capturedSavedGatewayConfigs[mocks.capturedSavedGatewayConfigs.length - 1]; + expect(lastSaved?.contextPath).toBe("/gws"); + }); + + it("clears saved contextPath when opts do not pass one (retarget scenario)", async () => { + mocks.ensureNodeHostConfig.mockResolvedValueOnce({ + version: 1, + nodeId: "node-test", + gateway: { contextPath: "/old-path" }, + } as any); + + await expect( + runNodeHost({ + gatewayHost: "192.168.1.1", + gatewayPort: 9999, + }), + ).rejects.toThrow("event loop readiness timeout"); + + const lastSaved = + mocks.capturedSavedGatewayConfigs[mocks.capturedSavedGatewayConfigs.length - 1]; + expect(lastSaved?.contextPath).toBeUndefined(); + expect(lastCapturedOptions()?.url).toBe("ws://192.168.1.1:9999"); + }); + + it("clears saved contextPath when explicitly passed as empty string", async () => { + mocks.ensureNodeHostConfig.mockResolvedValueOnce({ + version: 1, + nodeId: "node-test", + gateway: { contextPath: "/old-path" }, + } as any); + + await expect( + runNodeHost({ + gatewayHost: "127.0.0.1", + gatewayPort: 18789, + gatewayContextPath: "", + }), + ).rejects.toThrow("event loop readiness timeout"); + + const lastSaved = + mocks.capturedSavedGatewayConfigs[mocks.capturedSavedGatewayConfigs.length - 1]; + expect(lastSaved?.contextPath || undefined).toBeUndefined(); + expect(lastCapturedOptions()?.url).toBe("ws://127.0.0.1:18789"); + }); }); diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 6ca58a9eb4c..ae12b38621c 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -36,6 +36,8 @@ type NodeHostRunOptions = { gatewayPort: number; gatewayTls?: boolean; gatewayTlsFingerprint?: string; + /** Optional WebSocket context path (e.g. "/openclaw-gw"). */ + gatewayContextPath?: string; nodeId?: string; displayName?: string; }; @@ -246,6 +248,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { port: opts.gatewayPort, tls: opts.gatewayTls ?? getRuntimeConfig().gateway?.tls?.enabled ?? false, tlsFingerprint: opts.gatewayTlsFingerprint, + contextPath: opts.gatewayContextPath, }; config.gateway = gateway; await saveNodeHostConfig(config); @@ -261,7 +264,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const host = gateway.host ?? "127.0.0.1"; const port = gateway.port ?? 18789; const scheme = gateway.tls ? "wss" : "ws"; - const url = `${scheme}://${host}:${port}`; + const contextPath = gateway.contextPath + ? gateway.contextPath.startsWith("/") + ? gateway.contextPath + : `/${gateway.contextPath}` + : ""; + const url = `${scheme}://${host}:${port}${contextPath}`; const pathEnv = ensureNodePathEnv(); const client = new GatewayClient({ @@ -302,6 +310,9 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { // keep retrying (handled by GatewayClient) writeStderrLine(`node host gateway connect failed: ${err.message}`); }, + onHelloOk: () => { + writeStderrLine(`node host gateway connected: ${url}`); + }, onReconnectPaused: (info) => { handleNodeHostReconnectPaused(info); },