From da69daeb72cf47fb76afe55e5216319fd4245933 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Jul 2026 14:07:59 +0100 Subject: [PATCH] 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 --- docs/cli/onboard.md | 7 +- docs/docs_map.md | 1 + docs/gateway/configuration-reference.md | 5 +- docs/nodes/index.md | 11 +- docs/start/onboarding-redesign.md | 24 +- docs/start/wizard-cli-reference.md | 6 + src/cli/node-cli/daemon.ts | 2 + src/cli/node-cli/register.ts | 5 + src/commands/node-daemon-install-helpers.ts | 2 + src/commands/onboard-guided.custodian.test.ts | 359 +++++++++++++++++ src/commands/onboard-guided.test.ts | 216 +++------- src/commands/onboard-guided.ts | 39 +- src/config/types.openclaw.ts | 2 + src/config/zod-schema.ts | 1 + src/daemon/program-args.ts | 4 + src/gateway/node-command-policy.test.ts | 17 + src/gateway/node-command-policy.ts | 7 +- src/infra/installed-apps.test.ts | 127 ++++++ src/infra/installed-apps.ts | 163 ++++++++ src/infra/node-commands.ts | 1 + src/infra/state-migrations.node-host.test.ts | 1 + src/infra/state-migrations.test.ts | 1 + src/node-host/config.test.ts | 23 ++ src/node-host/config.ts | 10 + .../invoke-agent-cli-claude-handler.ts | 4 + src/node-host/invoke-device-apps.test.ts | 55 +++ src/node-host/invoke-device-apps.ts | 76 ++++ src/node-host/invoke.ts | 16 + src/node-host/runner.ts | 3 + src/node-host/runtime.test.ts | 28 ++ src/node-host/runtime.ts | 18 +- src/node-host/worker.ts | 7 +- src/state/openclaw-state-db.generated.d.ts | 1 + src/state/openclaw-state-db.ts | 1 + src/state/openclaw-state-schema.generated.ts | 1 + src/state/openclaw-state-schema.sql | 1 + .../setup-app-recommendations.live.test.ts | 98 +++++ .../setup-app-recommendations.test.ts | 241 +++++++++++ src/system-agent/setup-app-recommendations.ts | 378 ++++++++++++++++++ src/system-agent/setup-inference.ts | 109 ++++- src/wizard/i18n/locales/en.ts | 16 + src/wizard/i18n/locales/zh-CN.ts | 16 + src/wizard/i18n/locales/zh-TW.ts | 16 + src/wizard/setup.app-recommendations.test.ts | 164 ++++++++ src/wizard/setup.app-recommendations.ts | 206 ++++++++++ src/wizard/setup.ts | 25 +- test/scripts/test-live-shard.test.ts | 1 + 47 files changed, 2323 insertions(+), 192 deletions(-) create mode 100644 src/commands/onboard-guided.custodian.test.ts create mode 100644 src/infra/installed-apps.test.ts create mode 100644 src/infra/installed-apps.ts create mode 100644 src/node-host/invoke-device-apps.test.ts create mode 100644 src/node-host/invoke-device-apps.ts create mode 100644 src/system-agent/setup-app-recommendations.live.test.ts create mode 100644 src/system-agent/setup-app-recommendations.test.ts create mode 100644 src/system-agent/setup-app-recommendations.ts create mode 100644 src/wizard/setup.app-recommendations.test.ts create mode 100644 src/wizard/setup.app-recommendations.ts diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index b40fd9313e81..c60ee4c94188 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -102,8 +102,11 @@ wizard shows the same page after it prepares the workspace. After inference passes (and the memory-import offer), guided onboarding applies the standard setup automatically — workspace, Gateway, and sessions, the same plan the conversational `openclaw setup` chat would apply on "yes" — -announces where to find OpenClaw later, and hatches your agent directly in the -terminal chat. If applying setup fails, onboarding falls back to the +then offers plugin and skill recommendations from installed apps; app names +are matched through your configured model and ClawHub search, and the step can +be disabled with [`wizard.appRecommendations`](/gateway/configuration-reference#wizard). +It then announces where to find OpenClaw later and hatches your agent directly +in the terminal chat. If applying setup fails, onboarding falls back to the conversational OpenClaw chat to finish interactively. Channels, agents, plugins, and other optional features remain OpenClaw chat territory: run `openclaw` and use `open channel wizard for ` to hand channel diff --git a/docs/docs_map.md b/docs/docs_map.md index 4d4d9d5c4910..64b24e2473d1 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -9442,6 +9442,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Remote mode details - H2: Auth and model options - H2: Outputs and internals + - H3: Installed app recommendations - H2: Non-interactive setup - H2: Gateway wizard RPC - H2: Signal setup behavior diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ba09dbe391a0..f6688ca5c74b 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1441,12 +1441,13 @@ writer is best-effort, not a lossless compliance archive. ## Wizard -Metadata written by CLI guided setup flows (`onboard`, `configure`, `doctor`): +Behavior and metadata for CLI guided setup flows (`onboard`, `configure`, `doctor`): ```json5 { wizard: { accessMode: "full", + appRecommendations: true, lastRunAt: "2026-01-01T00:00:00.000Z", lastRunVersion: "2026.1.4", lastRunCommit: "abc1234", @@ -1459,6 +1460,8 @@ Metadata written by CLI guided setup flows (`onboard`, `configure`, `doctor`): - `wizard.accessMode`: discovery consent chosen at the start of guided onboarding. `"full"` (recommended) lets setup look for AI apps, keys, and local runtimes automatically; `"guarded"` makes setup ask once before looking around and offers manual configuration instead. +- `wizard.appRecommendations` defaults to `true`. Set it to `false` to disable installed-application recommendations during guided or classic onboarding and block Gateway `device.apps` access. Node hosts still require their separate, default-off installed-app sharing flag before they advertise the command. + --- ## Identity diff --git a/docs/nodes/index.md b/docs/nodes/index.md index d69c56306a04..a9c49e932461 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -93,7 +93,7 @@ On the node machine: openclaw node run --host --port 18789 --display-name "Build Node" ``` -`node run` also accepts `--context-path` (Gateway WS context path), `--tls`, `--tls-fingerprint `, and `--node-id` (override the legacy client instance ID; this does not reset pairing). +`node run` also accepts `--context-path` (Gateway WS context path), `--tls`, `--tls-fingerprint `, and `--node-id` (override the legacy client instance ID; this does not reset pairing). On macOS, pass `--share-installed-apps` to advertise `device.apps`; sharing is off by default. Use `--no-share-installed-apps` to disable a previously saved opt-in. ### Remote gateway via SSH tunnel (loopback bind) @@ -128,7 +128,7 @@ openclaw node start openclaw node restart ``` -`node install` also accepts `--context-path`, `--tls`, `--tls-fingerprint`, `--node-id` (legacy client instance ID only), `--runtime ` (default: node), and `--force` to reinstall. `node status`, `node stop`, and `node uninstall` are also available. +`node install` also accepts `--context-path`, `--tls`, `--tls-fingerprint`, `--node-id` (legacy client instance ID only), `--share-installed-apps` / `--no-share-installed-apps`, `--runtime ` (default: node), and `--force` to reinstall. `node status`, `node stop`, and `node uninstall` are also available. ### Pair + name @@ -449,7 +449,7 @@ Default allowlists by platform (before plugin defaults and `allowCommands`/`deny | iOS | `camera.list`, `location.get`, `device.info`, `device.status`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` | | watchOS | `device.info`, `device.status`, `system.notify` | | Android | `camera.list`, `location.get`, `notifications.list`, `notifications.actions`, `system.notify`, `device.info`, `device.status`, `device.permissions`, `device.health`, `device.apps`, `contacts.search`, `calendar.events`, `callLog.search`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer` | -| macOS | `camera.list`, `location.get`, `device.info`, `device.status`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` | +| macOS | `camera.list`, `location.get`, `device.info`, `device.status`, `device.apps`, `contacts.search`, `calendar.events`, `reminders.list`, `photos.latest`, `motion.activity`, `motion.pedometer`, `system.notify` | | Windows | `camera.list`, `location.get`, `device.info`, `device.status`, `system.notify` | | Linux | `system.notify` (node host commands like `system.run` are approval-gated, see below) | @@ -655,12 +655,13 @@ Notes: ## Device and personal data commands -iOS and Android nodes advertise several read-only data commands by default (see the [Command policy](#command-policy) table); Android additionally exposes a larger family gated by its own in-app settings. +iOS and Android nodes advertise several read-only data commands by default (see the [Command policy](#command-policy) table); Android additionally exposes a larger family gated by its own in-app settings. A macOS or headless-mac TypeScript node host advertises `device.apps` only after the operator enables installed-app sharing with `--share-installed-apps`. Available families: - `device.status`, `device.info` — iOS, Android, Windows. -- `device.permissions`, `device.health`, `device.apps` — Android only; `device.apps` requires Installed Apps sharing enabled in Android Settings and returns launcher-visible apps by default. +- `device.permissions`, `device.health` — Android only. +- `device.apps` — Android, macOS, and headless-mac nodes. Android requires Installed Apps sharing in Settings and returns launcher-visible apps by default. TypeScript node hosts keep sharing off by default and accept `query`, `limit`, and `includeSystem`; macOS results contain `label`, `bundleId`, `path`, and `system`. - `notifications.list`, `notifications.actions` — Android only. - `photos.latest` — iOS, Android. - `contacts.search` — iOS, Android (read-only default); `contacts.add` is dangerous and needs `gateway.nodes.allowCommands`. diff --git a/docs/start/onboarding-redesign.md b/docs/start/onboarding-redesign.md index 9ea6eb013cec..fced220dd15a 100644 --- a/docs/start/onboarding-redesign.md +++ b/docs/start/onboarding-redesign.md @@ -42,15 +42,15 @@ Design principles (decided, do not relitigate casually): ## Phases -| # | Phase | Surface | Status | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------- | -| 1 | Installed-app plugin recommendations (scan, candidates, AI matcher, wizard step, `device.apps` node command) | service + classic wizard | PR [#109668](https://github.com/openclaw/openclaw/pull/109668) — in review | -| 2 | CLI custodian spine (question zero, discovery theater, auto-apply + hatch) | guided CLI | PR [#109841](https://github.com/openclaw/openclaw/pull/109841) — in review | -| 3 | Browser-first handoff (GUI-session detection, wait-for-dashboard-connect, TUI as fallback) | CLI → web | planned | -| 4 | Web custodian surface (option-card renderer shared with the question tool, scripted pre-AI states over `openclaw.chat`, post-wizard chat handoff) | Control UI | planned | -| 5 | Hatch and bootstrap (blank-agent creation, self-naming, self-drawn avatar via image-gen when available, recommendations as the last bootstrap step, self-learning opt-in) | agent bootstrap | planned | -| 6 | Custodian presence (pinned sidebar entry, Settings dock with event-reactive commentary, channel summon and agent-down recovery, weak-model script) | web + channels | planned | -| 7 | Resilience (custodian reachable on broken config, partial-surface salvage, auto-doctor) | gateway | follow-up | +| # | Phase | Surface | Status | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------- | +| 1 | Installed-app plugin recommendations (scan, candidates, AI matcher, wizard step, `device.apps` node command) | classic + guided CLI | PR [#109668](https://github.com/openclaw/openclaw/pull/109668) — in review | +| 2 | CLI custodian spine (question zero, discovery theater, auto-apply + hatch) | guided CLI | merged ([`a83ed13204f1`](https://github.com/openclaw/openclaw/commit/a83ed13204f118adf1009e5ac88d5afe1905b86c)) | +| 3 | Browser-first handoff (GUI-session detection, wait-for-dashboard-connect, TUI as fallback) | CLI → web | planned | +| 4 | Web custodian surface (option-card renderer shared with the question tool, scripted pre-AI states over `openclaw.chat`, post-wizard chat handoff) | Control UI | planned | +| 5 | Hatch and bootstrap (blank-agent creation, self-naming, self-drawn avatar via image-gen when available, recommendations as the last bootstrap step, self-learning opt-in) | agent bootstrap | planned | +| 6 | Custodian presence (pinned sidebar entry, Settings dock with event-reactive commentary, channel summon and agent-down recovery, weak-model script) | web + channels | planned | +| 7 | Resilience (custodian reachable on broken config, partial-surface salvage, auto-doctor) | gateway | follow-up | ## Implementation notes per phase @@ -63,9 +63,9 @@ Design principles (decided, do not relitigate casually): the model rejects coincidental name overlaps. - Node command `device.apps` (TS node-host, Android envelope parity), sharing off by default; gateway kill switch `wizard.appRecommendations`. -- Delivery currently lives in the classic wizard - (`src/wizard/setup.app-recommendations.ts`); it re-targets to the bootstrap - tail in phase 5 (the service already takes an injectable inventory source). +- Delivery lives in the classic wizard and guided custodian flow + (`src/wizard/setup.app-recommendations.ts`); re-targeting to the bootstrap + tail remains phase 5 (the service already takes an injectable inventory source). - Also fixed: custom `completeSetupInference` prompts no longer inherit the 32-token verification-probe output cap. diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 580e239a8412..67eb3f300b6e 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -351,6 +351,12 @@ Some channels are delivered as plugins. When selected during setup, the wizard prompts to install the plugin (npm or local path) before channel configuration. +### Installed app recommendations + +After the model access check succeeds, classic interactive onboarding on macOS scans application names and bundle IDs without requesting macOS privacy permissions. It searches the official plugin catalogs and ClawHub, then asks the configured model to reject false name matches and recommend relevant plugins or skills. Recommended matches are selected by default; optional matches require an explicit selection. + +The results screen lists the detected applications and shows: "App names were matched using your configured model and ClawHub search." Set `wizard.appRecommendations` to `false` to disable both this onboarding step and Gateway access to node app inventories. The scan is not used in quickstart or non-macOS onboarding. + ## Non-interactive setup `--non-interactive` requires `--accept-risk` (acknowledges that agents are diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 7a1e68823484..b10e8be159e5 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -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) { diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index 49ef5a2ffd47..c45ebde39767 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -65,6 +65,8 @@ export function registerNodeCli(program: Command) { .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") .option("--node-id ", "Override the generated node instance id") .option("--display-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 ", "Expected TLS certificate fingerprint (sha256)") .option("--node-id ", "Override the generated node instance id") .option("--display-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 ", "Service runtime (node). Default: node") .option("--force", "Reinstall/overwrite if already installed", false) .option("--json", "Output JSON", false) diff --git a/src/commands/node-daemon-install-helpers.ts b/src/commands/node-daemon-install-helpers.ts index f2618fae8ee3..f9bbe1fce616 100644 --- a/src/commands/node-daemon-install-helpers.ts +++ b/src/commands/node-daemon-install-helpers.ts @@ -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, diff --git a/src/commands/onboard-guided.custodian.test.ts b/src/commands/onboard-guided.custodian.test.ts new file mode 100644 index 000000000000..8f20e039275d --- /dev/null +++ b/src/commands/onboard-guided.custodian.test.ts @@ -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()), + promptAuthChoiceGrouped, +})); + +vi.mock("../agents/auth-profiles.runtime.js", () => ({ ensureAuthProfileStore })); + +vi.mock("./onboard-interactive-runner.js", async (importActual) => { + const actual = await importActual(); + 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>>> = {}, +) { + 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>( + 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).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).mock.calls); + expect(notes).toContain("config write raced"); + }); +}); diff --git a/src/commands/onboard-guided.test.ts b/src/commands/onboard-guided.test.ts index ea5921f9fabd..1d2b990ab417 100644 --- a/src/commands/onboard-guided.test.ts +++ b/src/commands/onboard-guided.test.ts @@ -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>( 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>( + 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).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).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" }, diff --git a/src/commands/onboard-guided.ts b/src/commands/onboard-guided.ts index 92377cf1200e..e0262badf769 100644 --- a/src/commands/onboard-guided.ts +++ b/src/commands/onboard-guided.ts @@ -48,6 +48,7 @@ export type GuidedOnboardingDeps = { applySetup?: typeof import("../system-agent/setup-apply.js").applySystemAgentSetup; launchHatchTui?: (workspace: string) => Promise; 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 diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 40042eb6abc9..b4865fa1a401 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -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. */ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index c2ec3cb11335..452e9f91902f 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(), diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index e7a54954c868..5dee6fa9de33 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -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, diff --git a/src/gateway/node-command-policy.test.ts b/src/gateway/node-command-policy.test.ts index 729862ab68b5..daadd14b2d54 100644 --- a/src/gateway/node-command-policy.test.ts +++ b/src/gateway/node-command-policy.test.ts @@ -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", diff --git a/src/gateway/node-command-policy.ts b/src/gateway/node-command-policy.ts index 29be72330473..2a87c0df7d7a 100644 --- a/src/gateway/node-command-policy.ts +++ b/src/gateway/node-command-policy.ts @@ -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 = { ...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 diff --git a/src/infra/installed-apps.test.ts b/src/infra/installed-apps.test.ts new file mode 100644 index 000000000000..f89ef3f0e0b8 --- /dev/null +++ b/src/infra/installed-apps.test.ts @@ -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 { + 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"), + ` + +CFBundleIdentifier${bundleId}`, + ); +} + +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 => { + try { + const plist = await fs.readFile(path.join(appPath, "Contents", "Info.plist"), "utf8"); + return /CFBundleIdentifier<\/key>\s*([^<]+)<\/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: [], + }); + }); +}); diff --git a/src/infra/installed-apps.ts b/src/infra/installed-apps.ts new file mode 100644 index 000000000000..9ee5c8d3fbf0 --- /dev/null +++ b/src/infra/installed-apps.ts @@ -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; +}; + +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> { + 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 { + 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 { + 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 => { + 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), + ), + }; +} diff --git a/src/infra/node-commands.ts b/src/infra/node-commands.ts index 369448e9ed89..6dd1c0359b18 100644 --- a/src/infra/node-commands.ts +++ b/src/infra/node-commands.ts @@ -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. diff --git a/src/infra/state-migrations.node-host.test.ts b/src/infra/state-migrations.node-host.test.ts index f5026593b338..173a11d69563 100644 --- a/src/infra/state-migrations.node-host.test.ts +++ b/src/infra/state-migrations.node-host.test.ts @@ -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); diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 1d928ec564ff..0804b2b4d4b6 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -2306,6 +2306,7 @@ describe("state migrations", () => { tlsFingerprint: fixtureDigest, contextPath: "/doctor", }, + installedAppsSharing: false, }); await expectMissingPath(sourcePath); }); diff --git a/src/node-host/config.test.ts b/src/node-host/config.test.ts index 5dad4665d4ef..9b25a08e2597 100644 --- a/src/node-host/config.test.ts +++ b/src/node-host/config.test.ts @@ -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 }); diff --git a/src/node-host/config.ts b/src/node-host/config.ts index 3820e769dd53..ae1b23e5602f 100644 --- a/src/node-host/config.ts +++ b/src/node-host/config.ts @@ -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 { 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; diff --git a/src/node-host/invoke-agent-cli-claude-handler.ts b/src/node-host/invoke-agent-cli-claude-handler.ts index 463bd436fd82..fb3be2f3cd50 100644 --- a/src/node-host/invoke-agent-cli-claude-handler.ts +++ b/src/node-host/invoke-agent-cli-claude-handler.ts @@ -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< diff --git a/src/node-host/invoke-device-apps.test.ts b/src/node-host/invoke-device-apps.test.ts new file mode 100644 index 000000000000..5200d466f06b --- /dev/null +++ b/src/node-host/invoke-device-apps.test.ts @@ -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(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, + }, + ], + }, + }); + }); +}); diff --git a/src/node-host/invoke-device-apps.ts b/src/node-host/invoke-device-apps.ts new file mode 100644 index 000000000000..ca16886d32bd --- /dev/null +++ b/src/node-host/invoke-device-apps.ts @@ -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 { + 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; + 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, + }, + }; +} diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index a31534195a65..3e8e35045419 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -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(); diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index f16848792bba..262439b609ce 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -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 { 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 { config: cfg, env: process.env, enableAgentRuns: true, + installedAppsSharingEnabled: config.installedAppsSharing, }); const { token, password } = await resolveNodeHostGatewayCredentials({ config: cfg, diff --git a/src/node-host/runtime.test.ts b/src/node-host/runtime.test.ts index a02d6d70f153..23aaa8111398 100644 --- a/src/node-host/runtime.test.ts +++ b/src/node-host/runtime.test.ts @@ -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); + }); +}); diff --git a/src/node-host/runtime.ts b/src/node-host/runtime.ts index bd93eb4db7ed..815f14f25471 100644 --- a/src/node-host/runtime.ts +++ b/src/node-host/runtime.ts @@ -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 { 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 { diff --git a/src/node-host/worker.ts b/src/node-host/worker.ts index bd5ec4fa5f63..2e7a6913b2b8 100644 --- a/src/node-host/worker.ts +++ b/src/node-host/worker.ts @@ -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 { - 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; diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index 650c625ade8f..41d43e5f7c52 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -733,6 +733,7 @@ export interface NodeHostConfig { gateway_port: number | null; gateway_tls: number | null; gateway_tls_fingerprint: string | null; + installed_apps_sharing: Generated; node_id: string; token: string | null; updated_at_ms: number; diff --git a/src/state/openclaw-state-db.ts b/src/state/openclaw-state-db.ts index 00ce1c925351..ae77589be8a5 100644 --- a/src/state/openclaw-state-db.ts +++ b/src/state/openclaw-state-db.ts @@ -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"); diff --git a/src/state/openclaw-state-schema.generated.ts b/src/state/openclaw-state-schema.generated.ts index 6564c5464a7e..a689773ef4a5 100644 --- a/src/state/openclaw-state-schema.generated.ts +++ b/src/state/openclaw-state-schema.generated.ts @@ -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; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 8297237c9760..2b40579038e9 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -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; diff --git a/src/system-agent/setup-app-recommendations.live.test.ts b/src/system-agent/setup-app-recommendations.live.test.ts new file mode 100644 index 000000000000..ad0a921ce3a6 --- /dev/null +++ b/src/system-agent/setup-app-recommendations.live.test.ts @@ -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); +}); diff --git a/src/system-agent/setup-app-recommendations.test.ts b/src/system-agent/setup-app-recommendations.test.ts new file mode 100644 index 000000000000..dcf88ff05e97 --- /dev/null +++ b/src/system-agent/setup-app-recommendations.test.ts @@ -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" }); + }); +}); diff --git a/src/system-agent/setup-app-recommendations.ts b/src/system-agent/setup-app-recommendations.ts new file mode 100644 index 000000000000..2edf75580fb6 --- /dev/null +++ b/src/system-agent/setup-app-recommendations.ts @@ -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 = { + "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(); + 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, +): Array { + const tokens: Array = []; + 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, +): 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(); + 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 { + 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 => { + 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; + runtime: RuntimeEnv; + deps?: RecommendationDeps; +}): Promise { + 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>; + 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" }), + ), + }; +} diff --git a/src/system-agent/setup-inference.ts b/src/system-agent/setup-inference.ts index 36224f12fabd..8aa873067f6a 100644 --- a/src/system-agent/setup-inference.ts +++ b/src/system-agent/setup-inference.ts @@ -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 { + 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 { + 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 } : {}), diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index 7855b517e173..2db46f695e44 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -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.", diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index 2f48d0213691..18e709aaf82b 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -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: "没有找到带可配置字段的插件。", diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index 7d1511431c5e..6da2b41aa877 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -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: "沒有找到帶可設定欄位的插件。", diff --git a/src/wizard/setup.app-recommendations.test.ts b/src/wizard/setup.app-recommendations.test.ts new file mode 100644 index 000000000000..d711bdcbbe08 --- /dev/null +++ b/src/wizard/setup.app-recommendations.test.ts @@ -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 { + 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(); + }); +}); diff --git a/src/wizard/setup.app-recommendations.ts b/src/wizard/setup.app-recommendations.ts new file mode 100644 index 000000000000..33edaaeb1aa3 --- /dev/null +++ b/src/wizard/setup.app-recommendations.ts @@ -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; + 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(); + 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 { + 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; +} diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index ad4711991a1e..331e7992d9fe 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -52,13 +52,13 @@ async function offerLiveModelVerification(params: { runtime: RuntimeEnv; workspaceDir: string; writeConfig: (config: OpenClawConfig) => Promise; -}): Promise { +}): 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, diff --git a/test/scripts/test-live-shard.test.ts b/test/scripts/test-live-shard.test.ts index 7d1f00e4e181..18065d36b3d0 100644 --- a/test/scripts/test-live-shard.test.ts +++ b/test/scripts/test-live-shard.test.ts @@ -105,6 +105,7 @@ describe("scripts/test-live-shard", () => { "src/gateway/gateway-acp-spawn-defaults.live.test.ts", "src/gateway/gateway-trajectory-export.live.test.ts", "src/system-agent/rescue-channel.live.test.ts", + "src/system-agent/setup-app-recommendations.live.test.ts", ]); expect(selectLiveShardFiles("native-live-src-infra", allFiles)).toEqual([ "src/infra/push-apns-http2.live.test.ts",