mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 13:11:17 +00:00
* 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
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
|
import {
|
|
executeSqliteQuerySync,
|
|
executeSqliteQueryTakeFirstSync,
|
|
getNodeSqliteKysely,
|
|
} from "../infra/kysely-sync.js";
|
|
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
|
import {
|
|
closeOpenClawStateDatabaseForTest,
|
|
openOpenClawStateDatabase,
|
|
runOpenClawStateWriteTransaction,
|
|
} from "../state/openclaw-state-db.js";
|
|
import { configureNodeHost, loadNodeHostConfig, type NodeHostConfig } from "./config.js";
|
|
|
|
const fixtureDigest = ["fixture", "digest"].join("-");
|
|
|
|
function readStoredToken(env: NodeJS.ProcessEnv): string | null | undefined {
|
|
const database = openOpenClawStateDatabase({ env });
|
|
return executeSqliteQueryTakeFirstSync(
|
|
database.db,
|
|
getNodeSqliteKysely<Pick<OpenClawStateKyselyDatabase, "node_host_config">>(database.db)
|
|
.selectFrom("node_host_config")
|
|
.select("token")
|
|
.where("config_key", "=", "current"),
|
|
)?.token;
|
|
}
|
|
|
|
async function runConcurrentImplicitConfigures(
|
|
stateDir: string,
|
|
): Promise<[NodeHostConfig, NodeHostConfig]> {
|
|
const startPath = path.join(stateDir, "configure-start");
|
|
const moduleUrl = new URL("./config.ts", import.meta.url).href;
|
|
const workerSource = `
|
|
import fs from "node:fs";
|
|
const { configureNodeHost } = await import(process.env.OPENCLAW_NODE_HOST_CONFIG_MODULE);
|
|
fs.writeFileSync(process.env.OPENCLAW_NODE_HOST_READY_PATH, "ready");
|
|
const deadline = Date.now() + 15_000;
|
|
while (!fs.existsSync(process.env.OPENCLAW_NODE_HOST_START_PATH)) {
|
|
if (Date.now() >= deadline) {
|
|
throw new Error("timed out waiting for concurrent node-host configure start");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
}
|
|
const config = await configureNodeHost({
|
|
candidateNodeId: process.env.OPENCLAW_NODE_HOST_CANDIDATE,
|
|
fallbackDisplayName: "node",
|
|
gateway: {},
|
|
env: { ...process.env, OPENCLAW_STATE_DIR: process.env.OPENCLAW_NODE_HOST_STATE_DIR },
|
|
nowMs: Number(process.env.OPENCLAW_NODE_HOST_NOW_MS),
|
|
});
|
|
console.log(JSON.stringify(config));
|
|
`;
|
|
const workers = ["candidate-a", "candidate-b"].map((candidate, index) => {
|
|
const readyPath = path.join(stateDir, `configure-ready-${index}`);
|
|
const child = spawn(
|
|
process.execPath,
|
|
["--import", "tsx", "--input-type=module", "-e", workerSource],
|
|
{
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_NODE_HOST_CANDIDATE: candidate,
|
|
OPENCLAW_NODE_HOST_CONFIG_MODULE: moduleUrl,
|
|
OPENCLAW_NODE_HOST_NOW_MS: String(index + 1),
|
|
OPENCLAW_NODE_HOST_READY_PATH: readyPath,
|
|
OPENCLAW_NODE_HOST_START_PATH: startPath,
|
|
OPENCLAW_NODE_HOST_STATE_DIR: stateDir,
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => (stdout += String(chunk)));
|
|
child.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
|
const outcome = new Promise<NodeHostConfig>((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.once("close", (code, signal) => {
|
|
if (code !== 0) {
|
|
reject(new Error(`configure worker failed (${String(code ?? signal)}): ${stderr}`));
|
|
return;
|
|
}
|
|
const resultLine = stdout.trim().split("\n").at(-1);
|
|
if (!resultLine) {
|
|
reject(new Error("configure worker produced no result"));
|
|
return;
|
|
}
|
|
resolve(JSON.parse(resultLine) as NodeHostConfig);
|
|
});
|
|
});
|
|
return { child, outcome, readyPath };
|
|
});
|
|
|
|
try {
|
|
const deadline = Date.now() + 15_000;
|
|
while (true) {
|
|
const ready = await Promise.all(
|
|
workers.map(async ({ readyPath }) =>
|
|
fs.access(readyPath).then(
|
|
() => true,
|
|
() => false,
|
|
),
|
|
),
|
|
);
|
|
if (ready.every(Boolean)) {
|
|
break;
|
|
}
|
|
if (workers.some(({ child }) => child.exitCode !== null || child.signalCode !== null)) {
|
|
break;
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
throw new Error("timed out waiting for concurrent configure workers");
|
|
}
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 2);
|
|
});
|
|
}
|
|
await fs.writeFile(startPath, "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 {
|
|
for (const { child } of workers) {
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
child.kill();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("node-host SQLite config", () => {
|
|
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
|
|
afterEach(() => {
|
|
closeOpenClawStateDatabaseForTest();
|
|
cleanup();
|
|
});
|
|
});
|
|
|
|
function makeTestEnv(): { env: NodeJS.ProcessEnv; stateDir: string } {
|
|
const stateDir = tempDirs.make("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",
|
|
},
|
|
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",
|
|
},
|
|
});
|
|
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("adds the gateway context-path column to an existing state database", async () => {
|
|
const { env } = makeTestEnv();
|
|
const database = openOpenClawStateDatabase({ env });
|
|
database.db.exec("ALTER TABLE node_host_config DROP COLUMN gateway_context_path");
|
|
closeOpenClawStateDatabaseForTest();
|
|
|
|
const configured = await configureNodeHost({
|
|
fallbackDisplayName: "node",
|
|
gateway: { contextPath: "/upgraded" },
|
|
env,
|
|
nowMs: 1,
|
|
candidateNodeId: "upgraded-node",
|
|
});
|
|
|
|
expect(configured.gateway?.contextPath).toBe("/upgraded");
|
|
const columns = openOpenClawStateDatabase({ env })
|
|
.db.prepare("PRAGMA table_info(node_host_config)")
|
|
.all() as Array<{ name?: unknown }>;
|
|
expect(columns).toContainEqual(expect.objectContaining({ name: "gateway_context_path" }));
|
|
});
|
|
|
|
it("keeps the first committed implicit node id across processes", async () => {
|
|
const { env, stateDir } = makeTestEnv();
|
|
const [first, second] = await runConcurrentImplicitConfigures(stateDir);
|
|
|
|
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();
|
|
runOpenClawStateWriteTransaction(
|
|
({ db }) => {
|
|
executeSqliteQuerySync(
|
|
db,
|
|
getNodeSqliteKysely<Pick<OpenClawStateKyselyDatabase, "node_host_config">>(db)
|
|
.insertInto("node_host_config")
|
|
.values({
|
|
config_key: "current",
|
|
version: 2,
|
|
node_id: "stale-node",
|
|
token: null,
|
|
display_name: null,
|
|
gateway_host: null,
|
|
gateway_port: null,
|
|
gateway_tls: null,
|
|
gateway_tls_fingerprint: null,
|
|
gateway_context_path: null,
|
|
updated_at_ms: 1,
|
|
}),
|
|
);
|
|
},
|
|
{ env },
|
|
);
|
|
|
|
await expect(loadNodeHostConfig(env)).rejects.toThrow("unsupported version 2");
|
|
await expect(
|
|
configureNodeHost({ fallbackDisplayName: "node", gateway: {}, env }),
|
|
).rejects.toThrow("unsupported version 2");
|
|
});
|
|
|
|
it("never reads legacy token material and nulls it on every configure", async () => {
|
|
const { env } = makeTestEnv();
|
|
runOpenClawStateWriteTransaction(
|
|
({ db }) => {
|
|
executeSqliteQuerySync(
|
|
db,
|
|
getNodeSqliteKysely<Pick<OpenClawStateKyselyDatabase, "node_host_config">>(db)
|
|
.insertInto("node_host_config")
|
|
.values({
|
|
config_key: "current",
|
|
version: 1,
|
|
node_id: "node-with-token",
|
|
token: "test-token-placeholder",
|
|
display_name: null,
|
|
gateway_host: null,
|
|
gateway_port: null,
|
|
gateway_tls: null,
|
|
gateway_tls_fingerprint: null,
|
|
gateway_context_path: null,
|
|
updated_at_ms: 1,
|
|
}),
|
|
);
|
|
},
|
|
{ env },
|
|
);
|
|
|
|
await expect(loadNodeHostConfig(env)).resolves.toMatchObject({ nodeId: "node-with-token" });
|
|
expect(readStoredToken(env)).toBe("test-token-placeholder");
|
|
await configureNodeHost({ fallbackDisplayName: "node", gateway: {}, env, nowMs: 2 });
|
|
expect(readStoredToken(env)).toBeNull();
|
|
});
|
|
|
|
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");
|
|
},
|
|
);
|
|
});
|