mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
feat(onboarding): recommend plugins and skills from installed apps (#109668)
* feat(onboarding): recommend plugins and skills from installed apps Scan installed macOS apps during classic onboarding (TCC-free), gather candidates from official catalogs + ClawHub search, let the configured model pick genuine matches, and offer an opt-in multiselect install step. Adds a device.apps node-host command (default-off sharing, Android-parity envelope) so remote gateways can request a paired Mac's inventory, and a wizard.appRecommendations kill switch. Custom setup-inference completions no longer inherit the 32-token verification-probe output cap. * feat(onboarding): recommend apps in guided flow * fix(onboarding): harden app recommendations against ClawHub self-promotion Third-party ClawHub skills are never pre-selected regardless of model tier (publisher-controlled listing text reaches the matcher prompt and could promote itself); their labels now say they install third-party code. Installed-app scans follow symlinked .app bundles. Matcher output stays bounded by the resolved model's own maxTokens budget (documented invariant). * fix(onboarding): key official catalog candidates by resolved plugin id Real catalog entries are package manifests without a top-level id; keying the candidate map and channel/provider classification by entry.id collapsed the whole official catalog into one undefined-keyed entry, so no official plugin or channel was ever recommended. Regression test runs against the bundled catalogs. * fix(onboarding): satisfy lint, types, deadcode, and migration gates Split the guided-onboarding test into a self-contained custodian suite to stay under max-lines. Narrow app-recommendation exports (drop dead node-payload normalizer, unexport internal types/helpers, route candidate tests through the public API), replace map-spread with a helper, unexport device.apps result types, add installedAppsSharing to node-host migration expectations, cast the wizard multiselect mock, and regenerate the docs map. * test(onboarding): register new live test in the shard classifier
This commit is contained in:
committed by
GitHub
parent
8020dd3e08
commit
da69daeb72
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -2306,6 +2306,7 @@ describe("state migrations", () => {
|
||||
tlsFingerprint: fixtureDigest,
|
||||
contextPath: "/doctor",
|
||||
},
|
||||
installedAppsSharing: false,
|
||||
});
|
||||
await expectMissingPath(sourcePath);
|
||||
});
|
||||
|
||||
@@ -169,6 +169,7 @@ describe("node-host SQLite config", () => {
|
||||
version: 1,
|
||||
nodeId: "node-custom",
|
||||
displayName: "Build Node",
|
||||
installedAppsSharing: false,
|
||||
gateway: {
|
||||
host: "gateway.local",
|
||||
port: 18443,
|
||||
@@ -184,6 +185,28 @@ describe("node-host SQLite config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps installed-app sharing disabled by default and persists an explicit enable", async () => {
|
||||
const { env } = makeTestEnv();
|
||||
const initial = await configureNodeHost({
|
||||
fallbackDisplayName: "node",
|
||||
gateway: {},
|
||||
env,
|
||||
nowMs: 1,
|
||||
});
|
||||
expect(initial.installedAppsSharing).toBe(false);
|
||||
|
||||
const enabled = await configureNodeHost({
|
||||
fallbackDisplayName: "node",
|
||||
gateway: {},
|
||||
installedAppsSharing: true,
|
||||
env,
|
||||
nowMs: 2,
|
||||
});
|
||||
expect(enabled.installedAppsSharing).toBe(true);
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await expect(loadNodeHostConfig(env)).resolves.toMatchObject({ installedAppsSharing: true });
|
||||
});
|
||||
|
||||
it("adds the gateway context-path column to an existing state database", async () => {
|
||||
const { env } = makeTestEnv();
|
||||
const database = openOpenClawStateDatabase({ env });
|
||||
|
||||
@@ -31,6 +31,8 @@ export type NodeHostConfig = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
gateway?: NodeHostGatewayConfig;
|
||||
/** Share installed macOS applications through device.apps (default: false). */
|
||||
installedAppsSharing?: boolean;
|
||||
};
|
||||
|
||||
export const NODE_HOST_CONFIG_KEY = "current";
|
||||
@@ -120,6 +122,9 @@ function rowToNodeHostConfig(row: NodeHostConfigRuntimeRow): NodeHostConfig {
|
||||
if (row.gateway_tls !== null && row.gateway_tls !== 0 && row.gateway_tls !== 1) {
|
||||
throw new Error("invalid node-host SQLite row: gateway_tls must be 0, 1, or null");
|
||||
}
|
||||
if (row.installed_apps_sharing !== 0 && row.installed_apps_sharing !== 1) {
|
||||
throw new Error("invalid node-host SQLite row: installed_apps_sharing must be 0 or 1");
|
||||
}
|
||||
const gateway: NodeHostGatewayConfig = {
|
||||
host: optionalNonEmptyString(row.gateway_host, "gateway_host"),
|
||||
port: validatePort(row.gateway_port, "SQLite gateway_port"),
|
||||
@@ -133,6 +138,7 @@ function rowToNodeHostConfig(row: NodeHostConfigRuntimeRow): NodeHostConfig {
|
||||
nodeId,
|
||||
displayName: optionalNonEmptyString(row.display_name, "display_name"),
|
||||
gateway: hasGateway ? gateway : undefined,
|
||||
installedAppsSharing: row.installed_apps_sharing === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,6 +169,7 @@ function configToRow(params: {
|
||||
gateway_tls: gateway?.tls === undefined ? null : gateway.tls ? 1 : 0,
|
||||
gateway_tls_fingerprint: gateway?.tlsFingerprint ?? null,
|
||||
gateway_context_path: gateway?.contextPath ?? null,
|
||||
installed_apps_sharing: params.config.installedAppsSharing ? 1 : 0,
|
||||
updated_at_ms: params.updatedAtMs,
|
||||
};
|
||||
}
|
||||
@@ -184,6 +191,7 @@ function readNodeHostConfigRow(
|
||||
"gateway_tls",
|
||||
"gateway_tls_fingerprint",
|
||||
"gateway_context_path",
|
||||
"installed_apps_sharing",
|
||||
"updated_at_ms",
|
||||
])
|
||||
.where("config_key", "=", NODE_HOST_CONFIG_KEY),
|
||||
@@ -212,6 +220,7 @@ export async function configureNodeHost(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nowMs?: number;
|
||||
candidateNodeId?: string;
|
||||
installedAppsSharing?: boolean;
|
||||
}): Promise<NodeHostConfig> {
|
||||
const env = params.env ?? process.env;
|
||||
assertNodeHostLegacyStateMigrated(env);
|
||||
@@ -236,6 +245,7 @@ export async function configureNodeHost(params: {
|
||||
nodeId,
|
||||
displayName,
|
||||
gateway,
|
||||
installedAppsSharing: params.installedAppsSharing ?? existing?.installedAppsSharing ?? false,
|
||||
};
|
||||
const row = configToRow({ config: next, updatedAtMs });
|
||||
const { config_key: _configKey, ...updates } = row;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createExecApprovalPolicySnapshot } from "../infra/exec-approvals.js";
|
||||
import type { scanInstalledApps } from "../infra/installed-apps.js";
|
||||
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
|
||||
import type { OpenClawPluginNodeHostCommandContext } from "../plugins/types.node-host.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
@@ -21,6 +22,9 @@ export type NodeHostInvokeRuntime = {
|
||||
signal?: AbortSignal;
|
||||
pluginCommandIo?: OpenClawPluginNodeHostCommandIo;
|
||||
pluginCommandContext?: OpenClawPluginNodeHostCommandContext;
|
||||
installedAppsSharingEnabled?: boolean;
|
||||
installedAppsPlatform?: NodeJS.Platform;
|
||||
scanInstalledApps?: typeof scanInstalledApps;
|
||||
};
|
||||
|
||||
type ClaudeCliNodeInvokeDeps = Pick<
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod";
|
||||
import { scanInstalledApps, type InstalledApp } from "../infra/installed-apps.js";
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
const DeviceAppsParamsSchema = z
|
||||
.object({
|
||||
query: z.string().trim().min(1).optional(),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.transform((value) => Math.min(MAX_LIMIT, Math.max(1, value)))
|
||||
.optional(),
|
||||
includeSystem: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type DeviceAppsPayload = {
|
||||
count: number;
|
||||
totalMatched: number;
|
||||
truncated: boolean;
|
||||
apps: InstalledApp[];
|
||||
};
|
||||
|
||||
type DeviceAppsInvokeResult =
|
||||
| { ok: true; payload: DeviceAppsPayload }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
export async function invokeDeviceApps(params: {
|
||||
paramsJSON?: string | null;
|
||||
sharingEnabled: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
scan?: typeof scanInstalledApps;
|
||||
}): Promise<DeviceAppsInvokeResult> {
|
||||
if (!params.sharingEnabled) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "INSTALLED_APPS_SHARING_DISABLED",
|
||||
message: "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in node-host settings",
|
||||
};
|
||||
}
|
||||
let request: z.infer<typeof DeviceAppsParamsSchema>;
|
||||
try {
|
||||
request = DeviceAppsParamsSchema.parse(JSON.parse(params.paramsJSON || "{}"));
|
||||
} catch (error) {
|
||||
return { ok: false, code: "INVALID_REQUEST", message: String(error) };
|
||||
}
|
||||
const scan = params.scan ?? scanInstalledApps;
|
||||
const inventory = await scan({ platform: params.platform ?? process.platform });
|
||||
if (inventory.status === "unsupported") {
|
||||
return {
|
||||
ok: false,
|
||||
code: "UNAVAILABLE",
|
||||
message: "UNAVAILABLE: installed application inventory is only available on macOS",
|
||||
};
|
||||
}
|
||||
const query = request.query?.toLocaleLowerCase("en-US");
|
||||
const matching = inventory.apps.filter(
|
||||
(app) =>
|
||||
(request.includeSystem === true || !app.system) &&
|
||||
(!query ||
|
||||
app.label.toLocaleLowerCase("en-US").includes(query) ||
|
||||
app.bundleId?.toLocaleLowerCase("en-US").includes(query)),
|
||||
);
|
||||
const apps = matching.slice(0, request.limit ?? DEFAULT_LIMIT);
|
||||
return {
|
||||
ok: true,
|
||||
payload: {
|
||||
count: apps.length,
|
||||
totalMatched: matching.length,
|
||||
truncated: matching.length > apps.length,
|
||||
apps,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "../infra/host-env-security.js";
|
||||
import {
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
NODE_DEVICE_APPS_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
} from "../infra/node-commands.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
handleClaudeCliNodeInvoke,
|
||||
type NodeHostInvokeRuntime,
|
||||
} from "./invoke-agent-cli-claude-handler.js";
|
||||
import { invokeDeviceApps } from "./invoke-device-apps.js";
|
||||
import { invokeNodeFileCommand } from "./invoke-file-commands.js";
|
||||
import {
|
||||
buildSystemRunApprovalPlan,
|
||||
@@ -567,6 +569,20 @@ async function dispatchInvoke(
|
||||
runtime: NodeHostInvokeRuntime = {},
|
||||
) {
|
||||
const command = frame.command ?? "";
|
||||
if (command === NODE_DEVICE_APPS_COMMAND) {
|
||||
const result = await invokeDeviceApps({
|
||||
paramsJSON: frame.paramsJSON,
|
||||
sharingEnabled: runtime.installedAppsSharingEnabled === true,
|
||||
...(runtime.installedAppsPlatform ? { platform: runtime.installedAppsPlatform } : {}),
|
||||
...(runtime.scanInstalledApps ? { scan: runtime.scanInstalledApps } : {}),
|
||||
});
|
||||
if (result.ok) {
|
||||
await sendJsonPayloadResult(client, frame, result.payload);
|
||||
} else {
|
||||
await sendErrorResult(client, frame, result.code, result.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === "system.execApprovals.get") {
|
||||
try {
|
||||
const snapshot = await ensureExecApprovalsSnapshot();
|
||||
|
||||
@@ -32,6 +32,7 @@ type NodeHostRunOptions = {
|
||||
gatewayContextPath?: string;
|
||||
nodeId?: string;
|
||||
displayName?: string;
|
||||
installedAppsSharing?: boolean;
|
||||
};
|
||||
|
||||
function resolveNodeHostGatewayPlatform(platform: NodeJS.Platform): string {
|
||||
@@ -192,6 +193,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
displayName: opts.displayName,
|
||||
fallbackDisplayName,
|
||||
gateway: plannedGateway,
|
||||
installedAppsSharing: opts.installedAppsSharing,
|
||||
});
|
||||
const nodeId = config.nodeId;
|
||||
const displayName = config.displayName ?? fallbackDisplayName;
|
||||
@@ -202,6 +204,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
enableAgentRuns: true,
|
||||
installedAppsSharingEnabled: config.installedAppsSharing,
|
||||
});
|
||||
const { token, password } = await resolveNodeHostGatewayCredentials({
|
||||
config: cfg,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NODE_DEVICE_APPS_COMMAND } from "../infra/node-commands.js";
|
||||
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import { listRegisteredNodeHostCapsAndCommands } from "./plugin-node-host.js";
|
||||
@@ -165,3 +166,30 @@ describe("node-host duplex capability selection", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("installed application command advertisement", () => {
|
||||
it("advertises device.apps only when sharing is enabled on macOS", async () => {
|
||||
const disabled = await prepareNodeHostRuntime({
|
||||
config: { nodeHost: { skills: { enabled: false } } },
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "darwin",
|
||||
installedAppsSharingEnabled: false,
|
||||
});
|
||||
const enabled = await prepareNodeHostRuntime({
|
||||
config: { nodeHost: { skills: { enabled: false } } },
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "darwin",
|
||||
installedAppsSharingEnabled: true,
|
||||
});
|
||||
const nonDarwin = await prepareNodeHostRuntime({
|
||||
config: { nodeHost: { skills: { enabled: false } } },
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "linux",
|
||||
installedAppsSharingEnabled: true,
|
||||
});
|
||||
|
||||
expect(disabled.manifest.commands).not.toContain(NODE_DEVICE_APPS_COMMAND);
|
||||
expect(enabled.manifest.commands).toContain(NODE_DEVICE_APPS_COMMAND);
|
||||
expect(nonDarwin.manifest.commands).not.toContain(NODE_DEVICE_APPS_COMMAND);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
|
||||
import {
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
NODE_DEVICE_APPS_COMMAND,
|
||||
NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS,
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
@@ -232,6 +233,8 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
enableAgentRuns?: boolean;
|
||||
/** Embedded workers may still host long-lived plugin commands over the app-owned socket. */
|
||||
enableDuplexPluginCommands?: boolean;
|
||||
installedAppsSharingEnabled?: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
}): Promise<PreparedNodeHostRuntime> {
|
||||
void ensureTerminalUploadCleanup();
|
||||
const config = params?.config ?? getRuntimeConfig();
|
||||
@@ -241,6 +244,9 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
env.PATH = pathEnv;
|
||||
const duplexEnabled =
|
||||
params?.enableAgentRuns === true || params?.enableDuplexPluginCommands === true;
|
||||
const platform = params?.platform ?? process.platform;
|
||||
const installedAppsSharingEnabled =
|
||||
platform === "darwin" && params?.installedAppsSharingEnabled === true;
|
||||
const availabilityContext = { config, env };
|
||||
const resolvePluginNodeHost = () =>
|
||||
listRegisteredNodeHostCapsAndCommands(availabilityContext, {
|
||||
@@ -255,7 +261,14 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
: null;
|
||||
const skills = config.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
|
||||
const buildManifest = (pluginManifest: typeof pluginNodeHost): NodeHostManifest => ({
|
||||
caps: [...new Set(["system", "mcp", ...pluginManifest.caps])].toSorted(),
|
||||
caps: [
|
||||
...new Set([
|
||||
"system",
|
||||
"mcp",
|
||||
...(installedAppsSharingEnabled ? ["device"] : []),
|
||||
...pluginManifest.caps,
|
||||
]),
|
||||
].toSorted(),
|
||||
commands: [
|
||||
...new Set([
|
||||
...NODE_SYSTEM_RUN_COMMANDS,
|
||||
@@ -263,6 +276,7 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_TERMINAL_UPLOAD_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
...(installedAppsSharingEnabled ? [NODE_DEVICE_APPS_COMMAND] : []),
|
||||
...(claudePath ? [NODE_AGENT_CLI_CLAUDE_RUN_COMMAND] : []),
|
||||
...pluginManifest.commands,
|
||||
]),
|
||||
@@ -384,6 +398,8 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
...(claudePath ? { claudePath } : {}),
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
...(pluginCommandIo ? { pluginCommandIo } : {}),
|
||||
installedAppsSharingEnabled,
|
||||
installedAppsPlatform: platform,
|
||||
pluginCommandContext,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Private JSONL worker exposing the CLI node-host runtime to the macOS app. */
|
||||
import { createInterface } from "node:readline";
|
||||
import { VERSION } from "../version.js";
|
||||
import { loadNodeHostConfig } from "./config.js";
|
||||
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
|
||||
import {
|
||||
NodeHostWorkerBridgeClient,
|
||||
@@ -17,7 +18,11 @@ function emitInventory(inventory: NodeHostInventory): void {
|
||||
}
|
||||
|
||||
export async function runNodeHostWorker(): Promise<void> {
|
||||
const prepared = await prepareNodeHostRuntime({ enableDuplexPluginCommands: true });
|
||||
const nodeConfig = await loadNodeHostConfig();
|
||||
const prepared = await prepareNodeHostRuntime({
|
||||
enableDuplexPluginCommands: true,
|
||||
installedAppsSharingEnabled: nodeConfig?.installedAppsSharing === true,
|
||||
});
|
||||
const client = new NodeHostWorkerBridgeClient(writeMessage);
|
||||
let stopping = false;
|
||||
let resolveStopped: (() => void) | undefined;
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "没有找到带可配置字段的插件。",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user