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:
Peter Steinberger
2026-07-17 14:07:59 +01:00
committed by GitHub
parent 8020dd3e08
commit da69daeb72
47 changed files with 2323 additions and 192 deletions
+2
View File
@@ -46,6 +46,7 @@ type NodeDaemonInstallOptions = {
tlsFingerprint?: string;
nodeId?: string;
displayName?: string;
shareInstalledApps?: boolean;
runtime?: string;
force?: boolean;
json?: boolean;
@@ -162,6 +163,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
tlsFingerprint: tlsFingerprint || undefined,
nodeId: opts.nodeId,
displayName: opts.displayName,
installedAppsSharing: opts.shareInstalledApps,
runtime: runtimeRaw,
warn: (message) => {
if (json) {
+5
View File
@@ -65,6 +65,8 @@ export function registerNodeCli(program: Command) {
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override the generated node instance id")
.option("--display-name <name>", "Override node display name")
.option("--share-installed-apps", "Share installed macOS applications with the Gateway")
.option("--no-share-installed-apps", "Disable installed application sharing")
.action(async (opts) => {
const existing = await loadNodeHostConfig();
const host =
@@ -101,6 +103,7 @@ export function registerNodeCli(program: Command) {
(explicitContextPath || retargetedGateway ? undefined : existing?.gateway?.contextPath),
nodeId: opts.nodeId,
displayName: opts.displayName,
installedAppsSharing: opts.shareInstalledApps,
});
});
@@ -130,6 +133,8 @@ export function registerNodeCli(program: Command) {
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override the generated node instance id")
.option("--display-name <name>", "Override node display name")
.option("--share-installed-apps", "Share installed macOS applications with the Gateway")
.option("--no-share-installed-apps", "Disable installed application sharing")
.option("--runtime <runtime>", "Service runtime (node). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
@@ -39,6 +39,7 @@ export async function buildNodeInstallPlan(params: {
tlsFingerprint?: string;
nodeId?: string;
displayName?: string;
installedAppsSharing?: boolean;
runtime: GatewayDaemonRuntime;
devMode?: boolean;
nodePath?: string;
@@ -58,6 +59,7 @@ export async function buildNodeInstallPlan(params: {
tlsFingerprint: params.tlsFingerprint,
nodeId: params.nodeId,
displayName: params.displayName,
installedAppsSharing: params.installedAppsSharing,
dev: devMode,
runtime: params.runtime,
nodePath,
@@ -0,0 +1,359 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import { createSuiteLogPathTracker } from "../logging/log-test-helpers.js";
import { resetLogger } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { runGuidedOnboarding, type GuidedOnboardingDeps } from "./onboard-guided.js";
const restoreTerminalState = vi.hoisted(() => vi.fn());
const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn());
const ensureAuthProfileStore = vi.hoisted(() =>
vi.fn(() => ({ version: 1 as const, profiles: {} })),
);
vi.mock("../../packages/terminal-core/src/restore.js", () => ({ restoreTerminalState }));
vi.mock("./auth-choice-prompt.js", async (importActual) => ({
...(await importActual<typeof import("./auth-choice-prompt.js")>()),
promptAuthChoiceGrouped,
}));
vi.mock("../agents/auth-profiles.runtime.js", () => ({ ensureAuthProfileStore }));
vi.mock("./onboard-interactive-runner.js", async (importActual) => {
const actual = await importActual<typeof import("./onboard-interactive-runner.js")>();
return { ...actual, hasInteractiveOnboardingTty: () => true };
});
const readConfigFileSnapshot = vi.hoisted(() =>
vi.fn(async () => ({
exists: false,
valid: true,
path: "/tmp/openclaw.json",
issues: [] as Array<{ path?: string; message: string }>,
config: {},
})),
);
const logPathTracker = createSuiteLogPathTracker("openclaw-guided-onboard-log-");
vi.mock("../config/config.js", () => ({ readConfigFileSnapshot }));
vi.mock("./onboard-helpers.js", () => ({
DEFAULT_WORKSPACE: "/tmp/openclaw-workspace",
printWizardHeader: vi.fn(),
}));
function makeRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn() as unknown as RuntimeEnv["exit"],
};
}
function candidate(kind: "claude-cli" | "codex-cli", label: string) {
return {
kind,
label,
detail: "logged in",
modelRef: kind === "claude-cli" ? "claude-cli/opus" : "openai/gpt-5.5",
recommended: false,
credentials: true,
} as const;
}
function existingModelCandidate() {
return {
kind: "existing-model",
label: "Current model",
detail: "already configured",
modelRef: "acme/workspace-model",
recommended: false,
credentials: true,
} as const;
}
function detection(
overrides: Partial<Awaited<ReturnType<NonNullable<GuidedOnboardingDeps["detect"]>>>> = {},
) {
return {
candidates: [candidate("claude-cli", "Claude Code")],
unavailableCandidates: [],
manualProviders: [],
authOptions: [],
recommendedInstalls: [],
workspace: "/tmp/openclaw-workspace",
setupComplete: false,
...overrides,
};
}
function setupDeps(params: {
prompter: WizardPrompter;
detect?: GuidedOnboardingDeps["detect"];
activate?: GuidedOnboardingDeps["activate"];
runSystemAgentChat?: GuidedOnboardingDeps["runSystemAgentChat"];
persistRiskAcknowledgement?: GuidedOnboardingDeps["persistRiskAcknowledgement"];
runSetupMemoryImportStep?: GuidedOnboardingDeps["runSetupMemoryImportStep"];
runAppRecommendations?: GuidedOnboardingDeps["runAppRecommendations"];
applySetup?: GuidedOnboardingDeps["applySetup"];
handoffMode?: GuidedOnboardingDeps["handoffMode"];
}) {
const runSystemAgentChat = vi.fn<NonNullable<GuidedOnboardingDeps["runSystemAgentChat"]>>(
params.runSystemAgentChat ?? (async () => {}),
);
return {
createPrompter: () => params.prompter,
persistAccessMode: vi.fn(async () => undefined),
applySetup:
params.applySetup ??
vi.fn(async () => ({
configPath: "/tmp/openclaw.json",
configHashBefore: null,
configHashAfter: null,
lines: [],
})),
launchHatchTui: vi.fn(async () => undefined),
listManualOptions: vi.fn(async () => ({
manualProviders: [],
authOptions: [],
workspace: "/tmp/openclaw-workspace",
setupComplete: false,
})),
detect: params.detect ?? vi.fn(async () => detection()),
activate:
params.activate ??
vi.fn(async () => ({
ok: true as const,
modelRef: "claude-cli/opus",
latencyMs: 1250,
lines: ["Workspace: /tmp/work", "Gateway: running"],
})),
persistRiskAcknowledgement: params.persistRiskAcknowledgement ?? vi.fn(async () => undefined),
runSetupMemoryImportStep: params.runSetupMemoryImportStep ?? vi.fn(async () => undefined),
runAppRecommendations: params.runAppRecommendations ?? vi.fn(async ({ config }) => config),
runSystemAgentChat,
...(params.handoffMode ? { handoffMode: params.handoffMode } : {}),
} satisfies GuidedOnboardingDeps;
}
describe("runGuidedOnboarding custodian flow", () => {
beforeAll(async () => {
await logPathTracker.setup();
});
beforeEach(() => {
restoreTerminalState.mockClear();
promptAuthChoiceGrouped.mockReset();
ensureAuthProfileStore.mockClear();
readConfigFileSnapshot.mockReset();
readConfigFileSnapshot.mockResolvedValue({
exists: false,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: {},
});
});
afterEach(() => {
loggingState.rawConsole = null;
resetLogger();
});
afterAll(async () => {
await logPathTracker.cleanup();
});
it("routes guarded mode straight to manual config without any scanning", async () => {
promptAuthChoiceGrouped.mockResolvedValueOnce("skip");
const prompter = createWizardPrompter(undefined, { selectValues: ["guarded", "manual"] });
const deps = {
...setupDeps({ prompter }),
listManualOptions: vi.fn(async () => ({
manualProviders: [{ id: "openai-api-key", label: "OpenAI" }],
authOptions: [],
workspace: "/tmp/openclaw-workspace",
setupComplete: false,
})),
};
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(deps.detect).not.toHaveBeenCalled();
expect(deps.listManualOptions).toHaveBeenCalledOnce();
expect(deps.persistAccessMode).toHaveBeenCalledWith("guarded");
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
expect(deps.runAppRecommendations).not.toHaveBeenCalled();
});
it("does not recommend apps after guarded manual setup succeeds", async () => {
promptAuthChoiceGrouped.mockResolvedValueOnce("openai-api-key");
const prompter = createWizardPrompter(
{ text: vi.fn(async () => "manual-key") },
{ selectValues: ["guarded", "manual"] },
);
const deps = {
...setupDeps({ prompter }),
listManualOptions: vi.fn(async () => ({
manualProviders: [{ id: "openai-api-key", label: "OpenAI" }],
authOptions: [],
workspace: "/tmp/openclaw-workspace",
setupComplete: false,
})),
};
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(deps.applySetup).toHaveBeenCalledOnce();
expect(deps.runAppRecommendations).not.toHaveBeenCalled();
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
});
it("does not recommend local apps during remote chat handoff", async () => {
const prompter = createWizardPrompter();
const deps = setupDeps({ prompter, handoffMode: "chat" });
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(deps.runAppRecommendations).not.toHaveBeenCalled();
expect(deps.runSystemAgentChat).toHaveBeenCalledOnce();
expect(deps.launchHatchTui).not.toHaveBeenCalled();
});
it("scans in guarded mode only after the look-around consent", async () => {
const prompter = createWizardPrompter(undefined, { selectValues: ["guarded", "look"] });
const deps = setupDeps({ prompter });
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(deps.detect).toHaveBeenCalledOnce();
expect(deps.listManualOptions).not.toHaveBeenCalled();
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
});
it("skips persisting an unchanged access mode", async () => {
readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: {
wizard: { accessMode: "full", securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
},
});
const prompter = createWizardPrompter(undefined, { selectValues: ["full"] });
const deps = setupDeps({ prompter });
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(deps.persistAccessMode).not.toHaveBeenCalled();
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
});
it("keeps the working route when other options are explored and skipped", async () => {
promptAuthChoiceGrouped.mockResolvedValueOnce("skip");
const prompter = createWizardPrompter(undefined, { selectValues: ["full", "other"] });
const deps = setupDeps({ prompter });
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
expect(notes).toContain("Keeping the working AI you already have.");
expect(notes).not.toContain("Add AI later");
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
});
it("quips about detected coding agents", async () => {
const prompter = createWizardPrompter();
const deps = setupDeps({
prompter,
detect: vi.fn(async () =>
detection({
candidates: [candidate("claude-cli", "Claude Code"), candidate("codex-cli", "Codex")],
}),
),
});
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("good taste"),
expect.anything(),
);
});
it("never re-applies setup or bounces the gateway on a configured install", async () => {
readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: {
gateway: { mode: "local" },
wizard: { securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
},
});
const prompter = createWizardPrompter();
const deps = setupDeps({ prompter });
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(deps.applySetup).not.toHaveBeenCalled();
// Configured reruns hatch the persisted default workspace, not the probe context.
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/openclaw-workspace");
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("already set up"),
expect.anything(),
);
});
it("treats a model-only authored config as configured (no auto-apply)", async () => {
readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: {
agents: { defaults: { workspace: "/tmp/authored" } },
wizard: { securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
},
});
const prompter = createWizardPrompter();
const deps = setupDeps({
prompter,
detect: vi.fn(async () =>
detection({
candidates: [existingModelCandidate()],
configuredModel: "acme/workspace-model",
setupComplete: true,
}),
),
});
await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps);
expect(deps.applySetup).not.toHaveBeenCalled();
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/authored");
});
it("falls back to the OpenClaw chat when applying setup fails", async () => {
const prompter = createWizardPrompter();
const applySetup = vi.fn(async () => {
throw new Error("config write raced");
}) as unknown as GuidedOnboardingDeps["applySetup"];
const deps = setupDeps({ prompter, applySetup });
const runtime = makeRuntime();
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, runtime, deps);
expect(deps.launchHatchTui).not.toHaveBeenCalled();
expect(deps.runSystemAgentChat).toHaveBeenCalledWith("/tmp/work", runtime, true);
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
expect(notes).toContain("config write raced");
});
});
+59 -157
View File
@@ -107,7 +107,9 @@ function setupDeps(params: {
runSystemAgentChat?: GuidedOnboardingDeps["runSystemAgentChat"];
persistRiskAcknowledgement?: GuidedOnboardingDeps["persistRiskAcknowledgement"];
runSetupMemoryImportStep?: GuidedOnboardingDeps["runSetupMemoryImportStep"];
runAppRecommendations?: GuidedOnboardingDeps["runAppRecommendations"];
applySetup?: GuidedOnboardingDeps["applySetup"];
handoffMode?: GuidedOnboardingDeps["handoffMode"];
}) {
const runSystemAgentChat = vi.fn<NonNullable<GuidedOnboardingDeps["runSystemAgentChat"]>>(
params.runSystemAgentChat ?? (async () => {}),
@@ -141,7 +143,9 @@ function setupDeps(params: {
})),
persistRiskAcknowledgement: params.persistRiskAcknowledgement ?? vi.fn(async () => undefined),
runSetupMemoryImportStep: params.runSetupMemoryImportStep ?? vi.fn(async () => undefined),
runAppRecommendations: params.runAppRecommendations ?? vi.fn(async ({ config }) => config),
runSystemAgentChat,
...(params.handoffMode ? { handoffMode: params.handoffMode } : {}),
} satisfies GuidedOnboardingDeps;
}
@@ -174,6 +178,35 @@ describe("runGuidedOnboarding", () => {
});
it("auto-connects one credentialed candidate before any workspace prompt", async () => {
const persistedConfig: OpenClawConfig = {
agents: { defaults: { model: { primary: "claude-cli/opus" } } },
};
const appliedConfig: OpenClawConfig = {
...persistedConfig,
gateway: { mode: "local" },
};
readConfigFileSnapshot
.mockResolvedValueOnce({
exists: false,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: {},
})
.mockResolvedValueOnce({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: persistedConfig,
})
.mockResolvedValueOnce({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: appliedConfig,
});
const select = vi.fn(async () => "unexpected") as unknown as WizardPrompter["select"];
const text = vi.fn(async () => "unexpected");
const prompter = createWizardPrompter({
@@ -181,9 +214,19 @@ describe("runGuidedOnboarding", () => {
select,
confirm: vi.fn(async () => false),
});
const deps = setupDeps({ prompter });
const applySetup = vi.fn(async () => ({
configPath: "/tmp/openclaw.json",
configHashBefore: null,
configHashAfter: null,
lines: [],
}));
const runAppRecommendations = vi.fn<NonNullable<GuidedOnboardingDeps["runAppRecommendations"]>>(
async ({ config }) => config,
);
const deps = setupDeps({ prompter, applySetup, runAppRecommendations });
const runtime = makeRuntime();
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, runtime, deps);
expect(deps.activate).toHaveBeenCalledWith(
expect.objectContaining({
@@ -195,10 +238,23 @@ describe("runGuidedOnboarding", () => {
);
expect(text).not.toHaveBeenCalled();
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
expect(deps.applySetup).toHaveBeenCalledWith(
expect(applySetup).toHaveBeenCalledWith(
expect.objectContaining({ workspace: "/tmp/work", surface: "cli" }),
);
expect(deps.runSystemAgentChat).not.toHaveBeenCalled();
expect(runAppRecommendations).toHaveBeenCalledWith({
config: appliedConfig,
prompter,
runtime,
workspaceDir: "/tmp/work",
modelRouteVerified: true,
});
expect(applySetup.mock.invocationCallOrder[0]).toBeLessThan(
runAppRecommendations.mock.invocationCallOrder[0]!,
);
expect(runAppRecommendations.mock.invocationCallOrder[0]).toBeLessThan(
deps.launchHatchTui.mock.invocationCallOrder[0]!,
);
expect(restoreTerminalState.mock.invocationCallOrder[0]).toBeLessThan(
deps.launchHatchTui.mock.invocationCallOrder[0]!,
);
@@ -782,160 +838,6 @@ describe("runGuidedOnboarding", () => {
expect(deps.activate).not.toHaveBeenCalled();
});
it("routes guarded mode straight to manual config without any scanning", async () => {
promptAuthChoiceGrouped.mockResolvedValueOnce("skip");
const prompter = createWizardPrompter(undefined, { selectValues: ["guarded", "manual"] });
const deps = {
...setupDeps({ prompter }),
listManualOptions: vi.fn(async () => ({
manualProviders: [{ id: "openai-api-key", label: "OpenAI" }],
authOptions: [],
workspace: "/tmp/openclaw-workspace",
setupComplete: false,
})),
};
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(deps.detect).not.toHaveBeenCalled();
expect(deps.listManualOptions).toHaveBeenCalledOnce();
expect(deps.persistAccessMode).toHaveBeenCalledWith("guarded");
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
});
it("scans in guarded mode only after the look-around consent", async () => {
const prompter = createWizardPrompter(undefined, { selectValues: ["guarded", "look"] });
const deps = setupDeps({ prompter });
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(deps.detect).toHaveBeenCalledOnce();
expect(deps.listManualOptions).not.toHaveBeenCalled();
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
});
it("skips persisting an unchanged access mode", async () => {
readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: {
wizard: { accessMode: "full", securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
},
});
const prompter = createWizardPrompter(undefined, { selectValues: ["full"] });
const deps = setupDeps({ prompter });
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(deps.persistAccessMode).not.toHaveBeenCalled();
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
});
it("keeps the working route when other options are explored and skipped", async () => {
promptAuthChoiceGrouped.mockResolvedValueOnce("skip");
const prompter = createWizardPrompter(undefined, { selectValues: ["full", "other"] });
const deps = setupDeps({ prompter });
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
expect(notes).toContain("Keeping the working AI you already have.");
expect(notes).not.toContain("Add AI later");
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
});
it("quips about detected coding agents", async () => {
const prompter = createWizardPrompter();
const deps = setupDeps({
prompter,
detect: vi.fn(async () =>
detection({
candidates: [candidate("claude-cli", "Claude Code"), candidate("codex-cli", "Codex")],
}),
),
});
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("good taste"),
expect.anything(),
);
});
it("never re-applies setup or bounces the gateway on a configured install", async () => {
readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: {
gateway: { mode: "local" },
wizard: { securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
},
});
const prompter = createWizardPrompter();
const deps = setupDeps({ prompter });
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(deps.applySetup).not.toHaveBeenCalled();
// Configured reruns hatch the persisted default workspace, not the probe context.
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/openclaw-workspace");
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("already set up"),
expect.anything(),
);
});
it("treats a model-only authored config as configured (no auto-apply)", async () => {
readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: {
agents: { defaults: { workspace: "/tmp/authored" } },
wizard: { securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
},
});
const prompter = createWizardPrompter();
const deps = setupDeps({
prompter,
detect: vi.fn(async () =>
detection({
candidates: [existingModelCandidate()],
configuredModel: "acme/workspace-model",
setupComplete: true,
}),
),
});
await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps);
expect(deps.applySetup).not.toHaveBeenCalled();
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/authored");
});
it("falls back to the OpenClaw chat when applying setup fails", async () => {
const prompter = createWizardPrompter();
const applySetup = vi.fn(async () => {
throw new Error("config write raced");
}) as unknown as GuidedOnboardingDeps["applySetup"];
const deps = setupDeps({ prompter, applySetup });
const runtime = makeRuntime();
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, runtime, deps);
expect(deps.launchHatchTui).not.toHaveBeenCalled();
expect(deps.runSystemAgentChat).toHaveBeenCalledWith("/tmp/work", runtime, true);
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
expect(notes).toContain("config write raced");
});
it("converges remote inference before remote OpenClaw without mutating local config", async () => {
const localConfig = {
wizard: { securityAcknowledgedAt: "2026-07-11T00:00:00.000Z" },
+38 -1
View File
@@ -48,6 +48,7 @@ export type GuidedOnboardingDeps = {
applySetup?: typeof import("../system-agent/setup-apply.js").applySystemAgentSetup;
launchHatchTui?: (workspace: string) => Promise<void>;
runSetupMemoryImportStep?: typeof import("../wizard/setup.memory-import.js").runSetupMemoryImportStep;
runAppRecommendations?: typeof import("../wizard/setup.app-recommendations.js").setupAppRecommendations;
};
export type GuidedAccessMode = "full" | "guarded";
@@ -603,7 +604,7 @@ async function runGuidedOnboardingFlow(
await prompter.note(resultLines.join("\n"), t("wizard.guided.appliedTitle"));
const persistedSnapshot = await readConfigFileSnapshot();
const persistedConfig = persistedSnapshot.valid
let persistedConfig = persistedSnapshot.valid
? (persistedSnapshot.sourceConfig ?? persistedSnapshot.config)
: acknowledgedConfig;
// Memory import scans local Claude/Codex/Hermes data; a declined look-around
@@ -642,6 +643,11 @@ async function runGuidedOnboardingFlow(
if (applied.lines.length > 0) {
await prompter.note(applied.lines.join("\n"), t("wizard.guided.appliedTitle"));
}
const appliedSnapshot = await readConfigFileSnapshot();
if (!appliedSnapshot.valid) {
throw new Error("Setup wrote an invalid OpenClaw config.");
}
persistedConfig = appliedSnapshot.sourceConfig ?? appliedSnapshot.config;
} catch (error) {
applyProgress.stop(t("wizard.guided.testFailed"));
await prompter.note(
@@ -653,6 +659,37 @@ async function runGuidedOnboardingFlow(
return { workspace, next: "chat" };
}
}
if (wantsDiscovery) {
const runAppRecommendations =
deps.runAppRecommendations ??
(await import("../wizard/setup.app-recommendations.js")).setupAppRecommendations;
const recommendedConfig = await runAppRecommendations({
config: persistedConfig,
prompter,
runtime,
workspaceDir: workspace,
modelRouteVerified: true,
});
if (recommendedConfig !== persistedConfig) {
const latestSnapshot = await readConfigFileSnapshot();
if (!latestSnapshot.valid) {
throw new Error("App recommendations could not update an invalid OpenClaw config.");
}
const latestConfig = latestSnapshot.sourceConfig ?? latestSnapshot.config;
const { mergeWizardConfigOntoLatest, writeWizardConfigFile } =
await import("../wizard/setup.shared.js");
const mergedConfig = mergeWizardConfigOntoLatest(
latestConfig,
persistedConfig,
recommendedConfig,
);
await writeWizardConfigFile(mergedConfig, {
allowConfigSizeDrop: false,
...(latestSnapshot.hash ? { baseHash: latestSnapshot.hash } : {}),
migrationBaseConfig: latestConfig,
});
}
}
await prompter.note(t("wizard.guided.findMeLater"), t("wizard.guided.welcomeTitle"));
await prompter.outro(t("wizard.guided.hatchingNow"));
// The TUI opens the configured default agent/workspace; on a configured
+2
View File
@@ -131,6 +131,8 @@ export type OpenClawConfig = {
wizard?: {
/** Guided-onboarding discovery consent: "full" scans silently, "guarded" asks first. */
accessMode?: "full" | "guarded";
/** Offer installed-application plugin and skill recommendations during onboarding. */
appRecommendations?: boolean;
/** Last setup wizard completion timestamp. */
lastRunAt?: string;
/** OpenClaw version used by the last completed wizard run. */
+1
View File
@@ -667,6 +667,7 @@ export const OpenClawSchema = z
wizard: z
.strictObject({
accessMode: z.union([z.literal("full"), z.literal("guarded")]).optional(),
appRecommendations: z.boolean().optional(),
lastRunAt: z.string().optional(),
lastRunVersion: z.string().optional(),
lastRunCommit: z.string().optional(),
+4
View File
@@ -265,6 +265,7 @@ export async function resolveNodeProgramArguments(params: {
tlsFingerprint?: string;
nodeId?: string;
displayName?: string;
installedAppsSharing?: boolean;
dev?: boolean;
runtime?: GatewayRuntimePreference;
nodePath?: string;
@@ -289,6 +290,9 @@ export async function resolveNodeProgramArguments(params: {
if (params.displayName) {
args.push("--display-name", params.displayName);
}
if (params.installedAppsSharing !== undefined) {
args.push(params.installedAppsSharing ? "--share-installed-apps" : "--no-share-installed-apps");
}
return resolveCliProgramArguments({
args,
dev: params.dev,
+17
View File
@@ -452,6 +452,23 @@ describe("gateway/node-command-policy", () => {
expect(allowlist.has("system.run")).toBe(true);
});
it("uses the app-recommendation kill switch for gateway device.apps access", () => {
const macNode = {
platform: "macos",
deviceFamily: "Mac",
commands: ["device.apps"],
};
expect(resolveNodeCommandAllowlist({} as OpenClawConfig, macNode).has("device.apps")).toBe(
true,
);
expect(
resolveNodeCommandAllowlist(
{ wizard: { appRecommendations: false } } as OpenClawConfig,
macNode,
).has("device.apps"),
).toBe(false);
});
it("allows approved node-host MCP calls while denyCommands still wins", () => {
const node = {
platform: "linux",
+6 -1
View File
@@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
NODE_BROWSER_PROXY_COMMAND,
NODE_DEVICE_APPS_COMMAND,
NODE_EXEC_APPROVALS_COMMANDS,
NODE_FILE_COMMANDS,
NODE_MCP_TOOLS_CALL_COMMAND,
@@ -33,7 +34,7 @@ const ANDROID_DEVICE_COMMANDS = [
...MOBILE_NODE_COMMANDS.device,
"device.permissions",
"device.health",
"device.apps",
NODE_DEVICE_APPS_COMMAND,
];
const CONTACTS_COMMANDS = ["contacts.search"];
@@ -136,6 +137,7 @@ export const PLATFORM_DEFAULTS: Record<string, string[]> = {
...CAMERA_COMMANDS,
...MOBILE_NODE_COMMANDS.location,
...MOBILE_NODE_COMMANDS.device,
NODE_DEVICE_APPS_COMMAND,
...CONTACTS_COMMANDS,
...CALENDAR_COMMANDS,
...REMINDERS_COMMANDS,
@@ -421,6 +423,9 @@ function resolveNodeCommandAllowlistInternal(
allow.add(trimmed);
}
}
if (cfg.wizard?.appRecommendations === false) {
allow.delete(NODE_DEVICE_APPS_COMMAND);
}
// In pairing mode, denylisted dangerous defaults stay declarable so a node
// retains the surface it can later be armed for: arming removes them from
// denyCommands and adds them to allowCommands. Fresh setup seeds denyCommands
+127
View File
@@ -0,0 +1,127 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { scanInstalledApps } from "./installed-apps.js";
const tempRoots: string[] = [];
async function makeFixtureRoot(): Promise<{
root: string;
applications: string;
userApplications: string;
systemApplications: string;
}> {
const raw = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-installed-apps-"));
const root = await fs.realpath(raw);
tempRoots.push(root);
const applications = path.join(root, "Applications");
const userApplications = path.join(root, "UserApplications");
const systemApplications = path.join(root, "SystemApplications");
await Promise.all(
[applications, userApplications, systemApplications].map((directory) =>
fs.mkdir(directory, { recursive: true }),
),
);
return { root, applications, userApplications, systemApplications };
}
async function createApp(root: string, name: string, bundleId?: string): Promise<void> {
const contents = path.join(root, `${name}.app`, "Contents");
await fs.mkdir(contents, { recursive: true });
if (!bundleId) {
return;
}
await fs.writeFile(
path.join(contents, "Info.plist"),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>CFBundleIdentifier</key><string>${bundleId}</string></dict></plist>`,
);
}
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
);
});
describe("scanInstalledApps", () => {
it("scans app roots, filters copies and system apps, and sorts deterministically", async () => {
const roots = await makeFixtureRoot();
await Promise.all([
createApp(roots.applications, "Zulu", "com.example.zulu"),
createApp(roots.applications, "Alpha", "com.example.alpha"),
createApp(roots.applications, "Alpha previous", "com.example.previous"),
createApp(roots.applications, "Zulu-pre-update", "com.example.pre"),
createApp(roots.userApplications, "No Plist"),
createApp(roots.systemApplications, "Mail", "com.apple.mail"),
createApp(roots.systemApplications, "Calculator", "com.apple.calculator"),
fs.mkdir(path.join(roots.applications, "Not An App")),
]);
// The production reader shells out to macOS plutil; parse the XML fixture
// directly so this test also runs on Linux CI.
const readBundleId = async (appPath: string): Promise<string | undefined> => {
try {
const plist = await fs.readFile(path.join(appPath, "Contents", "Info.plist"), "utf8");
return /<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/.exec(plist)?.[1];
} catch {
return undefined;
}
};
const result = await scanInstalledApps({ platform: "darwin", roots, readBundleId });
expect(result).toEqual({
status: "ok",
apps: [
{
label: "Alpha",
bundleId: "com.example.alpha",
path: path.join(roots.applications, "Alpha.app"),
system: false,
},
{
label: "Mail",
bundleId: "com.apple.mail",
path: path.join(roots.systemApplications, "Mail.app"),
system: true,
},
{
label: "No Plist",
path: path.join(roots.userApplications, "No Plist.app"),
system: false,
},
{
label: "Zulu",
bundleId: "com.example.zulu",
path: path.join(roots.applications, "Zulu.app"),
system: false,
},
],
});
});
it("includes symlinked app bundles", async () => {
const roots = await makeFixtureRoot();
await createApp(roots.applications, "RealTarget", "com.example.symlinked");
await fs.symlink(
path.join(roots.applications, "RealTarget.app"),
path.join(roots.userApplications, "Linked.app"),
);
const readBundleId = async () => "com.example.symlinked";
const result = await scanInstalledApps({ platform: "darwin", roots, readBundleId });
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.apps.map((app) => app.label)).toContain("Linked");
}
});
it("returns a typed unsupported result off macOS", async () => {
await expect(scanInstalledApps({ platform: "linux" })).resolves.toEqual({
status: "unsupported",
platform: "linux",
apps: [],
});
});
});
+163
View File
@@ -0,0 +1,163 @@
import { execFile } from "node:child_process";
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import pLimit from "p-limit";
import { z } from "zod";
const execFileAsync = promisify(execFile);
const PLIST_READ_CONCURRENCY = 8;
const PLIST_READ_TIMEOUT_MS = 2_000;
const SYSTEM_APP_NAMES = new Set([
"Calendar",
"Contacts",
"FaceTime",
"Home",
"Mail",
"Maps",
"Messages",
"Music",
"Notes",
"Photos",
"Podcasts",
"Reminders",
"Shortcuts",
]);
const InfoPlistSchema = z
.object({
CFBundleIdentifier: z.string().trim().min(1).optional(),
})
.passthrough();
export type InstalledApp = {
label: string;
bundleId?: string;
path: string;
system: boolean;
};
export type InstalledAppsResult =
| { status: "ok"; apps: InstalledApp[] }
| { status: "unsupported"; platform: NodeJS.Platform; apps: [] };
type InstalledAppRoots = {
applications: string;
userApplications: string;
systemApplications: string;
};
type ScanInstalledAppsOptions = {
platform?: NodeJS.Platform;
roots?: InstalledAppRoots;
readBundleId?: (appPath: string) => Promise<string | undefined>;
};
function defaultRoots(): InstalledAppRoots {
return {
applications: "/Applications",
userApplications: path.join(os.homedir(), "Applications"),
systemApplications: "/System/Applications",
};
}
function isBackupishBundle(label: string): boolean {
return (
/(?:^|[\s._-])(?:backup|previous|rollback)(?:[\s._-]|$)/i.test(label) ||
/(?:^|[\s._-])pre-[\p{L}\p{N}._-]+$/iu.test(label)
);
}
async function listAppPaths(
root: string,
system: boolean,
): Promise<Array<{ path: string; system: boolean }>> {
let entries: Dirent[];
try {
entries = await fs.readdir(root, { withFileTypes: true });
} catch {
return [];
}
const results = await Promise.all(
entries.map(async (entry) => {
if (!entry.name.toLowerCase().endsWith(".app")) {
return [];
}
// Dirent.isDirectory() is false for symlinked bundles; /Applications
// commonly holds symlinks (brew cask, hand-linked apps) — stat through.
let isDirectory = entry.isDirectory();
if (!isDirectory && entry.isSymbolicLink()) {
isDirectory = await fs
.stat(path.join(root, entry.name))
.then((stats) => stats.isDirectory())
.catch(() => false);
}
if (!isDirectory) {
return [];
}
const label = entry.name.slice(0, -4);
if (isBackupishBundle(label) || (system && !SYSTEM_APP_NAMES.has(label))) {
return [];
}
return [{ path: path.join(root, entry.name), system }];
}),
);
return results.flat();
}
async function readBundleIdWithPlutil(appPath: string): Promise<string | undefined> {
try {
const { stdout } = await execFileAsync(
"/usr/bin/plutil",
["-convert", "json", "-o", "-", path.join(appPath, "Contents", "Info.plist")],
{ encoding: "utf8", maxBuffer: 1024 * 1024, timeout: PLIST_READ_TIMEOUT_MS },
);
return InfoPlistSchema.parse(JSON.parse(stdout)).CFBundleIdentifier;
} catch {
return undefined;
}
}
export async function scanInstalledApps(
options: ScanInstalledAppsOptions = {},
): Promise<InstalledAppsResult> {
const platform = options.platform ?? process.platform;
if (platform !== "darwin") {
return { status: "unsupported", platform, apps: [] };
}
const roots = options.roots ?? defaultRoots();
const appPaths = (
await Promise.all([
listAppPaths(roots.applications, false),
listAppPaths(roots.userApplications, false),
listAppPaths(roots.systemApplications, true),
])
).flat();
const readBundleId = options.readBundleId ?? readBundleIdWithPlutil;
const limit = pLimit(PLIST_READ_CONCURRENCY);
const apps = await Promise.all(
appPaths.map((entry) =>
limit(async (): Promise<InstalledApp> => {
const bundleId = await readBundleId(entry.path);
return {
label: path.basename(entry.path, ".app"),
...(bundleId ? { bundleId } : {}),
path: entry.path,
system: entry.system,
};
}),
),
);
return {
status: "ok",
apps: apps.toSorted(
(left, right) =>
left.label.localeCompare(right.label, "en", { sensitivity: "base" }) ||
left.path.localeCompare(right.path),
),
};
}
+1
View File
@@ -12,6 +12,7 @@ export const NODE_FILE_COMMANDS = [NODE_FS_LIST_DIR_COMMAND, NODE_TERMINAL_UPLOA
export const NODE_BROWSER_PROXY_COMMAND = "browser.proxy";
export const NODE_MCP_TOOLS_CALL_COMMAND = "mcp.tools.call.v1";
export const NODE_AGENT_CLI_CLAUDE_RUN_COMMAND = "agent.cli.claude.run.v1";
export const NODE_DEVICE_APPS_COMMAND = "device.apps";
// Node duplex heartbeats must arrive before the Gateway relay declares the
// invoke idle, so both processes share this timeout contract.
@@ -139,6 +139,7 @@ describe("legacy node-host Doctor migration", () => {
tlsFingerprint: fixtureDigest,
contextPath: "/openclaw-gw",
},
installedAppsSharing: false,
});
expect(readCanonicalRow(env)?.token).toBeNull();
expect(fs.existsSync(sourcePath)).toBe(false);
+1
View File
@@ -2306,6 +2306,7 @@ describe("state migrations", () => {
tlsFingerprint: fixtureDigest,
contextPath: "/doctor",
},
installedAppsSharing: false,
});
await expectMissingPath(sourcePath);
});
+23
View File
@@ -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 });
+10
View File
@@ -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
View 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
View 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,
},
};
}
+16
View File
@@ -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();
+3
View File
@@ -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,
+28
View File
@@ -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);
});
});
+17 -1
View File
@@ -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 {
+6 -1
View File
@@ -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;
+1
View File
@@ -733,6 +733,7 @@ export interface NodeHostConfig {
gateway_port: number | null;
gateway_tls: number | null;
gateway_tls_fingerprint: string | null;
installed_apps_sharing: Generated<number>;
node_id: string;
token: string | null;
updated_at_ms: number;
+1
View File
@@ -1364,6 +1364,7 @@ function backfillDeliveryQueueEntriesFromEntryJson(db: DatabaseSync): void {
function ensureAdditiveStateColumns(db: DatabaseSync): void {
ensureColumn(db, "worktrees", "provisioned_paths_json TEXT");
ensureColumn(db, "node_host_config", "gateway_context_path TEXT");
ensureColumn(db, "node_host_config", "installed_apps_sharing INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "apns_registrations", "relay_origin TEXT");
ensureColumn(db, "device_pairing_pending", "refreshed_at_ms INTEGER");
ensureColumn(db, "device_pairing_paired", "approved_via TEXT");
@@ -693,6 +693,7 @@ CREATE TABLE IF NOT EXISTS node_host_config (
gateway_tls INTEGER,
gateway_tls_fingerprint TEXT,
gateway_context_path TEXT,
installed_apps_sharing INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL
) STRICT;
+1
View File
@@ -688,6 +688,7 @@ CREATE TABLE IF NOT EXISTS node_host_config (
gateway_tls INTEGER,
gateway_tls_fingerprint TEXT,
gateway_context_path TEXT,
installed_apps_sharing INTEGER NOT NULL DEFAULT 0,
updated_at_ms INTEGER NOT NULL
) STRICT;
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import { getSetupAppRecommendations } from "./setup-app-recommendations.js";
import { completeSetupInferenceConfig } from "./setup-inference.js";
const LIVE = process.env.OPENCLAW_LIVE_TEST === "1" && Boolean(process.env.OPENAI_API_KEY?.trim());
const describeLive = LIVE ? describe : describe.skip;
const modelId = process.env.OPENCLAW_LIVE_APP_RECOMMENDATIONS_MODEL ?? "gpt-5.6-luna";
const config: OpenClawConfig = {
models: {
providers: {
openai: {
api: "openai-responses",
agentRuntime: { id: "openclaw" },
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
baseUrl: "https://api.openai.com/v1",
models: [
{
id: modelId,
name: modelId,
api: "openai-responses",
agentRuntime: { id: "openclaw" },
input: ["text"],
reasoning: true,
contextWindow: 1_047_576,
maxTokens: 60_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
},
},
},
agents: {
defaults: {
model: { primary: `openai/${modelId}` },
models: {
[`openai/${modelId}`]: {
agentRuntime: { id: "openclaw" },
params: { maxTokens: 60_000 },
},
},
},
},
};
const runtime: RuntimeEnv = {
log: () => undefined,
error: () => undefined,
exit: () => undefined,
};
describeLive("setup app recommendations live", () => {
it("uses real ClawHub search and OpenAI while rejecting substring traps", async () => {
const result = await getSetupAppRecommendations({
inventorySource: async () => [
{ label: "Notion", bundleId: "notion.id" },
{ label: "Obsidian", bundleId: "md.obsidian" },
{ label: "Slack", bundleId: "com.tinyspeck.slackmacgap" },
{ label: "1Password", bundleId: "com.agilebits.onepassword7" },
{ label: "Things", bundleId: "com.culturedcode.ThingsMac" },
{ label: "Linear", bundleId: "com.linear" },
{ label: "Zed", bundleId: "dev.zed.Zed" },
{ label: "Parallels Desktop", bundleId: "com.parallels.desktop.console" },
{ label: "ChatGPT", bundleId: "com.openai.codex" },
],
runtime,
deps: {
complete: async (prompt) => {
const completion = await completeSetupInferenceConfig({
config,
prompt,
runtime,
timeoutMs: 240_000,
});
return completion.ok ? { ok: true, text: completion.text } : { ok: false };
},
},
});
const status = result.status === "ok" ? "ok" : `skipped:${result.reason}`;
expect(status).toBe("ok");
if (result.status !== "ok") {
return;
}
const matchedApps = new Set(result.matches.map((match) => match.appLabel.toLowerCase()));
expect(matchedApps.has("notion")).toBe(true);
expect(matchedApps.has("obsidian")).toBe(true);
expect(matchedApps.has("slack")).toBe(true);
const pairs = result.matches.map(
(match) => `${match.appLabel.toLowerCase()}:${match.candidateId.toLowerCase()}`,
);
expect(pairs).not.toContain("linear:line");
expect(pairs).not.toContain("zed:zededa");
expect(pairs).not.toContain("parallels desktop:parallel");
}, 300_000);
});
@@ -0,0 +1,241 @@
import { describe, expect, it, vi } from "vitest";
import type { OfficialExternalPluginCatalogEntry } from "../plugins/official-external-plugin-catalog.js";
import { defaultRuntime } from "../runtime.js";
import { getSetupAppRecommendations } from "./setup-app-recommendations.js";
/** Force an "ok" result so the returned candidate `groups` can be asserted. */
function completeMatching(
pairs: Array<{ appLabel: string; candidateId: string }>,
): (prompt: string) => Promise<{ ok: true; text: string }> {
return async () => ({
ok: true,
text: JSON.stringify({
matches: pairs.map((pair) => ({ ...pair, tier: "optional", reason: "match" })),
}),
});
}
function officialEntry(params: {
id: string;
label: string;
description: string;
kind?: "channel" | "provider";
}): OfficialExternalPluginCatalogEntry {
return {
id: params.id,
description: params.description,
openclaw: {
plugin: { id: params.id, label: params.label },
...(params.kind === "channel" ? { channel: { id: params.id, label: params.label } } : {}),
...(params.kind === "provider" ? { providers: [{ id: params.id, name: params.label }] } : {}),
},
};
}
describe("setup app recommendation candidates", () => {
it("gathers, dedupes, and sorts official and ClawHub candidates", async () => {
const channel = officialEntry({
id: "chat",
label: "Chat",
description: "Chat desktop channel",
kind: "channel",
});
const generic = officialEntry({
id: "notes",
label: "Notes",
description: "Notes integration",
});
const searchSkills = vi.fn(async () => [
{ score: 2, slug: "notes", displayName: "Duplicate notes" },
{ score: 1, slug: "notes-tools", displayName: "Notes Tools", summary: "Work with notes" },
]);
const result = await getSetupAppRecommendations({
inventorySource: async () => [{ label: "Notes" }, { label: "Chat Desktop" }],
runtime: defaultRuntime,
deps: {
listPlugins: () => [generic, channel],
listChannels: () => [channel],
listProviders: () => [],
searchSkills,
complete: completeMatching([{ appLabel: "Notes", candidateId: "notes" }]),
},
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.groups.map((group) => group.app.label)).toEqual(["Chat Desktop", "Notes"]);
const notes = result.groups.find((group) => group.app.label === "Notes");
expect(notes?.candidates.map((candidate) => [candidate.source, candidate.id])).toEqual([
["official-plugin", "notes"],
["clawhub-skill", "notes-tools"],
]);
}
expect(searchSkills).toHaveBeenCalledTimes(2);
});
it("degrades one failed ClawHub search without aborting other apps", async () => {
const searchSkills = vi.fn(async ({ query }: { query: string }) => {
if (query === "Broken") {
throw new Error("offline");
}
return [{ score: 1, slug: "working", displayName: "Working" }];
});
const result = await getSetupAppRecommendations({
inventorySource: async () => [{ label: "Broken" }, { label: "Working" }],
runtime: defaultRuntime,
deps: {
listPlugins: () => [],
listChannels: () => [],
listProviders: () => [],
searchSkills,
complete: completeMatching([{ appLabel: "Working", candidateId: "working" }]),
},
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.groups.find((group) => group.app.label === "Broken")?.candidates).toEqual([]);
expect(result.groups.find((group) => group.app.label === "Working")?.candidates).toHaveLength(
1,
);
}
});
});
describe("official catalog candidates", () => {
it("produces channel candidates from the real package-shaped catalogs", async () => {
// Regression: real catalog entries carry no top-level id; keying by it
// used to collapse the whole catalog and drop every official candidate.
const result = await getSetupAppRecommendations({
inventorySource: async () => [{ label: "Discord" }, { label: "WhatsApp" }],
runtime: defaultRuntime,
deps: {
searchSkills: async () => [],
complete: completeMatching([
{ appLabel: "Discord", candidateId: "discord" },
{ appLabel: "WhatsApp", candidateId: "whatsapp" },
]),
},
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
const discord = result.groups.find((group) => group.app.label === "Discord");
const whatsapp = result.groups.find((group) => group.app.label === "WhatsApp");
expect(discord?.candidates).toContainEqual(
expect.objectContaining({ id: "discord", source: "official-channel" }),
);
expect(whatsapp?.candidates).toContainEqual(
expect.objectContaining({ id: "whatsapp", source: "official-channel" }),
);
}
});
});
describe("setup app recommendation matcher", () => {
const inventorySource = async () => [{ label: "Notes", bundleId: "com.example.notes" }];
const candidateDeps = {
listPlugins: () => [],
listChannels: () => [],
listProviders: () => [],
searchSkills: async () => [
{ score: 1, slug: "notes-tools", displayName: "Notes Tools", summary: "Work with notes" },
],
};
it("accepts strict JSON", async () => {
const result = await getSetupAppRecommendations({
inventorySource,
runtime: defaultRuntime,
deps: {
...candidateDeps,
complete: async () => ({
ok: true,
text: JSON.stringify({
matches: [
{
appLabel: "Notes",
candidateId: "notes-tools",
tier: "recommended",
reason: "Connects directly to your notes",
},
],
}),
}),
},
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.matches[0]).toMatchObject({ candidateId: "notes-tools", tier: "recommended" });
}
});
it("tolerates fenced JSON with extra keys and long reasons", async () => {
const reason = `useful ${"very ".repeat(40)}integration`;
const result = await getSetupAppRecommendations({
inventorySource,
runtime: defaultRuntime,
deps: {
...candidateDeps,
complete: async () => ({
ok: true,
text: [
"Here you go:",
"```json",
JSON.stringify({
matches: [
{
appLabel: "Notes",
candidateId: "notes-tools",
tier: "optional",
reason,
confidence: "high",
},
],
}),
"```",
].join("\n"),
}),
},
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.matches[0]?.candidateId).toBe("notes-tools");
expect(result.matches[0]?.reason.length).toBeLessThanOrEqual(120);
}
});
it("skips garbage model output", async () => {
await expect(
getSetupAppRecommendations({
inventorySource,
runtime: defaultRuntime,
deps: { ...candidateDeps, complete: async () => ({ ok: true, text: "not json" }) },
}),
).resolves.toEqual({ status: "skipped", reason: "model-failed" });
});
it("drops unknown candidate ids", async () => {
await expect(
getSetupAppRecommendations({
inventorySource,
runtime: defaultRuntime,
deps: {
...candidateDeps,
complete: async () => ({
ok: true,
text: JSON.stringify({
matches: [
{
appLabel: "Notes",
candidateId: "unknown",
tier: "optional",
reason: "Looks useful",
},
],
}),
}),
},
}),
).resolves.toEqual({ status: "skipped", reason: "no-matches" });
});
});
@@ -0,0 +1,378 @@
import pLimit from "p-limit";
import { z } from "zod";
import { searchClawHubSkills } from "../infra/clawhub.js";
import type { InstalledAppsResult } from "../infra/installed-apps.js";
import {
getOfficialExternalPluginCatalogManifest,
listOfficialExternalChannelCatalogEntries,
listOfficialExternalPluginCatalogEntries,
listOfficialExternalProviderCatalogEntries,
resolveOfficialExternalPluginId,
resolveOfficialExternalPluginLabel,
type OfficialExternalPluginCatalogEntry,
} from "../plugins/official-external-plugin-catalog.js";
import type { RuntimeEnv } from "../runtime.js";
import { completeSetupInference } from "./setup-inference.js";
const CLAWHUB_SEARCH_CONCURRENCY = 4;
const CLAWHUB_SEARCH_LIMIT = 3;
const CLAWHUB_SEARCH_TIMEOUT_MS = 5_000;
// Overall budget for the ClawHub phase: with per-request timeouts an
// unreachable host would otherwise stall onboarding ~(apps/4)*5s. Apps past
// the deadline keep their official-catalog candidates and skip hub search.
const CLAWHUB_SEARCH_TOTAL_BUDGET_MS = 20_000;
const CANDIDATE_SOURCE_ORDER: Record<SetupAppCandidateSource, number> = {
"official-plugin": 0,
"official-channel": 1,
"official-provider": 2,
"clawhub-skill": 3,
};
type SetupAppInventoryItem = {
label: string;
bundleId?: string;
};
type SetupAppCandidateSource =
| "official-plugin"
| "official-channel"
| "official-provider"
| "clawhub-skill";
type SetupAppCandidate = {
id: string;
displayName: string;
summary: string;
source: SetupAppCandidateSource;
downloads?: number;
};
type SetupAppCandidateGroup = {
app: SetupAppInventoryItem;
candidates: SetupAppCandidate[];
};
export type SetupAppRecommendationMatch = {
appLabel: string;
candidateId: string;
tier: "recommended" | "optional";
reason: string;
candidate: SetupAppCandidate;
};
export type SetupAppRecommendationsResult =
| {
status: "ok";
apps: SetupAppInventoryItem[];
groups: SetupAppCandidateGroup[];
matches: SetupAppRecommendationMatch[];
}
| {
status: "skipped";
reason: "unsupported" | "no-apps" | "no-candidates" | "model-failed" | "no-matches";
};
// Tolerant on purpose: models add extra keys and overlong reasons; a strict
// schema here would turn one sloppy field into a feature-wide "model-failed".
const MatcherOutputSchema = z.object({
matches: z.array(
z.object({
appLabel: z.string().trim().min(1),
candidateId: z.string().trim().min(1),
tier: z.enum(["recommended", "optional"]),
reason: z
.string()
.trim()
.min(1)
.transform((value) => (value.length > 120 ? `${value.slice(0, 119)}` : value)),
}),
),
});
type RecommendationDeps = {
listPlugins?: typeof listOfficialExternalPluginCatalogEntries;
listChannels?: typeof listOfficialExternalChannelCatalogEntries;
listProviders?: typeof listOfficialExternalProviderCatalogEntries;
searchSkills?: typeof searchClawHubSkills;
complete?: (prompt: string) => Promise<{ ok: true; text: string } | { ok: false }>;
};
function compareInventory(left: SetupAppInventoryItem, right: SetupAppInventoryItem): number {
return (
left.label.localeCompare(right.label, "en", { sensitivity: "base" }) ||
(left.bundleId ?? "").localeCompare(right.bundleId ?? "")
);
}
function normalizeInventory(apps: readonly SetupAppInventoryItem[]): SetupAppInventoryItem[] {
const byLabel = new Map<string, SetupAppInventoryItem>();
for (const app of apps.toSorted(compareInventory)) {
const label = app.label.trim();
if (!label) {
continue;
}
const bundleId = app.bundleId?.trim();
const key = label.toLocaleLowerCase("en-US");
const existing = byLabel.get(key);
if (!existing || (!existing.bundleId && bundleId)) {
byLabel.set(key, { label, ...(bundleId ? { bundleId } : {}) });
}
}
return [...byLabel.values()].toSorted(compareInventory);
}
function inventoryTokens(label: string): string[] {
return label
.toLocaleLowerCase("en-US")
.split(/[^\p{L}\p{N}]+/u)
.filter((token) => token.length >= 3)
.toSorted();
}
function providerSearchTokens(
manifest: ReturnType<typeof getOfficialExternalPluginCatalogManifest>,
): Array<string | undefined> {
const tokens: Array<string | undefined> = [];
for (const provider of manifest?.providers ?? []) {
tokens.push(provider.id, provider.name);
for (const alias of provider.aliases ?? []) {
tokens.push(alias);
}
}
return tokens;
}
function entrySearchText(entry: OfficialExternalPluginCatalogEntry): string {
const manifest = getOfficialExternalPluginCatalogManifest(entry);
return [
entry.id,
entry.name,
entry.title,
entry.description,
manifest?.plugin?.id,
manifest?.plugin?.label,
manifest?.channel?.id,
manifest?.channel?.label,
...providerSearchTokens(manifest),
]
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.join(" ")
.toLocaleLowerCase("en-US");
}
function entryMatchesApp(entry: OfficialExternalPluginCatalogEntry, appLabel: string): boolean {
const appTokens = inventoryTokens(appLabel);
const searchText = entrySearchText(entry);
const entryTokens = inventoryTokens(searchText);
return appTokens.some(
(appToken) =>
searchText.includes(appToken) ||
entryTokens.some((entryToken) => appToken.includes(entryToken)),
);
}
function officialCandidate(
entry: OfficialExternalPluginCatalogEntry,
source: Exclude<SetupAppCandidateSource, "clawhub-skill">,
): SetupAppCandidate | null {
const id = resolveOfficialExternalPluginId(entry);
if (!id) {
return null;
}
return {
id,
displayName: resolveOfficialExternalPluginLabel(entry),
summary: entry.description?.trim() || "Official OpenClaw plugin",
source,
};
}
function compareCandidates(left: SetupAppCandidate, right: SetupAppCandidate): number {
return (
CANDIDATE_SOURCE_ORDER[left.source] - CANDIDATE_SOURCE_ORDER[right.source] ||
left.displayName.localeCompare(right.displayName, "en", { sensitivity: "base" }) ||
left.id.localeCompare(right.id)
);
}
function dedupeCandidates(candidates: SetupAppCandidate[]): SetupAppCandidate[] {
const seen = new Set<string>();
return candidates.toSorted(compareCandidates).filter((candidate) => {
const key = candidate.id.toLocaleLowerCase("en-US");
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
async function gatherSetupAppCandidates(params: {
apps: SetupAppInventoryItem[];
deps?: RecommendationDeps;
}): Promise<SetupAppCandidateGroup[]> {
const deps = params.deps ?? {};
const channels = deps.listChannels?.() ?? listOfficialExternalChannelCatalogEntries();
const providers = deps.listProviders?.() ?? listOfficialExternalProviderCatalogEntries();
const allEntries = deps.listPlugins?.() ?? listOfficialExternalPluginCatalogEntries();
// Catalog entries are package manifests without a stable top-level `id`;
// key everything by the resolved plugin id or the map collapses to one
// undefined-keyed entry and no official candidate is ever produced.
const entryKey = (entry: OfficialExternalPluginCatalogEntry): string | undefined =>
resolveOfficialExternalPluginId(entry) ?? entry.name;
const channelIds = new Set(channels.map(entryKey));
const providerIds = new Set(providers.map(entryKey));
const entriesById = new Map(
[...allEntries, ...channels, ...providers].flatMap((entry) => {
const key = entryKey(entry);
return key ? ([[key, entry]] as const) : [];
}),
);
const officialEntries = [...entriesById.entries()].map(([key, entry]) => ({
entry,
source: channelIds.has(key)
? ("official-channel" as const)
: providerIds.has(key)
? ("official-provider" as const)
: ("official-plugin" as const),
}));
const searchSkills = deps.searchSkills ?? searchClawHubSkills;
const searchLimit = pLimit(CLAWHUB_SEARCH_CONCURRENCY);
const searchDeadline = Date.now() + CLAWHUB_SEARCH_TOTAL_BUDGET_MS;
const groups = await Promise.all(
normalizeInventory(params.apps).map(async (app): Promise<SetupAppCandidateGroup> => {
const official = officialEntries.flatMap(({ entry, source }) => {
if (!entryMatchesApp(entry, app.label)) {
return [];
}
const candidate = officialCandidate(entry, source);
return candidate ? [candidate] : [];
});
const skills = await searchLimit(async () => {
if (Date.now() >= searchDeadline) {
return [];
}
try {
const results = await searchSkills({
query: app.label.normalize("NFKC").trim(),
limit: CLAWHUB_SEARCH_LIMIT,
timeoutMs: CLAWHUB_SEARCH_TIMEOUT_MS,
});
return results.slice(0, CLAWHUB_SEARCH_LIMIT).map(
(result): SetupAppCandidate => ({
id: result.slug,
displayName: result.displayName,
summary: result.summary?.trim() || "ClawHub skill",
source: "clawhub-skill",
}),
);
} catch {
return [];
}
});
return { app, candidates: dedupeCandidates([...official, ...skills]) };
}),
);
return groups.toSorted((left, right) => compareInventory(left.app, right.app));
}
// Models routinely wrap JSON in markdown fences or prose despite "JSON only"
// instructions; parse the outermost object instead of the raw text.
function parseMatcherJson(text: string): unknown {
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start === -1 || end <= start) {
return null;
}
try {
return JSON.parse(text.slice(start, end + 1));
} catch {
return null;
}
}
function buildMatcherPrompt(groups: SetupAppCandidateGroup[]): string {
const payload = groups.map((group) => ({
app: group.app,
candidates: group.candidates,
}));
return [
"Match installed applications to genuinely related OpenClaw plugins or skills.",
"Reject coincidental substring, brand, or name overlaps.",
"Use tier recommended for messaging-channel integrations; otherwise choose recommended or optional by usefulness.",
"Give a reason of at most 12 words.",
'Return strict JSON only: {"matches":[{"appLabel":"...","candidateId":"...","tier":"recommended|optional","reason":"..."}]}.',
JSON.stringify(payload),
].join("\n");
}
export async function getSetupAppRecommendations(params: {
inventorySource: () => Promise<InstalledAppsResult | SetupAppInventoryItem[]>;
runtime: RuntimeEnv;
deps?: RecommendationDeps;
}): Promise<SetupAppRecommendationsResult> {
const inventory = await params.inventorySource();
if (!Array.isArray(inventory) && inventory.status === "unsupported") {
return { status: "skipped", reason: "unsupported" };
}
const apps = normalizeInventory(
Array.isArray(inventory)
? inventory
: inventory.apps.map((app) => ({ label: app.label, bundleId: app.bundleId })),
);
if (apps.length === 0) {
return { status: "skipped", reason: "no-apps" };
}
const groups = await gatherSetupAppCandidates({ apps, deps: params.deps });
if (groups.every((group) => group.candidates.length === 0)) {
return { status: "skipped", reason: "no-candidates" };
}
const complete =
params.deps?.complete ??
// Output is bounded by the resolved model's own maxTokens budget (the
// stream layer applies it when no explicit cap is passed), so a runaway
// completion cannot exceed what the model config already allows.
(async (prompt: string) => await completeSetupInference({ prompt, runtime: params.runtime }));
let completion: Awaited<ReturnType<typeof complete>>;
try {
completion = await complete(buildMatcherPrompt(groups));
} catch {
return { status: "skipped", reason: "model-failed" };
}
if (!completion.ok) {
return { status: "skipped", reason: "model-failed" };
}
const parsed = MatcherOutputSchema.safeParse(parseMatcherJson(completion.text));
if (!parsed.success) {
return { status: "skipped", reason: "model-failed" };
}
// Case-insensitive lookups: models normalize label/id casing in their output.
const matches = parsed.data.matches.flatMap((match): SetupAppRecommendationMatch[] => {
const appKey = match.appLabel.toLocaleLowerCase("en-US");
const candidateKey = match.candidateId.toLocaleLowerCase("en-US");
const group = groups.find(
(candidate) => candidate.app.label.toLocaleLowerCase("en-US") === appKey,
);
const candidate = group?.candidates.find(
(entry) => entry.id.toLocaleLowerCase("en-US") === candidateKey,
);
return candidate ? [{ ...match, appLabel: group?.app.label ?? match.appLabel, candidate }] : [];
});
if (matches.length === 0) {
return { status: "skipped", reason: "no-matches" };
}
return {
status: "ok",
apps,
groups,
matches: matches.toSorted(
(left, right) =>
(left.tier === right.tier ? 0 : left.tier === "recommended" ? -1 : 1) ||
left.candidate.displayName.localeCompare(right.candidate.displayName, "en", {
sensitivity: "base",
}) ||
left.appLabel.localeCompare(right.appLabel, "en", { sensitivity: "base" }),
),
};
}
+105 -4
View File
@@ -194,6 +194,10 @@ export type VerifySetupInferenceResult =
| { ok: true; modelRef: string; latencyMs: number }
| { ok: false; status: SetupInferenceFailureStatus; error: string };
export type CompleteSetupInferenceResult =
| { ok: true; modelRef: string; latencyMs: number; text: string }
| { ok: false; status: SetupInferenceFailureStatus; error: string };
export type BoundVerifySetupInferenceResult =
| {
ok: true;
@@ -2688,6 +2692,95 @@ export async function verifySetupInferenceConfig(params: {
}
}
/** Run one tool-free completion through the configured setup inference route. */
export async function completeSetupInference(params: {
prompt: string;
runtime: RuntimeEnv;
timeoutMs?: number;
deps?: ActivateSetupInferenceDeps;
}): Promise<CompleteSetupInferenceResult> {
const readSnapshot =
params.deps?.readConfigFileSnapshot ??
(await import("../config/config.js")).readConfigFileSnapshot;
const snapshot = await readSnapshot();
if (!snapshot.exists) {
return { ok: false, status: "unavailable", error: "No OpenClaw config exists." };
}
if (!snapshot.valid) {
return { ok: false, status: "format", error: invalidSetupConfigError(snapshot) };
}
return await completeSetupInferenceConfig({
config: snapshot.runtimeConfig ?? snapshot.config,
prompt: params.prompt,
runtime: params.runtime,
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
...(params.deps ? { deps: params.deps } : {}),
});
}
/** Config-injected variant used by setup clients and live provider tests. */
export async function completeSetupInferenceConfig(params: {
config: OpenClawConfig;
prompt: string;
runtime: RuntimeEnv;
timeoutMs?: number;
deps?: ActivateSetupInferenceDeps;
}): Promise<CompleteSetupInferenceResult> {
const deps: ActivateSetupInferenceDeps = {
...params.deps,
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
};
const routeAgentId = normalizeAgentId(resolveDefaultAgentId(params.config));
if (!resolveAgentEffectiveModelPrimary(params.config, routeAgentId)) {
return { ok: false, status: "unavailable", error: "No agent model is configured." };
}
const tempDir = await (
deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-")))
)();
try {
const plan = await buildTestPlan({
kind: "existing-model",
cfg: params.config,
sourceCfg: params.config,
workspaceDir: tempDir,
pluginWorkspaceDir: tempDir,
agentDir: path.join(tempDir, "agent"),
runtime: params.runtime,
routeAgentId,
deps,
});
if ("error" in plan) {
return { ok: false, status: "unavailable", error: plan.error };
}
const result = await runSetupInferenceTest({
plan,
prompt: params.prompt,
tempDir,
deps,
authProfileStateMode: "read-only",
requireExecutionOwner: false,
});
if (!result.ok) {
return { ...result, error: await redactSetupInferenceError(result.error) };
}
if (plan.authProfileId && result.auth.authProfileId !== plan.authProfileId) {
return {
ok: false,
status: "auth",
error: "The inference completion used a different credential than the configured route.",
};
}
return {
ok: true,
modelRef: plan.modelRef,
latencyMs: result.latencyMs,
text: result.text,
};
} finally {
await cleanupSetupInferenceTempDir({ tempDir, deps, runtime: params.runtime });
}
}
async function cleanupSetupInferenceTempDir(params: {
tempDir: string;
deps: ActivateSetupInferenceDeps;
@@ -3102,13 +3195,14 @@ async function rollbackManualAuthProfiles(
async function runSetupInferenceTest(params: {
plan: SetupInferenceTestPlan;
prompt?: string;
tempDir: string;
deps: ActivateSetupInferenceDeps;
authProfileStateMode: "read-write" | "read-only";
requireExecutionOwner: boolean;
signal?: AbortSignal;
}): Promise<
| { ok: true; latencyMs: number; auth: AgentExecutionAuthBinding }
| { ok: true; latencyMs: number; auth: AgentExecutionAuthBinding; text: string }
| {
ok: false;
status: SetupInferenceFailureStatus;
@@ -3152,7 +3246,7 @@ async function runSetupInferenceTest(params: {
workspaceDir: tempDir,
...(plan.agentDir ? { agentDir: plan.agentDir } : {}),
config: plan.config,
prompt: SETUP_INFERENCE_TEST_PROMPT,
prompt: params.prompt ?? SETUP_INFERENCE_TEST_PROMPT,
provider: plan.provider,
model: plan.model,
...(plan.authProfileId ? { authProfileId: plan.authProfileId } : {}),
@@ -3180,7 +3274,7 @@ async function runSetupInferenceTest(params: {
workspaceDir: tempDir,
...(plan.agentDir ? { agentDir: plan.agentDir } : {}),
config: plan.config,
prompt: SETUP_INFERENCE_TEST_PROMPT,
prompt: params.prompt ?? SETUP_INFERENCE_TEST_PROMPT,
provider: plan.provider,
model: plan.model,
...(plan.authProfileId
@@ -3197,7 +3291,13 @@ async function runSetupInferenceTest(params: {
thinkLevel: "off",
reasoningLevel: "off",
verboseLevel: "off",
...resolveSetupInferenceProbeStreamParams(plan.agentHarnessRuntimeOverride),
// The 32-token probe cap is sized for the "reply OK" verification
// prompt only. Custom completions pass no explicit cap: the stream
// layer then applies the resolved model's own required maxTokens
// budget, which both bounds output and never exceeds provider limits.
...(params.prompt === undefined
? resolveSetupInferenceProbeStreamParams(plan.agentHarnessRuntimeOverride)
: {}),
disableTools: true,
modelRun: true,
messageChannel: "openclaw",
@@ -3243,6 +3343,7 @@ async function runSetupInferenceTest(params: {
return {
ok: true,
latencyMs: Date.now() - started,
text,
auth:
successfulAuth ??
(!requireExecutionOwner && plan.authProfileId ? { authProfileId: plan.authProfileId } : {}),
+16
View File
@@ -153,6 +153,22 @@ export const en = {
summaryTitle: "Memory import summary",
title: "Memories found",
},
appRecommendations: {
catalogEntryMissing: "Official plugin catalog entry is unavailable.",
detected: "Detected apps: {apps}",
disclosure: "App names were matched using your configured model and ClawHub search.",
installFailed: "Could not install {name}: {reason}",
noneFound: "No app-based plugin or skill recommendations found.",
option: "{name} — {reason} (detected: {app})",
optionThirdParty:
"{name} — {reason} (detected: {app}) — third-party ClawHub skill; installs its publisher's code",
scanning:
"Scanning installed apps — names are matched with your configured model and ClawHub search (disable via wizard.appRecommendations)…",
select: "Install recommended plugins and skills",
skillTrust: "Trust and install the ClawHub skill {name}?",
skipped: "App recommendations skipped: {reason}",
title: "App recommendations",
},
plugins: {
configureBackHint: "Return to section menu",
configureEmpty: "No plugins with configurable fields found.",
+16
View File
@@ -152,6 +152,22 @@ export const zh_CN = {
summaryTitle: "Memory 导入摘要",
title: "发现 memory",
},
appRecommendations: {
catalogEntryMissing: "官方插件目录条目不可用。",
detected: "检测到的应用:{apps}",
disclosure: "应用名称使用你配置的模型和 ClawHub 搜索进行匹配。",
installFailed: "无法安装 {name}{reason}",
noneFound: "未找到基于应用的插件或技能推荐。",
option: "{name} — {reason}(检测到:{app}",
optionThirdParty:
"{name} — {reason}(检测到:{app})— 第三方 ClawHub 技能;将安装其发布者的代码",
scanning:
"正在扫描已安装应用 — 应用名称将通过你配置的模型与 ClawHub 搜索进行匹配(可通过 wizard.appRecommendations 关闭)…",
select: "安装推荐的插件和技能",
skillTrust: "信任并安装 ClawHub 技能 {name}",
skipped: "已跳过应用推荐:{reason}",
title: "应用推荐",
},
plugins: {
configureBackHint: "返回分区菜单",
configureEmpty: "没有找到带可配置字段的插件。",
+16
View File
@@ -152,6 +152,22 @@ export const zh_TW = {
summaryTitle: "Memory 匯入摘要",
title: "發現 memory",
},
appRecommendations: {
catalogEntryMissing: "官方插件目錄項目無法使用。",
detected: "偵測到的應用程式:{apps}",
disclosure: "應用程式名稱使用你設定的模型和 ClawHub 搜尋進行比對。",
installFailed: "無法安裝 {name}{reason}",
noneFound: "找不到依應用程式推薦的插件或技能。",
option: "{name} — {reason}(偵測到:{app}",
optionThirdParty:
"{name} — {reason}(偵測到:{app})— 第三方 ClawHub 技能;將安裝其發佈者的程式碼",
scanning:
"正在掃描已安裝的應用程式 — 應用程式名稱會透過你設定的模型與 ClawHub 搜尋進行比對(可透過 wizard.appRecommendations 停用)…",
select: "安裝推薦的插件和技能",
skillTrust: "信任並安裝 ClawHub 技能 {name}",
skipped: "已略過應用程式推薦:{reason}",
title: "應用程式推薦",
},
plugins: {
configureBackHint: "返回分區選單",
configureEmpty: "沒有找到帶可設定欄位的插件。",
@@ -0,0 +1,164 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import type { SetupAppRecommendationsResult } from "../system-agent/setup-app-recommendations.js";
import type { WizardPrompter } from "./prompts.js";
import { setupAppRecommendations } from "./setup.app-recommendations.js";
function createPrompter(selected: string[] = []): WizardPrompter {
return {
intro: vi.fn(async () => undefined),
outro: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
plain: vi.fn(async () => undefined),
select: vi.fn(),
multiselect: vi.fn(async () => selected) as WizardPrompter["multiselect"],
text: vi.fn(),
confirm: vi.fn(async () => true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
}
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
function recommendationResult(): Extract<SetupAppRecommendationsResult, { status: "ok" }> {
const apps = [{ label: "Chat", bundleId: "com.example.chat" }];
const matches = [
{
appLabel: "Chat",
candidateId: "chat-plugin",
tier: "recommended" as const,
reason: "Connects conversations",
candidate: {
id: "chat-plugin",
displayName: "Chat plugin",
summary: "Chat",
source: "official-channel" as const,
},
},
{
appLabel: "Chat",
candidateId: "chat-skill",
tier: "optional" as const,
reason: "Adds useful actions",
candidate: {
id: "chat-skill",
displayName: "Chat skill",
summary: "Chat skill",
source: "clawhub-skill" as const,
},
},
];
return { status: "ok", apps, groups: [{ app: apps[0]!, candidates: [] }], matches };
}
describe("setupAppRecommendations", () => {
it.each([
[{ wizard: { appRecommendations: false } }, "darwin" as const],
[{}, "linux" as const],
])("skips when gated", async (config, platform) => {
const recommend = vi.fn(async () => recommendationResult());
await setupAppRecommendations({
config,
prompter: createPrompter(),
runtime,
workspaceDir: "/tmp/workspace",
modelRouteVerified: true,
platform,
deps: { recommend },
});
expect(recommend).not.toHaveBeenCalled();
});
it("never preselects third-party ClawHub skills even when model-recommended", async () => {
const result = recommendationResult();
result.matches[1] = {
...result.matches[1]!,
tier: "recommended",
};
const prompter = createPrompter();
await setupAppRecommendations({
config: {},
prompter,
runtime,
workspaceDir: "/tmp/workspace",
modelRouteVerified: true,
platform: "darwin",
deps: { recommend: vi.fn(async () => result) },
});
expect(prompter.multiselect).toHaveBeenCalledWith(
expect.objectContaining({ initialValues: ["recommendation:0"] }),
);
});
it("preselects recommended matches and installs selected plugin and skill", async () => {
const config: OpenClawConfig = {};
const prompter = createPrompter(["recommendation:0", "recommendation:1"]);
const ensurePlugin = vi.fn(async () => ({
cfg: { ...config, plugins: { entries: { "chat-plugin": { enabled: true } } } },
installed: true,
pluginId: "chat-plugin",
status: "installed" as const,
}));
const installSkill = vi.fn(async () => ({
ok: true as const,
slug: "chat-skill",
version: "1.0.0",
targetDir: "/tmp/workspace/skills/chat-skill",
}));
const result = await setupAppRecommendations({
config,
prompter,
runtime,
workspaceDir: "/tmp/workspace",
modelRouteVerified: true,
platform: "darwin",
deps: {
recommend: async () => recommendationResult(),
ensurePlugin,
installSkill,
resolveOfficialEntry: (pluginId) => ({
pluginId,
label: "Chat plugin",
install: { npmSpec: "@openclaw/chat-plugin" },
}),
},
});
expect(prompter.multiselect).toHaveBeenCalledWith(
expect.objectContaining({ initialValues: ["recommendation:0"] }),
);
expect(ensurePlugin).toHaveBeenCalledOnce();
expect(installSkill).toHaveBeenCalledOnce();
expect(result.plugins?.entries?.["chat-plugin"]?.enabled).toBe(true);
});
it("installs nothing when the explicit skip entry is selected", async () => {
const ensurePlugin = vi.fn();
const installSkill = vi.fn();
const config: OpenClawConfig = {};
await expect(
setupAppRecommendations({
config,
prompter: createPrompter(["__skip__", "recommendation:0"]),
runtime,
workspaceDir: "/tmp/workspace",
modelRouteVerified: true,
platform: "darwin",
deps: {
recommend: async () => recommendationResult(),
ensurePlugin,
installSkill,
},
}),
).resolves.toBe(config);
expect(ensurePlugin).not.toHaveBeenCalled();
expect(installSkill).not.toHaveBeenCalled();
});
});
+206
View File
@@ -0,0 +1,206 @@
import {
ensureOnboardingPluginInstalled,
type OnboardingPluginInstallEntry,
} from "../commands/onboarding-plugin-install.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { scanInstalledApps } from "../infra/installed-apps.js";
import {
listOfficialExternalPluginCatalogEntries,
resolveOfficialExternalPluginId,
resolveOfficialExternalPluginInstall,
resolveOfficialExternalPluginLabel,
} from "../plugins/official-external-plugin-catalog.js";
import type { RuntimeEnv } from "../runtime.js";
import { installSkillFromClawHub } from "../skills/lifecycle/clawhub.js";
import {
getSetupAppRecommendations,
type SetupAppRecommendationMatch,
type SetupAppRecommendationsResult,
} from "../system-agent/setup-app-recommendations.js";
import { t } from "./i18n/index.js";
import type { WizardPrompter } from "./prompts.js";
const SKIP_VALUE = "__skip__";
type SetupAppRecommendationDeps = {
recommend?: () => Promise<SetupAppRecommendationsResult>;
ensurePlugin?: typeof ensureOnboardingPluginInstalled;
installSkill?: typeof installSkillFromClawHub;
resolveOfficialEntry?: (pluginId: string) => OnboardingPluginInstallEntry | undefined;
};
function resolveOfficialEntry(pluginId: string): OnboardingPluginInstallEntry | undefined {
const catalogEntry = listOfficialExternalPluginCatalogEntries().find(
(entry) => resolveOfficialExternalPluginId(entry) === pluginId,
);
const install = catalogEntry ? resolveOfficialExternalPluginInstall(catalogEntry) : undefined;
if (!catalogEntry || !install) {
return undefined;
}
return {
pluginId,
label: resolveOfficialExternalPluginLabel(catalogEntry),
install,
trustedSourceLinkedOfficialInstall: true,
};
}
function selectionValue(index: number): string {
return `recommendation:${index}`;
}
function uniqueSelectedMatches(
matches: SetupAppRecommendationMatch[],
selected: string[],
): SetupAppRecommendationMatch[] {
const selectedValues = new Set(selected);
const seen = new Set<string>();
return matches.filter((match, index) => {
const key = `${match.candidate.source}:${match.candidate.id}`;
if (!selectedValues.has(selectionValue(index)) || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
export async function setupAppRecommendations(params: {
config: OpenClawConfig;
prompter: WizardPrompter;
runtime: RuntimeEnv;
workspaceDir: string;
modelRouteVerified: boolean;
platform?: NodeJS.Platform;
deps?: SetupAppRecommendationDeps;
}): Promise<OpenClawConfig> {
const platform = params.platform ?? process.platform;
// Product decision: default-on "magical" scan with a kill switch, not
// consent-first. App labels/bundle ids go to the user's configured model and
// ClawHub search; the scanning progress line and the results note disclose
// this, and wizard.appRecommendations=false disables the step entirely.
if (
params.config.wizard?.appRecommendations === false ||
platform !== "darwin" ||
!params.modelRouteVerified
) {
return params.config;
}
const progress = params.prompter.progress(t("wizard.appRecommendations.scanning"));
let result: SetupAppRecommendationsResult;
try {
result = params.deps?.recommend
? await params.deps.recommend()
: await getSetupAppRecommendations({
inventorySource: async () => await scanInstalledApps({ platform }),
runtime: params.runtime,
});
} catch (error) {
progress.stop();
params.runtime.log(
t("wizard.appRecommendations.skipped", { reason: formatErrorMessage(error) }),
);
return params.config;
}
progress.stop();
if (result.status !== "ok") {
params.runtime.log(t("wizard.appRecommendations.noneFound"));
return params.config;
}
await params.prompter.note(
[
t("wizard.appRecommendations.detected", {
apps: result.apps.map((app) => app.label).join(", "),
}),
t("wizard.appRecommendations.disclosure"),
].join("\n"),
t("wizard.appRecommendations.title"),
);
const selected = await params.prompter.multiselect({
message: t("wizard.appRecommendations.select"),
options: [
{ value: SKIP_VALUE, label: t("common.skipForNow") },
...result.matches.map((match, index) => ({
value: selectionValue(index),
label:
match.candidate.source === "clawhub-skill"
? t("wizard.appRecommendations.optionThirdParty", {
name: match.candidate.displayName,
reason: match.reason,
app: match.appLabel,
})
: t("wizard.appRecommendations.option", {
name: match.candidate.displayName,
reason: match.reason,
app: match.appLabel,
}),
})),
],
// Supply-chain guard: ClawHub listing text is publisher-controlled and
// reaches the matcher prompt, so a listing can promote itself to
// "recommended". Only official catalog entries may be pre-selected;
// third-party skills always require an explicit opt-in tick.
initialValues: result.matches.flatMap((match, index) =>
match.tier === "recommended" && match.candidate.source !== "clawhub-skill"
? [selectionValue(index)]
: [],
),
});
if (selected.includes(SKIP_VALUE)) {
return params.config;
}
let next = params.config;
const ensurePlugin = params.deps?.ensurePlugin ?? ensureOnboardingPluginInstalled;
const installSkill = params.deps?.installSkill ?? installSkillFromClawHub;
for (const match of uniqueSelectedMatches(result.matches, selected)) {
try {
if (match.candidate.source === "clawhub-skill") {
const installed = await installSkill({
workspaceDir: params.workspaceDir,
slug: match.candidate.id,
config: next,
onClawHubRisk: async () =>
await params.prompter.confirm({
message: t("wizard.appRecommendations.skillTrust", {
name: match.candidate.displayName,
}),
initialValue: false,
}),
logger: { warn: (message) => params.runtime.error(message) },
});
if (!installed.ok) {
throw new Error(installed.error);
}
continue;
}
const entry = (params.deps?.resolveOfficialEntry ?? resolveOfficialEntry)(match.candidate.id);
if (!entry) {
throw new Error(t("wizard.appRecommendations.catalogEntryMissing"));
}
const installed = await ensurePlugin({
cfg: next,
entry,
prompter: params.prompter,
runtime: params.runtime,
workspaceDir: params.workspaceDir,
promptInstall: false,
});
next = installed.cfg;
if (!installed.installed) {
throw new Error(installed.error ?? installed.status);
}
} catch (error) {
params.runtime.error(
t("wizard.appRecommendations.installFailed", {
name: match.candidate.displayName,
reason: formatErrorMessage(error),
}),
);
}
}
return next;
}
+18 -7
View File
@@ -52,13 +52,13 @@ async function offerLiveModelVerification(params: {
runtime: RuntimeEnv;
workspaceDir: string;
writeConfig: (config: OpenClawConfig) => Promise<OpenClawConfig>;
}): Promise<OpenClawConfig> {
}): Promise<{ config: OpenClawConfig; verified: boolean }> {
const shouldTest = await params.prompter.confirm({
message: t("wizard.setup.testAiAccess"),
initialValue: true,
});
if (!shouldTest) {
return params.config;
return { config: params.config, verified: false };
}
const { verifySetupInference } = await import("../system-agent/setup-inference.js");
@@ -84,7 +84,7 @@ async function offerLiveModelVerification(params: {
const firstResult = await verify();
if (firstResult.ok) {
return params.config;
return { config: params.config, verified: true };
}
const action = await params.prompter.select({
message: t("wizard.setup.testAiFailureChoice"),
@@ -94,7 +94,7 @@ async function offerLiveModelVerification(params: {
],
});
if (action === "continue") {
return params.config;
return { config: params.config, verified: false };
}
const fixedConfig = await runSetupModelAuthStep({
@@ -105,8 +105,8 @@ async function offerLiveModelVerification(params: {
workspaceDir: params.workspaceDir,
});
const persistedConfig = await params.writeConfig(fixedConfig);
await verify();
return persistedConfig;
const retryResult = await verify();
return { config: persistedConfig, verified: retryResult.ok };
}
function isSetupImportFlowChoice(flow: SetupFlowChoice): boolean {
@@ -550,13 +550,14 @@ async function runSetupWizardOnce(
allowConfigSizeDrop: false,
});
let liveModelVerified = false;
if (
opts.nonInteractive !== true &&
opts.authChoice !== "skip" &&
!usedImportFlow &&
hasConfiguredDefaultModel(nextConfig)
) {
nextConfig = await offerLiveModelVerification({
const verification = await offerLiveModelVerification({
config: nextConfig,
opts,
prompter,
@@ -565,6 +566,8 @@ async function runSetupWizardOnce(
writeConfig: async (config) =>
await writeSetupConfigFile(config, { allowConfigSizeDrop: false }),
});
nextConfig = verification.config;
liveModelVerified = verification.verified;
}
prompter.disableBackNavigation?.();
@@ -633,6 +636,14 @@ async function runSetupWizardOnce(
runtime,
workspaceDir,
});
const { setupAppRecommendations } = await import("./setup.app-recommendations.js");
nextConfig = await setupAppRecommendations({
config: nextConfig,
prompter,
runtime,
workspaceDir,
modelRouteVerified: liveModelVerified,
});
const { setupPluginConfig } = await import("./setup.plugin-config.js");
nextConfig = await setupPluginConfig({
config: nextConfig,