mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 03:31:11 +00:00
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
This commit is contained in:
committed by
GitHub
parent
8020dd3e08
commit
da69daeb72
@@ -169,6 +169,7 @@ describe("node-host SQLite config", () => {
|
||||
version: 1,
|
||||
nodeId: "node-custom",
|
||||
displayName: "Build Node",
|
||||
installedAppsSharing: false,
|
||||
gateway: {
|
||||
host: "gateway.local",
|
||||
port: 18443,
|
||||
@@ -184,6 +185,28 @@ describe("node-host SQLite config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -31,6 +31,8 @@ export type NodeHostConfig = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
gateway?: NodeHostGatewayConfig;
|
||||
/** Share installed macOS applications through device.apps (default: false). */
|
||||
installedAppsSharing?: boolean;
|
||||
};
|
||||
|
||||
export const NODE_HOST_CONFIG_KEY = "current";
|
||||
@@ -120,6 +122,9 @@ function rowToNodeHostConfig(row: NodeHostConfigRuntimeRow): NodeHostConfig {
|
||||
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"),
|
||||
@@ -133,6 +138,7 @@ function rowToNodeHostConfig(row: NodeHostConfigRuntimeRow): NodeHostConfig {
|
||||
nodeId,
|
||||
displayName: optionalNonEmptyString(row.display_name, "display_name"),
|
||||
gateway: hasGateway ? gateway : undefined,
|
||||
installedAppsSharing: row.installed_apps_sharing === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,6 +169,7 @@ function configToRow(params: {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -184,6 +191,7 @@ function readNodeHostConfigRow(
|
||||
"gateway_tls",
|
||||
"gateway_tls_fingerprint",
|
||||
"gateway_context_path",
|
||||
"installed_apps_sharing",
|
||||
"updated_at_ms",
|
||||
])
|
||||
.where("config_key", "=", NODE_HOST_CONFIG_KEY),
|
||||
@@ -212,6 +220,7 @@ export async function configureNodeHost(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nowMs?: number;
|
||||
candidateNodeId?: string;
|
||||
installedAppsSharing?: boolean;
|
||||
}): Promise<NodeHostConfig> {
|
||||
const env = params.env ?? process.env;
|
||||
assertNodeHostLegacyStateMigrated(env);
|
||||
@@ -236,6 +245,7 @@ export async function configureNodeHost(params: {
|
||||
nodeId,
|
||||
displayName,
|
||||
gateway,
|
||||
installedAppsSharing: params.installedAppsSharing ?? existing?.installedAppsSharing ?? false,
|
||||
};
|
||||
const row = configToRow({ config: next, updatedAtMs });
|
||||
const { config_key: _configKey, ...updates } = row;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createExecApprovalPolicySnapshot } from "../infra/exec-approvals.js";
|
||||
import type { scanInstalledApps } from "../infra/installed-apps.js";
|
||||
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
|
||||
import type { OpenClawPluginNodeHostCommandContext } from "../plugins/types.node-host.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
@@ -21,6 +22,9 @@ export type NodeHostInvokeRuntime = {
|
||||
signal?: AbortSignal;
|
||||
pluginCommandIo?: OpenClawPluginNodeHostCommandIo;
|
||||
pluginCommandContext?: OpenClawPluginNodeHostCommandContext;
|
||||
installedAppsSharingEnabled?: boolean;
|
||||
installedAppsPlatform?: NodeJS.Platform;
|
||||
scanInstalledApps?: typeof scanInstalledApps;
|
||||
};
|
||||
|
||||
type ClaudeCliNodeInvokeDeps = Pick<
|
||||
|
||||
55
src/node-host/invoke-device-apps.test.ts
Normal file
55
src/node-host/invoke-device-apps.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { scanInstalledApps } from "../infra/installed-apps.js";
|
||||
import { invokeDeviceApps } from "./invoke-device-apps.js";
|
||||
|
||||
const scan = vi.fn<typeof scanInstalledApps>(async () => ({
|
||||
status: "ok",
|
||||
apps: [
|
||||
{ label: "Calendar", bundleId: "com.apple.iCal", path: "/System/Calendar.app", system: true },
|
||||
{
|
||||
label: "Notes App",
|
||||
bundleId: "com.example.notes",
|
||||
path: "/Applications/Notes.app",
|
||||
system: false,
|
||||
},
|
||||
{ label: "Other", path: "/Applications/Other.app", system: false },
|
||||
],
|
||||
}));
|
||||
|
||||
describe("invokeDeviceApps", () => {
|
||||
it("returns the typed privacy error while sharing is disabled", async () => {
|
||||
await expect(
|
||||
invokeDeviceApps({ sharingEnabled: false, platform: "darwin", scan }),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "INSTALLED_APPS_SHARING_DISABLED",
|
||||
message: "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in node-host settings",
|
||||
});
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches the Android count envelope with macOS app fields", async () => {
|
||||
const result = await invokeDeviceApps({
|
||||
sharingEnabled: true,
|
||||
platform: "darwin",
|
||||
paramsJSON: JSON.stringify({ query: "app", limit: 1, includeSystem: false }),
|
||||
scan,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
payload: {
|
||||
count: 1,
|
||||
totalMatched: 1,
|
||||
truncated: false,
|
||||
apps: [
|
||||
{
|
||||
label: "Notes App",
|
||||
bundleId: "com.example.notes",
|
||||
path: "/Applications/Notes.app",
|
||||
system: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/node-host/invoke-device-apps.ts
Normal file
76
src/node-host/invoke-device-apps.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod";
|
||||
import { scanInstalledApps, type InstalledApp } from "../infra/installed-apps.js";
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
const DeviceAppsParamsSchema = z
|
||||
.object({
|
||||
query: z.string().trim().min(1).optional(),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.transform((value) => Math.min(MAX_LIMIT, Math.max(1, value)))
|
||||
.optional(),
|
||||
includeSystem: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type DeviceAppsPayload = {
|
||||
count: number;
|
||||
totalMatched: number;
|
||||
truncated: boolean;
|
||||
apps: InstalledApp[];
|
||||
};
|
||||
|
||||
type DeviceAppsInvokeResult =
|
||||
| { ok: true; payload: DeviceAppsPayload }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
export async function invokeDeviceApps(params: {
|
||||
paramsJSON?: string | null;
|
||||
sharingEnabled: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
scan?: typeof scanInstalledApps;
|
||||
}): Promise<DeviceAppsInvokeResult> {
|
||||
if (!params.sharingEnabled) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "INSTALLED_APPS_SHARING_DISABLED",
|
||||
message: "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in node-host settings",
|
||||
};
|
||||
}
|
||||
let request: z.infer<typeof DeviceAppsParamsSchema>;
|
||||
try {
|
||||
request = DeviceAppsParamsSchema.parse(JSON.parse(params.paramsJSON || "{}"));
|
||||
} catch (error) {
|
||||
return { ok: false, code: "INVALID_REQUEST", message: String(error) };
|
||||
}
|
||||
const scan = params.scan ?? scanInstalledApps;
|
||||
const inventory = await scan({ platform: params.platform ?? process.platform });
|
||||
if (inventory.status === "unsupported") {
|
||||
return {
|
||||
ok: false,
|
||||
code: "UNAVAILABLE",
|
||||
message: "UNAVAILABLE: installed application inventory is only available on macOS",
|
||||
};
|
||||
}
|
||||
const query = request.query?.toLocaleLowerCase("en-US");
|
||||
const matching = inventory.apps.filter(
|
||||
(app) =>
|
||||
(request.includeSystem === true || !app.system) &&
|
||||
(!query ||
|
||||
app.label.toLocaleLowerCase("en-US").includes(query) ||
|
||||
app.bundleId?.toLocaleLowerCase("en-US").includes(query)),
|
||||
);
|
||||
const apps = matching.slice(0, request.limit ?? DEFAULT_LIMIT);
|
||||
return {
|
||||
ok: true,
|
||||
payload: {
|
||||
count: apps.length,
|
||||
totalMatched: matching.length,
|
||||
truncated: matching.length > apps.length,
|
||||
apps,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "../infra/host-env-security.js";
|
||||
import {
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
NODE_DEVICE_APPS_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
} from "../infra/node-commands.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
handleClaudeCliNodeInvoke,
|
||||
type NodeHostInvokeRuntime,
|
||||
} from "./invoke-agent-cli-claude-handler.js";
|
||||
import { invokeDeviceApps } from "./invoke-device-apps.js";
|
||||
import { invokeNodeFileCommand } from "./invoke-file-commands.js";
|
||||
import {
|
||||
buildSystemRunApprovalPlan,
|
||||
@@ -567,6 +569,20 @@ async function dispatchInvoke(
|
||||
runtime: NodeHostInvokeRuntime = {},
|
||||
) {
|
||||
const command = frame.command ?? "";
|
||||
if (command === NODE_DEVICE_APPS_COMMAND) {
|
||||
const result = await invokeDeviceApps({
|
||||
paramsJSON: frame.paramsJSON,
|
||||
sharingEnabled: runtime.installedAppsSharingEnabled === true,
|
||||
...(runtime.installedAppsPlatform ? { platform: runtime.installedAppsPlatform } : {}),
|
||||
...(runtime.scanInstalledApps ? { scan: runtime.scanInstalledApps } : {}),
|
||||
});
|
||||
if (result.ok) {
|
||||
await sendJsonPayloadResult(client, frame, result.payload);
|
||||
} else {
|
||||
await sendErrorResult(client, frame, result.code, result.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === "system.execApprovals.get") {
|
||||
try {
|
||||
const snapshot = await ensureExecApprovalsSnapshot();
|
||||
|
||||
@@ -32,6 +32,7 @@ type NodeHostRunOptions = {
|
||||
gatewayContextPath?: string;
|
||||
nodeId?: string;
|
||||
displayName?: string;
|
||||
installedAppsSharing?: boolean;
|
||||
};
|
||||
|
||||
function resolveNodeHostGatewayPlatform(platform: NodeJS.Platform): string {
|
||||
@@ -192,6 +193,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
displayName: opts.displayName,
|
||||
fallbackDisplayName,
|
||||
gateway: plannedGateway,
|
||||
installedAppsSharing: opts.installedAppsSharing,
|
||||
});
|
||||
const nodeId = config.nodeId;
|
||||
const displayName = config.displayName ?? fallbackDisplayName;
|
||||
@@ -202,6 +204,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
enableAgentRuns: true,
|
||||
installedAppsSharingEnabled: config.installedAppsSharing,
|
||||
});
|
||||
const { token, password } = await resolveNodeHostGatewayCredentials({
|
||||
config: cfg,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NODE_DEVICE_APPS_COMMAND } from "../infra/node-commands.js";
|
||||
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import { listRegisteredNodeHostCapsAndCommands } from "./plugin-node-host.js";
|
||||
@@ -165,3 +166,30 @@ describe("node-host duplex capability selection", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("installed application command advertisement", () => {
|
||||
it("advertises device.apps only when sharing is enabled on macOS", async () => {
|
||||
const disabled = await prepareNodeHostRuntime({
|
||||
config: { nodeHost: { skills: { enabled: false } } },
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "darwin",
|
||||
installedAppsSharingEnabled: false,
|
||||
});
|
||||
const enabled = await prepareNodeHostRuntime({
|
||||
config: { nodeHost: { skills: { enabled: false } } },
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "darwin",
|
||||
installedAppsSharingEnabled: true,
|
||||
});
|
||||
const nonDarwin = await prepareNodeHostRuntime({
|
||||
config: { nodeHost: { skills: { enabled: false } } },
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "linux",
|
||||
installedAppsSharingEnabled: true,
|
||||
});
|
||||
|
||||
expect(disabled.manifest.commands).not.toContain(NODE_DEVICE_APPS_COMMAND);
|
||||
expect(enabled.manifest.commands).toContain(NODE_DEVICE_APPS_COMMAND);
|
||||
expect(nonDarwin.manifest.commands).not.toContain(NODE_DEVICE_APPS_COMMAND);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
|
||||
import {
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
NODE_DEVICE_APPS_COMMAND,
|
||||
NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS,
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
@@ -232,6 +233,8 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
enableAgentRuns?: boolean;
|
||||
/** Embedded workers may still host long-lived plugin commands over the app-owned socket. */
|
||||
enableDuplexPluginCommands?: boolean;
|
||||
installedAppsSharingEnabled?: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
}): Promise<PreparedNodeHostRuntime> {
|
||||
void ensureTerminalUploadCleanup();
|
||||
const config = params?.config ?? getRuntimeConfig();
|
||||
@@ -241,6 +244,9 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
env.PATH = pathEnv;
|
||||
const duplexEnabled =
|
||||
params?.enableAgentRuns === true || params?.enableDuplexPluginCommands === true;
|
||||
const platform = params?.platform ?? process.platform;
|
||||
const installedAppsSharingEnabled =
|
||||
platform === "darwin" && params?.installedAppsSharingEnabled === true;
|
||||
const availabilityContext = { config, env };
|
||||
const resolvePluginNodeHost = () =>
|
||||
listRegisteredNodeHostCapsAndCommands(availabilityContext, {
|
||||
@@ -255,7 +261,14 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
: null;
|
||||
const skills = config.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
|
||||
const buildManifest = (pluginManifest: typeof pluginNodeHost): NodeHostManifest => ({
|
||||
caps: [...new Set(["system", "mcp", ...pluginManifest.caps])].toSorted(),
|
||||
caps: [
|
||||
...new Set([
|
||||
"system",
|
||||
"mcp",
|
||||
...(installedAppsSharingEnabled ? ["device"] : []),
|
||||
...pluginManifest.caps,
|
||||
]),
|
||||
].toSorted(),
|
||||
commands: [
|
||||
...new Set([
|
||||
...NODE_SYSTEM_RUN_COMMANDS,
|
||||
@@ -263,6 +276,7 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_TERMINAL_UPLOAD_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
...(installedAppsSharingEnabled ? [NODE_DEVICE_APPS_COMMAND] : []),
|
||||
...(claudePath ? [NODE_AGENT_CLI_CLAUDE_RUN_COMMAND] : []),
|
||||
...pluginManifest.commands,
|
||||
]),
|
||||
@@ -384,6 +398,8 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
...(claudePath ? { claudePath } : {}),
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
...(pluginCommandIo ? { pluginCommandIo } : {}),
|
||||
installedAppsSharingEnabled,
|
||||
installedAppsPlatform: platform,
|
||||
pluginCommandContext,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Private JSONL worker exposing the CLI node-host runtime to the macOS app. */
|
||||
import { createInterface } from "node:readline";
|
||||
import { VERSION } from "../version.js";
|
||||
import { loadNodeHostConfig } from "./config.js";
|
||||
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
|
||||
import {
|
||||
NodeHostWorkerBridgeClient,
|
||||
@@ -17,7 +18,11 @@ function emitInventory(inventory: NodeHostInventory): void {
|
||||
}
|
||||
|
||||
export async function runNodeHostWorker(): Promise<void> {
|
||||
const prepared = await prepareNodeHostRuntime({ enableDuplexPluginCommands: true });
|
||||
const nodeConfig = await loadNodeHostConfig();
|
||||
const prepared = await prepareNodeHostRuntime({
|
||||
enableDuplexPluginCommands: true,
|
||||
installedAppsSharingEnabled: nodeConfig?.installedAppsSharing === true,
|
||||
});
|
||||
const client = new NodeHostWorkerBridgeClient(writeMessage);
|
||||
let stopping = false;
|
||||
let resolveStopped: (() => void) | undefined;
|
||||
|
||||
Reference in New Issue
Block a user