mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 10:51:11 +00:00
feat(onboarding): recommend plugins and skills from installed apps (#109668)
* feat(onboarding): recommend plugins and skills from installed apps Scan installed macOS apps during classic onboarding (TCC-free), gather candidates from official catalogs + ClawHub search, let the configured model pick genuine matches, and offer an opt-in multiselect install step. Adds a device.apps node-host command (default-off sharing, Android-parity envelope) so remote gateways can request a paired Mac's inventory, and a wizard.appRecommendations kill switch. Custom setup-inference completions no longer inherit the 32-token verification-probe output cap. * feat(onboarding): recommend apps in guided flow * fix(onboarding): harden app recommendations against ClawHub self-promotion Third-party ClawHub skills are never pre-selected regardless of model tier (publisher-controlled listing text reaches the matcher prompt and could promote itself); their labels now say they install third-party code. Installed-app scans follow symlinked .app bundles. Matcher output stays bounded by the resolved model's own maxTokens budget (documented invariant). * fix(onboarding): key official catalog candidates by resolved plugin id Real catalog entries are package manifests without a top-level id; keying the candidate map and channel/provider classification by entry.id collapsed the whole official catalog into one undefined-keyed entry, so no official plugin or channel was ever recommended. Regression test runs against the bundled catalogs. * fix(onboarding): satisfy lint, types, deadcode, and migration gates Split the guided-onboarding test into a self-contained custodian suite to stay under max-lines. Narrow app-recommendation exports (drop dead node-payload normalizer, unexport internal types/helpers, route candidate tests through the public API), replace map-spread with a helper, unexport device.apps result types, add installedAppsSharing to node-host migration expectations, cast the wizard multiselect mock, and regenerate the docs map. * test(onboarding): register new live test in the shard classifier
This commit is contained in:
committed by
GitHub
parent
8020dd3e08
commit
da69daeb72
@@ -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 <channel>` to hand channel
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,7 +93,7 @@ On the node machine:
|
||||
openclaw node run --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
```
|
||||
|
||||
`node run` also accepts `--context-path` (Gateway WS context path), `--tls`, `--tls-fingerprint <sha256>`, 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 <sha256>`, 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 <node>` (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 <node>` (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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
</Note>
|
||||
|
||||
### 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
|
||||
|
||||
@@ -46,6 +46,7 @@ type NodeDaemonInstallOptions = {
|
||||
tlsFingerprint?: string;
|
||||
nodeId?: string;
|
||||
displayName?: string;
|
||||
shareInstalledApps?: boolean;
|
||||
runtime?: string;
|
||||
force?: boolean;
|
||||
json?: boolean;
|
||||
@@ -162,6 +163,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
|
||||
tlsFingerprint: tlsFingerprint || undefined,
|
||||
nodeId: opts.nodeId,
|
||||
displayName: opts.displayName,
|
||||
installedAppsSharing: opts.shareInstalledApps,
|
||||
runtime: runtimeRaw,
|
||||
warn: (message) => {
|
||||
if (json) {
|
||||
|
||||
@@ -65,6 +65,8 @@ export function registerNodeCli(program: Command) {
|
||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
||||
.option("--node-id <id>", "Override the generated node instance id")
|
||||
.option("--display-name <name>", "Override node display name")
|
||||
.option("--share-installed-apps", "Share installed macOS applications with the Gateway")
|
||||
.option("--no-share-installed-apps", "Disable installed application sharing")
|
||||
.action(async (opts) => {
|
||||
const existing = await loadNodeHostConfig();
|
||||
const host =
|
||||
@@ -101,6 +103,7 @@ export function registerNodeCli(program: Command) {
|
||||
(explicitContextPath || retargetedGateway ? undefined : existing?.gateway?.contextPath),
|
||||
nodeId: opts.nodeId,
|
||||
displayName: opts.displayName,
|
||||
installedAppsSharing: opts.shareInstalledApps,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,6 +133,8 @@ export function registerNodeCli(program: Command) {
|
||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
||||
.option("--node-id <id>", "Override the generated node instance id")
|
||||
.option("--display-name <name>", "Override node display name")
|
||||
.option("--share-installed-apps", "Share installed macOS applications with the Gateway")
|
||||
.option("--no-share-installed-apps", "Disable installed application sharing")
|
||||
.option("--runtime <runtime>", "Service runtime (node). Default: node")
|
||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
|
||||
@@ -39,6 +39,7 @@ export async function buildNodeInstallPlan(params: {
|
||||
tlsFingerprint?: string;
|
||||
nodeId?: string;
|
||||
displayName?: string;
|
||||
installedAppsSharing?: boolean;
|
||||
runtime: GatewayDaemonRuntime;
|
||||
devMode?: boolean;
|
||||
nodePath?: string;
|
||||
@@ -58,6 +59,7 @@ export async function buildNodeInstallPlan(params: {
|
||||
tlsFingerprint: params.tlsFingerprint,
|
||||
nodeId: params.nodeId,
|
||||
displayName: params.displayName,
|
||||
installedAppsSharing: params.installedAppsSharing,
|
||||
dev: devMode,
|
||||
runtime: params.runtime,
|
||||
nodePath,
|
||||
|
||||
359
src/commands/onboard-guided.custodian.test.ts
Normal file
359
src/commands/onboard-guided.custodian.test.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
|
||||
import { createSuiteLogPathTracker } from "../logging/log-test-helpers.js";
|
||||
import { resetLogger } from "../logging/logger.js";
|
||||
import { loggingState } from "../logging/state.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { runGuidedOnboarding, type GuidedOnboardingDeps } from "./onboard-guided.js";
|
||||
|
||||
const restoreTerminalState = vi.hoisted(() => vi.fn());
|
||||
const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn());
|
||||
const ensureAuthProfileStore = vi.hoisted(() =>
|
||||
vi.fn(() => ({ version: 1 as const, profiles: {} })),
|
||||
);
|
||||
|
||||
vi.mock("../../packages/terminal-core/src/restore.js", () => ({ restoreTerminalState }));
|
||||
|
||||
vi.mock("./auth-choice-prompt.js", async (importActual) => ({
|
||||
...(await importActual<typeof import("./auth-choice-prompt.js")>()),
|
||||
promptAuthChoiceGrouped,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles.runtime.js", () => ({ ensureAuthProfileStore }));
|
||||
|
||||
vi.mock("./onboard-interactive-runner.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("./onboard-interactive-runner.js")>();
|
||||
return { ...actual, hasInteractiveOnboardingTty: () => true };
|
||||
});
|
||||
|
||||
const readConfigFileSnapshot = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
exists: false,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [] as Array<{ path?: string; message: string }>,
|
||||
config: {},
|
||||
})),
|
||||
);
|
||||
|
||||
const logPathTracker = createSuiteLogPathTracker("openclaw-guided-onboard-log-");
|
||||
|
||||
vi.mock("../config/config.js", () => ({ readConfigFileSnapshot }));
|
||||
|
||||
vi.mock("./onboard-helpers.js", () => ({
|
||||
DEFAULT_WORKSPACE: "/tmp/openclaw-workspace",
|
||||
printWizardHeader: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeRuntime(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn() as unknown as RuntimeEnv["exit"],
|
||||
};
|
||||
}
|
||||
|
||||
function candidate(kind: "claude-cli" | "codex-cli", label: string) {
|
||||
return {
|
||||
kind,
|
||||
label,
|
||||
detail: "logged in",
|
||||
modelRef: kind === "claude-cli" ? "claude-cli/opus" : "openai/gpt-5.5",
|
||||
recommended: false,
|
||||
credentials: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function existingModelCandidate() {
|
||||
return {
|
||||
kind: "existing-model",
|
||||
label: "Current model",
|
||||
detail: "already configured",
|
||||
modelRef: "acme/workspace-model",
|
||||
recommended: false,
|
||||
credentials: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function detection(
|
||||
overrides: Partial<Awaited<ReturnType<NonNullable<GuidedOnboardingDeps["detect"]>>>> = {},
|
||||
) {
|
||||
return {
|
||||
candidates: [candidate("claude-cli", "Claude Code")],
|
||||
unavailableCandidates: [],
|
||||
manualProviders: [],
|
||||
authOptions: [],
|
||||
recommendedInstalls: [],
|
||||
workspace: "/tmp/openclaw-workspace",
|
||||
setupComplete: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setupDeps(params: {
|
||||
prompter: WizardPrompter;
|
||||
detect?: GuidedOnboardingDeps["detect"];
|
||||
activate?: GuidedOnboardingDeps["activate"];
|
||||
runSystemAgentChat?: GuidedOnboardingDeps["runSystemAgentChat"];
|
||||
persistRiskAcknowledgement?: GuidedOnboardingDeps["persistRiskAcknowledgement"];
|
||||
runSetupMemoryImportStep?: GuidedOnboardingDeps["runSetupMemoryImportStep"];
|
||||
runAppRecommendations?: GuidedOnboardingDeps["runAppRecommendations"];
|
||||
applySetup?: GuidedOnboardingDeps["applySetup"];
|
||||
handoffMode?: GuidedOnboardingDeps["handoffMode"];
|
||||
}) {
|
||||
const runSystemAgentChat = vi.fn<NonNullable<GuidedOnboardingDeps["runSystemAgentChat"]>>(
|
||||
params.runSystemAgentChat ?? (async () => {}),
|
||||
);
|
||||
return {
|
||||
createPrompter: () => params.prompter,
|
||||
persistAccessMode: vi.fn(async () => undefined),
|
||||
applySetup:
|
||||
params.applySetup ??
|
||||
vi.fn(async () => ({
|
||||
configPath: "/tmp/openclaw.json",
|
||||
configHashBefore: null,
|
||||
configHashAfter: null,
|
||||
lines: [],
|
||||
})),
|
||||
launchHatchTui: vi.fn(async () => undefined),
|
||||
listManualOptions: vi.fn(async () => ({
|
||||
manualProviders: [],
|
||||
authOptions: [],
|
||||
workspace: "/tmp/openclaw-workspace",
|
||||
setupComplete: false,
|
||||
})),
|
||||
detect: params.detect ?? vi.fn(async () => detection()),
|
||||
activate:
|
||||
params.activate ??
|
||||
vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
modelRef: "claude-cli/opus",
|
||||
latencyMs: 1250,
|
||||
lines: ["Workspace: /tmp/work", "Gateway: running"],
|
||||
})),
|
||||
persistRiskAcknowledgement: params.persistRiskAcknowledgement ?? vi.fn(async () => undefined),
|
||||
runSetupMemoryImportStep: params.runSetupMemoryImportStep ?? vi.fn(async () => undefined),
|
||||
runAppRecommendations: params.runAppRecommendations ?? vi.fn(async ({ config }) => config),
|
||||
runSystemAgentChat,
|
||||
...(params.handoffMode ? { handoffMode: params.handoffMode } : {}),
|
||||
} satisfies GuidedOnboardingDeps;
|
||||
}
|
||||
|
||||
describe("runGuidedOnboarding custodian flow", () => {
|
||||
beforeAll(async () => {
|
||||
await logPathTracker.setup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
restoreTerminalState.mockClear();
|
||||
promptAuthChoiceGrouped.mockReset();
|
||||
ensureAuthProfileStore.mockClear();
|
||||
readConfigFileSnapshot.mockReset();
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggingState.rawConsole = null;
|
||||
resetLogger();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await logPathTracker.cleanup();
|
||||
});
|
||||
|
||||
it("routes guarded mode straight to manual config without any scanning", async () => {
|
||||
promptAuthChoiceGrouped.mockResolvedValueOnce("skip");
|
||||
const prompter = createWizardPrompter(undefined, { selectValues: ["guarded", "manual"] });
|
||||
const deps = {
|
||||
...setupDeps({ prompter }),
|
||||
listManualOptions: vi.fn(async () => ({
|
||||
manualProviders: [{ id: "openai-api-key", label: "OpenAI" }],
|
||||
authOptions: [],
|
||||
workspace: "/tmp/openclaw-workspace",
|
||||
setupComplete: false,
|
||||
})),
|
||||
};
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.detect).not.toHaveBeenCalled();
|
||||
expect(deps.listManualOptions).toHaveBeenCalledOnce();
|
||||
expect(deps.persistAccessMode).toHaveBeenCalledWith("guarded");
|
||||
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
|
||||
expect(deps.runAppRecommendations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not recommend apps after guarded manual setup succeeds", async () => {
|
||||
promptAuthChoiceGrouped.mockResolvedValueOnce("openai-api-key");
|
||||
const prompter = createWizardPrompter(
|
||||
{ text: vi.fn(async () => "manual-key") },
|
||||
{ selectValues: ["guarded", "manual"] },
|
||||
);
|
||||
const deps = {
|
||||
...setupDeps({ prompter }),
|
||||
listManualOptions: vi.fn(async () => ({
|
||||
manualProviders: [{ id: "openai-api-key", label: "OpenAI" }],
|
||||
authOptions: [],
|
||||
workspace: "/tmp/openclaw-workspace",
|
||||
setupComplete: false,
|
||||
})),
|
||||
};
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.applySetup).toHaveBeenCalledOnce();
|
||||
expect(deps.runAppRecommendations).not.toHaveBeenCalled();
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
|
||||
});
|
||||
|
||||
it("does not recommend local apps during remote chat handoff", async () => {
|
||||
const prompter = createWizardPrompter();
|
||||
const deps = setupDeps({ prompter, handoffMode: "chat" });
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.runAppRecommendations).not.toHaveBeenCalled();
|
||||
expect(deps.runSystemAgentChat).toHaveBeenCalledOnce();
|
||||
expect(deps.launchHatchTui).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scans in guarded mode only after the look-around consent", async () => {
|
||||
const prompter = createWizardPrompter(undefined, { selectValues: ["guarded", "look"] });
|
||||
const deps = setupDeps({ prompter });
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.detect).toHaveBeenCalledOnce();
|
||||
expect(deps.listManualOptions).not.toHaveBeenCalled();
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
|
||||
});
|
||||
|
||||
it("skips persisting an unchanged access mode", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: {
|
||||
wizard: { accessMode: "full", securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
const prompter = createWizardPrompter(undefined, { selectValues: ["full"] });
|
||||
const deps = setupDeps({ prompter });
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.persistAccessMode).not.toHaveBeenCalled();
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
|
||||
});
|
||||
|
||||
it("keeps the working route when other options are explored and skipped", async () => {
|
||||
promptAuthChoiceGrouped.mockResolvedValueOnce("skip");
|
||||
const prompter = createWizardPrompter(undefined, { selectValues: ["full", "other"] });
|
||||
const deps = setupDeps({ prompter });
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
|
||||
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
|
||||
expect(notes).toContain("Keeping the working AI you already have.");
|
||||
expect(notes).not.toContain("Add AI later");
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
|
||||
});
|
||||
|
||||
it("quips about detected coding agents", async () => {
|
||||
const prompter = createWizardPrompter();
|
||||
const deps = setupDeps({
|
||||
prompter,
|
||||
detect: vi.fn(async () =>
|
||||
detection({
|
||||
candidates: [candidate("claude-cli", "Claude Code"), candidate("codex-cli", "Codex")],
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("good taste"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never re-applies setup or bounces the gateway on a configured install", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: {
|
||||
gateway: { mode: "local" },
|
||||
wizard: { securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
const prompter = createWizardPrompter();
|
||||
const deps = setupDeps({ prompter });
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.applySetup).not.toHaveBeenCalled();
|
||||
// Configured reruns hatch the persisted default workspace, not the probe context.
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/openclaw-workspace");
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("already set up"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a model-only authored config as configured (no auto-apply)", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: {
|
||||
agents: { defaults: { workspace: "/tmp/authored" } },
|
||||
wizard: { securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
const prompter = createWizardPrompter();
|
||||
const deps = setupDeps({
|
||||
prompter,
|
||||
detect: vi.fn(async () =>
|
||||
detection({
|
||||
candidates: [existingModelCandidate()],
|
||||
configuredModel: "acme/workspace-model",
|
||||
setupComplete: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.applySetup).not.toHaveBeenCalled();
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/authored");
|
||||
});
|
||||
|
||||
it("falls back to the OpenClaw chat when applying setup fails", async () => {
|
||||
const prompter = createWizardPrompter();
|
||||
const applySetup = vi.fn(async () => {
|
||||
throw new Error("config write raced");
|
||||
}) as unknown as GuidedOnboardingDeps["applySetup"];
|
||||
const deps = setupDeps({ prompter, applySetup });
|
||||
const runtime = makeRuntime();
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, runtime, deps);
|
||||
|
||||
expect(deps.launchHatchTui).not.toHaveBeenCalled();
|
||||
expect(deps.runSystemAgentChat).toHaveBeenCalledWith("/tmp/work", runtime, true);
|
||||
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
|
||||
expect(notes).toContain("config write raced");
|
||||
});
|
||||
});
|
||||
@@ -107,7 +107,9 @@ function setupDeps(params: {
|
||||
runSystemAgentChat?: GuidedOnboardingDeps["runSystemAgentChat"];
|
||||
persistRiskAcknowledgement?: GuidedOnboardingDeps["persistRiskAcknowledgement"];
|
||||
runSetupMemoryImportStep?: GuidedOnboardingDeps["runSetupMemoryImportStep"];
|
||||
runAppRecommendations?: GuidedOnboardingDeps["runAppRecommendations"];
|
||||
applySetup?: GuidedOnboardingDeps["applySetup"];
|
||||
handoffMode?: GuidedOnboardingDeps["handoffMode"];
|
||||
}) {
|
||||
const runSystemAgentChat = vi.fn<NonNullable<GuidedOnboardingDeps["runSystemAgentChat"]>>(
|
||||
params.runSystemAgentChat ?? (async () => {}),
|
||||
@@ -141,7 +143,9 @@ function setupDeps(params: {
|
||||
})),
|
||||
persistRiskAcknowledgement: params.persistRiskAcknowledgement ?? vi.fn(async () => undefined),
|
||||
runSetupMemoryImportStep: params.runSetupMemoryImportStep ?? vi.fn(async () => undefined),
|
||||
runAppRecommendations: params.runAppRecommendations ?? vi.fn(async ({ config }) => config),
|
||||
runSystemAgentChat,
|
||||
...(params.handoffMode ? { handoffMode: params.handoffMode } : {}),
|
||||
} satisfies GuidedOnboardingDeps;
|
||||
}
|
||||
|
||||
@@ -174,6 +178,35 @@ describe("runGuidedOnboarding", () => {
|
||||
});
|
||||
|
||||
it("auto-connects one credentialed candidate before any workspace prompt", async () => {
|
||||
const persistedConfig: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "claude-cli/opus" } } },
|
||||
};
|
||||
const appliedConfig: OpenClawConfig = {
|
||||
...persistedConfig,
|
||||
gateway: { mode: "local" },
|
||||
};
|
||||
readConfigFileSnapshot
|
||||
.mockResolvedValueOnce({
|
||||
exists: false,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: {},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
exists: true,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: persistedConfig,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
exists: true,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: appliedConfig,
|
||||
});
|
||||
const select = vi.fn(async () => "unexpected") as unknown as WizardPrompter["select"];
|
||||
const text = vi.fn(async () => "unexpected");
|
||||
const prompter = createWizardPrompter({
|
||||
@@ -181,9 +214,19 @@ describe("runGuidedOnboarding", () => {
|
||||
select,
|
||||
confirm: vi.fn(async () => false),
|
||||
});
|
||||
const deps = setupDeps({ prompter });
|
||||
const applySetup = vi.fn(async () => ({
|
||||
configPath: "/tmp/openclaw.json",
|
||||
configHashBefore: null,
|
||||
configHashAfter: null,
|
||||
lines: [],
|
||||
}));
|
||||
const runAppRecommendations = vi.fn<NonNullable<GuidedOnboardingDeps["runAppRecommendations"]>>(
|
||||
async ({ config }) => config,
|
||||
);
|
||||
const deps = setupDeps({ prompter, applySetup, runAppRecommendations });
|
||||
const runtime = makeRuntime();
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, runtime, deps);
|
||||
|
||||
expect(deps.activate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -195,10 +238,23 @@ describe("runGuidedOnboarding", () => {
|
||||
);
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
|
||||
expect(deps.applySetup).toHaveBeenCalledWith(
|
||||
expect(applySetup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workspace: "/tmp/work", surface: "cli" }),
|
||||
);
|
||||
expect(deps.runSystemAgentChat).not.toHaveBeenCalled();
|
||||
expect(runAppRecommendations).toHaveBeenCalledWith({
|
||||
config: appliedConfig,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir: "/tmp/work",
|
||||
modelRouteVerified: true,
|
||||
});
|
||||
expect(applySetup.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
runAppRecommendations.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(runAppRecommendations.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
deps.launchHatchTui.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(restoreTerminalState.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
deps.launchHatchTui.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
@@ -782,160 +838,6 @@ describe("runGuidedOnboarding", () => {
|
||||
expect(deps.activate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes guarded mode straight to manual config without any scanning", async () => {
|
||||
promptAuthChoiceGrouped.mockResolvedValueOnce("skip");
|
||||
const prompter = createWizardPrompter(undefined, { selectValues: ["guarded", "manual"] });
|
||||
const deps = {
|
||||
...setupDeps({ prompter }),
|
||||
listManualOptions: vi.fn(async () => ({
|
||||
manualProviders: [{ id: "openai-api-key", label: "OpenAI" }],
|
||||
authOptions: [],
|
||||
workspace: "/tmp/openclaw-workspace",
|
||||
setupComplete: false,
|
||||
})),
|
||||
};
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.detect).not.toHaveBeenCalled();
|
||||
expect(deps.listManualOptions).toHaveBeenCalledOnce();
|
||||
expect(deps.persistAccessMode).toHaveBeenCalledWith("guarded");
|
||||
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("scans in guarded mode only after the look-around consent", async () => {
|
||||
const prompter = createWizardPrompter(undefined, { selectValues: ["guarded", "look"] });
|
||||
const deps = setupDeps({ prompter });
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.detect).toHaveBeenCalledOnce();
|
||||
expect(deps.listManualOptions).not.toHaveBeenCalled();
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
|
||||
});
|
||||
|
||||
it("skips persisting an unchanged access mode", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: {
|
||||
wizard: { accessMode: "full", securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
const prompter = createWizardPrompter(undefined, { selectValues: ["full"] });
|
||||
const deps = setupDeps({ prompter });
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.persistAccessMode).not.toHaveBeenCalled();
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
|
||||
});
|
||||
|
||||
it("keeps the working route when other options are explored and skipped", async () => {
|
||||
promptAuthChoiceGrouped.mockResolvedValueOnce("skip");
|
||||
const prompter = createWizardPrompter(undefined, { selectValues: ["full", "other"] });
|
||||
const deps = setupDeps({ prompter });
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
|
||||
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
|
||||
expect(notes).toContain("Keeping the working AI you already have.");
|
||||
expect(notes).not.toContain("Add AI later");
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/work");
|
||||
});
|
||||
|
||||
it("quips about detected coding agents", async () => {
|
||||
const prompter = createWizardPrompter();
|
||||
const deps = setupDeps({
|
||||
prompter,
|
||||
detect: vi.fn(async () =>
|
||||
detection({
|
||||
candidates: [candidate("claude-cli", "Claude Code"), candidate("codex-cli", "Codex")],
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("good taste"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never re-applies setup or bounces the gateway on a configured install", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: {
|
||||
gateway: { mode: "local" },
|
||||
wizard: { securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
const prompter = createWizardPrompter();
|
||||
const deps = setupDeps({ prompter });
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.applySetup).not.toHaveBeenCalled();
|
||||
// Configured reruns hatch the persisted default workspace, not the probe context.
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/openclaw-workspace");
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("already set up"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a model-only authored config as configured (no auto-apply)", async () => {
|
||||
readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: true,
|
||||
valid: true,
|
||||
path: "/tmp/openclaw.json",
|
||||
issues: [],
|
||||
config: {
|
||||
agents: { defaults: { workspace: "/tmp/authored" } },
|
||||
wizard: { securityAcknowledgedAt: "2026-01-01T00:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
const prompter = createWizardPrompter();
|
||||
const deps = setupDeps({
|
||||
prompter,
|
||||
detect: vi.fn(async () =>
|
||||
detection({
|
||||
candidates: [existingModelCandidate()],
|
||||
configuredModel: "acme/workspace-model",
|
||||
setupComplete: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps);
|
||||
|
||||
expect(deps.applySetup).not.toHaveBeenCalled();
|
||||
expect(deps.launchHatchTui).toHaveBeenCalledWith("/tmp/authored");
|
||||
});
|
||||
|
||||
it("falls back to the OpenClaw chat when applying setup fails", async () => {
|
||||
const prompter = createWizardPrompter();
|
||||
const applySetup = vi.fn(async () => {
|
||||
throw new Error("config write raced");
|
||||
}) as unknown as GuidedOnboardingDeps["applySetup"];
|
||||
const deps = setupDeps({ prompter, applySetup });
|
||||
const runtime = makeRuntime();
|
||||
|
||||
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, runtime, deps);
|
||||
|
||||
expect(deps.launchHatchTui).not.toHaveBeenCalled();
|
||||
expect(deps.runSystemAgentChat).toHaveBeenCalledWith("/tmp/work", runtime, true);
|
||||
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
|
||||
expect(notes).toContain("config write raced");
|
||||
});
|
||||
|
||||
it("converges remote inference before remote OpenClaw without mutating local config", async () => {
|
||||
const localConfig = {
|
||||
wizard: { securityAcknowledgedAt: "2026-07-11T00:00:00.000Z" },
|
||||
|
||||
@@ -48,6 +48,7 @@ export type GuidedOnboardingDeps = {
|
||||
applySetup?: typeof import("../system-agent/setup-apply.js").applySystemAgentSetup;
|
||||
launchHatchTui?: (workspace: string) => Promise<void>;
|
||||
runSetupMemoryImportStep?: typeof import("../wizard/setup.memory-import.js").runSetupMemoryImportStep;
|
||||
runAppRecommendations?: typeof import("../wizard/setup.app-recommendations.js").setupAppRecommendations;
|
||||
};
|
||||
|
||||
export type GuidedAccessMode = "full" | "guarded";
|
||||
@@ -603,7 +604,7 @@ async function runGuidedOnboardingFlow(
|
||||
|
||||
await prompter.note(resultLines.join("\n"), t("wizard.guided.appliedTitle"));
|
||||
const persistedSnapshot = await readConfigFileSnapshot();
|
||||
const persistedConfig = persistedSnapshot.valid
|
||||
let persistedConfig = persistedSnapshot.valid
|
||||
? (persistedSnapshot.sourceConfig ?? persistedSnapshot.config)
|
||||
: acknowledgedConfig;
|
||||
// Memory import scans local Claude/Codex/Hermes data; a declined look-around
|
||||
@@ -642,6 +643,11 @@ async function runGuidedOnboardingFlow(
|
||||
if (applied.lines.length > 0) {
|
||||
await prompter.note(applied.lines.join("\n"), t("wizard.guided.appliedTitle"));
|
||||
}
|
||||
const appliedSnapshot = await readConfigFileSnapshot();
|
||||
if (!appliedSnapshot.valid) {
|
||||
throw new Error("Setup wrote an invalid OpenClaw config.");
|
||||
}
|
||||
persistedConfig = appliedSnapshot.sourceConfig ?? appliedSnapshot.config;
|
||||
} catch (error) {
|
||||
applyProgress.stop(t("wizard.guided.testFailed"));
|
||||
await prompter.note(
|
||||
@@ -653,6 +659,37 @@ async function runGuidedOnboardingFlow(
|
||||
return { workspace, next: "chat" };
|
||||
}
|
||||
}
|
||||
if (wantsDiscovery) {
|
||||
const runAppRecommendations =
|
||||
deps.runAppRecommendations ??
|
||||
(await import("../wizard/setup.app-recommendations.js")).setupAppRecommendations;
|
||||
const recommendedConfig = await runAppRecommendations({
|
||||
config: persistedConfig,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir: workspace,
|
||||
modelRouteVerified: true,
|
||||
});
|
||||
if (recommendedConfig !== persistedConfig) {
|
||||
const latestSnapshot = await readConfigFileSnapshot();
|
||||
if (!latestSnapshot.valid) {
|
||||
throw new Error("App recommendations could not update an invalid OpenClaw config.");
|
||||
}
|
||||
const latestConfig = latestSnapshot.sourceConfig ?? latestSnapshot.config;
|
||||
const { mergeWizardConfigOntoLatest, writeWizardConfigFile } =
|
||||
await import("../wizard/setup.shared.js");
|
||||
const mergedConfig = mergeWizardConfigOntoLatest(
|
||||
latestConfig,
|
||||
persistedConfig,
|
||||
recommendedConfig,
|
||||
);
|
||||
await writeWizardConfigFile(mergedConfig, {
|
||||
allowConfigSizeDrop: false,
|
||||
...(latestSnapshot.hash ? { baseHash: latestSnapshot.hash } : {}),
|
||||
migrationBaseConfig: latestConfig,
|
||||
});
|
||||
}
|
||||
}
|
||||
await prompter.note(t("wizard.guided.findMeLater"), t("wizard.guided.welcomeTitle"));
|
||||
await prompter.outro(t("wizard.guided.hatchingNow"));
|
||||
// The TUI opens the configured default agent/workspace; on a configured
|
||||
|
||||
@@ -131,6 +131,8 @@ export type OpenClawConfig = {
|
||||
wizard?: {
|
||||
/** Guided-onboarding discovery consent: "full" scans silently, "guarded" asks first. */
|
||||
accessMode?: "full" | "guarded";
|
||||
/** Offer installed-application plugin and skill recommendations during onboarding. */
|
||||
appRecommendations?: boolean;
|
||||
/** Last setup wizard completion timestamp. */
|
||||
lastRunAt?: string;
|
||||
/** OpenClaw version used by the last completed wizard run. */
|
||||
|
||||
@@ -667,6 +667,7 @@ export const OpenClawSchema = z
|
||||
wizard: z
|
||||
.strictObject({
|
||||
accessMode: z.union([z.literal("full"), z.literal("guarded")]).optional(),
|
||||
appRecommendations: z.boolean().optional(),
|
||||
lastRunAt: z.string().optional(),
|
||||
lastRunVersion: z.string().optional(),
|
||||
lastRunCommit: z.string().optional(),
|
||||
|
||||
@@ -265,6 +265,7 @@ export async function resolveNodeProgramArguments(params: {
|
||||
tlsFingerprint?: string;
|
||||
nodeId?: string;
|
||||
displayName?: string;
|
||||
installedAppsSharing?: boolean;
|
||||
dev?: boolean;
|
||||
runtime?: GatewayRuntimePreference;
|
||||
nodePath?: string;
|
||||
@@ -289,6 +290,9 @@ export async function resolveNodeProgramArguments(params: {
|
||||
if (params.displayName) {
|
||||
args.push("--display-name", params.displayName);
|
||||
}
|
||||
if (params.installedAppsSharing !== undefined) {
|
||||
args.push(params.installedAppsSharing ? "--share-installed-apps" : "--no-share-installed-apps");
|
||||
}
|
||||
return resolveCliProgramArguments({
|
||||
args,
|
||||
dev: params.dev,
|
||||
|
||||
@@ -452,6 +452,23 @@ describe("gateway/node-command-policy", () => {
|
||||
expect(allowlist.has("system.run")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the app-recommendation kill switch for gateway device.apps access", () => {
|
||||
const macNode = {
|
||||
platform: "macos",
|
||||
deviceFamily: "Mac",
|
||||
commands: ["device.apps"],
|
||||
};
|
||||
expect(resolveNodeCommandAllowlist({} as OpenClawConfig, macNode).has("device.apps")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
resolveNodeCommandAllowlist(
|
||||
{ wizard: { appRecommendations: false } } as OpenClawConfig,
|
||||
macNode,
|
||||
).has("device.apps"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows approved node-host MCP calls while denyCommands still wins", () => {
|
||||
const node = {
|
||||
platform: "linux",
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
NODE_BROWSER_PROXY_COMMAND,
|
||||
NODE_DEVICE_APPS_COMMAND,
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FILE_COMMANDS,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
@@ -33,7 +34,7 @@ const ANDROID_DEVICE_COMMANDS = [
|
||||
...MOBILE_NODE_COMMANDS.device,
|
||||
"device.permissions",
|
||||
"device.health",
|
||||
"device.apps",
|
||||
NODE_DEVICE_APPS_COMMAND,
|
||||
];
|
||||
|
||||
const CONTACTS_COMMANDS = ["contacts.search"];
|
||||
@@ -136,6 +137,7 @@ export const PLATFORM_DEFAULTS: Record<string, string[]> = {
|
||||
...CAMERA_COMMANDS,
|
||||
...MOBILE_NODE_COMMANDS.location,
|
||||
...MOBILE_NODE_COMMANDS.device,
|
||||
NODE_DEVICE_APPS_COMMAND,
|
||||
...CONTACTS_COMMANDS,
|
||||
...CALENDAR_COMMANDS,
|
||||
...REMINDERS_COMMANDS,
|
||||
@@ -421,6 +423,9 @@ function resolveNodeCommandAllowlistInternal(
|
||||
allow.add(trimmed);
|
||||
}
|
||||
}
|
||||
if (cfg.wizard?.appRecommendations === false) {
|
||||
allow.delete(NODE_DEVICE_APPS_COMMAND);
|
||||
}
|
||||
// In pairing mode, denylisted dangerous defaults stay declarable so a node
|
||||
// retains the surface it can later be armed for: arming removes them from
|
||||
// denyCommands and adds them to allowCommands. Fresh setup seeds denyCommands
|
||||
|
||||
127
src/infra/installed-apps.test.ts
Normal file
127
src/infra/installed-apps.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { scanInstalledApps } from "./installed-apps.js";
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function makeFixtureRoot(): Promise<{
|
||||
root: string;
|
||||
applications: string;
|
||||
userApplications: string;
|
||||
systemApplications: string;
|
||||
}> {
|
||||
const raw = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-installed-apps-"));
|
||||
const root = await fs.realpath(raw);
|
||||
tempRoots.push(root);
|
||||
const applications = path.join(root, "Applications");
|
||||
const userApplications = path.join(root, "UserApplications");
|
||||
const systemApplications = path.join(root, "SystemApplications");
|
||||
await Promise.all(
|
||||
[applications, userApplications, systemApplications].map((directory) =>
|
||||
fs.mkdir(directory, { recursive: true }),
|
||||
),
|
||||
);
|
||||
return { root, applications, userApplications, systemApplications };
|
||||
}
|
||||
|
||||
async function createApp(root: string, name: string, bundleId?: string): Promise<void> {
|
||||
const contents = path.join(root, `${name}.app`, "Contents");
|
||||
await fs.mkdir(contents, { recursive: true });
|
||||
if (!bundleId) {
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(contents, "Info.plist"),
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict><key>CFBundleIdentifier</key><string>${bundleId}</string></dict></plist>`,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("scanInstalledApps", () => {
|
||||
it("scans app roots, filters copies and system apps, and sorts deterministically", async () => {
|
||||
const roots = await makeFixtureRoot();
|
||||
await Promise.all([
|
||||
createApp(roots.applications, "Zulu", "com.example.zulu"),
|
||||
createApp(roots.applications, "Alpha", "com.example.alpha"),
|
||||
createApp(roots.applications, "Alpha previous", "com.example.previous"),
|
||||
createApp(roots.applications, "Zulu-pre-update", "com.example.pre"),
|
||||
createApp(roots.userApplications, "No Plist"),
|
||||
createApp(roots.systemApplications, "Mail", "com.apple.mail"),
|
||||
createApp(roots.systemApplications, "Calculator", "com.apple.calculator"),
|
||||
fs.mkdir(path.join(roots.applications, "Not An App")),
|
||||
]);
|
||||
|
||||
// The production reader shells out to macOS plutil; parse the XML fixture
|
||||
// directly so this test also runs on Linux CI.
|
||||
const readBundleId = async (appPath: string): Promise<string | undefined> => {
|
||||
try {
|
||||
const plist = await fs.readFile(path.join(appPath, "Contents", "Info.plist"), "utf8");
|
||||
return /<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/.exec(plist)?.[1];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const result = await scanInstalledApps({ platform: "darwin", roots, readBundleId });
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "ok",
|
||||
apps: [
|
||||
{
|
||||
label: "Alpha",
|
||||
bundleId: "com.example.alpha",
|
||||
path: path.join(roots.applications, "Alpha.app"),
|
||||
system: false,
|
||||
},
|
||||
{
|
||||
label: "Mail",
|
||||
bundleId: "com.apple.mail",
|
||||
path: path.join(roots.systemApplications, "Mail.app"),
|
||||
system: true,
|
||||
},
|
||||
{
|
||||
label: "No Plist",
|
||||
path: path.join(roots.userApplications, "No Plist.app"),
|
||||
system: false,
|
||||
},
|
||||
{
|
||||
label: "Zulu",
|
||||
bundleId: "com.example.zulu",
|
||||
path: path.join(roots.applications, "Zulu.app"),
|
||||
system: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("includes symlinked app bundles", async () => {
|
||||
const roots = await makeFixtureRoot();
|
||||
await createApp(roots.applications, "RealTarget", "com.example.symlinked");
|
||||
await fs.symlink(
|
||||
path.join(roots.applications, "RealTarget.app"),
|
||||
path.join(roots.userApplications, "Linked.app"),
|
||||
);
|
||||
const readBundleId = async () => "com.example.symlinked";
|
||||
const result = await scanInstalledApps({ platform: "darwin", roots, readBundleId });
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status === "ok") {
|
||||
expect(result.apps.map((app) => app.label)).toContain("Linked");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a typed unsupported result off macOS", async () => {
|
||||
await expect(scanInstalledApps({ platform: "linux" })).resolves.toEqual({
|
||||
status: "unsupported",
|
||||
platform: "linux",
|
||||
apps: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
163
src/infra/installed-apps.ts
Normal file
163
src/infra/installed-apps.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import pLimit from "p-limit";
|
||||
import { z } from "zod";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PLIST_READ_CONCURRENCY = 8;
|
||||
const PLIST_READ_TIMEOUT_MS = 2_000;
|
||||
|
||||
const SYSTEM_APP_NAMES = new Set([
|
||||
"Calendar",
|
||||
"Contacts",
|
||||
"FaceTime",
|
||||
"Home",
|
||||
"Mail",
|
||||
"Maps",
|
||||
"Messages",
|
||||
"Music",
|
||||
"Notes",
|
||||
"Photos",
|
||||
"Podcasts",
|
||||
"Reminders",
|
||||
"Shortcuts",
|
||||
]);
|
||||
|
||||
const InfoPlistSchema = z
|
||||
.object({
|
||||
CFBundleIdentifier: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type InstalledApp = {
|
||||
label: string;
|
||||
bundleId?: string;
|
||||
path: string;
|
||||
system: boolean;
|
||||
};
|
||||
|
||||
export type InstalledAppsResult =
|
||||
| { status: "ok"; apps: InstalledApp[] }
|
||||
| { status: "unsupported"; platform: NodeJS.Platform; apps: [] };
|
||||
|
||||
type InstalledAppRoots = {
|
||||
applications: string;
|
||||
userApplications: string;
|
||||
systemApplications: string;
|
||||
};
|
||||
|
||||
type ScanInstalledAppsOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
roots?: InstalledAppRoots;
|
||||
readBundleId?: (appPath: string) => Promise<string | undefined>;
|
||||
};
|
||||
|
||||
function defaultRoots(): InstalledAppRoots {
|
||||
return {
|
||||
applications: "/Applications",
|
||||
userApplications: path.join(os.homedir(), "Applications"),
|
||||
systemApplications: "/System/Applications",
|
||||
};
|
||||
}
|
||||
|
||||
function isBackupishBundle(label: string): boolean {
|
||||
return (
|
||||
/(?:^|[\s._-])(?:backup|previous|rollback)(?:[\s._-]|$)/i.test(label) ||
|
||||
/(?:^|[\s._-])pre-[\p{L}\p{N}._-]+$/iu.test(label)
|
||||
);
|
||||
}
|
||||
|
||||
async function listAppPaths(
|
||||
root: string,
|
||||
system: boolean,
|
||||
): Promise<Array<{ path: string; system: boolean }>> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
if (!entry.name.toLowerCase().endsWith(".app")) {
|
||||
return [];
|
||||
}
|
||||
// Dirent.isDirectory() is false for symlinked bundles; /Applications
|
||||
// commonly holds symlinks (brew cask, hand-linked apps) — stat through.
|
||||
let isDirectory = entry.isDirectory();
|
||||
if (!isDirectory && entry.isSymbolicLink()) {
|
||||
isDirectory = await fs
|
||||
.stat(path.join(root, entry.name))
|
||||
.then((stats) => stats.isDirectory())
|
||||
.catch(() => false);
|
||||
}
|
||||
if (!isDirectory) {
|
||||
return [];
|
||||
}
|
||||
const label = entry.name.slice(0, -4);
|
||||
if (isBackupishBundle(label) || (system && !SYSTEM_APP_NAMES.has(label))) {
|
||||
return [];
|
||||
}
|
||||
return [{ path: path.join(root, entry.name), system }];
|
||||
}),
|
||||
);
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
async function readBundleIdWithPlutil(appPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"/usr/bin/plutil",
|
||||
["-convert", "json", "-o", "-", path.join(appPath, "Contents", "Info.plist")],
|
||||
{ encoding: "utf8", maxBuffer: 1024 * 1024, timeout: PLIST_READ_TIMEOUT_MS },
|
||||
);
|
||||
return InfoPlistSchema.parse(JSON.parse(stdout)).CFBundleIdentifier;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanInstalledApps(
|
||||
options: ScanInstalledAppsOptions = {},
|
||||
): Promise<InstalledAppsResult> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform !== "darwin") {
|
||||
return { status: "unsupported", platform, apps: [] };
|
||||
}
|
||||
|
||||
const roots = options.roots ?? defaultRoots();
|
||||
const appPaths = (
|
||||
await Promise.all([
|
||||
listAppPaths(roots.applications, false),
|
||||
listAppPaths(roots.userApplications, false),
|
||||
listAppPaths(roots.systemApplications, true),
|
||||
])
|
||||
).flat();
|
||||
const readBundleId = options.readBundleId ?? readBundleIdWithPlutil;
|
||||
const limit = pLimit(PLIST_READ_CONCURRENCY);
|
||||
const apps = await Promise.all(
|
||||
appPaths.map((entry) =>
|
||||
limit(async (): Promise<InstalledApp> => {
|
||||
const bundleId = await readBundleId(entry.path);
|
||||
return {
|
||||
label: path.basename(entry.path, ".app"),
|
||||
...(bundleId ? { bundleId } : {}),
|
||||
path: entry.path,
|
||||
system: entry.system,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
return {
|
||||
status: "ok",
|
||||
apps: apps.toSorted(
|
||||
(left, right) =>
|
||||
left.label.localeCompare(right.label, "en", { sensitivity: "base" }) ||
|
||||
left.path.localeCompare(right.path),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export const NODE_FILE_COMMANDS = [NODE_FS_LIST_DIR_COMMAND, NODE_TERMINAL_UPLOA
|
||||
export const NODE_BROWSER_PROXY_COMMAND = "browser.proxy";
|
||||
export const NODE_MCP_TOOLS_CALL_COMMAND = "mcp.tools.call.v1";
|
||||
export const NODE_AGENT_CLI_CLAUDE_RUN_COMMAND = "agent.cli.claude.run.v1";
|
||||
export const NODE_DEVICE_APPS_COMMAND = "device.apps";
|
||||
|
||||
// Node duplex heartbeats must arrive before the Gateway relay declares the
|
||||
// invoke idle, so both processes share this timeout contract.
|
||||
|
||||
@@ -139,6 +139,7 @@ describe("legacy node-host Doctor migration", () => {
|
||||
tlsFingerprint: fixtureDigest,
|
||||
contextPath: "/openclaw-gw",
|
||||
},
|
||||
installedAppsSharing: false,
|
||||
});
|
||||
expect(readCanonicalRow(env)?.token).toBeNull();
|
||||
expect(fs.existsSync(sourcePath)).toBe(false);
|
||||
|
||||
@@ -2306,6 +2306,7 @@ describe("state migrations", () => {
|
||||
tlsFingerprint: fixtureDigest,
|
||||
contextPath: "/doctor",
|
||||
},
|
||||
installedAppsSharing: false,
|
||||
});
|
||||
await expectMissingPath(sourcePath);
|
||||
});
|
||||
|
||||
@@ -169,6 +169,7 @@ describe("node-host SQLite config", () => {
|
||||
version: 1,
|
||||
nodeId: "node-custom",
|
||||
displayName: "Build Node",
|
||||
installedAppsSharing: false,
|
||||
gateway: {
|
||||
host: "gateway.local",
|
||||
port: 18443,
|
||||
@@ -184,6 +185,28 @@ describe("node-host SQLite config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps installed-app sharing disabled by default and persists an explicit enable", async () => {
|
||||
const { env } = makeTestEnv();
|
||||
const initial = await configureNodeHost({
|
||||
fallbackDisplayName: "node",
|
||||
gateway: {},
|
||||
env,
|
||||
nowMs: 1,
|
||||
});
|
||||
expect(initial.installedAppsSharing).toBe(false);
|
||||
|
||||
const enabled = await configureNodeHost({
|
||||
fallbackDisplayName: "node",
|
||||
gateway: {},
|
||||
installedAppsSharing: true,
|
||||
env,
|
||||
nowMs: 2,
|
||||
});
|
||||
expect(enabled.installedAppsSharing).toBe(true);
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await expect(loadNodeHostConfig(env)).resolves.toMatchObject({ installedAppsSharing: true });
|
||||
});
|
||||
|
||||
it("adds the gateway context-path column to an existing state database", async () => {
|
||||
const { env } = makeTestEnv();
|
||||
const database = openOpenClawStateDatabase({ env });
|
||||
|
||||
@@ -31,6 +31,8 @@ export type NodeHostConfig = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
gateway?: NodeHostGatewayConfig;
|
||||
/** Share installed macOS applications through device.apps (default: false). */
|
||||
installedAppsSharing?: boolean;
|
||||
};
|
||||
|
||||
export const NODE_HOST_CONFIG_KEY = "current";
|
||||
@@ -120,6 +122,9 @@ function rowToNodeHostConfig(row: NodeHostConfigRuntimeRow): NodeHostConfig {
|
||||
if (row.gateway_tls !== null && row.gateway_tls !== 0 && row.gateway_tls !== 1) {
|
||||
throw new Error("invalid node-host SQLite row: gateway_tls must be 0, 1, or null");
|
||||
}
|
||||
if (row.installed_apps_sharing !== 0 && row.installed_apps_sharing !== 1) {
|
||||
throw new Error("invalid node-host SQLite row: installed_apps_sharing must be 0 or 1");
|
||||
}
|
||||
const gateway: NodeHostGatewayConfig = {
|
||||
host: optionalNonEmptyString(row.gateway_host, "gateway_host"),
|
||||
port: validatePort(row.gateway_port, "SQLite gateway_port"),
|
||||
@@ -133,6 +138,7 @@ function rowToNodeHostConfig(row: NodeHostConfigRuntimeRow): NodeHostConfig {
|
||||
nodeId,
|
||||
displayName: optionalNonEmptyString(row.display_name, "display_name"),
|
||||
gateway: hasGateway ? gateway : undefined,
|
||||
installedAppsSharing: row.installed_apps_sharing === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -163,6 +169,7 @@ function configToRow(params: {
|
||||
gateway_tls: gateway?.tls === undefined ? null : gateway.tls ? 1 : 0,
|
||||
gateway_tls_fingerprint: gateway?.tlsFingerprint ?? null,
|
||||
gateway_context_path: gateway?.contextPath ?? null,
|
||||
installed_apps_sharing: params.config.installedAppsSharing ? 1 : 0,
|
||||
updated_at_ms: params.updatedAtMs,
|
||||
};
|
||||
}
|
||||
@@ -184,6 +191,7 @@ function readNodeHostConfigRow(
|
||||
"gateway_tls",
|
||||
"gateway_tls_fingerprint",
|
||||
"gateway_context_path",
|
||||
"installed_apps_sharing",
|
||||
"updated_at_ms",
|
||||
])
|
||||
.where("config_key", "=", NODE_HOST_CONFIG_KEY),
|
||||
@@ -212,6 +220,7 @@ export async function configureNodeHost(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nowMs?: number;
|
||||
candidateNodeId?: string;
|
||||
installedAppsSharing?: boolean;
|
||||
}): Promise<NodeHostConfig> {
|
||||
const env = params.env ?? process.env;
|
||||
assertNodeHostLegacyStateMigrated(env);
|
||||
@@ -236,6 +245,7 @@ export async function configureNodeHost(params: {
|
||||
nodeId,
|
||||
displayName,
|
||||
gateway,
|
||||
installedAppsSharing: params.installedAppsSharing ?? existing?.installedAppsSharing ?? false,
|
||||
};
|
||||
const row = configToRow({ config: next, updatedAtMs });
|
||||
const { config_key: _configKey, ...updates } = row;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createExecApprovalPolicySnapshot } from "../infra/exec-approvals.js";
|
||||
import type { scanInstalledApps } from "../infra/installed-apps.js";
|
||||
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
|
||||
import type { OpenClawPluginNodeHostCommandContext } from "../plugins/types.node-host.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
@@ -21,6 +22,9 @@ export type NodeHostInvokeRuntime = {
|
||||
signal?: AbortSignal;
|
||||
pluginCommandIo?: OpenClawPluginNodeHostCommandIo;
|
||||
pluginCommandContext?: OpenClawPluginNodeHostCommandContext;
|
||||
installedAppsSharingEnabled?: boolean;
|
||||
installedAppsPlatform?: NodeJS.Platform;
|
||||
scanInstalledApps?: typeof scanInstalledApps;
|
||||
};
|
||||
|
||||
type ClaudeCliNodeInvokeDeps = Pick<
|
||||
|
||||
55
src/node-host/invoke-device-apps.test.ts
Normal file
55
src/node-host/invoke-device-apps.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { scanInstalledApps } from "../infra/installed-apps.js";
|
||||
import { invokeDeviceApps } from "./invoke-device-apps.js";
|
||||
|
||||
const scan = vi.fn<typeof scanInstalledApps>(async () => ({
|
||||
status: "ok",
|
||||
apps: [
|
||||
{ label: "Calendar", bundleId: "com.apple.iCal", path: "/System/Calendar.app", system: true },
|
||||
{
|
||||
label: "Notes App",
|
||||
bundleId: "com.example.notes",
|
||||
path: "/Applications/Notes.app",
|
||||
system: false,
|
||||
},
|
||||
{ label: "Other", path: "/Applications/Other.app", system: false },
|
||||
],
|
||||
}));
|
||||
|
||||
describe("invokeDeviceApps", () => {
|
||||
it("returns the typed privacy error while sharing is disabled", async () => {
|
||||
await expect(
|
||||
invokeDeviceApps({ sharingEnabled: false, platform: "darwin", scan }),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "INSTALLED_APPS_SHARING_DISABLED",
|
||||
message: "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in node-host settings",
|
||||
});
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches the Android count envelope with macOS app fields", async () => {
|
||||
const result = await invokeDeviceApps({
|
||||
sharingEnabled: true,
|
||||
platform: "darwin",
|
||||
paramsJSON: JSON.stringify({ query: "app", limit: 1, includeSystem: false }),
|
||||
scan,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
payload: {
|
||||
count: 1,
|
||||
totalMatched: 1,
|
||||
truncated: false,
|
||||
apps: [
|
||||
{
|
||||
label: "Notes App",
|
||||
bundleId: "com.example.notes",
|
||||
path: "/Applications/Notes.app",
|
||||
system: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/node-host/invoke-device-apps.ts
Normal file
76
src/node-host/invoke-device-apps.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod";
|
||||
import { scanInstalledApps, type InstalledApp } from "../infra/installed-apps.js";
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
const DeviceAppsParamsSchema = z
|
||||
.object({
|
||||
query: z.string().trim().min(1).optional(),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.transform((value) => Math.min(MAX_LIMIT, Math.max(1, value)))
|
||||
.optional(),
|
||||
includeSystem: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type DeviceAppsPayload = {
|
||||
count: number;
|
||||
totalMatched: number;
|
||||
truncated: boolean;
|
||||
apps: InstalledApp[];
|
||||
};
|
||||
|
||||
type DeviceAppsInvokeResult =
|
||||
| { ok: true; payload: DeviceAppsPayload }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
export async function invokeDeviceApps(params: {
|
||||
paramsJSON?: string | null;
|
||||
sharingEnabled: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
scan?: typeof scanInstalledApps;
|
||||
}): Promise<DeviceAppsInvokeResult> {
|
||||
if (!params.sharingEnabled) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "INSTALLED_APPS_SHARING_DISABLED",
|
||||
message: "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in node-host settings",
|
||||
};
|
||||
}
|
||||
let request: z.infer<typeof DeviceAppsParamsSchema>;
|
||||
try {
|
||||
request = DeviceAppsParamsSchema.parse(JSON.parse(params.paramsJSON || "{}"));
|
||||
} catch (error) {
|
||||
return { ok: false, code: "INVALID_REQUEST", message: String(error) };
|
||||
}
|
||||
const scan = params.scan ?? scanInstalledApps;
|
||||
const inventory = await scan({ platform: params.platform ?? process.platform });
|
||||
if (inventory.status === "unsupported") {
|
||||
return {
|
||||
ok: false,
|
||||
code: "UNAVAILABLE",
|
||||
message: "UNAVAILABLE: installed application inventory is only available on macOS",
|
||||
};
|
||||
}
|
||||
const query = request.query?.toLocaleLowerCase("en-US");
|
||||
const matching = inventory.apps.filter(
|
||||
(app) =>
|
||||
(request.includeSystem === true || !app.system) &&
|
||||
(!query ||
|
||||
app.label.toLocaleLowerCase("en-US").includes(query) ||
|
||||
app.bundleId?.toLocaleLowerCase("en-US").includes(query)),
|
||||
);
|
||||
const apps = matching.slice(0, request.limit ?? DEFAULT_LIMIT);
|
||||
return {
|
||||
ok: true,
|
||||
payload: {
|
||||
count: apps.length,
|
||||
totalMatched: matching.length,
|
||||
truncated: matching.length > apps.length,
|
||||
apps,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "../infra/host-env-security.js";
|
||||
import {
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
NODE_DEVICE_APPS_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
} from "../infra/node-commands.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
handleClaudeCliNodeInvoke,
|
||||
type NodeHostInvokeRuntime,
|
||||
} from "./invoke-agent-cli-claude-handler.js";
|
||||
import { invokeDeviceApps } from "./invoke-device-apps.js";
|
||||
import { invokeNodeFileCommand } from "./invoke-file-commands.js";
|
||||
import {
|
||||
buildSystemRunApprovalPlan,
|
||||
@@ -567,6 +569,20 @@ async function dispatchInvoke(
|
||||
runtime: NodeHostInvokeRuntime = {},
|
||||
) {
|
||||
const command = frame.command ?? "";
|
||||
if (command === NODE_DEVICE_APPS_COMMAND) {
|
||||
const result = await invokeDeviceApps({
|
||||
paramsJSON: frame.paramsJSON,
|
||||
sharingEnabled: runtime.installedAppsSharingEnabled === true,
|
||||
...(runtime.installedAppsPlatform ? { platform: runtime.installedAppsPlatform } : {}),
|
||||
...(runtime.scanInstalledApps ? { scan: runtime.scanInstalledApps } : {}),
|
||||
});
|
||||
if (result.ok) {
|
||||
await sendJsonPayloadResult(client, frame, result.payload);
|
||||
} else {
|
||||
await sendErrorResult(client, frame, result.code, result.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === "system.execApprovals.get") {
|
||||
try {
|
||||
const snapshot = await ensureExecApprovalsSnapshot();
|
||||
|
||||
@@ -32,6 +32,7 @@ type NodeHostRunOptions = {
|
||||
gatewayContextPath?: string;
|
||||
nodeId?: string;
|
||||
displayName?: string;
|
||||
installedAppsSharing?: boolean;
|
||||
};
|
||||
|
||||
function resolveNodeHostGatewayPlatform(platform: NodeJS.Platform): string {
|
||||
@@ -192,6 +193,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
displayName: opts.displayName,
|
||||
fallbackDisplayName,
|
||||
gateway: plannedGateway,
|
||||
installedAppsSharing: opts.installedAppsSharing,
|
||||
});
|
||||
const nodeId = config.nodeId;
|
||||
const displayName = config.displayName ?? fallbackDisplayName;
|
||||
@@ -202,6 +204,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
enableAgentRuns: true,
|
||||
installedAppsSharingEnabled: config.installedAppsSharing,
|
||||
});
|
||||
const { token, password } = await resolveNodeHostGatewayCredentials({
|
||||
config: cfg,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NODE_DEVICE_APPS_COMMAND } from "../infra/node-commands.js";
|
||||
import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js";
|
||||
import type { NodeHostClient } from "./client.js";
|
||||
import { listRegisteredNodeHostCapsAndCommands } from "./plugin-node-host.js";
|
||||
@@ -165,3 +166,30 @@ describe("node-host duplex capability selection", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("installed application command advertisement", () => {
|
||||
it("advertises device.apps only when sharing is enabled on macOS", async () => {
|
||||
const disabled = await prepareNodeHostRuntime({
|
||||
config: { nodeHost: { skills: { enabled: false } } },
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "darwin",
|
||||
installedAppsSharingEnabled: false,
|
||||
});
|
||||
const enabled = await prepareNodeHostRuntime({
|
||||
config: { nodeHost: { skills: { enabled: false } } },
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "darwin",
|
||||
installedAppsSharingEnabled: true,
|
||||
});
|
||||
const nonDarwin = await prepareNodeHostRuntime({
|
||||
config: { nodeHost: { skills: { enabled: false } } },
|
||||
env: { PATH: "/usr/bin" },
|
||||
platform: "linux",
|
||||
installedAppsSharingEnabled: true,
|
||||
});
|
||||
|
||||
expect(disabled.manifest.commands).not.toContain(NODE_DEVICE_APPS_COMMAND);
|
||||
expect(enabled.manifest.commands).toContain(NODE_DEVICE_APPS_COMMAND);
|
||||
expect(nonDarwin.manifest.commands).not.toContain(NODE_DEVICE_APPS_COMMAND);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||
import { resolveExecutableFromPathEnv } from "../infra/executable-path.js";
|
||||
import {
|
||||
NODE_AGENT_CLI_CLAUDE_RUN_COMMAND,
|
||||
NODE_DEVICE_APPS_COMMAND,
|
||||
NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS,
|
||||
NODE_EXEC_APPROVALS_COMMANDS,
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
@@ -232,6 +233,8 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
enableAgentRuns?: boolean;
|
||||
/** Embedded workers may still host long-lived plugin commands over the app-owned socket. */
|
||||
enableDuplexPluginCommands?: boolean;
|
||||
installedAppsSharingEnabled?: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
}): Promise<PreparedNodeHostRuntime> {
|
||||
void ensureTerminalUploadCleanup();
|
||||
const config = params?.config ?? getRuntimeConfig();
|
||||
@@ -241,6 +244,9 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
env.PATH = pathEnv;
|
||||
const duplexEnabled =
|
||||
params?.enableAgentRuns === true || params?.enableDuplexPluginCommands === true;
|
||||
const platform = params?.platform ?? process.platform;
|
||||
const installedAppsSharingEnabled =
|
||||
platform === "darwin" && params?.installedAppsSharingEnabled === true;
|
||||
const availabilityContext = { config, env };
|
||||
const resolvePluginNodeHost = () =>
|
||||
listRegisteredNodeHostCapsAndCommands(availabilityContext, {
|
||||
@@ -255,7 +261,14 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
: null;
|
||||
const skills = config.nodeHost?.skills?.enabled === false ? null : scanNodeHostedSkills();
|
||||
const buildManifest = (pluginManifest: typeof pluginNodeHost): NodeHostManifest => ({
|
||||
caps: [...new Set(["system", "mcp", ...pluginManifest.caps])].toSorted(),
|
||||
caps: [
|
||||
...new Set([
|
||||
"system",
|
||||
"mcp",
|
||||
...(installedAppsSharingEnabled ? ["device"] : []),
|
||||
...pluginManifest.caps,
|
||||
]),
|
||||
].toSorted(),
|
||||
commands: [
|
||||
...new Set([
|
||||
...NODE_SYSTEM_RUN_COMMANDS,
|
||||
@@ -263,6 +276,7 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
NODE_FS_LIST_DIR_COMMAND,
|
||||
NODE_TERMINAL_UPLOAD_COMMAND,
|
||||
NODE_MCP_TOOLS_CALL_COMMAND,
|
||||
...(installedAppsSharingEnabled ? [NODE_DEVICE_APPS_COMMAND] : []),
|
||||
...(claudePath ? [NODE_AGENT_CLI_CLAUDE_RUN_COMMAND] : []),
|
||||
...pluginManifest.commands,
|
||||
]),
|
||||
@@ -384,6 +398,8 @@ export async function prepareNodeHostRuntime(params?: {
|
||||
...(claudePath ? { claudePath } : {}),
|
||||
...(controller ? { signal: controller.signal } : {}),
|
||||
...(pluginCommandIo ? { pluginCommandIo } : {}),
|
||||
installedAppsSharingEnabled,
|
||||
installedAppsPlatform: platform,
|
||||
pluginCommandContext,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Private JSONL worker exposing the CLI node-host runtime to the macOS app. */
|
||||
import { createInterface } from "node:readline";
|
||||
import { VERSION } from "../version.js";
|
||||
import { loadNodeHostConfig } from "./config.js";
|
||||
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
|
||||
import {
|
||||
NodeHostWorkerBridgeClient,
|
||||
@@ -17,7 +18,11 @@ function emitInventory(inventory: NodeHostInventory): void {
|
||||
}
|
||||
|
||||
export async function runNodeHostWorker(): Promise<void> {
|
||||
const prepared = await prepareNodeHostRuntime({ enableDuplexPluginCommands: true });
|
||||
const nodeConfig = await loadNodeHostConfig();
|
||||
const prepared = await prepareNodeHostRuntime({
|
||||
enableDuplexPluginCommands: true,
|
||||
installedAppsSharingEnabled: nodeConfig?.installedAppsSharing === true,
|
||||
});
|
||||
const client = new NodeHostWorkerBridgeClient(writeMessage);
|
||||
let stopping = false;
|
||||
let resolveStopped: (() => void) | undefined;
|
||||
|
||||
1
src/state/openclaw-state-db.generated.d.ts
vendored
1
src/state/openclaw-state-db.generated.d.ts
vendored
@@ -733,6 +733,7 @@ export interface NodeHostConfig {
|
||||
gateway_port: number | null;
|
||||
gateway_tls: number | null;
|
||||
gateway_tls_fingerprint: string | null;
|
||||
installed_apps_sharing: Generated<number>;
|
||||
node_id: string;
|
||||
token: string | null;
|
||||
updated_at_ms: number;
|
||||
|
||||
@@ -1364,6 +1364,7 @@ function backfillDeliveryQueueEntriesFromEntryJson(db: DatabaseSync): void {
|
||||
function ensureAdditiveStateColumns(db: DatabaseSync): void {
|
||||
ensureColumn(db, "worktrees", "provisioned_paths_json TEXT");
|
||||
ensureColumn(db, "node_host_config", "gateway_context_path TEXT");
|
||||
ensureColumn(db, "node_host_config", "installed_apps_sharing INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn(db, "apns_registrations", "relay_origin TEXT");
|
||||
ensureColumn(db, "device_pairing_pending", "refreshed_at_ms INTEGER");
|
||||
ensureColumn(db, "device_pairing_paired", "approved_via TEXT");
|
||||
|
||||
@@ -693,6 +693,7 @@ CREATE TABLE IF NOT EXISTS node_host_config (
|
||||
gateway_tls INTEGER,
|
||||
gateway_tls_fingerprint TEXT,
|
||||
gateway_context_path TEXT,
|
||||
installed_apps_sharing INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
|
||||
@@ -688,6 +688,7 @@ CREATE TABLE IF NOT EXISTS node_host_config (
|
||||
gateway_tls INTEGER,
|
||||
gateway_tls_fingerprint TEXT,
|
||||
gateway_context_path TEXT,
|
||||
installed_apps_sharing INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
|
||||
98
src/system-agent/setup-app-recommendations.live.test.ts
Normal file
98
src/system-agent/setup-app-recommendations.live.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
241
src/system-agent/setup-app-recommendations.test.ts
Normal file
241
src/system-agent/setup-app-recommendations.test.ts
Normal file
@@ -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" });
|
||||
});
|
||||
});
|
||||
378
src/system-agent/setup-app-recommendations.ts
Normal file
378
src/system-agent/setup-app-recommendations.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import pLimit from "p-limit";
|
||||
import { z } from "zod";
|
||||
import { searchClawHubSkills } from "../infra/clawhub.js";
|
||||
import type { InstalledAppsResult } from "../infra/installed-apps.js";
|
||||
import {
|
||||
getOfficialExternalPluginCatalogManifest,
|
||||
listOfficialExternalChannelCatalogEntries,
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
listOfficialExternalProviderCatalogEntries,
|
||||
resolveOfficialExternalPluginId,
|
||||
resolveOfficialExternalPluginLabel,
|
||||
type OfficialExternalPluginCatalogEntry,
|
||||
} from "../plugins/official-external-plugin-catalog.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { completeSetupInference } from "./setup-inference.js";
|
||||
|
||||
const CLAWHUB_SEARCH_CONCURRENCY = 4;
|
||||
const CLAWHUB_SEARCH_LIMIT = 3;
|
||||
const CLAWHUB_SEARCH_TIMEOUT_MS = 5_000;
|
||||
// Overall budget for the ClawHub phase: with per-request timeouts an
|
||||
// unreachable host would otherwise stall onboarding ~(apps/4)*5s. Apps past
|
||||
// the deadline keep their official-catalog candidates and skip hub search.
|
||||
const CLAWHUB_SEARCH_TOTAL_BUDGET_MS = 20_000;
|
||||
const CANDIDATE_SOURCE_ORDER: Record<SetupAppCandidateSource, number> = {
|
||||
"official-plugin": 0,
|
||||
"official-channel": 1,
|
||||
"official-provider": 2,
|
||||
"clawhub-skill": 3,
|
||||
};
|
||||
|
||||
type SetupAppInventoryItem = {
|
||||
label: string;
|
||||
bundleId?: string;
|
||||
};
|
||||
|
||||
type SetupAppCandidateSource =
|
||||
| "official-plugin"
|
||||
| "official-channel"
|
||||
| "official-provider"
|
||||
| "clawhub-skill";
|
||||
|
||||
type SetupAppCandidate = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
summary: string;
|
||||
source: SetupAppCandidateSource;
|
||||
downloads?: number;
|
||||
};
|
||||
|
||||
type SetupAppCandidateGroup = {
|
||||
app: SetupAppInventoryItem;
|
||||
candidates: SetupAppCandidate[];
|
||||
};
|
||||
|
||||
export type SetupAppRecommendationMatch = {
|
||||
appLabel: string;
|
||||
candidateId: string;
|
||||
tier: "recommended" | "optional";
|
||||
reason: string;
|
||||
candidate: SetupAppCandidate;
|
||||
};
|
||||
|
||||
export type SetupAppRecommendationsResult =
|
||||
| {
|
||||
status: "ok";
|
||||
apps: SetupAppInventoryItem[];
|
||||
groups: SetupAppCandidateGroup[];
|
||||
matches: SetupAppRecommendationMatch[];
|
||||
}
|
||||
| {
|
||||
status: "skipped";
|
||||
reason: "unsupported" | "no-apps" | "no-candidates" | "model-failed" | "no-matches";
|
||||
};
|
||||
|
||||
// Tolerant on purpose: models add extra keys and overlong reasons; a strict
|
||||
// schema here would turn one sloppy field into a feature-wide "model-failed".
|
||||
const MatcherOutputSchema = z.object({
|
||||
matches: z.array(
|
||||
z.object({
|
||||
appLabel: z.string().trim().min(1),
|
||||
candidateId: z.string().trim().min(1),
|
||||
tier: z.enum(["recommended", "optional"]),
|
||||
reason: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.transform((value) => (value.length > 120 ? `${value.slice(0, 119)}…` : value)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type RecommendationDeps = {
|
||||
listPlugins?: typeof listOfficialExternalPluginCatalogEntries;
|
||||
listChannels?: typeof listOfficialExternalChannelCatalogEntries;
|
||||
listProviders?: typeof listOfficialExternalProviderCatalogEntries;
|
||||
searchSkills?: typeof searchClawHubSkills;
|
||||
complete?: (prompt: string) => Promise<{ ok: true; text: string } | { ok: false }>;
|
||||
};
|
||||
|
||||
function compareInventory(left: SetupAppInventoryItem, right: SetupAppInventoryItem): number {
|
||||
return (
|
||||
left.label.localeCompare(right.label, "en", { sensitivity: "base" }) ||
|
||||
(left.bundleId ?? "").localeCompare(right.bundleId ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeInventory(apps: readonly SetupAppInventoryItem[]): SetupAppInventoryItem[] {
|
||||
const byLabel = new Map<string, SetupAppInventoryItem>();
|
||||
for (const app of apps.toSorted(compareInventory)) {
|
||||
const label = app.label.trim();
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
const bundleId = app.bundleId?.trim();
|
||||
const key = label.toLocaleLowerCase("en-US");
|
||||
const existing = byLabel.get(key);
|
||||
if (!existing || (!existing.bundleId && bundleId)) {
|
||||
byLabel.set(key, { label, ...(bundleId ? { bundleId } : {}) });
|
||||
}
|
||||
}
|
||||
return [...byLabel.values()].toSorted(compareInventory);
|
||||
}
|
||||
|
||||
function inventoryTokens(label: string): string[] {
|
||||
return label
|
||||
.toLocaleLowerCase("en-US")
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((token) => token.length >= 3)
|
||||
.toSorted();
|
||||
}
|
||||
|
||||
function providerSearchTokens(
|
||||
manifest: ReturnType<typeof getOfficialExternalPluginCatalogManifest>,
|
||||
): Array<string | undefined> {
|
||||
const tokens: Array<string | undefined> = [];
|
||||
for (const provider of manifest?.providers ?? []) {
|
||||
tokens.push(provider.id, provider.name);
|
||||
for (const alias of provider.aliases ?? []) {
|
||||
tokens.push(alias);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function entrySearchText(entry: OfficialExternalPluginCatalogEntry): string {
|
||||
const manifest = getOfficialExternalPluginCatalogManifest(entry);
|
||||
return [
|
||||
entry.id,
|
||||
entry.name,
|
||||
entry.title,
|
||||
entry.description,
|
||||
manifest?.plugin?.id,
|
||||
manifest?.plugin?.label,
|
||||
manifest?.channel?.id,
|
||||
manifest?.channel?.label,
|
||||
...providerSearchTokens(manifest),
|
||||
]
|
||||
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("en-US");
|
||||
}
|
||||
|
||||
function entryMatchesApp(entry: OfficialExternalPluginCatalogEntry, appLabel: string): boolean {
|
||||
const appTokens = inventoryTokens(appLabel);
|
||||
const searchText = entrySearchText(entry);
|
||||
const entryTokens = inventoryTokens(searchText);
|
||||
return appTokens.some(
|
||||
(appToken) =>
|
||||
searchText.includes(appToken) ||
|
||||
entryTokens.some((entryToken) => appToken.includes(entryToken)),
|
||||
);
|
||||
}
|
||||
|
||||
function officialCandidate(
|
||||
entry: OfficialExternalPluginCatalogEntry,
|
||||
source: Exclude<SetupAppCandidateSource, "clawhub-skill">,
|
||||
): SetupAppCandidate | null {
|
||||
const id = resolveOfficialExternalPluginId(entry);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
displayName: resolveOfficialExternalPluginLabel(entry),
|
||||
summary: entry.description?.trim() || "Official OpenClaw plugin",
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function compareCandidates(left: SetupAppCandidate, right: SetupAppCandidate): number {
|
||||
return (
|
||||
CANDIDATE_SOURCE_ORDER[left.source] - CANDIDATE_SOURCE_ORDER[right.source] ||
|
||||
left.displayName.localeCompare(right.displayName, "en", { sensitivity: "base" }) ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function dedupeCandidates(candidates: SetupAppCandidate[]): SetupAppCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
return candidates.toSorted(compareCandidates).filter((candidate) => {
|
||||
const key = candidate.id.toLocaleLowerCase("en-US");
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function gatherSetupAppCandidates(params: {
|
||||
apps: SetupAppInventoryItem[];
|
||||
deps?: RecommendationDeps;
|
||||
}): Promise<SetupAppCandidateGroup[]> {
|
||||
const deps = params.deps ?? {};
|
||||
const channels = deps.listChannels?.() ?? listOfficialExternalChannelCatalogEntries();
|
||||
const providers = deps.listProviders?.() ?? listOfficialExternalProviderCatalogEntries();
|
||||
const allEntries = deps.listPlugins?.() ?? listOfficialExternalPluginCatalogEntries();
|
||||
// Catalog entries are package manifests without a stable top-level `id`;
|
||||
// key everything by the resolved plugin id or the map collapses to one
|
||||
// undefined-keyed entry and no official candidate is ever produced.
|
||||
const entryKey = (entry: OfficialExternalPluginCatalogEntry): string | undefined =>
|
||||
resolveOfficialExternalPluginId(entry) ?? entry.name;
|
||||
const channelIds = new Set(channels.map(entryKey));
|
||||
const providerIds = new Set(providers.map(entryKey));
|
||||
const entriesById = new Map(
|
||||
[...allEntries, ...channels, ...providers].flatMap((entry) => {
|
||||
const key = entryKey(entry);
|
||||
return key ? ([[key, entry]] as const) : [];
|
||||
}),
|
||||
);
|
||||
const officialEntries = [...entriesById.entries()].map(([key, entry]) => ({
|
||||
entry,
|
||||
source: channelIds.has(key)
|
||||
? ("official-channel" as const)
|
||||
: providerIds.has(key)
|
||||
? ("official-provider" as const)
|
||||
: ("official-plugin" as const),
|
||||
}));
|
||||
const searchSkills = deps.searchSkills ?? searchClawHubSkills;
|
||||
const searchLimit = pLimit(CLAWHUB_SEARCH_CONCURRENCY);
|
||||
const searchDeadline = Date.now() + CLAWHUB_SEARCH_TOTAL_BUDGET_MS;
|
||||
|
||||
const groups = await Promise.all(
|
||||
normalizeInventory(params.apps).map(async (app): Promise<SetupAppCandidateGroup> => {
|
||||
const official = officialEntries.flatMap(({ entry, source }) => {
|
||||
if (!entryMatchesApp(entry, app.label)) {
|
||||
return [];
|
||||
}
|
||||
const candidate = officialCandidate(entry, source);
|
||||
return candidate ? [candidate] : [];
|
||||
});
|
||||
const skills = await searchLimit(async () => {
|
||||
if (Date.now() >= searchDeadline) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const results = await searchSkills({
|
||||
query: app.label.normalize("NFKC").trim(),
|
||||
limit: CLAWHUB_SEARCH_LIMIT,
|
||||
timeoutMs: CLAWHUB_SEARCH_TIMEOUT_MS,
|
||||
});
|
||||
return results.slice(0, CLAWHUB_SEARCH_LIMIT).map(
|
||||
(result): SetupAppCandidate => ({
|
||||
id: result.slug,
|
||||
displayName: result.displayName,
|
||||
summary: result.summary?.trim() || "ClawHub skill",
|
||||
source: "clawhub-skill",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
return { app, candidates: dedupeCandidates([...official, ...skills]) };
|
||||
}),
|
||||
);
|
||||
return groups.toSorted((left, right) => compareInventory(left.app, right.app));
|
||||
}
|
||||
|
||||
// Models routinely wrap JSON in markdown fences or prose despite "JSON only"
|
||||
// instructions; parse the outermost object instead of the raw text.
|
||||
function parseMatcherJson(text: string): unknown {
|
||||
const start = text.indexOf("{");
|
||||
const end = text.lastIndexOf("}");
|
||||
if (start === -1 || end <= start) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text.slice(start, end + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMatcherPrompt(groups: SetupAppCandidateGroup[]): string {
|
||||
const payload = groups.map((group) => ({
|
||||
app: group.app,
|
||||
candidates: group.candidates,
|
||||
}));
|
||||
return [
|
||||
"Match installed applications to genuinely related OpenClaw plugins or skills.",
|
||||
"Reject coincidental substring, brand, or name overlaps.",
|
||||
"Use tier recommended for messaging-channel integrations; otherwise choose recommended or optional by usefulness.",
|
||||
"Give a reason of at most 12 words.",
|
||||
'Return strict JSON only: {"matches":[{"appLabel":"...","candidateId":"...","tier":"recommended|optional","reason":"..."}]}.',
|
||||
JSON.stringify(payload),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function getSetupAppRecommendations(params: {
|
||||
inventorySource: () => Promise<InstalledAppsResult | SetupAppInventoryItem[]>;
|
||||
runtime: RuntimeEnv;
|
||||
deps?: RecommendationDeps;
|
||||
}): Promise<SetupAppRecommendationsResult> {
|
||||
const inventory = await params.inventorySource();
|
||||
if (!Array.isArray(inventory) && inventory.status === "unsupported") {
|
||||
return { status: "skipped", reason: "unsupported" };
|
||||
}
|
||||
const apps = normalizeInventory(
|
||||
Array.isArray(inventory)
|
||||
? inventory
|
||||
: inventory.apps.map((app) => ({ label: app.label, bundleId: app.bundleId })),
|
||||
);
|
||||
if (apps.length === 0) {
|
||||
return { status: "skipped", reason: "no-apps" };
|
||||
}
|
||||
const groups = await gatherSetupAppCandidates({ apps, deps: params.deps });
|
||||
if (groups.every((group) => group.candidates.length === 0)) {
|
||||
return { status: "skipped", reason: "no-candidates" };
|
||||
}
|
||||
const complete =
|
||||
params.deps?.complete ??
|
||||
// Output is bounded by the resolved model's own maxTokens budget (the
|
||||
// stream layer applies it when no explicit cap is passed), so a runaway
|
||||
// completion cannot exceed what the model config already allows.
|
||||
(async (prompt: string) => await completeSetupInference({ prompt, runtime: params.runtime }));
|
||||
let completion: Awaited<ReturnType<typeof complete>>;
|
||||
try {
|
||||
completion = await complete(buildMatcherPrompt(groups));
|
||||
} catch {
|
||||
return { status: "skipped", reason: "model-failed" };
|
||||
}
|
||||
if (!completion.ok) {
|
||||
return { status: "skipped", reason: "model-failed" };
|
||||
}
|
||||
const parsed = MatcherOutputSchema.safeParse(parseMatcherJson(completion.text));
|
||||
if (!parsed.success) {
|
||||
return { status: "skipped", reason: "model-failed" };
|
||||
}
|
||||
// Case-insensitive lookups: models normalize label/id casing in their output.
|
||||
const matches = parsed.data.matches.flatMap((match): SetupAppRecommendationMatch[] => {
|
||||
const appKey = match.appLabel.toLocaleLowerCase("en-US");
|
||||
const candidateKey = match.candidateId.toLocaleLowerCase("en-US");
|
||||
const group = groups.find(
|
||||
(candidate) => candidate.app.label.toLocaleLowerCase("en-US") === appKey,
|
||||
);
|
||||
const candidate = group?.candidates.find(
|
||||
(entry) => entry.id.toLocaleLowerCase("en-US") === candidateKey,
|
||||
);
|
||||
return candidate ? [{ ...match, appLabel: group?.app.label ?? match.appLabel, candidate }] : [];
|
||||
});
|
||||
if (matches.length === 0) {
|
||||
return { status: "skipped", reason: "no-matches" };
|
||||
}
|
||||
return {
|
||||
status: "ok",
|
||||
apps,
|
||||
groups,
|
||||
matches: matches.toSorted(
|
||||
(left, right) =>
|
||||
(left.tier === right.tier ? 0 : left.tier === "recommended" ? -1 : 1) ||
|
||||
left.candidate.displayName.localeCompare(right.candidate.displayName, "en", {
|
||||
sensitivity: "base",
|
||||
}) ||
|
||||
left.appLabel.localeCompare(right.appLabel, "en", { sensitivity: "base" }),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -194,6 +194,10 @@ export type VerifySetupInferenceResult =
|
||||
| { ok: true; modelRef: string; latencyMs: number }
|
||||
| { ok: false; status: SetupInferenceFailureStatus; error: string };
|
||||
|
||||
export type CompleteSetupInferenceResult =
|
||||
| { ok: true; modelRef: string; latencyMs: number; text: string }
|
||||
| { ok: false; status: SetupInferenceFailureStatus; error: string };
|
||||
|
||||
export type BoundVerifySetupInferenceResult =
|
||||
| {
|
||||
ok: true;
|
||||
@@ -2688,6 +2692,95 @@ export async function verifySetupInferenceConfig(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one tool-free completion through the configured setup inference route. */
|
||||
export async function completeSetupInference(params: {
|
||||
prompt: string;
|
||||
runtime: RuntimeEnv;
|
||||
timeoutMs?: number;
|
||||
deps?: ActivateSetupInferenceDeps;
|
||||
}): Promise<CompleteSetupInferenceResult> {
|
||||
const readSnapshot =
|
||||
params.deps?.readConfigFileSnapshot ??
|
||||
(await import("../config/config.js")).readConfigFileSnapshot;
|
||||
const snapshot = await readSnapshot();
|
||||
if (!snapshot.exists) {
|
||||
return { ok: false, status: "unavailable", error: "No OpenClaw config exists." };
|
||||
}
|
||||
if (!snapshot.valid) {
|
||||
return { ok: false, status: "format", error: invalidSetupConfigError(snapshot) };
|
||||
}
|
||||
return await completeSetupInferenceConfig({
|
||||
config: snapshot.runtimeConfig ?? snapshot.config,
|
||||
prompt: params.prompt,
|
||||
runtime: params.runtime,
|
||||
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
|
||||
...(params.deps ? { deps: params.deps } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Config-injected variant used by setup clients and live provider tests. */
|
||||
export async function completeSetupInferenceConfig(params: {
|
||||
config: OpenClawConfig;
|
||||
prompt: string;
|
||||
runtime: RuntimeEnv;
|
||||
timeoutMs?: number;
|
||||
deps?: ActivateSetupInferenceDeps;
|
||||
}): Promise<CompleteSetupInferenceResult> {
|
||||
const deps: ActivateSetupInferenceDeps = {
|
||||
...params.deps,
|
||||
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
|
||||
};
|
||||
const routeAgentId = normalizeAgentId(resolveDefaultAgentId(params.config));
|
||||
if (!resolveAgentEffectiveModelPrimary(params.config, routeAgentId)) {
|
||||
return { ok: false, status: "unavailable", error: "No agent model is configured." };
|
||||
}
|
||||
const tempDir = await (
|
||||
deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-")))
|
||||
)();
|
||||
try {
|
||||
const plan = await buildTestPlan({
|
||||
kind: "existing-model",
|
||||
cfg: params.config,
|
||||
sourceCfg: params.config,
|
||||
workspaceDir: tempDir,
|
||||
pluginWorkspaceDir: tempDir,
|
||||
agentDir: path.join(tempDir, "agent"),
|
||||
runtime: params.runtime,
|
||||
routeAgentId,
|
||||
deps,
|
||||
});
|
||||
if ("error" in plan) {
|
||||
return { ok: false, status: "unavailable", error: plan.error };
|
||||
}
|
||||
const result = await runSetupInferenceTest({
|
||||
plan,
|
||||
prompt: params.prompt,
|
||||
tempDir,
|
||||
deps,
|
||||
authProfileStateMode: "read-only",
|
||||
requireExecutionOwner: false,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { ...result, error: await redactSetupInferenceError(result.error) };
|
||||
}
|
||||
if (plan.authProfileId && result.auth.authProfileId !== plan.authProfileId) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "auth",
|
||||
error: "The inference completion used a different credential than the configured route.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
modelRef: plan.modelRef,
|
||||
latencyMs: result.latencyMs,
|
||||
text: result.text,
|
||||
};
|
||||
} finally {
|
||||
await cleanupSetupInferenceTempDir({ tempDir, deps, runtime: params.runtime });
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupSetupInferenceTempDir(params: {
|
||||
tempDir: string;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
@@ -3102,13 +3195,14 @@ async function rollbackManualAuthProfiles(
|
||||
|
||||
async function runSetupInferenceTest(params: {
|
||||
plan: SetupInferenceTestPlan;
|
||||
prompt?: string;
|
||||
tempDir: string;
|
||||
deps: ActivateSetupInferenceDeps;
|
||||
authProfileStateMode: "read-write" | "read-only";
|
||||
requireExecutionOwner: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<
|
||||
| { ok: true; latencyMs: number; auth: AgentExecutionAuthBinding }
|
||||
| { ok: true; latencyMs: number; auth: AgentExecutionAuthBinding; text: string }
|
||||
| {
|
||||
ok: false;
|
||||
status: SetupInferenceFailureStatus;
|
||||
@@ -3152,7 +3246,7 @@ async function runSetupInferenceTest(params: {
|
||||
workspaceDir: tempDir,
|
||||
...(plan.agentDir ? { agentDir: plan.agentDir } : {}),
|
||||
config: plan.config,
|
||||
prompt: SETUP_INFERENCE_TEST_PROMPT,
|
||||
prompt: params.prompt ?? SETUP_INFERENCE_TEST_PROMPT,
|
||||
provider: plan.provider,
|
||||
model: plan.model,
|
||||
...(plan.authProfileId ? { authProfileId: plan.authProfileId } : {}),
|
||||
@@ -3180,7 +3274,7 @@ async function runSetupInferenceTest(params: {
|
||||
workspaceDir: tempDir,
|
||||
...(plan.agentDir ? { agentDir: plan.agentDir } : {}),
|
||||
config: plan.config,
|
||||
prompt: SETUP_INFERENCE_TEST_PROMPT,
|
||||
prompt: params.prompt ?? SETUP_INFERENCE_TEST_PROMPT,
|
||||
provider: plan.provider,
|
||||
model: plan.model,
|
||||
...(plan.authProfileId
|
||||
@@ -3197,7 +3291,13 @@ async function runSetupInferenceTest(params: {
|
||||
thinkLevel: "off",
|
||||
reasoningLevel: "off",
|
||||
verboseLevel: "off",
|
||||
...resolveSetupInferenceProbeStreamParams(plan.agentHarnessRuntimeOverride),
|
||||
// The 32-token probe cap is sized for the "reply OK" verification
|
||||
// prompt only. Custom completions pass no explicit cap: the stream
|
||||
// layer then applies the resolved model's own required maxTokens
|
||||
// budget, which both bounds output and never exceeds provider limits.
|
||||
...(params.prompt === undefined
|
||||
? resolveSetupInferenceProbeStreamParams(plan.agentHarnessRuntimeOverride)
|
||||
: {}),
|
||||
disableTools: true,
|
||||
modelRun: true,
|
||||
messageChannel: "openclaw",
|
||||
@@ -3243,6 +3343,7 @@ async function runSetupInferenceTest(params: {
|
||||
return {
|
||||
ok: true,
|
||||
latencyMs: Date.now() - started,
|
||||
text,
|
||||
auth:
|
||||
successfulAuth ??
|
||||
(!requireExecutionOwner && plan.authProfileId ? { authProfileId: plan.authProfileId } : {}),
|
||||
|
||||
@@ -153,6 +153,22 @@ export const en = {
|
||||
summaryTitle: "Memory import summary",
|
||||
title: "Memories found",
|
||||
},
|
||||
appRecommendations: {
|
||||
catalogEntryMissing: "Official plugin catalog entry is unavailable.",
|
||||
detected: "Detected apps: {apps}",
|
||||
disclosure: "App names were matched using your configured model and ClawHub search.",
|
||||
installFailed: "Could not install {name}: {reason}",
|
||||
noneFound: "No app-based plugin or skill recommendations found.",
|
||||
option: "{name} — {reason} (detected: {app})",
|
||||
optionThirdParty:
|
||||
"{name} — {reason} (detected: {app}) — third-party ClawHub skill; installs its publisher's code",
|
||||
scanning:
|
||||
"Scanning installed apps — names are matched with your configured model and ClawHub search (disable via wizard.appRecommendations)…",
|
||||
select: "Install recommended plugins and skills",
|
||||
skillTrust: "Trust and install the ClawHub skill {name}?",
|
||||
skipped: "App recommendations skipped: {reason}",
|
||||
title: "App recommendations",
|
||||
},
|
||||
plugins: {
|
||||
configureBackHint: "Return to section menu",
|
||||
configureEmpty: "No plugins with configurable fields found.",
|
||||
|
||||
@@ -152,6 +152,22 @@ export const zh_CN = {
|
||||
summaryTitle: "Memory 导入摘要",
|
||||
title: "发现 memory",
|
||||
},
|
||||
appRecommendations: {
|
||||
catalogEntryMissing: "官方插件目录条目不可用。",
|
||||
detected: "检测到的应用:{apps}",
|
||||
disclosure: "应用名称使用你配置的模型和 ClawHub 搜索进行匹配。",
|
||||
installFailed: "无法安装 {name}:{reason}",
|
||||
noneFound: "未找到基于应用的插件或技能推荐。",
|
||||
option: "{name} — {reason}(检测到:{app})",
|
||||
optionThirdParty:
|
||||
"{name} — {reason}(检测到:{app})— 第三方 ClawHub 技能;将安装其发布者的代码",
|
||||
scanning:
|
||||
"正在扫描已安装应用 — 应用名称将通过你配置的模型与 ClawHub 搜索进行匹配(可通过 wizard.appRecommendations 关闭)…",
|
||||
select: "安装推荐的插件和技能",
|
||||
skillTrust: "信任并安装 ClawHub 技能 {name}?",
|
||||
skipped: "已跳过应用推荐:{reason}",
|
||||
title: "应用推荐",
|
||||
},
|
||||
plugins: {
|
||||
configureBackHint: "返回分区菜单",
|
||||
configureEmpty: "没有找到带可配置字段的插件。",
|
||||
|
||||
@@ -152,6 +152,22 @@ export const zh_TW = {
|
||||
summaryTitle: "Memory 匯入摘要",
|
||||
title: "發現 memory",
|
||||
},
|
||||
appRecommendations: {
|
||||
catalogEntryMissing: "官方插件目錄項目無法使用。",
|
||||
detected: "偵測到的應用程式:{apps}",
|
||||
disclosure: "應用程式名稱使用你設定的模型和 ClawHub 搜尋進行比對。",
|
||||
installFailed: "無法安裝 {name}:{reason}",
|
||||
noneFound: "找不到依應用程式推薦的插件或技能。",
|
||||
option: "{name} — {reason}(偵測到:{app})",
|
||||
optionThirdParty:
|
||||
"{name} — {reason}(偵測到:{app})— 第三方 ClawHub 技能;將安裝其發佈者的程式碼",
|
||||
scanning:
|
||||
"正在掃描已安裝的應用程式 — 應用程式名稱會透過你設定的模型與 ClawHub 搜尋進行比對(可透過 wizard.appRecommendations 停用)…",
|
||||
select: "安裝推薦的插件和技能",
|
||||
skillTrust: "信任並安裝 ClawHub 技能 {name}?",
|
||||
skipped: "已略過應用程式推薦:{reason}",
|
||||
title: "應用程式推薦",
|
||||
},
|
||||
plugins: {
|
||||
configureBackHint: "返回分區選單",
|
||||
configureEmpty: "沒有找到帶可設定欄位的插件。",
|
||||
|
||||
164
src/wizard/setup.app-recommendations.test.ts
Normal file
164
src/wizard/setup.app-recommendations.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { SetupAppRecommendationsResult } from "../system-agent/setup-app-recommendations.js";
|
||||
import type { WizardPrompter } from "./prompts.js";
|
||||
import { setupAppRecommendations } from "./setup.app-recommendations.js";
|
||||
|
||||
function createPrompter(selected: string[] = []): WizardPrompter {
|
||||
return {
|
||||
intro: vi.fn(async () => undefined),
|
||||
outro: vi.fn(async () => undefined),
|
||||
note: vi.fn(async () => undefined),
|
||||
plain: vi.fn(async () => undefined),
|
||||
select: vi.fn(),
|
||||
multiselect: vi.fn(async () => selected) as WizardPrompter["multiselect"],
|
||||
text: vi.fn(),
|
||||
confirm: vi.fn(async () => true),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
};
|
||||
}
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
function recommendationResult(): Extract<SetupAppRecommendationsResult, { status: "ok" }> {
|
||||
const apps = [{ label: "Chat", bundleId: "com.example.chat" }];
|
||||
const matches = [
|
||||
{
|
||||
appLabel: "Chat",
|
||||
candidateId: "chat-plugin",
|
||||
tier: "recommended" as const,
|
||||
reason: "Connects conversations",
|
||||
candidate: {
|
||||
id: "chat-plugin",
|
||||
displayName: "Chat plugin",
|
||||
summary: "Chat",
|
||||
source: "official-channel" as const,
|
||||
},
|
||||
},
|
||||
{
|
||||
appLabel: "Chat",
|
||||
candidateId: "chat-skill",
|
||||
tier: "optional" as const,
|
||||
reason: "Adds useful actions",
|
||||
candidate: {
|
||||
id: "chat-skill",
|
||||
displayName: "Chat skill",
|
||||
summary: "Chat skill",
|
||||
source: "clawhub-skill" as const,
|
||||
},
|
||||
},
|
||||
];
|
||||
return { status: "ok", apps, groups: [{ app: apps[0]!, candidates: [] }], matches };
|
||||
}
|
||||
|
||||
describe("setupAppRecommendations", () => {
|
||||
it.each([
|
||||
[{ wizard: { appRecommendations: false } }, "darwin" as const],
|
||||
[{}, "linux" as const],
|
||||
])("skips when gated", async (config, platform) => {
|
||||
const recommend = vi.fn(async () => recommendationResult());
|
||||
await setupAppRecommendations({
|
||||
config,
|
||||
prompter: createPrompter(),
|
||||
runtime,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
modelRouteVerified: true,
|
||||
platform,
|
||||
deps: { recommend },
|
||||
});
|
||||
expect(recommend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never preselects third-party ClawHub skills even when model-recommended", async () => {
|
||||
const result = recommendationResult();
|
||||
result.matches[1] = {
|
||||
...result.matches[1]!,
|
||||
tier: "recommended",
|
||||
};
|
||||
const prompter = createPrompter();
|
||||
await setupAppRecommendations({
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
modelRouteVerified: true,
|
||||
platform: "darwin",
|
||||
deps: { recommend: vi.fn(async () => result) },
|
||||
});
|
||||
expect(prompter.multiselect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialValues: ["recommendation:0"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("preselects recommended matches and installs selected plugin and skill", async () => {
|
||||
const config: OpenClawConfig = {};
|
||||
const prompter = createPrompter(["recommendation:0", "recommendation:1"]);
|
||||
const ensurePlugin = vi.fn(async () => ({
|
||||
cfg: { ...config, plugins: { entries: { "chat-plugin": { enabled: true } } } },
|
||||
installed: true,
|
||||
pluginId: "chat-plugin",
|
||||
status: "installed" as const,
|
||||
}));
|
||||
const installSkill = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
slug: "chat-skill",
|
||||
version: "1.0.0",
|
||||
targetDir: "/tmp/workspace/skills/chat-skill",
|
||||
}));
|
||||
|
||||
const result = await setupAppRecommendations({
|
||||
config,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
modelRouteVerified: true,
|
||||
platform: "darwin",
|
||||
deps: {
|
||||
recommend: async () => recommendationResult(),
|
||||
ensurePlugin,
|
||||
installSkill,
|
||||
resolveOfficialEntry: (pluginId) => ({
|
||||
pluginId,
|
||||
label: "Chat plugin",
|
||||
install: { npmSpec: "@openclaw/chat-plugin" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(prompter.multiselect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ initialValues: ["recommendation:0"] }),
|
||||
);
|
||||
expect(ensurePlugin).toHaveBeenCalledOnce();
|
||||
expect(installSkill).toHaveBeenCalledOnce();
|
||||
expect(result.plugins?.entries?.["chat-plugin"]?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("installs nothing when the explicit skip entry is selected", async () => {
|
||||
const ensurePlugin = vi.fn();
|
||||
const installSkill = vi.fn();
|
||||
const config: OpenClawConfig = {};
|
||||
|
||||
await expect(
|
||||
setupAppRecommendations({
|
||||
config,
|
||||
prompter: createPrompter(["__skip__", "recommendation:0"]),
|
||||
runtime,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
modelRouteVerified: true,
|
||||
platform: "darwin",
|
||||
deps: {
|
||||
recommend: async () => recommendationResult(),
|
||||
ensurePlugin,
|
||||
installSkill,
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(config);
|
||||
expect(ensurePlugin).not.toHaveBeenCalled();
|
||||
expect(installSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
206
src/wizard/setup.app-recommendations.ts
Normal file
206
src/wizard/setup.app-recommendations.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
type OnboardingPluginInstallEntry,
|
||||
} from "../commands/onboarding-plugin-install.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { scanInstalledApps } from "../infra/installed-apps.js";
|
||||
import {
|
||||
listOfficialExternalPluginCatalogEntries,
|
||||
resolveOfficialExternalPluginId,
|
||||
resolveOfficialExternalPluginInstall,
|
||||
resolveOfficialExternalPluginLabel,
|
||||
} from "../plugins/official-external-plugin-catalog.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { installSkillFromClawHub } from "../skills/lifecycle/clawhub.js";
|
||||
import {
|
||||
getSetupAppRecommendations,
|
||||
type SetupAppRecommendationMatch,
|
||||
type SetupAppRecommendationsResult,
|
||||
} from "../system-agent/setup-app-recommendations.js";
|
||||
import { t } from "./i18n/index.js";
|
||||
import type { WizardPrompter } from "./prompts.js";
|
||||
|
||||
const SKIP_VALUE = "__skip__";
|
||||
|
||||
type SetupAppRecommendationDeps = {
|
||||
recommend?: () => Promise<SetupAppRecommendationsResult>;
|
||||
ensurePlugin?: typeof ensureOnboardingPluginInstalled;
|
||||
installSkill?: typeof installSkillFromClawHub;
|
||||
resolveOfficialEntry?: (pluginId: string) => OnboardingPluginInstallEntry | undefined;
|
||||
};
|
||||
|
||||
function resolveOfficialEntry(pluginId: string): OnboardingPluginInstallEntry | undefined {
|
||||
const catalogEntry = listOfficialExternalPluginCatalogEntries().find(
|
||||
(entry) => resolveOfficialExternalPluginId(entry) === pluginId,
|
||||
);
|
||||
const install = catalogEntry ? resolveOfficialExternalPluginInstall(catalogEntry) : undefined;
|
||||
if (!catalogEntry || !install) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
pluginId,
|
||||
label: resolveOfficialExternalPluginLabel(catalogEntry),
|
||||
install,
|
||||
trustedSourceLinkedOfficialInstall: true,
|
||||
};
|
||||
}
|
||||
|
||||
function selectionValue(index: number): string {
|
||||
return `recommendation:${index}`;
|
||||
}
|
||||
|
||||
function uniqueSelectedMatches(
|
||||
matches: SetupAppRecommendationMatch[],
|
||||
selected: string[],
|
||||
): SetupAppRecommendationMatch[] {
|
||||
const selectedValues = new Set(selected);
|
||||
const seen = new Set<string>();
|
||||
return matches.filter((match, index) => {
|
||||
const key = `${match.candidate.source}:${match.candidate.id}`;
|
||||
if (!selectedValues.has(selectionValue(index)) || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function setupAppRecommendations(params: {
|
||||
config: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
workspaceDir: string;
|
||||
modelRouteVerified: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
deps?: SetupAppRecommendationDeps;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const platform = params.platform ?? process.platform;
|
||||
// Product decision: default-on "magical" scan with a kill switch, not
|
||||
// consent-first. App labels/bundle ids go to the user's configured model and
|
||||
// ClawHub search; the scanning progress line and the results note disclose
|
||||
// this, and wizard.appRecommendations=false disables the step entirely.
|
||||
if (
|
||||
params.config.wizard?.appRecommendations === false ||
|
||||
platform !== "darwin" ||
|
||||
!params.modelRouteVerified
|
||||
) {
|
||||
return params.config;
|
||||
}
|
||||
|
||||
const progress = params.prompter.progress(t("wizard.appRecommendations.scanning"));
|
||||
let result: SetupAppRecommendationsResult;
|
||||
try {
|
||||
result = params.deps?.recommend
|
||||
? await params.deps.recommend()
|
||||
: await getSetupAppRecommendations({
|
||||
inventorySource: async () => await scanInstalledApps({ platform }),
|
||||
runtime: params.runtime,
|
||||
});
|
||||
} catch (error) {
|
||||
progress.stop();
|
||||
params.runtime.log(
|
||||
t("wizard.appRecommendations.skipped", { reason: formatErrorMessage(error) }),
|
||||
);
|
||||
return params.config;
|
||||
}
|
||||
progress.stop();
|
||||
if (result.status !== "ok") {
|
||||
params.runtime.log(t("wizard.appRecommendations.noneFound"));
|
||||
return params.config;
|
||||
}
|
||||
|
||||
await params.prompter.note(
|
||||
[
|
||||
t("wizard.appRecommendations.detected", {
|
||||
apps: result.apps.map((app) => app.label).join(", "),
|
||||
}),
|
||||
t("wizard.appRecommendations.disclosure"),
|
||||
].join("\n"),
|
||||
t("wizard.appRecommendations.title"),
|
||||
);
|
||||
const selected = await params.prompter.multiselect({
|
||||
message: t("wizard.appRecommendations.select"),
|
||||
options: [
|
||||
{ value: SKIP_VALUE, label: t("common.skipForNow") },
|
||||
...result.matches.map((match, index) => ({
|
||||
value: selectionValue(index),
|
||||
label:
|
||||
match.candidate.source === "clawhub-skill"
|
||||
? t("wizard.appRecommendations.optionThirdParty", {
|
||||
name: match.candidate.displayName,
|
||||
reason: match.reason,
|
||||
app: match.appLabel,
|
||||
})
|
||||
: t("wizard.appRecommendations.option", {
|
||||
name: match.candidate.displayName,
|
||||
reason: match.reason,
|
||||
app: match.appLabel,
|
||||
}),
|
||||
})),
|
||||
],
|
||||
// Supply-chain guard: ClawHub listing text is publisher-controlled and
|
||||
// reaches the matcher prompt, so a listing can promote itself to
|
||||
// "recommended". Only official catalog entries may be pre-selected;
|
||||
// third-party skills always require an explicit opt-in tick.
|
||||
initialValues: result.matches.flatMap((match, index) =>
|
||||
match.tier === "recommended" && match.candidate.source !== "clawhub-skill"
|
||||
? [selectionValue(index)]
|
||||
: [],
|
||||
),
|
||||
});
|
||||
if (selected.includes(SKIP_VALUE)) {
|
||||
return params.config;
|
||||
}
|
||||
|
||||
let next = params.config;
|
||||
const ensurePlugin = params.deps?.ensurePlugin ?? ensureOnboardingPluginInstalled;
|
||||
const installSkill = params.deps?.installSkill ?? installSkillFromClawHub;
|
||||
for (const match of uniqueSelectedMatches(result.matches, selected)) {
|
||||
try {
|
||||
if (match.candidate.source === "clawhub-skill") {
|
||||
const installed = await installSkill({
|
||||
workspaceDir: params.workspaceDir,
|
||||
slug: match.candidate.id,
|
||||
config: next,
|
||||
onClawHubRisk: async () =>
|
||||
await params.prompter.confirm({
|
||||
message: t("wizard.appRecommendations.skillTrust", {
|
||||
name: match.candidate.displayName,
|
||||
}),
|
||||
initialValue: false,
|
||||
}),
|
||||
logger: { warn: (message) => params.runtime.error(message) },
|
||||
});
|
||||
if (!installed.ok) {
|
||||
throw new Error(installed.error);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const entry = (params.deps?.resolveOfficialEntry ?? resolveOfficialEntry)(match.candidate.id);
|
||||
if (!entry) {
|
||||
throw new Error(t("wizard.appRecommendations.catalogEntryMissing"));
|
||||
}
|
||||
const installed = await ensurePlugin({
|
||||
cfg: next,
|
||||
entry,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
workspaceDir: params.workspaceDir,
|
||||
promptInstall: false,
|
||||
});
|
||||
next = installed.cfg;
|
||||
if (!installed.installed) {
|
||||
throw new Error(installed.error ?? installed.status);
|
||||
}
|
||||
} catch (error) {
|
||||
params.runtime.error(
|
||||
t("wizard.appRecommendations.installFailed", {
|
||||
name: match.candidate.displayName,
|
||||
reason: formatErrorMessage(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -52,13 +52,13 @@ async function offerLiveModelVerification(params: {
|
||||
runtime: RuntimeEnv;
|
||||
workspaceDir: string;
|
||||
writeConfig: (config: OpenClawConfig) => Promise<OpenClawConfig>;
|
||||
}): Promise<OpenClawConfig> {
|
||||
}): Promise<{ config: OpenClawConfig; verified: boolean }> {
|
||||
const shouldTest = await params.prompter.confirm({
|
||||
message: t("wizard.setup.testAiAccess"),
|
||||
initialValue: true,
|
||||
});
|
||||
if (!shouldTest) {
|
||||
return params.config;
|
||||
return { config: params.config, verified: false };
|
||||
}
|
||||
|
||||
const { verifySetupInference } = await import("../system-agent/setup-inference.js");
|
||||
@@ -84,7 +84,7 @@ async function offerLiveModelVerification(params: {
|
||||
|
||||
const firstResult = await verify();
|
||||
if (firstResult.ok) {
|
||||
return params.config;
|
||||
return { config: params.config, verified: true };
|
||||
}
|
||||
const action = await params.prompter.select({
|
||||
message: t("wizard.setup.testAiFailureChoice"),
|
||||
@@ -94,7 +94,7 @@ async function offerLiveModelVerification(params: {
|
||||
],
|
||||
});
|
||||
if (action === "continue") {
|
||||
return params.config;
|
||||
return { config: params.config, verified: false };
|
||||
}
|
||||
|
||||
const fixedConfig = await runSetupModelAuthStep({
|
||||
@@ -105,8 +105,8 @@ async function offerLiveModelVerification(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const persistedConfig = await params.writeConfig(fixedConfig);
|
||||
await verify();
|
||||
return persistedConfig;
|
||||
const retryResult = await verify();
|
||||
return { config: persistedConfig, verified: retryResult.ok };
|
||||
}
|
||||
|
||||
function isSetupImportFlowChoice(flow: SetupFlowChoice): boolean {
|
||||
@@ -550,13 +550,14 @@ async function runSetupWizardOnce(
|
||||
allowConfigSizeDrop: false,
|
||||
});
|
||||
|
||||
let liveModelVerified = false;
|
||||
if (
|
||||
opts.nonInteractive !== true &&
|
||||
opts.authChoice !== "skip" &&
|
||||
!usedImportFlow &&
|
||||
hasConfiguredDefaultModel(nextConfig)
|
||||
) {
|
||||
nextConfig = await offerLiveModelVerification({
|
||||
const verification = await offerLiveModelVerification({
|
||||
config: nextConfig,
|
||||
opts,
|
||||
prompter,
|
||||
@@ -565,6 +566,8 @@ async function runSetupWizardOnce(
|
||||
writeConfig: async (config) =>
|
||||
await writeSetupConfigFile(config, { allowConfigSizeDrop: false }),
|
||||
});
|
||||
nextConfig = verification.config;
|
||||
liveModelVerified = verification.verified;
|
||||
}
|
||||
|
||||
prompter.disableBackNavigation?.();
|
||||
@@ -633,6 +636,14 @@ async function runSetupWizardOnce(
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
const { setupAppRecommendations } = await import("./setup.app-recommendations.js");
|
||||
nextConfig = await setupAppRecommendations({
|
||||
config: nextConfig,
|
||||
prompter,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
modelRouteVerified: liveModelVerified,
|
||||
});
|
||||
const { setupPluginConfig } = await import("./setup.plugin-config.js");
|
||||
nextConfig = await setupPluginConfig({
|
||||
config: nextConfig,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user