openclaw / src /node-host /config.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
253e783 verified
Raw
History Blame Contribute Delete
12.1 kB
import type { ChildProcess } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runManagedCommand } from "../../scripts/lib/managed-child-process.mts";
import { createBoundedChildOutput } from "../../test/helpers/bounded-child-output.js";
import { createFixtureLifetime } from "../../test/helpers/fixture-lifetime.js";
import { createDeferred, withTestTimeout } from "../../test/helpers/promise.js";
import { resolveRuntimeWorkerArgv, resolveRuntimeWorkerUrl } from "../infra/runtime-worker-url.js";
import { logInfo } from "../logger.js";
import { writeConfigMachineState } from "../state/config-machine-state-write.js";
import {
readConfigMachineState,
readConfigMachineStateWithMetadata,
} from "../state/config-machine-state.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { nodeHostConfigRuntimeEntrypoint } from "./config-runtime.test-support.js";
import {
configureNodeHost,
loadNodeHostConfig,
NODE_HOST_CONFIG_KEY,
type NodeHostConfig,
} from "./config.js";
const fixtureDigest = ["fixture", "digest"].join("-");
const fixture = createFixtureLifetime();
vi.mock("../logger.js", () => ({ logInfo: vi.fn() }));
async function runConcurrentImplicitConfigures(
stateDir: string,
signal: AbortSignal,
): Promise<[NodeHostConfig, NodeHostConfig]> {
const workerUrl = resolveRuntimeWorkerUrl(nodeHostConfigRuntimeEntrypoint);
const cancellation = new AbortController();
const childSignal = AbortSignal.any([signal, cancellation.signal]);
const workers = ["candidate-a", "candidate-b"].map((candidate, index) => {
const ready = createDeferred<ChildProcess>();
const stderr = createBoundedChildOutput();
let config: NodeHostConfig | undefined;
const outcome = fixture.track(
runManagedCommand({
bin: process.execPath,
args: [...resolveRuntimeWorkerArgv(workerUrl), candidate, String(index + 1)],
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
shell: false,
stdio: ["ignore", "ignore", "pipe", "ipc"],
signal: childSignal,
requireProcessTreeExit: process.platform !== "win32",
onReady(child) {
child.stderr!.on("data", stderr.append);
child.on("message", (message: "ready" | NodeHostConfig) => {
if (message === "ready") {
ready.resolve(child);
} else {
config = message;
}
});
},
}).then((code) => {
if (code !== 0) {
throw new Error(`configure worker failed (${code}): ${stderr.text()}`);
}
if (!config) {
throw new Error("configure worker produced no result");
}
return config;
}),
);
return {
ready: Promise.race([
ready.promise,
outcome.then(() => {
throw new Error("configure worker exited before the start barrier");
}),
]),
outcome,
};
});
try {
// Both real processes must finish imports before either can choose its implicit id.
const children = await withTestTimeout(
Promise.all(workers.map(({ ready }) => ready)),
15_000,
"timed out waiting for concurrent configure workers",
);
for (const child of children) {
child.send("start");
}
const outcomes = await Promise.all(workers.map(({ outcome }) => outcome));
const first = outcomes[0];
const second = outcomes[1];
if (!first || !second) {
throw new Error("expected two concurrent configure results");
}
return [first, second];
} finally {
cancellation.abort();
await Promise.allSettled(workers.map(({ outcome }) => outcome));
}
}
describe("node-host SQLite config", () => {
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
await fixture.cleanup();
});
function makeTestEnv(): { env: NodeJS.ProcessEnv; stateDir: string } {
const stateDir = fixture.createTempDir("openclaw-node-host-config-");
return { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, stateDir };
}
it("round-trips the complete gateway snapshot across database reopen", async () => {
const { env, stateDir } = makeTestEnv();
const configured = await configureNodeHost({
nodeId: "node-custom",
displayName: "Build Node",
fallbackDisplayName: "fallback",
gateway: {
host: "gateway.local",
port: 18443,
tls: false,
tlsFingerprint: fixtureDigest,
contextPath: "/openclaw-gw",
cloudflareAccess: {
clientId: { source: "env", provider: "default", id: "CF_ACCESS_CLIENT_ID" },
clientSecret: {
source: "env",
provider: "default",
id: "CF_ACCESS_CLIENT_SECRET",
},
},
},
env,
nowMs: 1_234,
});
expect(configured).toEqual({
version: 1,
nodeId: "node-custom",
displayName: "Build Node",
installedAppsSharing: false,
gateway: {
host: "gateway.local",
port: 18443,
tls: false,
tlsFingerprint: fixtureDigest,
contextPath: "/openclaw-gw",
cloudflareAccess: {
clientId: { source: "env", provider: "default", id: "CF_ACCESS_CLIENT_ID" },
clientSecret: {
source: "env",
provider: "default",
id: "CF_ACCESS_CLIENT_SECRET",
},
},
},
});
expect(readConfigMachineState<NodeHostConfig>(NODE_HOST_CONFIG_KEY, { env })).toEqual(
configured,
);
expect(readConfigMachineStateWithMetadata(NODE_HOST_CONFIG_KEY, { env })?.updatedAtMs).toBe(
1_234,
);
expect(readConfigMachineState(NODE_HOST_CONFIG_KEY, { env })).not.toHaveProperty("token");
closeOpenClawStateDatabaseForTest();
await expect(loadNodeHostConfig(env)).resolves.toEqual(configured);
await expect(fs.stat(path.join(stateDir, "node.json"))).rejects.toMatchObject({
code: "ENOENT",
});
});
it("keeps installed-app sharing disabled by default and persists an explicit enable", async () => {
const { env } = makeTestEnv();
const initial = await configureNodeHost({
fallbackDisplayName: "node",
gateway: {},
env,
nowMs: 1,
});
expect(initial.installedAppsSharing).toBe(false);
const enabled = await configureNodeHost({
fallbackDisplayName: "node",
gateway: {},
installedAppsSharing: true,
env,
nowMs: 2,
});
expect(enabled.installedAppsSharing).toBe(true);
closeOpenClawStateDatabaseForTest();
await expect(loadNodeHostConfig(env)).resolves.toMatchObject({ installedAppsSharing: true });
});
it("normalizes command restrictions and preserves them across reopen and endpoint updates", async () => {
const { env } = makeTestEnv();
await configureNodeHost({
fallbackDisplayName: "node",
gateway: {},
commands: [" fixture.read ", "fixture.list", "fixture.read"],
env,
});
closeOpenClawStateDatabaseForTest();
await expect(loadNodeHostConfig(env)).resolves.toMatchObject({
commands: ["fixture.list", "fixture.read"],
});
await expect(
configureNodeHost({ fallbackDisplayName: "node", gateway: { host: "new.example" }, env }),
).resolves.toMatchObject({ commands: ["fixture.list", "fixture.read"] });
await configureNodeHost({ fallbackDisplayName: "node", gateway: {}, commands: [], env });
closeOpenClawStateDatabaseForTest();
await expect(loadNodeHostConfig(env)).resolves.toMatchObject({ commands: [] });
});
it("removes a saved command allowlist durably when all commands are selected", async () => {
vi.mocked(logInfo).mockClear();
const { env } = makeTestEnv();
const params = { fallbackDisplayName: "node", gateway: {}, env };
const restricted = await configureNodeHost({ ...params, commands: ["fixture.read"] });
const cleared = await configureNodeHost({ ...params, allCommands: true });
expect(cleared).not.toHaveProperty("commands");
expect(cleared.nodeId).toBe(restricted.nodeId);
closeOpenClawStateDatabaseForTest();
expect(readConfigMachineState(NODE_HOST_CONFIG_KEY, { env })).not.toHaveProperty("commands");
await expect(loadNodeHostConfig(env)).resolves.not.toHaveProperty("commands");
await expect(configureNodeHost(params)).resolves.not.toHaveProperty("commands");
await configureNodeHost({ ...params, allCommands: true });
expect(logInfo).toHaveBeenCalledExactlyOnceWith(
"node-host: cleared saved command allowlist; advertising the full default command surface",
);
});
it.each([{ commands: "fixture.read" }, { commands: [""] }, { commands: [42] }])(
"rejects corrupt command restrictions rather than exposing the full host: $commands",
async ({ commands }) => {
const { env } = makeTestEnv();
writeConfigMachineState(
NODE_HOST_CONFIG_KEY,
{ version: 1, nodeId: "node", commands },
{ env },
);
await expect(loadNodeHostConfig(env)).rejects.toThrow("invalid node-host commands");
},
);
it("keeps the first committed implicit node id across processes", async ({ signal }) => {
const { env, stateDir } = makeTestEnv();
const [first, second] = await fixture.run(() =>
runConcurrentImplicitConfigures(stateDir, signal),
);
expect(["candidate-a", "candidate-b"]).toContain(first.nodeId);
expect(second.nodeId).toBe(first.nodeId);
expect(first.gateway).toBeUndefined();
expect(second.gateway).toBeUndefined();
await expect(loadNodeHostConfig(env)).resolves.toEqual(second);
}, 30_000);
it("preserves explicit custom ids and atomically clears omitted gateway fields", async () => {
const { env } = makeTestEnv();
await configureNodeHost({
nodeId: "first-custom-id",
fallbackDisplayName: "node",
gateway: {
host: "old.example",
port: 443,
tls: true,
tlsFingerprint: fixtureDigest,
contextPath: "/old",
},
env,
nowMs: 20,
});
const configured = await configureNodeHost({
nodeId: "custom id with spaces inside",
fallbackDisplayName: "node",
gateway: { host: "new.example", port: 18789, tls: false },
env,
nowMs: 21,
});
expect(configured).toMatchObject({
nodeId: "custom id with spaces inside",
gateway: { host: "new.example", port: 18789, tls: false },
});
expect(configured.gateway?.tlsFingerprint).toBeUndefined();
expect(configured.gateway?.contextPath).toBeUndefined();
await expect(loadNodeHostConfig(env)).resolves.toEqual(configured);
});
it("rejects corrupt canonical rows instead of rotating identity", async () => {
const { env } = makeTestEnv();
writeConfigMachineState(NODE_HOST_CONFIG_KEY, { version: 2, nodeId: "stale-node" }, { env });
await expect(loadNodeHostConfig(env)).rejects.toThrow("unsupported version 2");
await expect(
configureNodeHost({ fallbackDisplayName: "node", gateway: {}, env }),
).rejects.toThrow("unsupported version 2");
});
it.each(["source", "claim", "dangling-source-symlink"] as const)(
"blocks runtime while retired state remains: %s",
async (kind) => {
const { env, stateDir } = makeTestEnv();
const sourcePath = path.join(stateDir, "node.json");
const claimPath = `${sourcePath}.doctor-importing`;
if (kind === "source") {
await fs.writeFile(sourcePath, "{}\n", "utf8");
} else if (kind === "claim") {
await fs.writeFile(claimPath, "{}\n", "utf8");
} else {
await fs.symlink(path.join(stateDir, "missing-node.json"), sourcePath);
}
await expect(loadNodeHostConfig(env)).rejects.toThrow("openclaw doctor --fix");
await expect(
configureNodeHost({ fallbackDisplayName: "node", gateway: {}, env }),
).rejects.toThrow("openclaw doctor --fix");
},
);
});