mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
feat(node): add gateway context path support
This commit is contained in:
parent
98254634f1
commit
dcc2db1825
8 changed files with 152 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue