mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-17 10:21:37 +00:00
fix(nodes): keep Gateway connections healthy and consistent (#103093)
* fix(gateway): evict unresponsive node sockets Co-authored-by: hoangsaga123 <hoangsaga123@gmail.com> * fix(nodes): align invoke timeout semantics * fix(node): preserve managed connection settings * docs(changelog): note node connection fixes * fix(node): redact service credentials in status * fix(systemd): preserve escaped operator values * fix(node): carry explicit plaintext service mode * fix(systemd): avoid promoting stale shell references * fix(node): clear fingerprints for plaintext runs * docs(changelog): defer node notes to release * fix(nodes): normalize forwarded system run timeout * fix(systemd): preserve operator environment values * fix(nodes): type normalized invoke payload --------- Co-authored-by: hoangsaga123 <hoangsaga123@gmail.com>
This commit is contained in:
committed by
GitHub
parent
b8537cd81e
commit
b4428fb9df
@@ -1,7 +1,8 @@
|
||||
// Node daemon tests cover node daemon command runtime behavior and errors.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js";
|
||||
import { runNodeDaemonStatus } from "./daemon.js";
|
||||
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
|
||||
import { runNodeDaemonInstall, runNodeDaemonStatus } from "./daemon.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const service = {
|
||||
@@ -14,7 +15,7 @@ const mocks = vi.hoisted(() => {
|
||||
stop: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
isLoaded: vi.fn(async () => true),
|
||||
readCommand: vi.fn(async () => null),
|
||||
readCommand: vi.fn<() => Promise<GatewayServiceCommandConfig | null>>(async () => null),
|
||||
readRuntime: vi.fn<() => Promise<GatewayServiceRuntime>>(async () => ({ status: "running" })),
|
||||
};
|
||||
return {
|
||||
@@ -25,6 +26,12 @@ const mocks = vi.hoisted(() => {
|
||||
exit: vi.fn(),
|
||||
},
|
||||
service,
|
||||
buildNodeInstallPlan: vi.fn(async () => ({
|
||||
programArguments: ["node", "node-host"],
|
||||
environment: {},
|
||||
environmentValueSources: {},
|
||||
})),
|
||||
loadNodeHostConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -36,6 +43,14 @@ vi.mock("../../daemon/node-service.js", () => ({
|
||||
resolveNodeService: () => mocks.service,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/node-daemon-install-helpers.js", () => ({
|
||||
buildNodeInstallPlan: mocks.buildNodeInstallPlan,
|
||||
}));
|
||||
|
||||
vi.mock("../../node-host/config.js", () => ({
|
||||
loadNodeHostConfig: mocks.loadNodeHostConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../../daemon/runtime-hints.js", () => ({
|
||||
buildPlatformRuntimeLogHints: () => [
|
||||
"Logs: node service log",
|
||||
@@ -70,9 +85,77 @@ vi.mock("../daemon-cli/shared.js", async () => {
|
||||
}),
|
||||
formatRuntimeStatus: (runtime: GatewayServiceRuntime | undefined) => runtime?.status ?? "",
|
||||
resolveRuntimeStatusColor: () => "",
|
||||
failIfNixDaemonInstallMode: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
describe("runNodeDaemonInstall", () => {
|
||||
beforeEach(() => {
|
||||
mocks.runtime.log.mockClear();
|
||||
mocks.runtime.error.mockClear();
|
||||
mocks.runtime.writeJson.mockClear();
|
||||
mocks.runtime.exit.mockClear();
|
||||
mocks.service.install.mockReset().mockResolvedValue(undefined);
|
||||
mocks.service.isLoaded.mockReset().mockResolvedValue(false);
|
||||
mocks.buildNodeInstallPlan.mockReset().mockResolvedValue({
|
||||
programArguments: ["node", "node-host"],
|
||||
environment: {},
|
||||
environmentValueSources: {},
|
||||
});
|
||||
mocks.loadNodeHostConfig.mockReset().mockResolvedValue({
|
||||
gateway: {
|
||||
host: "saved-gateway.local",
|
||||
port: 18789,
|
||||
contextPath: "/saved",
|
||||
tls: true,
|
||||
tlsFingerprint: "saved-fingerprint",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["host", { host: "new-gateway.local" }],
|
||||
["port", { port: 19_001 }],
|
||||
])("does not inherit saved TLS when %s explicitly retargets the gateway", async (_name, opts) => {
|
||||
await runNodeDaemonInstall({ ...opts, force: true });
|
||||
|
||||
expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tls: false,
|
||||
tlsFingerprint: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("inherits saved TLS when the gateway endpoint is unchanged", async () => {
|
||||
await runNodeDaemonInstall({ force: true });
|
||||
|
||||
expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: "saved-gateway.local",
|
||||
port: 18789,
|
||||
contextPath: "/saved",
|
||||
tls: true,
|
||||
tlsFingerprint: "saved-fingerprint",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["host", { host: "saved-gateway.local" }],
|
||||
["port", { port: 18_789 }],
|
||||
])("keeps saved TLS when explicit %s resolves to the saved endpoint", async (_name, opts) => {
|
||||
await runNodeDaemonInstall({ ...opts, force: true });
|
||||
|
||||
expect(mocks.buildNodeInstallPlan).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tls: true,
|
||||
tlsFingerprint: "saved-fingerprint",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runNodeDaemonStatus", () => {
|
||||
function stdout(): string {
|
||||
return mocks.runtime.log.mock.calls.map(([line]) => line).join("\n");
|
||||
@@ -115,4 +198,28 @@ describe("runNodeDaemonStatus", () => {
|
||||
expect(stderr()).not.toContain("Logs: node service log");
|
||||
expect(stderr()).not.toContain("Restart attempts: node restart log");
|
||||
});
|
||||
|
||||
it("redacts service credentials from JSON status output", async () => {
|
||||
mocks.service.readCommand.mockResolvedValue({
|
||||
programArguments: ["node", "node-host"],
|
||||
environment: {
|
||||
OPENCLAW_PROFILE: "work",
|
||||
OPENCLAW_GATEWAY_TOKEN: "gateway-token",
|
||||
OPENCLAW_GATEWAY_PASSWORD: "gateway-password",
|
||||
},
|
||||
});
|
||||
|
||||
await runNodeDaemonStatus({ json: true });
|
||||
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith({
|
||||
service: expect.objectContaining({
|
||||
command: expect.objectContaining({
|
||||
environment: { OPENCLAW_PROFILE: "work" },
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const payload = JSON.stringify(mocks.runtime.writeJson.mock.calls[0]?.[0]);
|
||||
expect(payload).not.toContain("gateway-token");
|
||||
expect(payload).not.toContain("gateway-password");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
createCliStatusTextStyles,
|
||||
createDaemonInstallActionContext,
|
||||
failIfNixDaemonInstallMode,
|
||||
filterDaemonEnv,
|
||||
formatRuntimeStatus,
|
||||
parsePort,
|
||||
resolveRuntimeStatusColor,
|
||||
@@ -81,18 +82,23 @@ function resolveNodeDefaults(
|
||||
config: Awaited<ReturnType<typeof loadNodeHostConfig>>,
|
||||
) {
|
||||
// CLI flags override node-host config; missing values fall back to loopback Gateway defaults.
|
||||
const host = normalizeOptionalString(opts.host) || config?.gateway?.host || "127.0.0.1";
|
||||
const savedHost = config?.gateway?.host || "127.0.0.1";
|
||||
const host = normalizeOptionalString(opts.host) || savedHost;
|
||||
const retargeted = opts.host !== undefined || opts.port !== undefined;
|
||||
const portOverride = parsePort(opts.port);
|
||||
if (opts.port !== undefined && portOverride === null) {
|
||||
return { host, port: null };
|
||||
return { host, port: null, retargeted, endpointChanged: false };
|
||||
}
|
||||
const port = portOverride ?? config?.gateway?.port ?? 18789;
|
||||
const retargeted = opts.host !== undefined || opts.port !== undefined;
|
||||
const savedPort = config?.gateway?.port ?? 18789;
|
||||
const port = portOverride ?? savedPort;
|
||||
const endpointChanged =
|
||||
(opts.host !== undefined && host !== savedHost) ||
|
||||
(opts.port !== undefined && port !== savedPort);
|
||||
const explicitContextPath = opts.contextPath !== undefined;
|
||||
const contextPath =
|
||||
normalizeOptionalString(opts.contextPath) ||
|
||||
(explicitContextPath || retargeted ? undefined : config?.gateway?.contextPath);
|
||||
return { host, port, contextPath };
|
||||
return { host, port, contextPath, retargeted, endpointChanged };
|
||||
}
|
||||
|
||||
export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
|
||||
@@ -102,7 +108,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
|
||||
}
|
||||
|
||||
const config = await loadNodeHostConfig();
|
||||
const { host, port, contextPath } = resolveNodeDefaults(opts, config);
|
||||
const { host, port, contextPath, endpointChanged } = resolveNodeDefaults(opts, config);
|
||||
if (!Number.isFinite(port ?? Number.NaN) || (port ?? 0) <= 0 || (port ?? 0) > 65_535) {
|
||||
fail(
|
||||
opts.port !== undefined
|
||||
@@ -142,8 +148,10 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
|
||||
}
|
||||
|
||||
const tlsFingerprint =
|
||||
normalizeOptionalString(opts.tlsFingerprint) || config?.gateway?.tlsFingerprint;
|
||||
const tls = Boolean(opts.tls) || Boolean(tlsFingerprint) || Boolean(config?.gateway?.tls);
|
||||
normalizeOptionalString(opts.tlsFingerprint) ||
|
||||
(endpointChanged ? undefined : config?.gateway?.tlsFingerprint);
|
||||
const inheritedTls = endpointChanged ? undefined : config?.gateway?.tls;
|
||||
const tls = Boolean(opts.tls) || Boolean(tlsFingerprint) || Boolean(inheritedTls);
|
||||
const { programArguments, workingDirectory, environment, environmentValueSources, description } =
|
||||
await buildNodeInstallPlan({
|
||||
env: process.env,
|
||||
@@ -248,7 +256,18 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
|
||||
};
|
||||
|
||||
if (json) {
|
||||
defaultRuntime.writeJson(payload);
|
||||
const safeEnvironment = filterDaemonEnv(command?.environment);
|
||||
defaultRuntime.writeJson({
|
||||
service: {
|
||||
...payload.service,
|
||||
command: command
|
||||
? {
|
||||
...command,
|
||||
environment: Object.keys(safeEnvironment).length > 0 ? safeEnvironment : undefined,
|
||||
}
|
||||
: command,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -134,4 +134,39 @@ describe("registerNodeCli", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes an explicit plaintext selection to the node host", async () => {
|
||||
daemonMocks.loadNodeHostConfig.mockResolvedValue({
|
||||
version: 1,
|
||||
nodeId: "node-existing",
|
||||
gateway: {
|
||||
host: "10.0.0.2",
|
||||
port: 19001,
|
||||
tls: true,
|
||||
tlsFingerprint: "saved-fingerprint",
|
||||
},
|
||||
});
|
||||
|
||||
await createProgram().parseAsync(["node", "run", "--no-tls"], { from: "user" });
|
||||
|
||||
expect(daemonMocks.runNodeHost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gatewayTls: false,
|
||||
gatewayTlsFingerprint: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a TLS fingerprint with an explicit plaintext selection", async () => {
|
||||
await createProgram().parseAsync(
|
||||
["node", "run", "--no-tls", "--tls-fingerprint", "sha256:fingerprint"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(daemonMocks.runNodeHost).not.toHaveBeenCalled();
|
||||
expect(daemonMocks.defaultRuntime.error).toHaveBeenCalledWith(
|
||||
"--no-tls cannot be combined with --tls-fingerprint",
|
||||
);
|
||||
expect(daemonMocks.defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ export function registerNodeCli(program: Command) {
|
||||
.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("--no-tls", "Disable TLS for the gateway connection")
|
||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
||||
.option("--node-id <id>", "Override node id (clears pairing token)")
|
||||
.option("--display-name <name>", "Override node display name")
|
||||
@@ -69,8 +70,16 @@ export function registerNodeCli(program: Command) {
|
||||
}
|
||||
const retargetedGateway = opts.host !== undefined || opts.port !== undefined;
|
||||
const explicitContextPath = opts.contextPath !== undefined;
|
||||
const explicitTlsDisabled = opts.tls === false;
|
||||
if (explicitTlsDisabled && opts.tlsFingerprint !== undefined) {
|
||||
defaultRuntime.error("--no-tls cannot be combined with --tls-fingerprint");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const tlsFingerprint =
|
||||
opts.tlsFingerprint ?? (retargetedGateway ? undefined : existing?.gateway?.tlsFingerprint);
|
||||
explicitTlsDisabled || retargetedGateway
|
||||
? opts.tlsFingerprint
|
||||
: (opts.tlsFingerprint ?? existing?.gateway?.tlsFingerprint);
|
||||
const inheritedTls = retargetedGateway ? undefined : existing?.gateway?.tls;
|
||||
await runNodeHost({
|
||||
gatewayHost: host,
|
||||
|
||||
Reference in New Issue
Block a user