feat(node): add gateway context path support

This commit is contained in:
wm0018 2026-07-01 18:58:47 +08:00 committed by GitHub
parent 98254634f1
commit dcc2db1825
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 152 additions and 4 deletions

View file

@ -58,6 +58,7 @@ Options:
- `--host <host>`: Gateway WebSocket host (default: `127.0.0.1`)
- `--port <port>`: Gateway WebSocket port (default: `18789`)
- `--context-path <path>`: Gateway WebSocket context path (e.g. `/openclaw-gw`). Appended to the WebSocket URL.
- `--tls`: Use TLS for the gateway connection
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256)
- `--node-id <id>`: Override node id (clears pairing token)
@ -95,6 +96,7 @@ Options:
- `--host <host>`: Gateway WebSocket host (default: `127.0.0.1`)
- `--port <port>`: Gateway WebSocket port (default: `18789`)
- `--context-path <path>`: Gateway WebSocket context path (e.g. `/openclaw-gw`). Appended to the WebSocket URL.
- `--tls`: Use TLS for the gateway connection
- `--tls-fingerprint <sha256>`: Expected TLS certificate fingerprint (sha256)
- `--node-id <id>`: Override node id (clears pairing token)

View file

@ -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,

View file

@ -50,6 +50,7 @@ export function registerNodeCli(program: Command) {
.description("Run the headless node host (foreground)")
.option("--host <host>", "Gateway host")
.option("--port <port>", "Gateway port")
.option("--context-path <path>", "Gateway WebSocket context path (e.g. /openclaw-gw)")
.option("--tls", "Use TLS for the gateway connection")
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <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 <host>", "Gateway host")
.option("--port <port>", "Gateway port")
.option("--context-path <path>", "Gateway WebSocket context path (e.g. /openclaw-gw)")
.option("--tls", "Use TLS for the gateway connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")

View file

@ -33,6 +33,7 @@ export async function buildNodeInstallPlan(params: {
env: Record<string, string | undefined>;
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,

View file

@ -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);
}

View file

@ -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 = {

View file

@ -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");
});
});

View file

@ -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<void> {
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<void> {
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<void> {
// keep retrying (handled by GatewayClient)
writeStderrLine(`node host gateway connect failed: ${err.message}`);
},
onHelloOk: () => {
writeStderrLine(`node host gateway connected: ${url}`);
},
onReconnectPaused: (info) => {
handleNodeHostReconnectPaused(info);
},