From cf7c36ac65e228d25acd59de0669201cbd8da9b2 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:31:05 -0600 Subject: [PATCH] Onboard: add tools profile selection --- docs/cli/index.md | 1 + docs/cli/onboard.md | 3 + docs/gateway/configuration-reference.md | 2 +- docs/reference/wizard.md | 2 +- docs/start/onboarding.md | 2 +- docs/start/wizard-cli-reference.md | 2 +- docs/start/wizard.md | 2 +- src/cli/program/register.onboard.test.ts | 10 ++ src/cli/program/register.onboard.ts | 3 + src/commands/onboard-config.test.ts | 13 ++ src/commands/onboard-config.ts | 5 +- .../onboard-non-interactive.gateway.test.ts | 27 ++++ src/commands/onboard-non-interactive/local.ts | 4 +- src/commands/onboard-types.ts | 4 + src/commands/onboard.test.ts | 18 +++ src/commands/onboard.ts | 6 + src/wizard/onboarding.test.ts | 121 +++++++++++++++++- src/wizard/onboarding.ts | 44 ++++++- 18 files changed, 255 insertions(+), 14 deletions(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index cddd2a7d634..d0ea21ce1bb 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -333,6 +333,7 @@ Options: - `--non-interactive` - `--mode ` - `--flow ` (manual is an alias for advanced) +- `--tools-profile ` - `--auth-choice ` - `--token-provider ` (non-interactive; used with `--auth-choice token`) - `--token ` (non-interactive; used with `--auth-choice token`) diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 36629a3bb8d..f2ed3ca2936 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -23,6 +23,7 @@ Interactive onboarding wizard (local or remote Gateway setup). openclaw onboard openclaw onboard --flow quickstart openclaw onboard --flow manual +openclaw onboard --tools-profile coding openclaw onboard --mode remote --remote-url wss://gateway-host:18789 ``` @@ -122,6 +123,8 @@ Flow notes: - `quickstart`: minimal prompts, auto-generates a gateway token. - `manual`: full prompts for port/bind/auth (alias of `advanced`). - Local onboarding DM scope behavior: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals). +- Local onboarding prompts for a tool profile (`Messaging`, `Coding`, `Full`, `Minimal`). +- Non-interactive profile selection: `--tools-profile `. - Fastest first chat: `openclaw dashboard` (Control UI, no channel setup). - Custom Provider: connect any OpenAI or Anthropic compatible endpoint, including hosted providers not listed. Use Unknown to auto-detect. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 1ba60bee31d..7b17910e565 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1674,7 +1674,7 @@ Defaults for Talk mode (macOS/iOS/Android). `tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`: -Local onboarding defaults new local configs to `tools.profile: "messaging"` when unset (existing explicit profiles are preserved). +Local onboarding prompts for a tool profile. In non-interactive onboarding, `--tools-profile` can set it explicitly; if omitted, new local configs default to `tools.profile: "messaging"` when unset (existing explicit profiles are preserved). | Profile | Includes | | ----------- | ----------------------------------------------------------------------------------------- | diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 328063a0102..815b8ee0a84 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -270,7 +270,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) -- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved) +- `tools.profile` (local onboarding prompts for a tool profile; when unset in non-interactive runs, defaults to `"messaging"` and preserves existing explicit values) - `gateway.*` (mode, bind, auth, tailscale) - `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index 3a5c86c360e..b1ccf2e41e7 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -34,7 +34,7 @@ Security trust model: - By default, OpenClaw is a personal agent: one trusted operator boundary. - Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)). -- Local onboarding now defaults new configs to `tools.profile: "messaging"` so broad runtime/filesystem tools are opt-in. +- Local onboarding now prompts for a tool profile; when unset, new configs default to `tools.profile: "messaging"` so broad runtime/filesystem tools are opt-in. - If hooks/webhooks or other untrusted content feeds are enabled, use a strong modern model tier and keep strict tool policy/sandboxing. diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index df2149897a5..0e46fc05b20 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -247,7 +247,7 @@ Typical fields in `~/.openclaw/openclaw.json`: - `agents.defaults.workspace` - `agents.defaults.model` / `models.providers` (if Minimax chosen) -- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved) +- `tools.profile` (local onboarding prompts for a tool profile; use `--tools-profile ` in non-interactive mode. If omitted, defaults to `"messaging"` when unset and preserves existing explicit values) - `gateway.*` (mode, bind, auth, tailscale) - `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved) - `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*` diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 5a7ddcd4020..f181effdd7f 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -50,7 +50,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). - Workspace default (or existing workspace) - Gateway port **18789** - Gateway auth **Token** (auto‑generated, even on loopback) - - Tool policy default for new local setups: `tools.profile: "messaging"` (existing explicit profile is preserved) + - Tool profile prompt (`Messaging`, `Coding`, `Full`, `Minimal`); defaults to `messaging` when unset and preserves existing explicit profiles. - DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals) - Tailscale exposure **Off** - Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number) diff --git a/src/cli/program/register.onboard.test.ts b/src/cli/program/register.onboard.test.ts index b1cf8478118..04fae1fcd7e 100644 --- a/src/cli/program/register.onboard.test.ts +++ b/src/cli/program/register.onboard.test.ts @@ -139,6 +139,16 @@ describe("registerOnboardCommand", () => { ); }); + it("forwards --tools-profile", async () => { + await runCli(["onboard", "--tools-profile", "coding"]); + expect(onboardCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + toolsProfile: "coding", + }), + runtime, + ); + }); + it("reports errors via runtime on onboard command failures", async () => { onboardCommandMock.mockRejectedValueOnce(new Error("onboard failed")); diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 7555b5c6b4e..08a97421963 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -10,6 +10,7 @@ import type { ResetScope, SecretInputMode, TailscaleMode, + ToolProfileId, } from "../../commands/onboard-types.js"; import { onboardCommand } from "../../commands/onboard.js"; import { defaultRuntime } from "../../runtime.js"; @@ -69,6 +70,7 @@ export function registerOnboardCommand(program: Command) { ) .option("--flow ", "Wizard flow: quickstart|advanced|manual") .option("--mode ", "Wizard mode: local|remote") + .option("--tools-profile ", "Tool profile: minimal|coding|messaging|full") .option("--auth-choice ", `Auth: ${AUTH_CHOICE_HELP}`) .option( "--token-provider ", @@ -138,6 +140,7 @@ export function registerOnboardCommand(program: Command) { acceptRisk: Boolean(opts.acceptRisk), flow: opts.flow as "quickstart" | "advanced" | "manual" | undefined, mode: opts.mode as "local" | "remote" | undefined, + toolsProfile: opts.toolsProfile as ToolProfileId | undefined, authChoice: opts.authChoice as AuthChoice | undefined, tokenProvider: opts.tokenProvider as string | undefined, token: opts.token as string | undefined, diff --git a/src/commands/onboard-config.test.ts b/src/commands/onboard-config.test.ts index 076f98a02f1..66268c16e37 100644 --- a/src/commands/onboard-config.test.ts +++ b/src/commands/onboard-config.test.ts @@ -49,4 +49,17 @@ describe("applyOnboardingLocalWorkspaceConfig", () => { expect(result.tools?.profile).toBe("full"); }); + + it("applies explicit tools.profile override when provided", () => { + const baseConfig: OpenClawConfig = { + tools: { + profile: "messaging", + }, + }; + const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace", { + toolsProfile: "coding", + }); + + expect(result.tools?.profile).toBe("coding"); + }); }); diff --git a/src/commands/onboard-config.ts b/src/commands/onboard-config.ts index f2ae8991141..8e853eafd72 100644 --- a/src/commands/onboard-config.ts +++ b/src/commands/onboard-config.ts @@ -8,7 +8,10 @@ export const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "messaging"; export function applyOnboardingLocalWorkspaceConfig( baseConfig: OpenClawConfig, workspaceDir: string, + params?: { toolsProfile?: ToolProfileId }, ): OpenClawConfig { + const toolsProfile = + params?.toolsProfile ?? baseConfig.tools?.profile ?? ONBOARDING_DEFAULT_TOOLS_PROFILE; return { ...baseConfig, agents: { @@ -28,7 +31,7 @@ export function applyOnboardingLocalWorkspaceConfig( }, tools: { ...baseConfig.tools, - profile: baseConfig.tools?.profile ?? ONBOARDING_DEFAULT_TOOLS_PROFILE, + profile: toolsProfile, }, }; } diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 1d9e8bc5881..100df65da3c 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -151,6 +151,33 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("writes explicit tools profile from --tools-profile", async () => { + await withStateDir("state-tools-profile-", async (stateDir) => { + const workspace = path.join(stateDir, "openclaw"); + + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace, + toolsProfile: "coding", + authChoice: "skip", + skipSkills: true, + skipHealth: true, + installDaemon: false, + gatewayBind: "loopback", + gatewayAuth: "token", + gatewayToken: "tok_test_123", + }, + runtime, + ); + + const configPath = resolveStateConfigPath(process.env, stateDir); + const cfg = await readJsonFile<{ tools?: { profile?: string } }>(configPath); + expect(cfg?.tools?.profile).toBe("coding"); + }); + }, 60_000); + it("uses OPENCLAW_GATEWAY_TOKEN when --gateway-token is omitted", async () => { await withStateDir("state-env-token-", async (stateDir) => { const envToken = "tok_env_fallback_123"; diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 4e0482ae2c8..203717046b3 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -33,7 +33,9 @@ export async function runNonInteractiveOnboardingLocal(params: { defaultWorkspaceDir: DEFAULT_WORKSPACE, }); - let nextConfig: OpenClawConfig = applyOnboardingLocalWorkspaceConfig(baseConfig, workspaceDir); + let nextConfig: OpenClawConfig = applyOnboardingLocalWorkspaceConfig(baseConfig, workspaceDir, { + toolsProfile: opts.toolsProfile, + }); const inferredAuthChoice = inferAuthChoiceFromFlags(opts); if (!opts.authChoice && inferredAuthChoice.matches.length > 1) { diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index fcb823f96b8..f090fb34a9d 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -1,6 +1,9 @@ import type { ChannelId } from "../channels/plugins/types.js"; +import type { ToolProfileId } from "../config/types.tools.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; +export type { ToolProfileId } from "../config/types.tools.js"; + export type OnboardMode = "local" | "remote"; export type AuthChoice = // Legacy alias for `setup-token` (kept for backwards CLI compatibility). @@ -100,6 +103,7 @@ export type OnboardOptions = { reset?: boolean; resetScope?: ResetScope; authChoice?: AuthChoice; + toolsProfile?: ToolProfileId; /** Used when `authChoice=token` in non-interactive mode. */ tokenProvider?: string; /** Used when `authChoice=token` in non-interactive mode. */ diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index 4fa6b04cc12..882923d6bba 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -138,4 +138,22 @@ describe("onboardCommand", () => { expect(mocks.runInteractiveOnboarding).not.toHaveBeenCalled(); expect(mocks.runNonInteractiveOnboarding).not.toHaveBeenCalled(); }); + + it("fails fast for invalid --tools-profile", async () => { + const runtime = makeRuntime(); + + await onboardCommand( + { + toolsProfile: "invalid" as never, + }, + runtime, + ); + + expect(runtime.error).toHaveBeenCalledWith( + 'Invalid --tools-profile. Use "minimal", "coding", "messaging", or "full".', + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(mocks.runInteractiveOnboarding).not.toHaveBeenCalled(); + expect(mocks.runNonInteractiveOnboarding).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 1901d70e08f..7b4d839f1f1 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -11,6 +11,7 @@ import { runNonInteractiveOnboarding } from "./onboard-non-interactive.js"; import type { OnboardOptions, ResetScope } from "./onboard-types.js"; const VALID_RESET_SCOPES = new Set(["config", "config+creds+sessions", "full"]); +const VALID_TOOLS_PROFILES = new Set(["minimal", "coding", "messaging", "full"]); export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) { assertSupportedRuntime(runtime); @@ -46,6 +47,11 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = runtime.exit(1); return; } + if (normalizedOpts.toolsProfile && !VALID_TOOLS_PROFILES.has(normalizedOpts.toolsProfile)) { + runtime.error('Invalid --tools-profile. Use "minimal", "coding", "messaging", or "full".'); + runtime.exit(1); + return; + } if (normalizedOpts.resetScope && !VALID_RESET_SCOPES.has(normalizedOpts.resetScope)) { runtime.error('Invalid --reset-scope. Use "config", "config+creds+sessions", or "full".'); diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index 91d761ca569..98930fb8d04 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -276,9 +276,12 @@ describe("runOnboardingWizard", () => { }); it("skips prompts and setup steps when flags are set", async () => { - const select = vi.fn( - async (_params: WizardSelectParams) => "quickstart", - ) as unknown as WizardPrompter["select"]; + const select = vi.fn(async (params: WizardSelectParams) => { + if (params.message === "Tool access profile") { + return "messaging"; + } + return "quickstart"; + }) as unknown as WizardPrompter["select"]; const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter = buildWizardPrompter({ select, multiselect }); const runtime = createRuntime({ throwsOnExit: true }); @@ -298,7 +301,12 @@ describe("runOnboardingWizard", () => { prompter, ); - expect(select).not.toHaveBeenCalled(); + expect(select).toHaveBeenCalledTimes(1); + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Tool access profile", + }), + ); expect(setupChannels).not.toHaveBeenCalled(); expect(setupSkills).not.toHaveBeenCalled(); expect(healthCommand).not.toHaveBeenCalled(); @@ -320,6 +328,9 @@ describe("runOnboardingWizard", () => { if (opts.message === "How do you want to hatch your bot?") { return "tui"; } + if (opts.message === "Tool access profile") { + return "messaging"; + } return "quickstart"; }) as unknown as WizardPrompter["select"]; @@ -364,7 +375,13 @@ describe("runOnboardingWizard", () => { try { const note: WizardPrompter["note"] = vi.fn(async () => {}); - const prompter = buildWizardPrompter({ note }); + const select = vi.fn(async (params: WizardSelectParams) => { + if (params.message === "Tool access profile") { + return "messaging"; + } + return "quickstart"; + }) as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ note, select }); const runtime = createRuntime(); await runOnboardingWizard( @@ -425,6 +442,9 @@ describe("runOnboardingWizard", () => { if (opts.message === "Config handling") { return "keep"; } + if (opts.message === "Tool access profile") { + return "messaging"; + } return "quickstart"; }) as unknown as WizardPrompter["select"]; const prompter = buildWizardPrompter({ select }); @@ -464,7 +484,13 @@ describe("runOnboardingWizard", () => { it("passes secretInputMode through to local gateway config step", async () => { configureGatewayForOnboarding.mockClear(); - const prompter = buildWizardPrompter({}); + const select = vi.fn(async (params: WizardSelectParams) => { + if (params.message === "Tool access profile") { + return "messaging"; + } + return "quickstart"; + }) as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ select }); const runtime = createRuntime(); await runOnboardingWizard( @@ -490,4 +516,87 @@ describe("runOnboardingWizard", () => { }), ); }); + + it("writes selected tool profile from onboarding prompt", async () => { + writeConfigFile.mockClear(); + const select = vi.fn(async (params: WizardSelectParams) => { + if (params.message === "Tool access profile") { + return "coding"; + } + return "quickstart"; + }) as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ select }); + + await runOnboardingWizard( + { + acceptRisk: true, + flow: "quickstart", + mode: "local", + authChoice: "skip", + installDaemon: false, + skipProviders: true, + skipSkills: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + prompter, + ); + + const firstWrite = writeConfigFile.mock.calls[0]?.[0] as { tools?: { profile?: string } }; + expect(firstWrite?.tools?.profile).toBe("coding"); + }); + + it("preselects existing tools.profile in the onboarding prompt", async () => { + readConfigFileSnapshot.mockResolvedValueOnce({ + path: "/tmp/.openclaw/openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + resolved: {}, + valid: true, + config: { + tools: { + profile: "coding", + }, + }, + issues: [], + warnings: [], + legacyIssues: [], + }); + + const select = vi.fn(async (params: WizardSelectParams) => { + if (params.message === "Config handling") { + return "keep"; + } + if (params.message === "Tool access profile") { + return "coding"; + } + return "quickstart"; + }) as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ select }); + + await runOnboardingWizard( + { + acceptRisk: true, + flow: "quickstart", + mode: "local", + authChoice: "skip", + installDaemon: false, + skipProviders: true, + skipSkills: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + prompter, + ); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Tool access profile", + initialValue: "coding", + }), + ); + }); }); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 923bc5d7dfb..3d48aee8960 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -4,6 +4,7 @@ import type { OnboardMode, OnboardOptions, ResetScope, + ToolProfileId, } from "../commands/onboard-types.js"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -70,6 +71,31 @@ async function requireRiskAcknowledgement(params: { } } +const VALID_TOOLS_PROFILES = new Set(["minimal", "coding", "messaging", "full"]); + +const TOOL_PROFILE_CHOICES: Array<{ value: ToolProfileId; label: string; hint: string }> = [ + { + value: "messaging", + label: "Messaging", + hint: "Chat-focused: send messages + use session history; no files, shell, or browser automation.", + }, + { + value: "coding", + label: "Coding", + hint: "Builder mode: read/edit files, run shell, use coding tools + sessions; no direct channel messaging.", + }, + { + value: "full", + label: "Full", + hint: "Unrestricted built-in tool profile, including higher-risk capabilities.", + }, + { + value: "minimal", + label: "Minimal", + hint: "Status-only: check session status; no file access, shell commands, browsing, or messaging.", + }, +]; + export async function runOnboardingWizard( opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime, @@ -406,8 +432,24 @@ export async function runOnboardingWizard( const workspaceDir = resolveUserPath(workspaceInput.trim() || onboardHelpers.DEFAULT_WORKSPACE); + const existingToolsProfile = baseConfig.tools?.profile; + const resolvedExistingToolsProfile = existingToolsProfile + ? VALID_TOOLS_PROFILES.has(existingToolsProfile) + ? existingToolsProfile + : undefined + : undefined; + const toolsProfile = + opts.toolsProfile ?? + (await prompter.select({ + message: "Tool access profile", + options: TOOL_PROFILE_CHOICES, + initialValue: resolvedExistingToolsProfile ?? "messaging", + })); + const { applyOnboardingLocalWorkspaceConfig } = await import("../commands/onboard-config.js"); - let nextConfig: OpenClawConfig = applyOnboardingLocalWorkspaceConfig(baseConfig, workspaceDir); + let nextConfig: OpenClawConfig = applyOnboardingLocalWorkspaceConfig(baseConfig, workspaceDir, { + toolsProfile, + }); const { ensureAuthProfileStore } = await import("../agents/auth-profiles.js"); const { promptAuthChoiceGrouped } = await import("../commands/auth-choice-prompt.js");