Files
openclaw/src/node-host/config.ts
Peter Steinberger da69daeb72 feat(onboarding): recommend plugins and skills from installed apps (#109668)
* feat(onboarding): recommend plugins and skills from installed apps

Scan installed macOS apps during classic onboarding (TCC-free), gather
candidates from official catalogs + ClawHub search, let the configured
model pick genuine matches, and offer an opt-in multiselect install step.
Adds a device.apps node-host command (default-off sharing, Android-parity
envelope) so remote gateways can request a paired Mac's inventory, and a
wizard.appRecommendations kill switch. Custom setup-inference completions
no longer inherit the 32-token verification-probe output cap.

* feat(onboarding): recommend apps in guided flow

* fix(onboarding): harden app recommendations against ClawHub self-promotion

Third-party ClawHub skills are never pre-selected regardless of model tier
(publisher-controlled listing text reaches the matcher prompt and could
promote itself); their labels now say they install third-party code.
Installed-app scans follow symlinked .app bundles. Matcher output stays
bounded by the resolved model's own maxTokens budget (documented invariant).

* fix(onboarding): key official catalog candidates by resolved plugin id

Real catalog entries are package manifests without a top-level id; keying the
candidate map and channel/provider classification by entry.id collapsed the
whole official catalog into one undefined-keyed entry, so no official plugin
or channel was ever recommended. Regression test runs against the bundled
catalogs.

* fix(onboarding): satisfy lint, types, deadcode, and migration gates

Split the guided-onboarding test into a self-contained custodian suite to stay
under max-lines. Narrow app-recommendation exports (drop dead node-payload
normalizer, unexport internal types/helpers, route candidate tests through the
public API), replace map-spread with a helper, unexport device.apps result
types, add installedAppsSharing to node-host migration expectations, cast the
wizard multiselect mock, and regenerate the docs map.

* test(onboarding): register new live test in the shard classifier
2026-07-17 14:07:59 +01:00

266 lines
9.7 KiB
TypeScript

/** 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 type { Insertable, Selectable } from "kysely";
import { resolveStateDir } from "../config/paths.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.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;
};
export type NodeHostConfig = {
version: 1;
nodeId: string;
displayName?: string;
gateway?: NodeHostGatewayConfig;
/** Share installed macOS applications through device.apps (default: false). */
installedAppsSharing?: boolean;
};
export const NODE_HOST_CONFIG_KEY = "current";
export const LEGACY_NODE_HOST_CONFIG_FILE = "node.json";
export const LEGACY_NODE_HOST_CONFIG_CLAIM_SUFFIX = ".doctor-importing";
type NodeHostConfigDatabase = Pick<OpenClawStateKyselyDatabase, "node_host_config">;
type NodeHostConfigRow = Selectable<NodeHostConfigDatabase["node_host_config"]>;
type NodeHostConfigRuntimeRow = Omit<NodeHostConfigRow, "token">;
type NodeHostConfigInsert = Insertable<NodeHostConfigDatabase["node_host_config"]>;
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: string | null, label: string): string | undefined {
if (value === null) {
return undefined;
}
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: number | null | undefined, label: string): number | undefined {
if (value === null || value === undefined) {
return undefined;
}
if (!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 rowToNodeHostConfig(row: NodeHostConfigRuntimeRow): NodeHostConfig {
if (row.version !== 1) {
throw new Error(`invalid node-host SQLite row: unsupported version ${String(row.version)}`);
}
const nodeId = row.node_id.trim();
if (!nodeId) {
throw new Error("invalid node-host SQLite row: node_id must not be empty");
}
if (!Number.isSafeInteger(row.updated_at_ms) || row.updated_at_ms < 0) {
throw new Error("invalid node-host SQLite row: updated_at_ms must be a non-negative integer");
}
if (row.gateway_tls !== null && row.gateway_tls !== 0 && row.gateway_tls !== 1) {
throw new Error("invalid node-host SQLite row: gateway_tls must be 0, 1, or null");
}
if (row.installed_apps_sharing !== 0 && row.installed_apps_sharing !== 1) {
throw new Error("invalid node-host SQLite row: installed_apps_sharing must be 0 or 1");
}
const gateway: NodeHostGatewayConfig = {
host: optionalNonEmptyString(row.gateway_host, "gateway_host"),
port: validatePort(row.gateway_port, "SQLite gateway_port"),
tls: row.gateway_tls === null ? undefined : row.gateway_tls === 1,
tlsFingerprint: optionalNonEmptyString(row.gateway_tls_fingerprint, "gateway_tls_fingerprint"),
contextPath: optionalNonEmptyString(row.gateway_context_path, "gateway_context_path"),
};
const hasGateway = Object.values(gateway).some((value) => value !== undefined);
return {
version: 1,
nodeId,
displayName: optionalNonEmptyString(row.display_name, "display_name"),
gateway: hasGateway ? gateway : undefined,
installedAppsSharing: row.installed_apps_sharing === 1,
};
}
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),
};
return Object.values(normalized).some((value) => value !== undefined) ? normalized : undefined;
}
function configToRow(params: {
config: NodeHostConfig;
updatedAtMs: number;
}): NodeHostConfigInsert {
const gateway = params.config.gateway;
return {
config_key: NODE_HOST_CONFIG_KEY,
version: 1,
node_id: params.config.nodeId,
token: null,
display_name: params.config.displayName ?? null,
gateway_host: gateway?.host ?? null,
gateway_port: gateway?.port ?? null,
gateway_tls: gateway?.tls === undefined ? null : gateway.tls ? 1 : 0,
gateway_tls_fingerprint: gateway?.tlsFingerprint ?? null,
gateway_context_path: gateway?.contextPath ?? null,
installed_apps_sharing: params.config.installedAppsSharing ? 1 : 0,
updated_at_ms: params.updatedAtMs,
};
}
function readNodeHostConfigRow(
database: ReturnType<typeof openOpenClawStateDatabase>,
): NodeHostConfigRuntimeRow | undefined {
return executeSqliteQueryTakeFirstSync(
database.db,
getNodeSqliteKysely<NodeHostConfigDatabase>(database.db)
.selectFrom("node_host_config")
.select([
"config_key",
"version",
"node_id",
"display_name",
"gateway_host",
"gateway_port",
"gateway_tls",
"gateway_tls_fingerprint",
"gateway_context_path",
"installed_apps_sharing",
"updated_at_ms",
])
.where("config_key", "=", NODE_HOST_CONFIG_KEY),
);
}
/** 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);
const database = openOpenClawStateDatabase(databaseOptions(env));
const row = readNodeHostConfigRow(database);
return row ? rowToNodeHostConfig(row) : null;
}
/**
* 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;
}): 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 updatedAtMs = params.nowMs ?? Date.now();
if (!Number.isSafeInteger(updatedAtMs) || updatedAtMs < 0) {
throw new Error("invalid node-host updatedAtMs: expected a non-negative integer");
}
const config = runOpenClawStateWriteTransaction((database) => {
const { db } = database;
const existingRow = readNodeHostConfigRow(database);
const existing = existingRow ? rowToNodeHostConfig(existingRow) : null;
const nodeId = explicitNodeId ?? existing?.nodeId ?? candidateNodeId;
const displayName = explicitDisplayName ?? existing?.displayName ?? fallbackDisplayName;
const next: NodeHostConfig = {
version: 1,
nodeId,
displayName,
gateway,
installedAppsSharing: params.installedAppsSharing ?? existing?.installedAppsSharing ?? false,
};
const row = configToRow({ config: next, updatedAtMs });
const { config_key: _configKey, ...updates } = row;
executeSqliteQuerySync(
db,
getNodeSqliteKysely<NodeHostConfigDatabase>(db)
.insertInto("node_host_config")
.values(row)
.onConflict((conflict) => conflict.column("config_key").doUpdateSet(updates)),
);
return next;
}, databaseOptions(env));
// Detect a retired writer that recreated node.json while the transaction committed.
assertNodeHostLegacyStateMigrated(env);
return config;
}