openclaw / src /node-host /config.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
253e783 verified
Raw History Blame Contribute Delete
11.8 kB
/** Canonical shared-SQLite configuration for the node-host runner. */
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { resolveStateDir } from "../config/paths.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import { logInfo } from "../logger.js";
import { readConfigMachineStateWithMetadata } from "../state/config-machine-state.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import {
normalizeNodeHostCloudflareAccessConfig,
type NodeHostCloudflareAccessConfig,
} from "./gateway-cloudflare-access.js";
/** Gateway endpoint metadata persisted with node-host config. */
export type NodeHostGatewayConfig = {
host?: string;
port?: number;
tls?: boolean;
tlsFingerprint?: string;
/** Gateway WebSocket context path (e.g. "/openclaw-gw"). */
contextPath?: string;
/** Cloudflare Access service-token inputs bound to this exact Gateway origin. */
cloudflareAccess?: NodeHostCloudflareAccessConfig;
};
export type NodeHostConfig = {
version: 1;
nodeId: string;
displayName?: string;
gateway?: NodeHostGatewayConfig;
/** Share installed macOS applications through device.apps (default: false). */
installedAppsSharing?: boolean;
/** Restrict this host to these exact command ids; omission keeps the full surface. */
commands?: string[];
};
export const NODE_HOST_CONFIG_KEY = "nodeHost.config";
export const LEGACY_NODE_HOST_CONFIG_FILE = "node.json";
export const LEGACY_NODE_HOST_CONFIG_CLAIM_SUFFIX = ".doctor-importing";
type NodeHostConfigDatabase = Pick<OpenClawStateKyselyDatabase, "config_machine_state">;
function databaseOptions(env: NodeJS.ProcessEnv): OpenClawStateDatabaseOptions {
return { env };
}
function resolveLegacyNodeHostConfigPath(env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveStateDir(env), LEGACY_NODE_HOST_CONFIG_FILE);
}
function resolveLegacyNodeHostConfigClaimPath(env: NodeJS.ProcessEnv = process.env): string {
return `${resolveLegacyNodeHostConfigPath(env)}${LEGACY_NODE_HOST_CONFIG_CLAIM_SUFFIX}`;
}
function legacyPathMayExist(filePath: string): boolean {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw new Error(`unable to verify retired node-host state path ${filePath}`, {
cause: error,
});
}
}
/** Runtime must not choose between canonical SQLite state and a retired file store. */
function assertNodeHostLegacyStateMigrated(env: NodeJS.ProcessEnv = process.env): void {
const sourcePath = resolveLegacyNodeHostConfigPath(env);
const claimPath = resolveLegacyNodeHostConfigClaimPath(env);
if (!legacyPathMayExist(sourcePath) && !legacyPathMayExist(claimPath)) {
return;
}
throw new Error(
`retired node-host state remains at ${sourcePath}; stop the node host and run \`openclaw doctor --fix\``,
);
}
function optionalNonEmptyString(value: unknown, label: string): string | undefined {
if (value === null || value === undefined) {
return undefined;
}
if (typeof value !== "string") {
throw new Error(`invalid node-host SQLite row: ${label} must be a string`);
}
const normalized = value.trim();
if (!normalized) {
throw new Error(`invalid node-host SQLite row: ${label} must not be empty`);
}
return normalized;
}
function optionalInputString(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized || undefined;
}
function validatePort(value: unknown, label: string): number | undefined {
if (value === null || value === undefined) {
return undefined;
}
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > 65_535) {
throw new Error(`invalid node-host ${label}: expected an integer between 1 and 65535`);
}
return value;
}
function normalizeStoredNodeHostConfig(value: unknown): NodeHostConfig {
if (!isRecord(value)) {
throw new Error("invalid node-host SQLite row: expected a configuration object");
}
if (value.version !== 1) {
throw new Error(`invalid node-host SQLite row: unsupported version ${String(value.version)}`);
}
const nodeId = typeof value.nodeId === "string" ? value.nodeId.trim() : "";
if (!nodeId) {
throw new Error("invalid node-host SQLite row: node_id must not be empty");
}
const storedGateway = value.gateway;
if (storedGateway !== undefined && !isRecord(storedGateway)) {
throw new Error("invalid node-host SQLite row: gateway must be an object");
}
const gatewayTls = storedGateway?.tls;
if (gatewayTls !== undefined && typeof gatewayTls !== "boolean") {
throw new Error("invalid node-host SQLite row: gateway_tls must be a boolean");
}
if (value.installedAppsSharing !== undefined && typeof value.installedAppsSharing !== "boolean") {
throw new Error("invalid node-host SQLite row: installed_apps_sharing must be a boolean");
}
const gateway = storedGateway
? normalizeGatewayConfig({
host: optionalNonEmptyString(storedGateway.host, "gateway_host"),
port: validatePort(storedGateway.port, "SQLite gateway_port"),
tls: typeof gatewayTls === "boolean" ? gatewayTls : undefined,
tlsFingerprint: optionalNonEmptyString(
storedGateway.tlsFingerprint,
"gateway_tls_fingerprint",
),
contextPath: optionalNonEmptyString(storedGateway.contextPath, "gateway_context_path"),
...cloudflareAccessEntry(
normalizeNodeHostCloudflareAccessConfig(storedGateway.cloudflareAccess),
),
})
: undefined;
return {
version: 1,
nodeId,
displayName: optionalNonEmptyString(value.displayName, "display_name"),
gateway,
installedAppsSharing: value.installedAppsSharing === true,
...(value.commands !== undefined
? { commands: normalizeNodeHostCommands(value.commands) }
: {}),
};
}
function normalizeNodeHostCommands(value: unknown): string[] {
if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || !id.trim())) {
throw new Error("invalid node-host commands: expected an array of non-empty command ids");
}
return [...new Set(value.map((id: string) => id.trim()))].toSorted();
}
// Own-property parity with the retired column reader: an absent Cloudflare
// Access config omits the key entirely so toStrictEqual consumers match.
function cloudflareAccessEntry(cloudflareAccess: NodeHostCloudflareAccessConfig | undefined): {
cloudflareAccess?: NodeHostCloudflareAccessConfig;
} {
return cloudflareAccess ? { cloudflareAccess } : {};
}
function normalizeGatewayConfig(gateway: NodeHostGatewayConfig): NodeHostGatewayConfig | undefined {
const normalized: NodeHostGatewayConfig = {
host: optionalInputString(gateway.host),
port: validatePort(gateway.port, "gateway port"),
tls: gateway.tls,
tlsFingerprint: optionalInputString(gateway.tlsFingerprint),
contextPath: optionalInputString(gateway.contextPath),
...cloudflareAccessEntry(normalizeNodeHostCloudflareAccessConfig(gateway.cloudflareAccess)),
};
return Object.values(normalized).some((value) => value !== undefined) ? normalized : undefined;
}
function readNodeHostConfig(env: NodeJS.ProcessEnv): NodeHostConfig | null {
const stored = readConfigMachineStateWithMetadata<unknown>(
NODE_HOST_CONFIG_KEY,
databaseOptions(env),
);
if (!stored) {
return null;
}
if (!Number.isSafeInteger(stored.updatedAtMs) || stored.updatedAtMs < 0) {
throw new Error("invalid node-host SQLite row: updated_at_ms must be a non-negative integer");
}
return normalizeStoredNodeHostConfig(stored.value);
}
/** Load canonical node-host state. Legacy files block the read until Doctor migrates them. */
export async function loadNodeHostConfig(
env: NodeJS.ProcessEnv = process.env,
): Promise<NodeHostConfig | null> {
assertNodeHostLegacyStateMigrated(env);
return readNodeHostConfig(env);
}
/** Load existing node-host state without creating or joining the writable shared-state lifecycle. */
export async function loadNodeHostConfigReadOnly(
env: NodeJS.ProcessEnv = process.env,
): Promise<NodeHostConfig | null> {
assertNodeHostLegacyStateMigrated(env);
return readNodeHostConfig(env);
}
/**
* Atomically create or replace the complete node-host snapshot.
* Candidate facts are prepared before BEGIN; the transaction rereads the authoritative row.
*/
export async function configureNodeHost(params: {
nodeId?: string;
displayName?: string;
fallbackDisplayName: string;
gateway: NodeHostGatewayConfig;
env?: NodeJS.ProcessEnv;
nowMs?: number;
candidateNodeId?: string;
installedAppsSharing?: boolean;
commands?: string[];
allCommands?: boolean;
}): Promise<NodeHostConfig> {
const env = params.env ?? process.env;
assertNodeHostLegacyStateMigrated(env);
const explicitNodeId = optionalInputString(params.nodeId);
const explicitDisplayName = optionalInputString(params.displayName);
const fallbackDisplayName = optionalInputString(params.fallbackDisplayName);
const candidateNodeId = params.candidateNodeId?.trim() || crypto.randomUUID();
const gateway = normalizeGatewayConfig(params.gateway);
const commands =
params.commands === undefined ? undefined : normalizeNodeHostCommands(params.commands);
const updatedAtMs = params.nowMs ?? Date.now();
if (!Number.isSafeInteger(updatedAtMs) || updatedAtMs < 0) {
throw new Error("invalid node-host updatedAtMs: expected a non-negative integer");
}
let clearedCommands = false;
const config = runOpenClawStateWriteTransaction(({ db }) => {
const stateDb = getNodeSqliteKysely<NodeHostConfigDatabase>(db);
const stored = executeSqliteQueryTakeFirstSync(
db,
stateDb
.selectFrom("config_machine_state")
.select("value_json")
.where("state_key", "=", NODE_HOST_CONFIG_KEY),
);
const existing = stored
? normalizeStoredNodeHostConfig(JSON.parse(stored.value_json) as unknown)
: undefined;
clearedCommands = params.allCommands === true && existing?.commands !== undefined;
const nextCommands = params.allCommands ? undefined : (commands ?? existing?.commands);
const next: NodeHostConfig = {
version: 1,
nodeId: explicitNodeId ?? existing?.nodeId ?? candidateNodeId,
displayName: explicitDisplayName ?? existing?.displayName ?? fallbackDisplayName,
gateway,
installedAppsSharing: params.installedAppsSharing ?? existing?.installedAppsSharing ?? false,
...(nextCommands !== undefined ? { commands: nextCommands } : {}),
};
const valueJson = JSON.stringify(next);
executeSqliteQuerySync(
db,
stateDb
.insertInto("config_machine_state")
.values({
state_key: NODE_HOST_CONFIG_KEY,
value_json: valueJson,
updated_at_ms: updatedAtMs,
})
.onConflict((conflict) =>
conflict
.column("state_key")
.doUpdateSet({ value_json: valueJson, updated_at_ms: updatedAtMs }),
),
);
return next;
}, databaseOptions(env));
// Detect a retired writer that recreated node.json while the transaction committed.
assertNodeHostLegacyStateMigrated(env);
if (clearedCommands) {
logInfo(
"node-host: cleared saved command allowlist; advertising the full default command surface",
);
}
return config;
}